181 lines
8.2 KiB
TypeScript
181 lines
8.2 KiB
TypeScript
// The ports the knowledge module's use cases are written against, and the tokens the composition
|
|
// roots bind. Core imports no SDK and no adapter (spec §2): the search service and the enablement
|
|
// platform are shapes, and adapters/ is the only place an SDK exists.
|
|
|
|
import { type Container, type TenantScope, token } from "@conusai-kit/core"
|
|
import type { DataSource } from "@conusai-kit/data"
|
|
import type { FilesDeps } from "@conusai-kit/files"
|
|
import type { EnablementReason, KnowledgeRefusal, ServiceState } from "../contract/index.js"
|
|
import { config, KNOWLEDGE_FLAG_DEFAULTS } from "../contract/index.js"
|
|
|
|
/** A project scope: collections and documents live at this level. */
|
|
export type KnowledgeScope = TenantScope & { workspaceId: string; projectId: string }
|
|
|
|
/** An org scope: the enablement configuration is the organization's own. */
|
|
export type KnowledgeOrgScope = TenantScope & { orgId: string }
|
|
|
|
export interface KnowledgeDeps {
|
|
readonly data: DataSource
|
|
readonly files: FilesDeps
|
|
readonly flags: KnowledgeFlags
|
|
readonly beforeIndex?: (subjectUserId: string) => Promise<void>
|
|
/**
|
|
* The search service. Present for the effect procedure (search); a read or write procedure's deps
|
|
* never carry it, because resolving an effect port from either is a kind violation — the write
|
|
* only records the row and publishes the event, and the status read asks `KNOWLEDGE_READINESS`.
|
|
*/
|
|
readonly search?: KnowledgeSearchService | undefined
|
|
/** The document reader and the passage index, for the automatic PDF intake (core-11); bound in the worker. */
|
|
readonly reader?: KnowledgeReader | undefined
|
|
readonly passages?: KnowledgePassageIndex | undefined
|
|
/** The organization's eligibility and indexing quota, which the PDF intake checks before each read. */
|
|
readonly enablement?: DocumentAiPlatform | undefined
|
|
}
|
|
|
|
/** One cited search answer. */
|
|
export interface KnowledgeHit {
|
|
fileId: string
|
|
filename: string
|
|
collectionName: string
|
|
/** 0..1; higher is closer. */
|
|
score: number
|
|
chunk: number
|
|
snippet: string
|
|
page?: number
|
|
blockId?: string
|
|
chunkId?: string
|
|
}
|
|
|
|
/**
|
|
* The search service: index a document, search a collection with citations, remove a document, and
|
|
* report readiness. Its one adapter is the platform's managed documents-index service (ADR-061), which
|
|
* owns chunking, embeddings and the vector store. Every call leaves the process, so this port is an
|
|
* effect. `refusal` is why the service will not serve this deployment, when that is the answer.
|
|
*/
|
|
export interface KnowledgeSearchService {
|
|
readiness(scope: KnowledgeScope): Promise<{ state: ServiceState; reasons: string[]; refusal?: KnowledgeRefusal }>
|
|
index(
|
|
scope: KnowledgeScope,
|
|
input: { collectionName: string; fileId: string; filename: string; bytes: Uint8Array; signal?: AbortSignal },
|
|
): Promise<void>
|
|
search(
|
|
scope: KnowledgeScope,
|
|
input: { collectionName: string; query: string; limit: number; actorId: string },
|
|
): Promise<readonly KnowledgeHit[]>
|
|
/** `storageKey` names the object, resolved when the removal was asked for; only an older record lacks it, and then the file is looked up. */
|
|
remove(scope: KnowledgeScope, input: { collectionName: string; fileId: string; storageKey?: string }): Promise<void>
|
|
}
|
|
|
|
/**
|
|
* One passage of a read PDF, as the automatic intake indexes it: its page (1-based), its place on that
|
|
* page (0-based; with the page, its stable name across reads of the same document), the document's own
|
|
* block ids it holds lines of, and its text.
|
|
*/
|
|
export interface KnowledgePassage {
|
|
readonly page: number
|
|
readonly index: number
|
|
readonly blockIds: readonly string[]
|
|
readonly text: string
|
|
}
|
|
|
|
/**
|
|
* The platform's document reader, as the automatic intake uses it: a PDF in, its passages out. The
|
|
* same `idempotencyKey` reads the same document once. A document it can never make passages of throws
|
|
* with `permanent: "not_indexable"`; anything else is transient, and the queue retries the job.
|
|
*/
|
|
export interface KnowledgeReader {
|
|
read(
|
|
bytes: Uint8Array,
|
|
options: { filename: string; idempotencyKey: string; signal?: AbortSignal },
|
|
): Promise<readonly KnowledgePassage[]>
|
|
}
|
|
|
|
/**
|
|
* The knowledge service's inline index, one item per passage, each under the file's reference plus its
|
|
* page and place. The reference is asked for first, so the caller can record what it is about to index
|
|
* before any of it is sent, and `remove` takes it back because the file may be gone by then: the service
|
|
* removes a reference only exactly, so removal names every passage that was indexed.
|
|
*/
|
|
export interface KnowledgePassageIndex {
|
|
reference(scope: KnowledgeScope, fileId: string): Promise<string>
|
|
index(
|
|
scope: KnowledgeScope,
|
|
input: {
|
|
collectionName: string
|
|
documentRef: string
|
|
fileId: string
|
|
passages: readonly KnowledgePassage[]
|
|
signal?: AbortSignal
|
|
},
|
|
): Promise<void>
|
|
remove(
|
|
scope: KnowledgeScope,
|
|
input: { documentRef: string; passages: ReadonlyArray<Pick<KnowledgePassage, "page" | "index">> },
|
|
): Promise<void>
|
|
}
|
|
|
|
/**
|
|
* What the status read asks of the deployment's search backend. It is a port of its own because a read
|
|
* handler may not resolve `KNOWLEDGE_SEARCH`, and must not build it: a factory runs with the resolving
|
|
* handler's restrictions, so a search service built for a read would hold a read-only data source.
|
|
*/
|
|
export type KnowledgeReadiness = Pick<KnowledgeSearchService, "readiness">
|
|
|
|
/**
|
|
* The enablement platform: the checks the enablement flow needs, over the platform's own APIs.
|
|
* `checkEligibility` is where the plan capability and the rollout flags surface as typed reasons;
|
|
* `indexingQuota` answers today's remaining indexing volume.
|
|
*/
|
|
export interface DocumentAiPlatform {
|
|
checkEligibility(
|
|
scope: KnowledgeOrgScope,
|
|
): Promise<{ ok: true } | { ok: false; reason: EnablementReason; detail?: string }>
|
|
indexingQuota(scope: KnowledgeOrgScope): Promise<number>
|
|
}
|
|
|
|
/** What a flag service answers for the project a call runs in. */
|
|
export interface KnowledgeFlags {
|
|
enabled(flag: keyof typeof KNOWLEDGE_FLAG_DEFAULTS, scope: KnowledgeScope): boolean | Promise<boolean>
|
|
}
|
|
|
|
/**
|
|
* Bound by an app that evaluates flags (the platform's flag service). Unbound: knowledge is on for an
|
|
* organization once an administrator has enabled document AI, and off until then (`enablementFlags`).
|
|
*/
|
|
export const KNOWLEDGE_FLAGS = token<KnowledgeFlags>("knowledge.flags")
|
|
|
|
/** The organization's own switch: the row `knowledge.settings.enableDocumentAi` writes. */
|
|
export const enablementFlags = (data: DataSource): KnowledgeFlags => ({
|
|
enabled: async (flag, scope) => {
|
|
const row = (await data.forScope(scope).of(config).list({ limit: 1 })).items[0]
|
|
return row?.enabled ?? KNOWLEDGE_FLAG_DEFAULTS[flag]
|
|
},
|
|
})
|
|
|
|
export const flagsOf = (container: Container, data: DataSource): KnowledgeFlags =>
|
|
container.has(KNOWLEDGE_FLAGS) ? container.resolve(KNOWLEDGE_FLAGS) : enablementFlags(data)
|
|
|
|
/** Bound by the worker and the web shell with the module's search backend adapter. */
|
|
export const KNOWLEDGE_SEARCH = token<KnowledgeSearchService>("knowledge.search", { access: "effect" })
|
|
|
|
/** Bound by the worker with the platform's document reader: the automatic PDF intake reads through it. */
|
|
export const KNOWLEDGE_READER = token<KnowledgeReader>("knowledge.reader", { access: "effect" })
|
|
|
|
/** Bound by the worker with the knowledge service's inline index: the PDF intake's passages, and their removal. */
|
|
export const KNOWLEDGE_PASSAGES = token<KnowledgePassageIndex>("knowledge.passages", { access: "effect" })
|
|
|
|
/** Bound by the web shell with the readiness of the service `KNOWLEDGE_SEARCH` serves. */
|
|
export const KNOWLEDGE_READINESS = token<KnowledgeReadiness>("knowledge.readiness")
|
|
|
|
/**
|
|
* Bound by the web shell (the enablement flow runs in a request) and by the worker (the PDF intake checks
|
|
* eligibility and quota again before each read, core-11).
|
|
*/
|
|
export const KNOWLEDGE_ENABLEMENT = token<DocumentAiPlatform>("knowledge.enablement", { access: "effect" })
|
|
|
|
/** The per-run scope, under the module's own name (decision AT-F6). */
|
|
export const KNOWLEDGE_SCOPE = token<TenantScope>("knowledge.scope")
|
|
|
|
export const knowledgeEnabled = async (deps: Pick<KnowledgeDeps, "flags">, scope: KnowledgeScope): Promise<boolean> =>
|
|
deps.flags.enabled("knowledge.enabled", scope)
|