@@ -0,0 +1,61 @@
|
||||
// The automatic intake's enabling-person metadata is an account-data share. The
|
||||
// account kernel supplies the subject; this has no public procedure or bearer.
|
||||
import type { TenantScope } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import { DATA_SOURCE, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { type AccountDataContext, type AccountDataContribution, servedProjects } from "@conusai-kit/tenancy"
|
||||
import { autoIndex } from "../contract/index.js"
|
||||
|
||||
async function* ownedOptIns(context: AccountDataContext) {
|
||||
context.signal?.throwIfAborted()
|
||||
const data = context.container.resolve(DATA_SOURCE)
|
||||
const system = context.container.resolve(SYSTEM_DATA)
|
||||
for await (const project of servedProjects(system)) {
|
||||
context.signal?.throwIfAborted()
|
||||
const scope: TenantScope = { level: "project", ...project, actor: systemActor("knowledge.account") }
|
||||
for (let cursor: string | undefined; ; ) {
|
||||
const page = await data
|
||||
.forScope(scope)
|
||||
.of(autoIndex)
|
||||
.list({
|
||||
where: [{ field: "enabledBy", op: "eq", value: context.subject }],
|
||||
limit: 100,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
})
|
||||
for (const row of page.items) yield { scope, row }
|
||||
if (!page.hasMore || page.cursor === undefined) break
|
||||
cursor = page.cursor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeAccountData: AccountDataContribution = {
|
||||
key: "knowledge",
|
||||
async export(context) {
|
||||
const automaticIndexing = []
|
||||
for await (const { scope, row } of ownedOptIns(context)) {
|
||||
automaticIndexing.push({
|
||||
orgId: scope.orgId,
|
||||
workspaceId: scope.workspaceId,
|
||||
projectId: scope.projectId,
|
||||
collectionName: row.collectionName,
|
||||
enabledAt: row.enabledAt,
|
||||
})
|
||||
}
|
||||
return { automaticIndexing }
|
||||
},
|
||||
async erase(context) {
|
||||
const data = context.container.resolve(DATA_SOURCE)
|
||||
for await (const { scope, row } of ownedOptIns(context)) {
|
||||
context.signal?.throwIfAborted()
|
||||
await data.unitOfWork.run(scope, async (tx) => {
|
||||
const repository = tx.of(autoIndex)
|
||||
const current = repository.lock ? await repository.lock(row.id) : await repository.get(row.id)
|
||||
// A different administrator may have replaced the opt-in since discovery.
|
||||
if (!current || current.enabledBy !== context.subject) return
|
||||
if (!repository.lock) await repository.update(current.id, {}, current.version)
|
||||
await repository.delete(current.id)
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// The enablement platform over the platform's own APIs, server-side. Eligibility reads the
|
||||
// rollout flags (agent_document_tools, agent_knowledge_tools) from the flag snapshot and probes
|
||||
// the AI tools registry for the document tool family — discovery is the cheap plan/binding/flag
|
||||
// filter, and "a working OCR endpoint does not by itself make OCR a discoverable tool". Nothing is
|
||||
// provisioned for the app: the platform's knowledge service owns the index (ADR-061). The quota
|
||||
// pre-check has no platform API yet (the metering slice owns it); the indexing calls themselves
|
||||
// carry the typed quota_exhausted refusal at run time.
|
||||
|
||||
import { createRegistryClient, ToolIndexingError } from "@conusai-sdk/ai"
|
||||
import type { EnablementReason } from "../contract/index.js"
|
||||
import type { DocumentAiPlatform } from "../core/ports.js"
|
||||
|
||||
export const AGENT_DOCUMENT_TOOLS_FLAG = "agent_document_tools"
|
||||
export const AGENT_KNOWLEDGE_TOOLS_FLAG = "agent_knowledge_tools"
|
||||
|
||||
const FLAG_KEYS = [AGENT_DOCUMENT_TOOLS_FLAG, AGENT_KNOWLEDGE_TOOLS_FLAG] as const
|
||||
|
||||
export interface SdkDocumentAiPlatformOptions {
|
||||
/** The deployment contract's API URL and token. */
|
||||
apiUrl: string
|
||||
token: string
|
||||
fetch?: typeof fetch
|
||||
}
|
||||
|
||||
export function sdkDocumentAiPlatform(options: SdkDocumentAiPlatformOptions): DocumentAiPlatform {
|
||||
const http = options.fetch ?? fetch
|
||||
|
||||
const rolloutFlagsOff = async (): Promise<string | null> => {
|
||||
try {
|
||||
const response = await http(new URL(`${options.apiUrl.replace(/\/+$/, "")}/flags/snapshot`), {
|
||||
headers: { authorization: `Bearer ${options.token}`, accept: "application/json" },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (!response.ok) return null // the flags service is not the eligibility authority
|
||||
const body = (await response.json()) as { flags?: Array<{ key?: string; enabled?: boolean }> }
|
||||
for (const flag of body.flags ?? []) {
|
||||
if (
|
||||
typeof flag.key === "string" &&
|
||||
(FLAG_KEYS as readonly string[]).includes(flag.key) &&
|
||||
flag.enabled === false
|
||||
) {
|
||||
return flag.key
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async checkEligibility(): Promise<{ ok: true } | { ok: false; reason: EnablementReason; detail?: string }> {
|
||||
// The rollout flags first: a kill switch or a disabled feature refuses before any probe.
|
||||
const off = await rolloutFlagsOff()
|
||||
if (off !== null) return { ok: false, reason: "feature_disabled", detail: `the ${off} flag is off` }
|
||||
// The plan probe: can this deployment discover the document tool family at all?
|
||||
try {
|
||||
const registry = createRegistryClient({
|
||||
env: { CONUSAI_API_URL: options.apiUrl, CONUSAI_API_TOKEN: options.token },
|
||||
fetch: http,
|
||||
})
|
||||
const found = await registry.tools.search("read and index documents")
|
||||
if (found.length === 0) {
|
||||
return { ok: false, reason: "plan_required", detail: "the ai_tools plan capability lists no document tools" }
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
if (error instanceof ToolIndexingError) {
|
||||
return { ok: false, reason: "feature_disabled", detail: "the tool registry is still indexing" }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: "plan_required",
|
||||
detail: "the AI tools registry did not answer the eligibility probe",
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async indexingQuota() {
|
||||
// No pre-check API exists on the platform yet; the call-time quota refusal is the hard
|
||||
// gate. Until the limit endpoint lands, the admission check answers "within quota".
|
||||
return Number.MAX_SAFE_INTEGER
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// The knowledge jobs and the subscribers that enqueue them.
|
||||
//
|
||||
// A web request writes the document row and publishes the outbox event in one transaction; the
|
||||
// module's own subscriber (running in the worker's outbox relay) turns the event into the job,
|
||||
// with the (collection, file) pair as the singleton key — a redelivered event adds no second job.
|
||||
//
|
||||
// The relay delivers every record in its organization's scope, with a system actor, and the worker binds
|
||||
// `KNOWLEDGE_SCOPE` to that delivery scope. The organization comes from there, never from the payload
|
||||
// (decision AT-F6); only the workspace and the project come from the record, which this module or the files
|
||||
// module wrote inside that organization's transaction, as `projectScopeOf` of @conusai-kit/files does. A job,
|
||||
// unlike a delivery, runs in the project scope it was enqueued in.
|
||||
|
||||
import { type EventContext, internal, type JobDef, serviceRefusal, subscribe } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE, JOB_QUEUE } from "@conusai-kit/data"
|
||||
import { fileDeleted, type ProcessorJobPayload, processorJobPayloadSchema } from "@conusai-kit/files"
|
||||
import { NATIVE_JOB_ADMISSION } from "@conusai-kit/platform"
|
||||
import { projects, workspaces } from "@conusai-kit/tenancy"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
AUTO_INDEX_JOB,
|
||||
documentIndexRequested,
|
||||
documentRemoveRequested,
|
||||
INDEX_JOB,
|
||||
REMOVE_JOB,
|
||||
} from "../contract/index.js"
|
||||
import { autoIndexUpload } from "../core/auto-index-job.js"
|
||||
import { requestSourceRemoval } from "../core/documents.js"
|
||||
import { indexDocument, removeDocument } from "../core/index-job.js"
|
||||
import { KNOWLEDGE_SCOPE, type KnowledgeScope } from "../core/ports.js"
|
||||
import { knowledgeDeps, knowledgeSearchDeps } from "./procedures.js"
|
||||
|
||||
export const documentPayload = z.object({
|
||||
documentId: z.string(),
|
||||
collectionName: z.string(),
|
||||
fileId: z.string(),
|
||||
/** A remove job's: the object to remove, resolved when the removal was asked for; absent only on an older record. */
|
||||
storageKey: z.string().optional(),
|
||||
})
|
||||
export type DocumentJobPayload = z.output<typeof documentPayload>
|
||||
|
||||
export const indexJobKey = (collectionName: string, fileId: string): string =>
|
||||
`knowledge.index.${collectionName}.${fileId}`
|
||||
export const removeJobKey = (collectionName: string, fileId: string): string =>
|
||||
`knowledge.remove.${collectionName}.${fileId}`
|
||||
|
||||
/**
|
||||
* The project a delivery acts in: the delivery's organization and actor, and the record's workspace and project.
|
||||
* A record that names another organization is refused here rather than acted on there, and so is one whose workspace
|
||||
* the organization does not have or whose project is not in that workspace: both are read in the delivery's own
|
||||
* organization before anything is enqueued or changed. That refusal can never succeed on a retry; the relay has no
|
||||
* terminal-failure path yet, so it is retried until marked stuck, with the reason (a kit-jobs gap, recorded in the
|
||||
* template lessons). An archived project is acted on: an archive hides a project and does not end its work
|
||||
* (kit-tenancy `suspension.ts`).
|
||||
*/
|
||||
async function projectScopeOf(
|
||||
ctx: EventContext,
|
||||
payload: { orgId: string; workspaceId: string; projectId: string },
|
||||
): Promise<KnowledgeScope & { level: "project" }> {
|
||||
const ambient = ctx.container.resolve(KNOWLEDGE_SCOPE)
|
||||
if (ambient.orgId !== payload.orgId) {
|
||||
throw internal(`a knowledge event of organization ${payload.orgId} was delivered in ${ambient.orgId}`)
|
||||
}
|
||||
const data = ctx.container.resolve(DATA_SOURCE)
|
||||
const inOrg = { level: "org" as const, actor: ambient.actor, orgId: ambient.orgId }
|
||||
if (!(await data.forScope(inOrg).of(workspaces).get(payload.workspaceId))) {
|
||||
throw internal(
|
||||
`a knowledge event names workspace ${payload.workspaceId}, which organization ${ambient.orgId} does not have`,
|
||||
)
|
||||
}
|
||||
const inWorkspace = {
|
||||
level: "workspace" as const,
|
||||
actor: ambient.actor,
|
||||
orgId: ambient.orgId,
|
||||
workspaceId: payload.workspaceId,
|
||||
}
|
||||
if (!(await data.forScope(inWorkspace).of(projects).get(payload.projectId))) {
|
||||
throw internal(
|
||||
`a knowledge event names project ${payload.projectId}, which is not in workspace ${payload.workspaceId}`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
level: "project",
|
||||
actor: ambient.actor,
|
||||
orgId: ambient.orgId,
|
||||
workspaceId: payload.workspaceId,
|
||||
projectId: payload.projectId,
|
||||
}
|
||||
}
|
||||
|
||||
export const indexJob: JobDef<DocumentJobPayload> = {
|
||||
payload: documentPayload,
|
||||
async handler(payload, ctx) {
|
||||
const scope = ctx.container.resolve(KNOWLEDGE_SCOPE)
|
||||
if (scope.level !== "project") throw internal(`a knowledge job runs in a project scope, not a ${scope.level} one`)
|
||||
await indexDocument(
|
||||
{
|
||||
...knowledgeSearchDeps(ctx.container),
|
||||
beforeIndex: async (subject) => {
|
||||
if (!ctx.signal || !ctx.container.has(NATIVE_JOB_ADMISSION)) throw serviceRefusal("observation_missing")
|
||||
ctx.signal.throwIfAborted()
|
||||
await ctx.container.resolve(NATIVE_JOB_ADMISSION).authorize(scope, INDEX_JOB, subject, ctx.signal)
|
||||
},
|
||||
},
|
||||
scope,
|
||||
payload.documentId,
|
||||
{
|
||||
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
||||
...(ctx.finalAttempt === undefined ? {} : { finalAttempt: ctx.finalAttempt }),
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* The automatic PDF intake (core-11): the files module enqueues it for every stored upload this module's
|
||||
* processor wants, in the upload's project. It acts for the administrator who opted the project in, so
|
||||
* the worker's native admission checks that administrator's permission and the platform's services again
|
||||
* before each step that leaves the process, as the index job does for the person who asked.
|
||||
*/
|
||||
export const autoIndexJob: JobDef<ProcessorJobPayload> = {
|
||||
payload: processorJobPayloadSchema,
|
||||
async handler(payload, ctx) {
|
||||
const scope = ctx.container.resolve(KNOWLEDGE_SCOPE)
|
||||
if (scope.level !== "project") throw internal(`a knowledge job runs in a project scope, not a ${scope.level} one`)
|
||||
await autoIndexUpload(
|
||||
{
|
||||
...knowledgeSearchDeps(ctx.container),
|
||||
beforeIndex: async (subject) => {
|
||||
if (!ctx.signal || !ctx.container.has(NATIVE_JOB_ADMISSION)) throw serviceRefusal("observation_missing")
|
||||
ctx.signal.throwIfAborted()
|
||||
await ctx.container.resolve(NATIVE_JOB_ADMISSION).authorize(scope, AUTO_INDEX_JOB, subject, ctx.signal)
|
||||
},
|
||||
},
|
||||
scope,
|
||||
payload,
|
||||
{
|
||||
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
||||
...(ctx.finalAttempt === undefined ? {} : { finalAttempt: ctx.finalAttempt }),
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const removeJob: JobDef<DocumentJobPayload> = {
|
||||
payload: documentPayload,
|
||||
async handler(payload, ctx) {
|
||||
const scope = ctx.container.resolve(KNOWLEDGE_SCOPE)
|
||||
if (scope.level !== "project") throw internal(`a knowledge job runs in a project scope, not a ${scope.level} one`)
|
||||
await removeDocument(knowledgeSearchDeps(ctx.container), scope, payload.documentId, payload.storageKey)
|
||||
},
|
||||
}
|
||||
|
||||
async function onDocumentIndexRequested(
|
||||
event: {
|
||||
name: string
|
||||
payload: {
|
||||
orgId: string
|
||||
workspaceId: string
|
||||
projectId: string
|
||||
documentId: string
|
||||
collectionName: string
|
||||
fileId: string
|
||||
}
|
||||
},
|
||||
ctx: EventContext,
|
||||
): Promise<void> {
|
||||
const scope = await projectScopeOf(ctx, event.payload)
|
||||
await ctx.container.resolve(JOB_QUEUE).enqueue(
|
||||
scope,
|
||||
INDEX_JOB,
|
||||
{
|
||||
documentId: event.payload.documentId,
|
||||
collectionName: event.payload.collectionName,
|
||||
fileId: event.payload.fileId,
|
||||
},
|
||||
{
|
||||
key: indexJobKey(event.payload.collectionName, event.payload.fileId),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function onDocumentRemoveRequested(
|
||||
event: {
|
||||
name: string
|
||||
payload: {
|
||||
orgId: string
|
||||
workspaceId: string
|
||||
projectId: string
|
||||
documentId: string
|
||||
collectionName: string
|
||||
fileId: string
|
||||
storageKey?: string | undefined
|
||||
}
|
||||
},
|
||||
ctx: EventContext,
|
||||
): Promise<void> {
|
||||
const scope = await projectScopeOf(ctx, event.payload)
|
||||
await ctx.container.resolve(JOB_QUEUE).enqueue(
|
||||
scope,
|
||||
REMOVE_JOB,
|
||||
{
|
||||
documentId: event.payload.documentId,
|
||||
collectionName: event.payload.collectionName,
|
||||
fileId: event.payload.fileId,
|
||||
...(event.payload.storageKey === undefined ? {} : { storageKey: event.payload.storageKey }),
|
||||
},
|
||||
{
|
||||
key: removeJobKey(event.payload.collectionName, event.payload.fileId),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function onSourceDeleted(
|
||||
event: { payload: { orgId: string; workspaceId: string; projectId: string; fileId: string; storageKey: string } },
|
||||
ctx: EventContext,
|
||||
): Promise<void> {
|
||||
const scope = await projectScopeOf(ctx, event.payload)
|
||||
// The file's row is gone by now: the key it had travels with the removal (F407).
|
||||
await requestSourceRemoval(knowledgeDeps(ctx.container), scope, event.payload.fileId, event.payload.storageKey)
|
||||
}
|
||||
|
||||
export const KNOWLEDGE_JOBS = { [INDEX_JOB]: indexJob, [REMOVE_JOB]: removeJob, [AUTO_INDEX_JOB]: autoIndexJob }
|
||||
export const KNOWLEDGE_SUBSCRIPTIONS = [
|
||||
subscribe(fileDeleted, onSourceDeleted),
|
||||
subscribe(documentIndexRequested, onDocumentIndexRequested),
|
||||
subscribe(documentRemoveRequested, onDocumentRemoveRequested),
|
||||
]
|
||||
@@ -0,0 +1,280 @@
|
||||
// The managed search service: the platform's documents-index service through the kit-platform
|
||||
// Knowledge client. Indexing and search run server-side there (the service owns chunking,
|
||||
// embeddings and the vector store); this module supplies the job lifecycle and the citations it
|
||||
// answers with.
|
||||
//
|
||||
// Contract notes (the service's real shapes): the index submission carries the content's SHA-256
|
||||
// (computed here from the job's bytes) as its version assertion; a quota denial surfaces on the
|
||||
// job, which the poll below reads as the job's typed error. Blob pathnames and returned citations
|
||||
// resolve through the files module's scoped rows, so an opaque file id never becomes a pathname
|
||||
// and a foreign or stale service reference never becomes a valid app hit.
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import {
|
||||
type FilesDeps,
|
||||
findFileStorageByKey,
|
||||
getFileStorage,
|
||||
platformBlobPathname,
|
||||
platformBlobStorageKey,
|
||||
} from "@conusai-kit/files"
|
||||
import { KNOWLEDGE_LIMITS, type Knowledge, type KnowledgeClient, KnowledgeError } from "@conusai-kit/platform"
|
||||
import type { KnowledgeHit, KnowledgePassageIndex, KnowledgeScope, KnowledgeSearchService } from "../core/ports.js"
|
||||
import { platformKnowledgeReadiness } from "./readiness.js"
|
||||
import { permanentRefusal } from "./refusals.js"
|
||||
|
||||
/** The service's tenant partition of a project: a search of the project and its indexed documents agree on it. */
|
||||
const tenantOf = (scope: KnowledgeScope): string =>
|
||||
createHash("sha256")
|
||||
.update(JSON.stringify([scope.orgId, scope.workspaceId, scope.projectId]))
|
||||
.digest("hex")
|
||||
|
||||
/** A passage's reference: the file's own plus `#p{page}.{index}` (the automatic PDF intake, core-11). */
|
||||
const passageRef = (documentRef: string, passage: { page: number; index: number }): string =>
|
||||
`${documentRef}#p${passage.page}.${passage.index}`
|
||||
|
||||
const PASSAGE_REF = /^(.+)#p([1-9][0-9]*)\.(0|[1-9][0-9]*)$/
|
||||
|
||||
/**
|
||||
* `environment` is the deployment's (`CONUSAI_ENVIRONMENT`): the service keeps a collection in one environment,
|
||||
* and a deployment token reaches only its own.
|
||||
*/
|
||||
export function platformKnowledgeSearch(
|
||||
client: Knowledge,
|
||||
files: FilesDeps,
|
||||
options: { environment?: string | undefined; timeoutMs?: number; intervalMs?: number } = {},
|
||||
): KnowledgeSearchService {
|
||||
const environment = options.environment?.trim()
|
||||
const configuredEnvironment = (): string => {
|
||||
if (!environment)
|
||||
throw new KnowledgeError("configuration_missing", "CONUSAI_ENVIRONMENT is required for knowledge indexing")
|
||||
return environment
|
||||
}
|
||||
const unwrap = (error: unknown): never => {
|
||||
// The client's errors are already typed by reason; a non-knowledge failure is unavailable.
|
||||
if (error instanceof KnowledgeError) throw error
|
||||
throw new KnowledgeError(
|
||||
"runtime_unavailable",
|
||||
error instanceof Error ? error.message : "the knowledge service failed",
|
||||
true,
|
||||
)
|
||||
}
|
||||
/** The same, for an index job: a refusal waiting cannot fix is recorded on the document, not retried. */
|
||||
const unwrapJob = (error: unknown): never => {
|
||||
if (error instanceof KnowledgeError) {
|
||||
const permanent = permanentRefusal(error)
|
||||
if (permanent !== undefined) throw Object.assign(error, { permanent })
|
||||
}
|
||||
return unwrap(error)
|
||||
}
|
||||
const documentRef = async (scope: KnowledgeScope, fileId: string): Promise<string> => {
|
||||
const reference = await getFileStorage(files, scope, fileId)
|
||||
return platformBlobPathname(scope, reference.storageKey)
|
||||
}
|
||||
|
||||
return {
|
||||
readiness(scope) {
|
||||
if (!environment)
|
||||
return Promise.resolve({
|
||||
state: "unavailable",
|
||||
reasons: ["CONUSAI_ENVIRONMENT is not set"],
|
||||
refusal: "configuration_missing",
|
||||
})
|
||||
return platformKnowledgeReadiness(client).readiness(scope)
|
||||
},
|
||||
|
||||
async index(scope, input) {
|
||||
try {
|
||||
// The service answers 404 for a collection it does not hold. An app's collection is only a row of
|
||||
// this module (a write cannot leave the process), so the first index creates it; creating it again
|
||||
// finds it.
|
||||
const activeEnvironment = configuredEnvironment()
|
||||
const reference = await documentRef(scope, input.fileId)
|
||||
const signal = AbortSignal.any([
|
||||
AbortSignal.timeout(options.timeoutMs ?? 300_000),
|
||||
...(input.signal ? [input.signal] : []),
|
||||
])
|
||||
signal.throwIfAborted()
|
||||
await client.createCollection({ collection: input.collectionName, environment: activeEnvironment }, { signal })
|
||||
const { jobId } = await client.index(
|
||||
{
|
||||
collection: input.collectionName,
|
||||
documentRef: reference,
|
||||
options: {
|
||||
visibility: "tenant",
|
||||
tenant: tenantOf(scope),
|
||||
contentSha256: createHash("sha256").update(input.bytes).digest("hex"),
|
||||
},
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
// The service's job is durable; poll until it leaves the queue. A caller-side cancel is
|
||||
// the job's own AbortSignal, checked between polls.
|
||||
for (;;) {
|
||||
signal.throwIfAborted()
|
||||
const job = await client.job(jobId, { signal })
|
||||
if (job.status === "succeeded") return
|
||||
if (job.status === "failed")
|
||||
throw new KnowledgeError(
|
||||
job.error === "quota-exhausted" ? "quota_exhausted" : "runtime_unavailable",
|
||||
job.error ?? "the indexing job failed",
|
||||
)
|
||||
if (job.status === "cancelled")
|
||||
throw new KnowledgeError("runtime_unavailable", "the indexing job was cancelled")
|
||||
await delay(options.intervalMs ?? 1_000, undefined, { signal })
|
||||
}
|
||||
} catch (error) {
|
||||
return unwrapJob(error)
|
||||
}
|
||||
},
|
||||
|
||||
async search(scope, input) {
|
||||
try {
|
||||
configuredEnvironment()
|
||||
const results = await client.search({
|
||||
collection: input.collectionName,
|
||||
query: input.query,
|
||||
limit: input.limit,
|
||||
tenant: tenantOf(scope),
|
||||
})
|
||||
const hits: KnowledgeHit[] = []
|
||||
for (const [at, hit] of results.entries()) {
|
||||
// A passage of a PDF the intake indexed names its file's reference, and cites from its metadata:
|
||||
// the service reports pages only for sources it parsed itself.
|
||||
const passage = PASSAGE_REF.exec(hit.documentRef)
|
||||
const storageKey = platformBlobStorageKey(scope, passage?.[1] ?? hit.documentRef)
|
||||
if (storageKey === null) continue
|
||||
const reference = await findFileStorageByKey(files, scope, storageKey)
|
||||
if (reference === null) continue
|
||||
const metadata = "metadata" in hit ? (hit.metadata as Readonly<Record<string, string>>) : {}
|
||||
if (passage && metadata.file_id !== reference.file.fileId) continue
|
||||
const page = passage ? Number(passage[2]) : hit.citations[0]?.page
|
||||
const blockId = passage ? metadata.block_ids?.split(",")[0] || undefined : hit.citations[0]?.blockId
|
||||
hits.push({
|
||||
fileId: reference.file.fileId,
|
||||
filename: reference.file.filename,
|
||||
collectionName: input.collectionName,
|
||||
score: hit.score,
|
||||
chunk: at + 1,
|
||||
snippet: hit.snippet,
|
||||
...(page === undefined ? {} : { page }),
|
||||
...(blockId === undefined ? {} : { blockId }),
|
||||
...("chunkId" in hit && typeof hit.chunkId === "string" ? { chunkId: hit.chunkId } : {}),
|
||||
})
|
||||
}
|
||||
return hits
|
||||
} catch (error) {
|
||||
// Nothing has been indexed into this collection yet, so the service has not heard of it.
|
||||
if (error instanceof KnowledgeError && error.reason === "unavailable_resource") return []
|
||||
return unwrap(error)
|
||||
}
|
||||
},
|
||||
|
||||
async remove(scope, input) {
|
||||
try {
|
||||
// Every removal asked for since F407 names its object; only a record from before it is looked up.
|
||||
const reference =
|
||||
input.storageKey === undefined
|
||||
? await documentRef(scope, input.fileId)
|
||||
: platformBlobPathname(scope, input.storageKey)
|
||||
await client.remove(reference)
|
||||
} catch (error) {
|
||||
return unwrap(error)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The automatic PDF intake's index (core-11): one inline item per passage, in the project's tenant partition,
|
||||
* under the file's Blob reference plus `#p{page}.{index}`, with its page, its blocks and its file in metadata.
|
||||
* Items go at most the service's count per call, and every item's durable job is waited for; a refusal waiting
|
||||
* cannot fix is the document's, as with a whole file.
|
||||
*/
|
||||
export function platformPassageIndex(
|
||||
client: Pick<KnowledgeClient, "createCollection" | "indexInline" | "job" | "remove">,
|
||||
files: FilesDeps,
|
||||
options: { environment?: string | undefined; timeoutMs?: number; intervalMs?: number } = {},
|
||||
): KnowledgePassageIndex {
|
||||
const environment = options.environment?.trim()
|
||||
const failure = (error: unknown): never => {
|
||||
if (error instanceof KnowledgeError) {
|
||||
const permanent = permanentRefusal(error)
|
||||
if (permanent !== undefined) throw Object.assign(error, { permanent })
|
||||
throw error
|
||||
}
|
||||
throw new KnowledgeError(
|
||||
"runtime_unavailable",
|
||||
error instanceof Error ? error.message : "the knowledge service failed",
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
async reference(scope, fileId) {
|
||||
const stored = await getFileStorage(files, scope, fileId)
|
||||
return platformBlobPathname(scope, stored.storageKey)
|
||||
},
|
||||
|
||||
async index(scope, input) {
|
||||
try {
|
||||
if (!environment)
|
||||
throw new KnowledgeError("configuration_missing", "CONUSAI_ENVIRONMENT is required for knowledge indexing")
|
||||
const signal = AbortSignal.any([
|
||||
AbortSignal.timeout(options.timeoutMs ?? 300_000),
|
||||
...(input.signal ? [input.signal] : []),
|
||||
])
|
||||
await client.createCollection({ collection: input.collectionName, environment }, { signal })
|
||||
const tenant = tenantOf(scope)
|
||||
const jobs: string[] = []
|
||||
for (let at = 0; at < input.passages.length; at += KNOWLEDGE_LIMITS.inlineItems) {
|
||||
signal.throwIfAborted()
|
||||
const accepted = await client.indexInline({
|
||||
collection: input.collectionName,
|
||||
items: input.passages.slice(at, at + KNOWLEDGE_LIMITS.inlineItems).map((passage) => ({
|
||||
documentRef: passageRef(input.documentRef, passage),
|
||||
text: passage.text,
|
||||
visibility: "tenant" as const,
|
||||
tenant,
|
||||
metadata: {
|
||||
page: String(passage.page),
|
||||
...(passage.blockIds.length === 0 ? {} : { block_ids: passage.blockIds.join(",") }),
|
||||
file_id: input.fileId,
|
||||
},
|
||||
})),
|
||||
})
|
||||
jobs.push(...accepted.map((job) => job.jobId))
|
||||
}
|
||||
for (const jobId of jobs) {
|
||||
for (;;) {
|
||||
signal.throwIfAborted()
|
||||
const job = await client.job(jobId, { signal })
|
||||
if (job.status === "succeeded") break
|
||||
if (job.status === "failed")
|
||||
throw new KnowledgeError(
|
||||
job.error === "quota-exhausted" ? "quota_exhausted" : "runtime_unavailable",
|
||||
job.error ?? "the indexing job failed",
|
||||
)
|
||||
if (job.status === "cancelled")
|
||||
throw new KnowledgeError("runtime_unavailable", "the indexing job was cancelled")
|
||||
await delay(options.intervalMs ?? 1_000, undefined, { signal })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return failure(error)
|
||||
}
|
||||
},
|
||||
|
||||
async remove(_scope, input) {
|
||||
for (const passage of input.passages) {
|
||||
try {
|
||||
await client.remove(passageRef(input.documentRef, passage))
|
||||
} catch (error) {
|
||||
// A passage the service never indexed (the run was refused before it) is already removed.
|
||||
if (error instanceof KnowledgeError && error.reason === "unavailable_resource") continue
|
||||
failure(error)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// The handlers of the knowledge module's procedures: each resolves its ports from the request's
|
||||
// container and calls one use case. No rule lives here.
|
||||
|
||||
import type { Container } from "@conusai-kit/core"
|
||||
import { implement } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE } from "@conusai-kit/data"
|
||||
import { filesDeps } from "@conusai-kit/files"
|
||||
import {
|
||||
autoIndexGet,
|
||||
autoIndexSet,
|
||||
collectionCreate,
|
||||
collectionList,
|
||||
documentGetMany,
|
||||
documentIndex,
|
||||
documentList,
|
||||
documentRemove,
|
||||
knowledgeSearch,
|
||||
knowledgeStatus,
|
||||
settingsDisableDocumentAi,
|
||||
settingsEnableDocumentAi,
|
||||
settingsGet,
|
||||
} from "../contract/index.js"
|
||||
import { getAutoIndex, setAutoIndex } from "../core/auto-index.js"
|
||||
import { createCollection, listCollections } from "../core/collections.js"
|
||||
import { getManyDocuments, listDocuments, requestIndex, requestRemove } from "../core/documents.js"
|
||||
import {
|
||||
flagsOf,
|
||||
KNOWLEDGE_ENABLEMENT,
|
||||
KNOWLEDGE_PASSAGES,
|
||||
KNOWLEDGE_READER,
|
||||
KNOWLEDGE_READINESS,
|
||||
KNOWLEDGE_SEARCH,
|
||||
type KnowledgeDeps,
|
||||
} from "../core/ports.js"
|
||||
import { searchKnowledge } from "../core/search.js"
|
||||
import { disableDocumentAi, enableDocumentAi, getSettings, type SettingsDeps } from "../core/settings.js"
|
||||
import { type KnowledgeStatusDeps, knowledgeStatusOf } from "../core/status.js"
|
||||
import { knowledgeRefusal } from "./refusals.js"
|
||||
|
||||
/** The ports a write or read handler needs. Never the search service: that port is an effect, and
|
||||
* resolving it from either is a kind violation. */
|
||||
export function knowledgeDeps(container: Container): KnowledgeDeps {
|
||||
const data = container.resolve(DATA_SOURCE)
|
||||
return { data, files: filesDeps(container), flags: flagsOf(container, data) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The effect handler's deps: the search service, bound in the processes that may leave the process, and the
|
||||
* PDF intake's reader, passage index and enablement checks where the worker binds the reader (the jobs; a
|
||||
* request never reads a PDF).
|
||||
*/
|
||||
export function knowledgeSearchDeps(container: Container): KnowledgeDeps {
|
||||
return {
|
||||
...knowledgeDeps(container),
|
||||
search: container.resolve(KNOWLEDGE_SEARCH),
|
||||
...(container.has(KNOWLEDGE_READER) ? { reader: container.resolve(KNOWLEDGE_READER) } : {}),
|
||||
...(container.has(KNOWLEDGE_PASSAGES) ? { passages: container.resolve(KNOWLEDGE_PASSAGES) } : {}),
|
||||
...(container.has(KNOWLEDGE_READER) ? { enablement: container.resolve(KNOWLEDGE_ENABLEMENT) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** The status read's deps: the backend's readiness, which a read may resolve, instead of the search service. */
|
||||
function knowledgeStatusDeps(container: Container): KnowledgeStatusDeps {
|
||||
const data = container.resolve(DATA_SOURCE)
|
||||
return {
|
||||
data,
|
||||
flags: flagsOf(container, data),
|
||||
...(container.has(KNOWLEDGE_READINESS) ? { readiness: container.resolve(KNOWLEDGE_READINESS) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** The enablement flow's deps, for the effect that leaves the process. The other settings procedures read and write the module's own row. */
|
||||
function settingsDeps(container: Container): SettingsDeps {
|
||||
return { data: container.resolve(DATA_SOURCE), platform: container.resolve(KNOWLEDGE_ENABLEMENT) }
|
||||
}
|
||||
|
||||
const settingsDataOf = (container: Container) => ({ data: container.resolve(DATA_SOURCE) })
|
||||
|
||||
export const collectionListImpl = implement(collectionList, ({ ctx }) =>
|
||||
listCollections(knowledgeDeps(ctx.container), ctx.scope),
|
||||
)
|
||||
|
||||
export const collectionCreateImpl = implement(collectionCreate, ({ input, ctx }) =>
|
||||
createCollection(knowledgeDeps(ctx.container), ctx.scope, input.name),
|
||||
)
|
||||
|
||||
export const documentIndexImpl = implement(documentIndex, ({ input, ctx }) =>
|
||||
requestIndex(knowledgeDeps(ctx.container), ctx.scope, input),
|
||||
)
|
||||
|
||||
export const documentListImpl = implement(documentList, ({ input, ctx }) =>
|
||||
listDocuments(knowledgeDeps(ctx.container), ctx.scope, input.collectionName),
|
||||
)
|
||||
|
||||
export const documentGetManyImpl = implement(documentGetMany, async ({ input, ctx }) => ({
|
||||
items: await getManyDocuments(knowledgeDeps(ctx.container), ctx.scope, input.ids),
|
||||
}))
|
||||
|
||||
export const documentRemoveImpl = implement(documentRemove, ({ input, ctx }) =>
|
||||
requestRemove(knowledgeDeps(ctx.container), ctx.scope, input),
|
||||
)
|
||||
|
||||
export const knowledgeSearchImpl = implement(knowledgeSearch, async ({ input, ctx }) => {
|
||||
try {
|
||||
return await searchKnowledge(knowledgeSearchDeps(ctx.container), ctx.scope, {
|
||||
collectionName: input.collectionName,
|
||||
query: input.query,
|
||||
...(input.limit === undefined ? {} : { limit: input.limit }),
|
||||
})
|
||||
} catch (error) {
|
||||
throw knowledgeRefusal(error)
|
||||
}
|
||||
})
|
||||
|
||||
export const knowledgeStatusImpl = implement(knowledgeStatus, ({ ctx }) =>
|
||||
knowledgeStatusOf(knowledgeStatusDeps(ctx.container), ctx.scope),
|
||||
)
|
||||
|
||||
export const settingsGetImpl = implement(settingsGet, ({ ctx }) =>
|
||||
getSettings(settingsDataOf(ctx.container), ctx.scope),
|
||||
)
|
||||
|
||||
export const settingsEnableDocumentAiImpl = implement(settingsEnableDocumentAi, async ({ ctx }) => {
|
||||
const settings = await enableDocumentAi(settingsDeps(ctx.container), ctx.scope)
|
||||
return { enabled: settings.enabled, backend: settings.backend ?? "platform" }
|
||||
})
|
||||
|
||||
export const settingsDisableDocumentAiImpl = implement(settingsDisableDocumentAi, ({ ctx }) =>
|
||||
disableDocumentAi(settingsDataOf(ctx.container), ctx.scope),
|
||||
)
|
||||
|
||||
export const autoIndexGetImpl = implement(autoIndexGet, ({ ctx }) =>
|
||||
getAutoIndex(settingsDataOf(ctx.container), ctx.scope),
|
||||
)
|
||||
|
||||
export const autoIndexSetImpl = implement(autoIndexSet, ({ input, ctx }) =>
|
||||
setAutoIndex(knowledgeDeps(ctx.container), ctx.scope, input.collectionName),
|
||||
)
|
||||
|
||||
export const KNOWLEDGE_PROCEDURE_IMPLS = [
|
||||
collectionListImpl,
|
||||
collectionCreateImpl,
|
||||
documentIndexImpl,
|
||||
documentListImpl,
|
||||
documentGetManyImpl,
|
||||
documentRemoveImpl,
|
||||
knowledgeSearchImpl,
|
||||
knowledgeStatusImpl,
|
||||
settingsGetImpl,
|
||||
settingsEnableDocumentAiImpl,
|
||||
settingsDisableDocumentAiImpl,
|
||||
autoIndexGetImpl,
|
||||
autoIndexSetImpl,
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
// The automatic PDF intake's reader (core-11): the platform's document reader (`DocumentReader`, kit-platform's
|
||||
// `@conusai-sdk/ocr` wired to the deployment contract) and the read document's passages
|
||||
// (`@conusai-sdk/ocr/passages`), cut to the knowledge service's limit for one indexed item. The one thing this
|
||||
// adapter decides is what is permanent: a job the platform failed, or a document whose blocks no metadata
|
||||
// value can cite (`passages` throws `RangeError`), is not indexable; anything else is thrown for the queue.
|
||||
|
||||
import type { DocumentReader } from "@conusai-kit/platform"
|
||||
import { KNOWLEDGE_LIMITS } from "@conusai-kit/platform"
|
||||
import { OcrError } from "@conusai-sdk/ocr"
|
||||
import { passages } from "@conusai-sdk/ocr/passages"
|
||||
import type { KnowledgeReader } from "../core/ports.js"
|
||||
|
||||
export interface SdkKnowledgeReaderOptions {
|
||||
/** Poll interval of `wait`, in milliseconds. */
|
||||
readonly intervalMs?: number
|
||||
/** Give up on one job after this long; the queue's retry submits again under the same key. */
|
||||
readonly timeoutMs?: number
|
||||
}
|
||||
|
||||
export function sdkKnowledgeReader(
|
||||
reader: Pick<DocumentReader, "submit" | "wait">,
|
||||
options: SdkKnowledgeReaderOptions = {},
|
||||
): KnowledgeReader {
|
||||
return {
|
||||
async read(bytes, { filename, idempotencyKey, signal }) {
|
||||
try {
|
||||
const job = await reader.submit(bytes, { filename, idempotencyKey })
|
||||
const document = await reader.wait(job.id, {
|
||||
...(options.intervalMs === undefined ? {} : { intervalMs: options.intervalMs }),
|
||||
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
return passages(document, { maxBytes: KNOWLEDGE_LIMITS.inlineTextBytes }).map((passage) => ({
|
||||
page: passage.page,
|
||||
index: passage.index,
|
||||
blockIds: passage.blockIds,
|
||||
text: passage.text,
|
||||
}))
|
||||
} catch (error) {
|
||||
if ((error instanceof OcrError && error.type === "job-failed") || error instanceof RangeError)
|
||||
throw Object.assign(error, { permanent: "not_indexable" })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// The platform service's readiness, on its own so the status read can ask it without the search service
|
||||
// (`KNOWLEDGE_READINESS`): the search adapter answers the same way, from here.
|
||||
|
||||
import { type Knowledge, KnowledgeError } from "@conusai-kit/platform"
|
||||
import type { KnowledgeReadiness } from "../core/ports.js"
|
||||
import { refusalOfReason, stateOfRefusal } from "./refusals.js"
|
||||
|
||||
/**
|
||||
* The platform's managed service answers for itself. A refusal is the service's answer, as a typed code
|
||||
* the page shows in the reader's language; a failure to ask it is its being unavailable.
|
||||
*/
|
||||
export const platformKnowledgeReadiness = (client: Pick<Knowledge, "readiness">): KnowledgeReadiness => ({
|
||||
async readiness() {
|
||||
try {
|
||||
const answer = await client.readiness()
|
||||
return { state: answer.state, reasons: [...answer.reasons] }
|
||||
} catch (error) {
|
||||
if (error instanceof KnowledgeError) {
|
||||
const refusal = refusalOfReason(error.reason)
|
||||
return { state: stateOfRefusal(refusal), reasons: [], ...(refusal === undefined ? {} : { refusal }) }
|
||||
}
|
||||
return { state: "unavailable", reasons: [], refusal: "runtime_unavailable" }
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
// A refusal of the platform's knowledge service as this module's one closed set of codes
|
||||
// (`KNOWLEDGE_REFUSALS`), which the pages and forms show in the reader's language. A refusal that is the
|
||||
// deployment's or the platform's says so; the service's own words stay in `detail`, for the log and the API.
|
||||
|
||||
import { AppError, notFound } from "@conusai-kit/core"
|
||||
import { KnowledgeError, type KnowledgeReasonCode } from "@conusai-kit/platform"
|
||||
import type { KnowledgeRefusal, ServiceState } from "../contract/index.js"
|
||||
|
||||
const REFUSAL: Readonly<Record<Exclude<KnowledgeReasonCode, "unavailable_resource">, KnowledgeRefusal>> = {
|
||||
plan_required: "plan_required",
|
||||
feature_disabled: "feature_disabled",
|
||||
quota_exhausted: "quota_exhausted",
|
||||
budget_exhausted: "budget_exhausted",
|
||||
// The service refused the token itself: `knowledge.collections:use` is missing, `*` never carries it.
|
||||
forbidden: "scope_missing",
|
||||
indexing: "indexing",
|
||||
approval_required: "approval_required",
|
||||
runtime_unavailable: "runtime_unavailable",
|
||||
configuration_missing: "configuration_missing",
|
||||
// The platform refused the token's identity: nothing the person can act on, the app is not served.
|
||||
unauthenticated: "runtime_unavailable",
|
||||
}
|
||||
|
||||
const STATUS: Readonly<Record<KnowledgeRefusal, number>> = {
|
||||
plan_required: 403,
|
||||
feature_disabled: 403,
|
||||
approval_required: 403,
|
||||
quota_exhausted: 429,
|
||||
budget_exhausted: 429,
|
||||
scope_missing: 503,
|
||||
indexing: 503,
|
||||
runtime_unavailable: 503,
|
||||
configuration_missing: 503,
|
||||
}
|
||||
|
||||
const TITLE: Readonly<Record<KnowledgeRefusal, string>> = {
|
||||
plan_required: "The organization's plan does not include document search",
|
||||
feature_disabled: "Document search is disabled by policy",
|
||||
approval_required: "The platform asks for an approval first",
|
||||
quota_exhausted: "The indexing quota is exhausted",
|
||||
budget_exhausted: "The AI budget is exhausted",
|
||||
scope_missing: "This deployment's token does not hold knowledge.collections:use",
|
||||
indexing: "The knowledge service is building its index",
|
||||
runtime_unavailable: "The knowledge service is unavailable",
|
||||
configuration_missing: "The knowledge deployment configuration is incomplete",
|
||||
}
|
||||
|
||||
/** The typed refusal of a service error; a resource it does not hold has none, it is simply not there. */
|
||||
export function refusalOfReason(reason: KnowledgeReasonCode): KnowledgeRefusal | undefined {
|
||||
return reason === "unavailable_resource" ? undefined : REFUSAL[reason]
|
||||
}
|
||||
|
||||
/** The readiness state a refusal puts the service in: a plan or a policy is "disabled", not "down". */
|
||||
export function stateOfRefusal(refusal: KnowledgeRefusal | undefined): ServiceState {
|
||||
if (refusal === "plan_required" || refusal === "feature_disabled") return "disabled"
|
||||
return refusal === "indexing" ? "indexing" : "unavailable"
|
||||
}
|
||||
|
||||
/** The app error for anything the service threw; a programming error passes through as it is. */
|
||||
export function knowledgeRefusal(error: unknown): unknown {
|
||||
if (!(error instanceof KnowledgeError)) return error
|
||||
const refusal = refusalOfReason(error.reason)
|
||||
if (refusal === undefined) return notFound(error.message)
|
||||
return new AppError({
|
||||
type: `/problems/${refusal}`,
|
||||
title: TITLE[refusal],
|
||||
status: STATUS[refusal],
|
||||
code: refusal,
|
||||
detail: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
/** A refusal waiting cannot fix: the index job records it on the document instead of retrying it. */
|
||||
export function permanentRefusal(error: KnowledgeError): KnowledgeRefusal | undefined {
|
||||
const refusal = refusalOfReason(error.reason)
|
||||
return refusal === "indexing" || refusal === "runtime_unavailable" ? undefined : refusal
|
||||
}
|
||||
Reference in New Issue
Block a user