This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# knowledge
|
||||
|
||||
The retrieval module: named collections of the app's documents, an idempotent document-AI
|
||||
enablement flow (`knowledge.settings.enableDocumentAi`), durable index/remove jobs, and cited
|
||||
search over the indexed chunks.
|
||||
|
||||
## Search backends
|
||||
|
||||
The worker binds one of two `KnowledgeSearchService` adapters per deployment
|
||||
(`KNOWLEDGE_BACKEND`, see `apps/worker/src/ports.ts`):
|
||||
|
||||
- `vectors` (default): chunk locally, embed through the AI gateway, store in the bound vector
|
||||
store (`KNOWLEDGE_VECTOR_STORE`): Qdrant when a service is linked (`QDRANT_URL`) — the enablement
|
||||
flow links a managed one — or pgvector over the app's own Postgres when chosen explicitly
|
||||
(`KNOWLEDGE_VECTOR_STORE=pgvector`, on a Postgres that has the extension; the platform's default
|
||||
managed Postgres image does not). With neither, the module binds an unavailable search service:
|
||||
readiness answers `unavailable` with the reason, an index job fails its row with
|
||||
`vector_store_unavailable`, and the migrate job creates no vector table, so a fresh app deploys on
|
||||
any Postgres.
|
||||
- `platform`: the platform's managed documents-index service through the kit-platform `Knowledge`
|
||||
client. The service's `document_ref` is the object's pathname inside the project's Blob prefix
|
||||
and `options.content_sha256` is required. The adapter hashes the job's bytes, resolves that
|
||||
pathname through the files module's scoped storage reference, and maps returned citations back
|
||||
through the same project scope. Foreign or stale references are omitted; public file views
|
||||
still do not expose storage keys.
|
||||
|
||||
## Why the module is Postgres-only (no Convex profile yet)
|
||||
|
||||
`module.convex.ts` ships a fail-closed Convex binding for a future wiring, but the module is
|
||||
**not** registered in the Convex profile (`convex/lib/runtime.ts`), and the manifest stays
|
||||
`profiles: ["postgres"]`. The integration review (2026-10-23) kept it Postgres-only because the
|
||||
module genuinely cannot run on the Convex runtime today:
|
||||
|
||||
1. `knowledge.search` is an **effect** procedure — every backend leaves the process (the managed
|
||||
service over HTTP, or embeddings through the AI gateway plus a vector store). Convex
|
||||
queries/mutations cannot perform network I/O; effects need an explicit action composition like
|
||||
the files module's action→mutation workflows, and none exists for search.
|
||||
2. The index/remove **jobs** must run where the platform SDKs can leave the process. The Convex
|
||||
binding fails closed (`… must run through the Convex Node action boundary`); unlike documents'
|
||||
`documentsActions:process`, no composed Node action exists for these jobs yet, so every
|
||||
indexing request on the Convex profile would dispatch straight into the fence.
|
||||
3. `knowledge.settings.enableDocumentAi` is a `write` whose use case performs network I/O
|
||||
(eligibility, quota, the Qdrant probe/link) — impossible inside a Convex mutation; it needs
|
||||
its own action composition.
|
||||
4. `knowledge.status` resolves the effect search port for the readiness snapshot, which the
|
||||
Convex container does not bind.
|
||||
|
||||
Wiring the profile means composing those Node actions first (mirror `convex/documentsActions.ts`);
|
||||
registering the module before then would ship a broken search, a broken enablement flow and
|
||||
always-failing jobs, not a fail-closed gate.
|
||||
@@ -0,0 +1,33 @@
|
||||
// The chunker: paragraph packing, hard splits of over-long paragraphs, positions a citation can
|
||||
// point at. Pure paths — no store, no model.
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { CHUNK_SIZE, chunkText } from "../core/chunk.js"
|
||||
|
||||
describe("chunkText", () => {
|
||||
it("keeps a short document as one chunk", () => {
|
||||
const chunks = chunkText("One paragraph.\n\nTwo paragraphs.")
|
||||
expect(chunks).toHaveLength(1)
|
||||
expect(chunks[0]?.position).toBe(1)
|
||||
expect(chunks[0]?.text).toContain("Two paragraphs.")
|
||||
})
|
||||
|
||||
it("splits on paragraph boundaries when the pack overflows the size", () => {
|
||||
const paragraph = "word ".repeat(500) // 2500 characters
|
||||
const chunks = chunkText(`${paragraph.trim()}\n\n${paragraph.trim()}`)
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(2)
|
||||
for (const chunk of chunks) expect(chunk.text.length).toBeLessThanOrEqual(CHUNK_SIZE)
|
||||
expect(chunks.map((chunk) => chunk.position)).toEqual(chunks.map((_, at) => at + 1))
|
||||
})
|
||||
|
||||
it("hard-splits a paragraph longer than the size", () => {
|
||||
const long = "x".repeat(CHUNK_SIZE * 2 + 100)
|
||||
const chunks = chunkText(long)
|
||||
expect(chunks.length).toBeGreaterThan(2)
|
||||
expect(chunks.every((chunk) => chunk.text.length <= CHUNK_SIZE)).toBe(true)
|
||||
})
|
||||
|
||||
it("refuses an empty document with a named field", () => {
|
||||
expect(() => chunkText("")).toThrow(/nothing to index/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
// The document requests and the index job's orchestration: idempotent (collection, file) pairs,
|
||||
// the flag gate, the content-type and size admission, the row's progress states, and which
|
||||
// failures are permanent.
|
||||
|
||||
import type { TenantScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { DEFAULT_FILES_POLICY, files } from "@conusai-kit/files"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { collections, documents, KNOWLEDGE_ENTITIES } from "../contract/index.js"
|
||||
import { listDocuments, requestIndex, requestRemove } from "../core/documents.js"
|
||||
import { indexDocument, removeDocument } from "../core/index-job.js"
|
||||
import type { KnowledgeDeps } from "../core/ports.js"
|
||||
|
||||
const USER = "01900000-0000-7000-0000-000000000009"
|
||||
const scope: TenantScope = {
|
||||
level: "project",
|
||||
actor: { userId: USER },
|
||||
orgId: "01900000-0000-7000-0000-000000000001",
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
|
||||
const fileRow = {
|
||||
status: "stored" as const,
|
||||
storageKey: "k/seed",
|
||||
filename: "a.txt",
|
||||
contentType: "text/plain",
|
||||
size: 12,
|
||||
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
uploadedBy: USER,
|
||||
storedAt: "2026-10-23T00:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: null,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
}
|
||||
|
||||
const makeDeps = (options: { flag?: boolean; search?: KnowledgeDeps["search"] } = {}) => {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES, files] })
|
||||
const store = fakeObjectStore()
|
||||
const search = options.search ?? {
|
||||
readiness: async () => ({ state: "ready" as const, reasons: [] }),
|
||||
index: async () => undefined,
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
}
|
||||
const deps: KnowledgeDeps = {
|
||||
data,
|
||||
files: { data, store, policy: DEFAULT_FILES_POLICY },
|
||||
search,
|
||||
flags: { enabled: async () => options.flag ?? true },
|
||||
}
|
||||
void deps
|
||||
return { data, deps, store }
|
||||
}
|
||||
|
||||
const seed = async (data: ReturnType<typeof createMemoryDataSource>, contentType = "text/plain") => {
|
||||
await data.forScope(scope).of(collections).insert({ name: "contracts", createdBy: USER })
|
||||
const row = await data
|
||||
.forScope(scope)
|
||||
.of(files)
|
||||
.insert({ ...fileRow, ...(contentType === "text/plain" ? {} : { contentType }) })
|
||||
return row.id
|
||||
}
|
||||
|
||||
/** Seeds the file row AND the object the row names, the way a completed upload leaves both. */
|
||||
const seedWithBytes = async (
|
||||
data: ReturnType<typeof createMemoryDataSource>,
|
||||
store: ReturnType<typeof fakeObjectStore>,
|
||||
bytes: Uint8Array,
|
||||
) => {
|
||||
const fileId = await seed(data)
|
||||
await store.put(scope, { key: fileRow.storageKey, body: bytes, contentType: "text/plain" })
|
||||
return fileId
|
||||
}
|
||||
|
||||
describe("requesting a document's indexing", () => {
|
||||
it("creates the row, publishes the event, and a repeat request changes nothing", async () => {
|
||||
const { data, deps } = makeDeps()
|
||||
const fileId = await seed(data)
|
||||
const first = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
expect(first.status).toBe("queued")
|
||||
const second = await requestIndex(deps, scope, { collectionName: "contracts", fileId: first.fileId })
|
||||
expect(second.documentId).toBe(first.documentId)
|
||||
expect(await listDocuments(deps, scope, "contracts")).toEqual({
|
||||
documents: [expect.objectContaining({ documentId: first.documentId, status: "queued" })],
|
||||
})
|
||||
// One outbox event: the subscriber's enqueue is the exactly-once path.
|
||||
const outbox = data.outbox.pending(10)
|
||||
expect((await outbox).filter((record) => record.name === "knowledge.documentIndexRequested")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("re-queues an indexed pair instead of duplicating it", async () => {
|
||||
const { data, deps } = makeDeps()
|
||||
const fileId = await seed(data)
|
||||
const first = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
await data.forScope(scope).of(documents).update(first.documentId, { status: "indexed" }, 1)
|
||||
const again = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
expect(again.documentId).toBe(first.documentId)
|
||||
expect(again.status).toBe("queued")
|
||||
})
|
||||
|
||||
it("refuses when the flag is off, and admits text files only, within the size cap", async () => {
|
||||
const { data, deps } = makeDeps({ flag: false })
|
||||
const fileId = await seed(data)
|
||||
const off = await requestIndex(deps, scope, { collectionName: "contracts", fileId }).catch((e: unknown) => e)
|
||||
expect((off as { status?: number }).status).toBe(403)
|
||||
|
||||
const refusing = makeDeps()
|
||||
const pdfFile = await seed(refusing.data, "application/pdf")
|
||||
const pdf = await requestIndex(refusing.deps, scope, { collectionName: "contracts", fileId: pdfFile }).catch(
|
||||
(e: unknown) => e,
|
||||
)
|
||||
expect((pdf as { status?: number }).status).toBe(422)
|
||||
|
||||
const big = makeDeps()
|
||||
const bigFile = await seed(big.data)
|
||||
await big.data.forScope(scope).of(files).update(bigFile, { size: 2_000_000 }, 1)
|
||||
const tooBig = await requestIndex(big.deps, scope, { collectionName: "contracts", fileId: bigFile }).catch(
|
||||
(e: unknown) => e,
|
||||
)
|
||||
expect((tooBig as { status?: number }).status).toBe(422)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the index job's orchestration", () => {
|
||||
it("moves the row queued -> indexing -> indexed and calls the search service once", async () => {
|
||||
const indexed: string[] = []
|
||||
const { data, deps, store } = makeDeps({
|
||||
search: {
|
||||
readiness: async () => ({ state: "ready", reasons: [] }),
|
||||
index: async (_scope, input) => {
|
||||
indexed.push(input.fileId)
|
||||
},
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
},
|
||||
})
|
||||
const fileId = await seedWithBytes(data, store, new TextEncoder().encode("hello knowledge"))
|
||||
const requested = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
const done = await indexDocument(deps, scope, requested.documentId)
|
||||
expect(done.status).toBe("indexed")
|
||||
expect(indexed).toEqual([fileId])
|
||||
// A redelivered job finds the row indexed and does the external work again never.
|
||||
const again = await indexDocument(deps, scope, requested.documentId)
|
||||
expect(again.status).toBe("indexed")
|
||||
expect(indexed).toEqual([fileId])
|
||||
})
|
||||
|
||||
it("records a permanent failure on the row; a transient one throws for the queue to retry", async () => {
|
||||
const permanent = makeDeps({
|
||||
search: {
|
||||
readiness: async () => ({ state: "ready", reasons: [] }),
|
||||
index: async () => {
|
||||
throw Object.assign(new Error("not text"), { permanent: "not_text" })
|
||||
},
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
},
|
||||
})
|
||||
const permanentFile = await seedWithBytes(
|
||||
permanent.data,
|
||||
permanent.store,
|
||||
new TextEncoder().encode("not text at all"),
|
||||
)
|
||||
const requested = await requestIndex(permanent.deps, scope, { collectionName: "contracts", fileId: permanentFile })
|
||||
const failed = await indexDocument(permanent.deps, scope, requested.documentId)
|
||||
expect(failed.status).toBe("failed")
|
||||
expect(failed.error).toBe("not_text")
|
||||
|
||||
const transient = makeDeps({
|
||||
search: {
|
||||
readiness: async () => ({ state: "ready", reasons: [] }),
|
||||
index: async () => {
|
||||
throw new Error("the store hiccuped")
|
||||
},
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
},
|
||||
})
|
||||
const transientFile = await seedWithBytes(transient.data, transient.store, new TextEncoder().encode("hello"))
|
||||
const requested2 = await requestIndex(transient.deps, scope, { collectionName: "contracts", fileId: transientFile })
|
||||
await expect(indexDocument(transient.deps, scope, requested2.documentId)).rejects.toThrow("hiccuped")
|
||||
})
|
||||
|
||||
it("removal deletes the row and asks the service to drop the vectors", async () => {
|
||||
const removed: string[] = []
|
||||
const { data, deps } = makeDeps({
|
||||
search: {
|
||||
readiness: async () => ({ state: "ready", reasons: [] }),
|
||||
index: async () => undefined,
|
||||
search: async () => [],
|
||||
remove: async (_scope, input) => {
|
||||
removed.push(input.fileId)
|
||||
},
|
||||
},
|
||||
})
|
||||
const fileId = await seed(data)
|
||||
const requested = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
const cancelled = await requestRemove(deps, scope, { collectionName: "contracts", fileId })
|
||||
expect(cancelled.status).toBe("cancelled")
|
||||
// The remove job's body: delete the vectors, then the row.
|
||||
await removeDocument(deps, scope, requested.documentId)
|
||||
expect(removed).toEqual([fileId])
|
||||
expect((await listDocuments(deps, scope, "contracts")).documents).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
// The module's messages: en and lt declare the same keys, or a page in one language breaks.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { filesModule } from "@conusai-kit/files"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { knowledgeModule } from "../index.js"
|
||||
|
||||
describe("the knowledge module's messages", () => {
|
||||
const catalogues = buildRegistry([tenancyModule, filesModule, knowledgeModule]).catalogues
|
||||
|
||||
it("declares en and lt, and every en key resolves in lt", () => {
|
||||
const en = catalogues.en?.knowledge ?? {}
|
||||
const lt = catalogues.lt?.knowledge ?? {}
|
||||
const flatten = (tree: Record<string, unknown>, prefix = ""): string[] =>
|
||||
Object.entries(tree).flatMap(([key, value]) =>
|
||||
typeof value === "string" ? [`${prefix}${key}`] : flatten(value as Record<string, unknown>, `${prefix}${key}.`),
|
||||
)
|
||||
const enKeys = flatten(en)
|
||||
const ltKeys = new Set(flatten(lt))
|
||||
expect(enKeys.length).toBeGreaterThan(0)
|
||||
for (const key of enKeys) expect(ltKeys.has(key), `knowledge.${key} has no Lithuanian message`).toBe(true)
|
||||
})
|
||||
|
||||
it("formats a message with values in both locales", () => {
|
||||
const i18n = createI18n("lt", { lt: { knowledge: catalogues.lt?.knowledge ?? {} } })
|
||||
expect(i18n.message("knowledge.status.documents", { queued: 1, indexing: 2, indexed: 3, failed: 0 })).toContain("1")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module: rows it
|
||||
// seeds in every tenant, and one fixture per tenant-scoped procedure. A fixture is built from the
|
||||
// OTHER tenant's ids: the call is made as a member of organization A with organization B's slugs
|
||||
// and ids, and has to be refused or answered empty.
|
||||
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import { type IsolationFixture, type IsolationTenant, uploadViaProcedures } from "@conusai-kit/testing"
|
||||
|
||||
/** What a module's isolation file exports: the shape the suite reads. */
|
||||
export interface ModuleIsolation {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
}
|
||||
|
||||
const NOWHERE = "00000000-0000-0000-0000-000000000000"
|
||||
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
|
||||
const collectionOf = (t: IsolationTenant) => t.extra.knowledgeCollection ?? "contracts"
|
||||
const fileOf = (t: IsolationTenant) => t.extra.knowledgeFileId ?? NOWHERE
|
||||
|
||||
export const isolation: ModuleIsolation = {
|
||||
async seed(call, inProject) {
|
||||
const file = await uploadViaProcedures(call, inProject, {
|
||||
filename: "knowledge-seed.txt",
|
||||
contentType: "text/plain",
|
||||
bytes: new TextEncoder().encode("seed knowledge"),
|
||||
})
|
||||
// The seed needs a collection row to exist; collection.create needs the admin role, which the
|
||||
// isolation harness does not grant the seeding caller, so the fixture names a collection and
|
||||
// the write fixtures create it through the manage permission where a denial is the assertion.
|
||||
return { knowledgeCollection: "contracts", knowledgeFileId: file.fileId }
|
||||
},
|
||||
fixtures: {
|
||||
"knowledge.collection.list": { input: (t) => inProjectOf(t) },
|
||||
"knowledge.collection.create": { input: (t) => ({ ...inProjectOf(t), name: "intruders" }) },
|
||||
"knowledge.document.list": {
|
||||
input: (t) => ({ ...inProjectOf(t), collectionName: collectionOf(t) }),
|
||||
},
|
||||
"knowledge.document.index": {
|
||||
input: (t) => ({ ...inProjectOf(t), collectionName: collectionOf(t), fileId: fileOf(t) }),
|
||||
},
|
||||
"knowledge.document.remove": {
|
||||
input: (t) => ({ ...inProjectOf(t), collectionName: collectionOf(t), fileId: fileOf(t) }),
|
||||
},
|
||||
"knowledge.search": { input: (t) => ({ ...inProjectOf(t), collectionName: collectionOf(t), query: "seed" }) },
|
||||
"knowledge.status": { input: (t) => inProjectOf(t) },
|
||||
"knowledge.settings.get": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"knowledge.settings.enableDocumentAi": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"knowledge.settings.disableDocumentAi": { input: (t) => ({ org: t.orgSlug }) },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// The module's migration is its own file and exactly `schemaStatements()` over its three entities
|
||||
// (decision AT-D20: the entity definitions are the one source of a column). The module ships the
|
||||
// Postgres profile; the Convex binding is provided for the integration owner and has no SQL.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { schemaStatements } from "@conusai-kit/data-kysely"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { KNOWLEDGE_ENTITIES, knowledgeModule } from "../index.js"
|
||||
|
||||
/** The statements for these tables alone: `schemaStatements` leads with the kernel's own two. */
|
||||
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
|
||||
const kernel = schemaStatements([]).length
|
||||
return `${schemaStatements(entities)
|
||||
.slice(kernel)
|
||||
.map((statement) => `${statement};`)
|
||||
.join("\n\n")}\n`
|
||||
}
|
||||
|
||||
describe("the migration of the knowledge module", () => {
|
||||
it("is its own file, exactly its three tables", async () => {
|
||||
await expect(
|
||||
`-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0\n${tablesOf(KNOWLEDGE_ENTITIES)}`,
|
||||
).toMatchFileSnapshot("../migrations/0001_knowledge.sql")
|
||||
})
|
||||
|
||||
it("is declared by the manifest, so the migrate job finds it, and every table is row-level secured", () => {
|
||||
expect(knowledgeModule.migrations).toEqual({ postgres: "migrations" })
|
||||
expect(knowledgeModule.profiles).toEqual(["postgres"])
|
||||
const sql = readFileSync(new URL("../migrations/0001_knowledge.sql", import.meta.url), "utf8")
|
||||
for (const table of ["knowledge_collection", "knowledge_document", "knowledge_config"]) {
|
||||
expect(sql).toContain(`create table "${table}"`)
|
||||
expect(sql).toContain(`alter table "${table}" force row level security`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { DEFAULT_FILES_POLICY, type FilesDeps, files } from "@conusai-kit/files"
|
||||
import type { Knowledge, KnowledgeSearchHit } from "@conusai-kit/platform"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { platformKnowledgeSearch } from "../adapters/platform-knowledge.js"
|
||||
import type { KnowledgeScope } from "../core/ports.js"
|
||||
|
||||
const scope: KnowledgeScope = {
|
||||
level: "project",
|
||||
actor: { userId: "01900000-0000-7000-0000-000000000009" },
|
||||
orgId: "01900000-0000-7000-0000-000000000001",
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
const otherScope: KnowledgeScope = {
|
||||
...scope,
|
||||
projectId: "01900000-0000-7000-0000-000000000004",
|
||||
}
|
||||
const storageKey = `files/${scope.workspaceId}/${scope.projectId}/01900000-0000-7000-0000-000000000100`
|
||||
const otherStorageKey = `files/${otherScope.workspaceId}/${otherScope.projectId}/01900000-0000-7000-0000-000000000101`
|
||||
const pathname = `org/${scope.orgId}/${storageKey}`
|
||||
|
||||
interface ClientStub {
|
||||
indexed: string[]
|
||||
removed: string[]
|
||||
searches: Array<Parameters<Knowledge["search"]>[0]>
|
||||
results: readonly KnowledgeSearchHit[]
|
||||
}
|
||||
|
||||
function knowledgeClient(stub: ClientStub): Knowledge {
|
||||
return {
|
||||
readiness: async () => ({ state: "ready", reasons: [], checkedAt: new Date().toISOString() }),
|
||||
createCollection: async (input) => input,
|
||||
search: async (input) => {
|
||||
stub.searches.push(input)
|
||||
return stub.results
|
||||
},
|
||||
index: async (input) => {
|
||||
stub.indexed.push(input.documentRef)
|
||||
return { jobId: "job-1" }
|
||||
},
|
||||
job: async (jobId) => ({ jobId, status: "succeeded", progress: 1, generation: 1, error: null }),
|
||||
cancel: async () => undefined,
|
||||
remove: async (documentRef) => {
|
||||
stub.removed.push(documentRef)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stub(results: readonly KnowledgeSearchHit[] = []): ClientStub {
|
||||
return { indexed: [], removed: [], searches: [], results }
|
||||
}
|
||||
|
||||
function makeFiles() {
|
||||
const data = createMemoryDataSource({ entities: [files] })
|
||||
const deps: FilesDeps = { data, store: fakeObjectStore(), policy: DEFAULT_FILES_POLICY }
|
||||
return { data, deps }
|
||||
}
|
||||
|
||||
async function seedFile(
|
||||
data: ReturnType<typeof createMemoryDataSource>,
|
||||
atScope: KnowledgeScope,
|
||||
key: string,
|
||||
filename = "contract.txt",
|
||||
): Promise<string> {
|
||||
const row = await data.forScope(atScope).of(files).insert({
|
||||
status: "stored",
|
||||
storageKey: key,
|
||||
filename,
|
||||
contentType: "text/plain",
|
||||
size: 17,
|
||||
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
uploadedBy: atScope.actor.userId,
|
||||
storedAt: "2026-10-24T00:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: null,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
})
|
||||
return row.id
|
||||
}
|
||||
|
||||
describe("the managed knowledge Blob reference", () => {
|
||||
it("indexes and removes by Blob pathname rather than opaque file id", async () => {
|
||||
const { data, deps } = makeFiles()
|
||||
const fileId = await seedFile(data, scope, storageKey)
|
||||
const client = stub()
|
||||
const service = platformKnowledgeSearch(knowledgeClient(client), deps)
|
||||
|
||||
await service.index(scope, {
|
||||
collectionName: "contracts",
|
||||
fileId,
|
||||
filename: "contract.txt",
|
||||
bytes: new TextEncoder().encode("managed knowledge"),
|
||||
})
|
||||
await service.remove(scope, { collectionName: "contracts", fileId })
|
||||
|
||||
expect(client.indexed).toEqual([pathname])
|
||||
expect(client.removed).toEqual([pathname])
|
||||
})
|
||||
|
||||
it("maps citations through scoped file rows and drops foreign or stale references", async () => {
|
||||
const { data, deps } = makeFiles()
|
||||
const fileId = await seedFile(data, scope, storageKey)
|
||||
await seedFile(data, otherScope, otherStorageKey, "other-project.txt")
|
||||
const client = stub([
|
||||
{ documentRef: pathname, snippet: "own snippet", score: 0.9, citations: [{ page: 1 }] },
|
||||
{
|
||||
documentRef: `org/${scope.orgId}/${otherStorageKey}`,
|
||||
snippet: "other project snippet",
|
||||
score: 0.8,
|
||||
citations: [],
|
||||
},
|
||||
{
|
||||
documentRef: `org/01900000-0000-7000-0000-000000000777/${storageKey}`,
|
||||
snippet: "other organization snippet",
|
||||
score: 0.7,
|
||||
citations: [],
|
||||
},
|
||||
{
|
||||
documentRef: `org/${scope.orgId}/files/${scope.workspaceId}/${scope.projectId}/01900000-0000-7000-0000-000000000200`,
|
||||
snippet: "stale snippet",
|
||||
score: 0.6,
|
||||
citations: [],
|
||||
},
|
||||
])
|
||||
const service = platformKnowledgeSearch(knowledgeClient(client), deps)
|
||||
|
||||
const hits = await service.search(scope, {
|
||||
collectionName: "contracts",
|
||||
query: "contract",
|
||||
limit: 10,
|
||||
actorId: scope.actor.userId,
|
||||
})
|
||||
|
||||
expect(hits).toEqual([
|
||||
{
|
||||
fileId,
|
||||
filename: "contract.txt",
|
||||
collectionName: "contracts",
|
||||
score: 0.9,
|
||||
chunk: 1,
|
||||
snippet: "own snippet",
|
||||
},
|
||||
])
|
||||
expect(client.searches).toHaveLength(1)
|
||||
expect(client.searches[0]).toMatchObject({ collection: "contracts", query: "contract", limit: 10 })
|
||||
expect(client.searches[0]).not.toHaveProperty("tenant")
|
||||
expect(client.searches[0]).not.toHaveProperty("ownerRef")
|
||||
})
|
||||
|
||||
it("refuses a foreign file id before any managed index call", async () => {
|
||||
const { data, deps } = makeFiles()
|
||||
const foreignFileId = await seedFile(data, otherScope, otherStorageKey)
|
||||
const client = stub()
|
||||
const service = platformKnowledgeSearch(knowledgeClient(client), deps)
|
||||
|
||||
await expect(
|
||||
service.index(scope, {
|
||||
collectionName: "contracts",
|
||||
fileId: foreignFileId,
|
||||
filename: "other-project.txt",
|
||||
bytes: new TextEncoder().encode("foreign content"),
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
expect(client.indexed).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
// The enablement flow's semantics: idempotent enable, typed denials from the platform checks, the
|
||||
// operator endpoint's explicit validation, and disable that preserves every row and every vector.
|
||||
|
||||
import type { TenantScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { DEFAULT_FILES_POLICY } from "@conusai-kit/files"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { collections, documents, KNOWLEDGE_ENTITIES } from "../contract/index.js"
|
||||
import { createCollection } from "../core/collections.js"
|
||||
import type { DocumentAiPlatform } from "../core/ports.js"
|
||||
import { disableDocumentAi, enableDocumentAi, getSettings } from "../core/settings.js"
|
||||
|
||||
const ORG = "01900000-0000-7000-0000-000000000001"
|
||||
const scope: TenantScope = { level: "org", actor: { userId: "01900000-0000-7000-0000-000000000009" }, orgId: ORG }
|
||||
|
||||
const platform = (over: Partial<DocumentAiPlatform> = {}): DocumentAiPlatform => ({
|
||||
checkEligibility: async () => ({ ok: true }),
|
||||
indexingQuota: async () => 1000,
|
||||
linkManagedQdrant: async () => ({ serviceId: "svc-1", url: null }),
|
||||
probeQdrant: async () => ({ ok: true }),
|
||||
operatorQdrant: () => null,
|
||||
...over,
|
||||
})
|
||||
|
||||
const deps = (data: ReturnType<typeof createMemoryDataSource>, p: DocumentAiPlatform) => ({
|
||||
data,
|
||||
platform: p,
|
||||
now: () => Date.UTC(2026, 9, 23),
|
||||
})
|
||||
|
||||
describe("the enablement flow", () => {
|
||||
it("enables idempotently: the second call writes nothing and answers the same configuration", async () => {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES] })
|
||||
const first = await enableDocumentAi(deps(data, platform()), scope)
|
||||
expect(first).toEqual({ enabled: true, backend: "vectors", source: "managed", url: null })
|
||||
const second = await enableDocumentAi(deps(data, platform()), scope)
|
||||
expect(second).toEqual(first)
|
||||
expect((await getSettings(deps(data, platform()), scope)).enabled).toBe(true)
|
||||
})
|
||||
|
||||
it("surfaces the platform's denials as typed reason codes, retirable", async () => {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES] })
|
||||
const denied = await enableDocumentAi(
|
||||
deps(data, platform({ checkEligibility: async () => ({ ok: false, reason: "plan_required" }) })),
|
||||
scope,
|
||||
).catch((error: unknown) => error)
|
||||
expect((denied as { code?: string }).code).toBe("plan_required")
|
||||
// Nothing was persisted by the denied attempt; the retry path runs the same checks again.
|
||||
expect((await getSettings(deps(data, platform()), scope)).enabled).toBe(false)
|
||||
const quota = await enableDocumentAi(deps(data, platform({ indexingQuota: async () => 0 })), scope).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect((quota as { code?: string }).code).toBe("quota_exhausted")
|
||||
})
|
||||
|
||||
it("validates an operator-configured endpoint before it trusts it, and records it without the key", async () => {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES] })
|
||||
const p = platform({
|
||||
operatorQdrant: () => ({ url: "http://qdrant:6333", apiKey: "secret-key" }),
|
||||
probeQdrant: async () => ({ ok: false, detail: "refused" }),
|
||||
})
|
||||
const refused = await enableDocumentAi(deps(data, p), scope).catch((error: unknown) => error)
|
||||
expect((refused as { status?: number }).status).toBe(422)
|
||||
// A failed probe persists nothing.
|
||||
expect((await getSettings(deps(data, platform()), scope)).enabled).toBe(false)
|
||||
|
||||
const ok = platform({ operatorQdrant: () => ({ url: "http://qdrant:6333", apiKey: "secret-key" }) })
|
||||
const enabled = await enableDocumentAi(deps(data, ok), scope)
|
||||
expect(enabled.source).toBe("operator")
|
||||
expect(enabled.url).toBe("http://qdrant:6333")
|
||||
// The key never lands in the configuration row.
|
||||
const rows = await data
|
||||
.forScope(scope)
|
||||
.of((await import("../contract/index.js")).config)
|
||||
.list({ limit: 1 })
|
||||
expect(JSON.stringify(rows.items[0])).not.toContain("secret-key")
|
||||
})
|
||||
|
||||
it("disable preserves collections, documents and the recorded configuration; re-enable resumes without duplication", async () => {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES] })
|
||||
const projectScope: TenantScope = {
|
||||
level: "project",
|
||||
actor: { userId: "01900000-0000-7000-0000-000000000009" },
|
||||
orgId: ORG,
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
const knowledgeDepsForProject = {
|
||||
data,
|
||||
files: { data, store: fakeObjectStore(), policy: DEFAULT_FILES_POLICY },
|
||||
flags: { enabled: async () => true },
|
||||
}
|
||||
await enableDocumentAi(deps(data, platform()), scope)
|
||||
const collection = await createCollection(knowledgeDepsForProject, projectScope as never, "contracts")
|
||||
expect(collection.name).toBe("contracts")
|
||||
// Seed a document row directly (the upload half is the files module's own suite).
|
||||
await data.forScope(projectScope).of(documents).insert({
|
||||
collectionName: "contracts",
|
||||
fileId: "01900000-0000-7000-0000-000000000099",
|
||||
filename: "a.txt",
|
||||
status: "indexed",
|
||||
error: null,
|
||||
indexedAt: "2026-10-23T00:00:00.000Z",
|
||||
requestedBy: "01900000-0000-7000-0000-000000000009",
|
||||
})
|
||||
|
||||
const disabled = await disableDocumentAi(deps(data, platform()), scope)
|
||||
expect(disabled.enabled).toBe(false)
|
||||
// Everything is still there.
|
||||
expect((await getSettings(deps(data, platform()), scope)).backend).toBe("vectors")
|
||||
const collectionsAfter = await data
|
||||
.forScope(projectScope)
|
||||
.of(collections)
|
||||
.list({ where: [{ field: "name", op: "eq", value: "contracts" }], limit: 1 })
|
||||
expect(collectionsAfter.items).toHaveLength(1)
|
||||
const documentsAfter = await data
|
||||
.forScope(projectScope)
|
||||
.of(documents)
|
||||
.list({ where: [{ field: "collectionName", op: "eq", value: "contracts" }], limit: 1 })
|
||||
expect(documentsAfter.items[0]?.status).toBe("indexed")
|
||||
|
||||
// Re-enable: idempotent, same row, no second collection.
|
||||
await enableDocumentAi(deps(data, platform()), scope)
|
||||
const again = await data
|
||||
.forScope(projectScope)
|
||||
.of(collections)
|
||||
.list({ where: [{ field: "name", op: "eq", value: "contracts" }], limit: 1 })
|
||||
expect(again.items).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
// The components' markup, rendered on the server the way a page renders it: what a screen reader
|
||||
// and a keyboard user get is in the HTML — a name for every control, a status that says what a
|
||||
// colour would, and the AI disclosure where AI output appears.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { TransportProvider } from "@conusai-kit/ui"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { searchFormLabels } from "../ui/labels.js"
|
||||
import { SearchForm } from "../ui/search-form.js"
|
||||
import { SettingsSection } from "../ui/settings-section.js"
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const i18n = createI18n("en", { en: { knowledge: catalogues.en?.knowledge ?? {} } })
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`knowledge.${key}`, values)
|
||||
const failed = "That did not work: {detail}"
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
|
||||
const inProvider = (node: React.ReactNode) =>
|
||||
renderToStaticMarkup(
|
||||
<TransportProvider call={async () => undefined} describe={() => "x"}>
|
||||
{node}
|
||||
</TransportProvider>,
|
||||
)
|
||||
|
||||
describe("the search form", () => {
|
||||
const html = inProvider(<SearchForm slugs={slugs} collectionName="contracts" labels={searchFormLabels(t, failed)} />)
|
||||
|
||||
it("names its controls and carries the AI disclosure", () => {
|
||||
expect(html).toContain(`<label for=`)
|
||||
expect(html).toContain('type="search"')
|
||||
expect(html).toContain("kit-ai-disclosure")
|
||||
expect(html).toContain(t("search.disclosure"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("the settings section", () => {
|
||||
const labels = {
|
||||
heading: t("settings.heading"),
|
||||
state: t("settings.state"),
|
||||
stateOn: t("settings.stateOn"),
|
||||
stateOff: t("settings.stateOff"),
|
||||
backend: t("settings.backend"),
|
||||
backendVectors: t("settings.backendVectors"),
|
||||
source: t("settings.source"),
|
||||
sourceOperator: t("settings.sourceOperator"),
|
||||
sourceManaged: t("settings.sourceManaged"),
|
||||
endpoint: t("settings.endpoint"),
|
||||
enable: t("settings.enable"),
|
||||
enabling: t("settings.enabling"),
|
||||
disable: t("settings.disable"),
|
||||
disabling: t("settings.disabling"),
|
||||
enabledDone: t("settings.enabledDone"),
|
||||
disabledDone: t("settings.disabledDone"),
|
||||
planRequired: t("settings.planRequired"),
|
||||
featureDisabled: t("settings.featureDisabled"),
|
||||
quotaExhausted: t("settings.quotaExhausted"),
|
||||
upgrade: t("settings.upgrade"),
|
||||
hint: t("settings.hint"),
|
||||
failed,
|
||||
}
|
||||
|
||||
it("renders the state and the enable action when disabled", () => {
|
||||
const html = inProvider(
|
||||
<SettingsSection
|
||||
org="acme"
|
||||
initial={{ enabled: false, backend: null, source: null, url: null }}
|
||||
labels={labels}
|
||||
/>,
|
||||
)
|
||||
expect(html).toContain(t("settings.stateOff"))
|
||||
expect(html).toContain(`>${t("settings.enable")}<`)
|
||||
})
|
||||
|
||||
it("renders the endpoint and the disable action when enabled, without any secret in the markup", () => {
|
||||
const html = inProvider(
|
||||
<SettingsSection
|
||||
org="acme"
|
||||
initial={{ enabled: true, backend: "vectors", source: "operator", url: "http://qdrant:6333" }}
|
||||
labels={labels}
|
||||
/>,
|
||||
)
|
||||
expect(html).toContain("http://qdrant:6333")
|
||||
expect(html).toContain(`>${t("settings.disable")}<`)
|
||||
expect(html).not.toContain("apiKey")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
// The search service of a deployment with no vector store (no Qdrant linked, pgvector not chosen):
|
||||
// readiness and the status page say `unavailable` with the actionable reason, an index job fails its
|
||||
// row with the stable `vector_store_unavailable` code (permanent: the queue does not retry it), and a
|
||||
// search or removal is a typed 503, never an empty answer that reads as "nothing indexed".
|
||||
|
||||
import { AppError, type TenantScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { DEFAULT_FILES_POLICY, files } from "@conusai-kit/files"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { unavailableKnowledgeSearch } from "../adapters/unavailable.js"
|
||||
import { collections, KNOWLEDGE_ENTITIES } from "../contract/index.js"
|
||||
import { requestIndex } from "../core/documents.js"
|
||||
import { indexDocument } from "../core/index-job.js"
|
||||
import type { KnowledgeDeps } from "../core/ports.js"
|
||||
import { knowledgeStatusOf } from "../core/status.js"
|
||||
|
||||
const USER = "01900000-0000-7000-0000-000000000009"
|
||||
const scope: TenantScope & { workspaceId: string; projectId: string } = {
|
||||
level: "project",
|
||||
actor: { userId: USER },
|
||||
orgId: "01900000-0000-7000-0000-000000000001",
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
|
||||
const REASON = "no vector store is configured: link a Qdrant service"
|
||||
|
||||
const setup = () => {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES, files] })
|
||||
const store = fakeObjectStore()
|
||||
const deps: KnowledgeDeps = {
|
||||
data,
|
||||
files: { data, store, policy: DEFAULT_FILES_POLICY },
|
||||
search: unavailableKnowledgeSearch(REASON),
|
||||
flags: { enabled: async () => true },
|
||||
}
|
||||
return { data, deps, store }
|
||||
}
|
||||
|
||||
describe("a deployment with no vector store", () => {
|
||||
it("reports unavailable with the reason, on the port and on the status page", async () => {
|
||||
const { deps } = setup()
|
||||
expect(await deps.search?.readiness(scope)).toEqual({ state: "unavailable", reasons: [REASON] })
|
||||
const status = await knowledgeStatusOf(deps, scope)
|
||||
expect(status.service).toEqual({ state: "unavailable", reasons: [REASON] })
|
||||
})
|
||||
|
||||
it("fails an index job permanently with vector_store_unavailable", async () => {
|
||||
const { data, deps, store } = setup()
|
||||
await data.forScope(scope).of(collections).insert({ name: "contracts", createdBy: USER })
|
||||
const file = await data.forScope(scope).of(files).insert({
|
||||
status: "stored",
|
||||
storageKey: "k/seed",
|
||||
filename: "a.txt",
|
||||
contentType: "text/plain",
|
||||
size: 5,
|
||||
sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
uploadedBy: USER,
|
||||
storedAt: "2026-10-25T00:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: null,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
})
|
||||
await store.put(scope, { key: "k/seed", body: new TextEncoder().encode("hello"), contentType: "text/plain" })
|
||||
const requested = await requestIndex(deps, scope, { collectionName: "contracts", fileId: file.id })
|
||||
const failed = await indexDocument(deps, scope, requested.documentId)
|
||||
expect(failed.status).toBe("failed")
|
||||
expect(failed.error).toBe("vector_store_unavailable")
|
||||
})
|
||||
|
||||
it("refuses search and removal with a typed 503 that names the reason", async () => {
|
||||
const service = unavailableKnowledgeSearch(REASON)
|
||||
for (const call of [
|
||||
() => service.search(scope, { collectionName: "contracts", query: "q", limit: 5, actorId: USER }),
|
||||
() => service.remove(scope, { collectionName: "contracts", fileId: "01900000-0000-7000-0000-0000000000aa" }),
|
||||
]) {
|
||||
const error = await call().then(
|
||||
() => null,
|
||||
(thrown: unknown) => thrown,
|
||||
)
|
||||
expect(error).toBeInstanceOf(AppError)
|
||||
expect((error as AppError).status).toBe(503)
|
||||
expect((error as AppError).code).toBe("runtime_unavailable")
|
||||
expect((error as AppError).problem.detail).toBe(REASON)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
// The vector contract suite against the store shape this module binds in tests: a conforming
|
||||
// in-memory VectorStore that keeps the tenant wall the real adapters guarantee (org filter on
|
||||
// every search and delete). The suite is the kit's own two-tenant contract; running it here pins
|
||||
// the module's metadata conventions (collection filters, subject deletion) to what every adapter
|
||||
// must provide, so a convention drift fails in this module's unit suite.
|
||||
|
||||
import { newId, type TenantScope } from "@conusai-kit/core"
|
||||
import type { VectorMatch, VectorQuery, VectorRecord, VectorStore, VectorSubject } from "@conusai-kit/data"
|
||||
import { runVectorStoreContract } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
/** The in-memory stand-in with the tenant wall the adapters implement. */
|
||||
function fakeVectorStore(): { store: VectorStore; failures: () => unknown[] } {
|
||||
const rows = new Map<string, { scope: string; record: VectorRecord }>()
|
||||
const keyOf = (orgId: string, id: string) => `${orgId}:${id}`
|
||||
const failures: unknown[] = []
|
||||
const cosine = (a: readonly number[], b: readonly number[]) => {
|
||||
let dot = 0
|
||||
let na = 0
|
||||
let nb = 0
|
||||
for (const [index, value] of a.entries()) {
|
||||
dot += value * (b[index] ?? 0)
|
||||
na += value * value
|
||||
nb += (b[index] ?? 0) ** 2
|
||||
}
|
||||
return na === 0 || nb === 0 ? 0 : dot / Math.sqrt(na * nb)
|
||||
}
|
||||
return {
|
||||
failures: () => failures,
|
||||
store: {
|
||||
async upsert(scope: TenantScope, records: readonly VectorRecord[]) {
|
||||
for (const record of records) rows.set(keyOf(scope.orgId, record.id), { scope: scope.orgId, record })
|
||||
},
|
||||
async search(scope: TenantScope, query: VectorQuery): Promise<readonly VectorMatch[]> {
|
||||
const out: VectorMatch[] = []
|
||||
for (const row of rows.values()) {
|
||||
if (row.scope !== scope.orgId) continue
|
||||
const metadata = row.record.metadata ?? {}
|
||||
const filter = query.filter ?? {}
|
||||
if (!Object.entries(filter).every(([key, value]) => metadata[key] === value)) continue
|
||||
out.push({
|
||||
id: row.record.id,
|
||||
score: cosine(query.vector, row.record.vector),
|
||||
subject: row.record.subject,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => b.score - a.score).slice(0, query.limit ?? 10)
|
||||
},
|
||||
async deleteBySubject(scope: TenantScope, subject: VectorSubject) {
|
||||
for (const [key, row] of rows.entries()) {
|
||||
if (
|
||||
row.scope === scope.orgId &&
|
||||
row.record.subject.type === subject.type &&
|
||||
row.record.subject.id === subject.id
|
||||
) {
|
||||
rows.delete(key)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const dimensions = 4
|
||||
|
||||
describe("the vector store the knowledge module relies on", () => {
|
||||
it("passes the kit's two-tenant vector contract", async () => {
|
||||
const { store } = fakeVectorStore()
|
||||
const result = await runVectorStoreContract(async () => ({ store, dimensions }))
|
||||
expect(result.failures.map((failure) => `${failure.check}: ${failure.error}`)).toEqual([])
|
||||
expect(result.passed.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// The collection-filter convention this module writes into every chunk's metadata: a search names
|
||||
// the collection and the wall holds. Pinning it here keeps the adapters' payload indexes useful.
|
||||
describe("the module's metadata conventions", () => {
|
||||
it("stores collection, filename, chunk and snippet so a cited result can be rendered", () => {
|
||||
const record: VectorRecord = {
|
||||
id: newId(),
|
||||
vector: [1, 0, 0, 0],
|
||||
subject: { type: "knowledge_document", id: newId() },
|
||||
metadata: { collection: "contracts", filename: "a.txt", chunk: 2, snippet: "passage" },
|
||||
}
|
||||
expect(Object.keys(record.metadata ?? {}).sort()).toEqual(["chunk", "collection", "filename", "snippet"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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". The
|
||||
// managed Qdrant link goes through the providers API (the workspace services surface): an existing
|
||||
// link is reused, so re-running the enablement flow never provisions a second service. 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 { ConusAIClient, type CreateServiceRequest, type ServiceInfo } from "@conusai-sdk/node-sdk"
|
||||
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
|
||||
/** The organization's slug and the workspace the app links services into. */
|
||||
orgSlug: string
|
||||
workspaceSlug: string
|
||||
/** The deployment environment, for the operator-configured Qdrant variables. */
|
||||
env?: Record<string, string | undefined>
|
||||
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
|
||||
},
|
||||
|
||||
async linkManagedQdrant() {
|
||||
const client = new ConusAIClient({ baseUrl: options.apiUrl, apiKey: options.token })
|
||||
const path = { org_slug: options.orgSlug, workspace_slug: options.workspaceSlug }
|
||||
const existing = await client.listServices({ path })
|
||||
const services = (existing.data ?? []) as ServiceInfo[]
|
||||
const reused = services.find((service) => service.service_type === "qdrant" && service.status !== "deleted")
|
||||
if (reused) return { serviceId: String(reused.id), url: null }
|
||||
const body: CreateServiceRequest = { name: "knowledge-vectors", service_type: "qdrant", parameters: {} }
|
||||
const created = await client.createService({ path, body })
|
||||
const status = created.response?.status ?? 0
|
||||
if (status === 403) {
|
||||
throw Object.assign(new Error("the plan does not allow a managed Qdrant service"), {
|
||||
reason: "plan_required" as const,
|
||||
})
|
||||
}
|
||||
if (status !== 201 || !created.data) {
|
||||
throw new Error(`the providers API answered ${status} for the Qdrant link`)
|
||||
}
|
||||
return { serviceId: String(created.data.id), url: null }
|
||||
},
|
||||
|
||||
async probeQdrant(url, apiKey) {
|
||||
try {
|
||||
const response = await http(new URL("/collections", url), {
|
||||
headers: { "api-key": apiKey },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (response.ok) return { ok: true }
|
||||
return { ok: false, detail: `the endpoint answered ${response.status}` }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
detail:
|
||||
error instanceof Error ? (error.message.split("\n")[0] ?? error.message) : "the endpoint did not answer",
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
operatorQdrant() {
|
||||
// The operator-configured endpoint: exactly the variables a linked Qdrant service injects,
|
||||
// set by hand in the deployment instead. The key is validated by probing; it is never
|
||||
// persisted — only the endpoint is recorded.
|
||||
const env = options.env ?? process.env
|
||||
const url = env.QDRANT_URL?.trim()
|
||||
const apiKey = env.QDRANT_API_KEY?.trim()
|
||||
if (!url || !apiKey) return null
|
||||
return { url, apiKey }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// 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 tenant comes from the container, never from the payload (decision AT-F6).
|
||||
|
||||
import { type EventContext, internal, type JobDef, subscribe } from "@conusai-kit/core"
|
||||
import { JOB_QUEUE } from "@conusai-kit/data"
|
||||
import { z } from "zod"
|
||||
import { documentIndexRequested, documentRemoveRequested, INDEX_JOB, REMOVE_JOB } from "../contract/index.js"
|
||||
import { indexDocument, removeDocument } from "../core/index-job.js"
|
||||
import { KNOWLEDGE_SCOPE, type KnowledgeScope } from "../core/ports.js"
|
||||
import { knowledgeSearchDeps } from "./procedures.js"
|
||||
|
||||
export const documentPayload = z.object({
|
||||
documentId: z.string(),
|
||||
collectionName: z.string(),
|
||||
fileId: z.string(),
|
||||
})
|
||||
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}`
|
||||
|
||||
function projectScopeOf(ctx: EventContext, payload: { workspaceId: string; projectId: string }): KnowledgeScope {
|
||||
const ambient = ctx.container.resolve(KNOWLEDGE_SCOPE)
|
||||
if (ambient.level !== "project") throw internal("a knowledge event resolves to a project scope")
|
||||
return { ...ambient, 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), scope, payload.documentId, {
|
||||
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
async function onDocumentIndexRequested(
|
||||
event: {
|
||||
name: string
|
||||
payload: { workspaceId: string; projectId: string; documentId: string; collectionName: string; fileId: string }
|
||||
},
|
||||
ctx: EventContext,
|
||||
): Promise<void> {
|
||||
const scope = 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: { workspaceId: string; projectId: string; documentId: string; collectionName: string; fileId: string }
|
||||
},
|
||||
ctx: EventContext,
|
||||
): Promise<void> {
|
||||
const scope = 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,
|
||||
},
|
||||
{
|
||||
key: removeJobKey(event.payload.collectionName, event.payload.fileId),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export const KNOWLEDGE_JOBS = { [INDEX_JOB]: indexJob, [REMOVE_JOB]: removeJob }
|
||||
export const KNOWLEDGE_SUBSCRIPTIONS = [
|
||||
subscribe(documentIndexRequested, onDocumentIndexRequested),
|
||||
subscribe(documentRemoveRequested, onDocumentRemoveRequested),
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
// 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 {
|
||||
type FilesDeps,
|
||||
findFileStorageByKey,
|
||||
getFileStorage,
|
||||
platformBlobPathname,
|
||||
platformBlobStorageKey,
|
||||
} from "@conusai-kit/files"
|
||||
import { type Knowledge, KnowledgeError } from "@conusai-kit/platform"
|
||||
import type { KnowledgeHit, KnowledgeScope, KnowledgeSearchService } from "../core/ports.js"
|
||||
|
||||
export function platformKnowledgeSearch(client: Knowledge, files: FilesDeps): KnowledgeSearchService {
|
||||
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,
|
||||
)
|
||||
}
|
||||
const documentRef = async (scope: KnowledgeScope, fileId: string): Promise<string> => {
|
||||
const reference = await getFileStorage(files, scope, fileId)
|
||||
return platformBlobPathname(scope, reference.storageKey)
|
||||
}
|
||||
|
||||
return {
|
||||
async readiness() {
|
||||
try {
|
||||
const answer = await client.readiness()
|
||||
return { state: answer.state, reasons: [...answer.reasons] }
|
||||
} catch (error) {
|
||||
return {
|
||||
state: "unavailable",
|
||||
reasons: [error instanceof Error ? (error.message.split("\n")[0] ?? error.message) : "not ready"],
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async index(scope, input) {
|
||||
try {
|
||||
const { jobId } = await client.index({
|
||||
collection: input.collectionName,
|
||||
documentRef: await documentRef(scope, input.fileId),
|
||||
options: {
|
||||
visibility: "public",
|
||||
contentSha256: createHash("sha256").update(input.bytes).digest("hex"),
|
||||
},
|
||||
})
|
||||
// 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 (;;) {
|
||||
const job = await client.job(jobId)
|
||||
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 new Promise((resolve) => setTimeout(resolve, 1_000))
|
||||
}
|
||||
} catch (error) {
|
||||
return unwrap(error)
|
||||
}
|
||||
},
|
||||
|
||||
async search(scope, input) {
|
||||
try {
|
||||
const results = await client.search({
|
||||
collection: input.collectionName,
|
||||
query: input.query,
|
||||
limit: input.limit,
|
||||
})
|
||||
const hits: KnowledgeHit[] = []
|
||||
for (const [at, hit] of results.entries()) {
|
||||
const storageKey = platformBlobStorageKey(scope, hit.documentRef)
|
||||
if (storageKey === null) continue
|
||||
const reference = await findFileStorageByKey(files, scope, storageKey)
|
||||
if (reference === null) continue
|
||||
hits.push({
|
||||
fileId: reference.file.fileId,
|
||||
filename: reference.file.filename,
|
||||
collectionName: input.collectionName,
|
||||
score: hit.score,
|
||||
chunk: at + 1,
|
||||
snippet: hit.snippet,
|
||||
})
|
||||
}
|
||||
return hits
|
||||
} catch (error) {
|
||||
return unwrap(error)
|
||||
}
|
||||
},
|
||||
|
||||
async remove(scope, input) {
|
||||
try {
|
||||
await client.remove(await documentRef(scope, input.fileId))
|
||||
} catch (error) {
|
||||
return unwrap(error)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 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 {
|
||||
collectionCreate,
|
||||
collectionList,
|
||||
documentIndex,
|
||||
documentList,
|
||||
documentRemove,
|
||||
knowledgeSearch,
|
||||
knowledgeStatus,
|
||||
settingsDisableDocumentAi,
|
||||
settingsEnableDocumentAi,
|
||||
settingsGet,
|
||||
} from "../contract/index.js"
|
||||
import { createCollection, listCollections } from "../core/collections.js"
|
||||
import { listDocuments, requestIndex, requestRemove } from "../core/documents.js"
|
||||
import { flagsOf, KNOWLEDGE_ENABLEMENT, 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 { knowledgeStatusOf } from "../core/status.js"
|
||||
|
||||
/** The ports a write or read handler needs. Never the search service: that port is an effect, and
|
||||
* resolving it from a write is a kind violation. */
|
||||
export function knowledgeDeps(container: Container): KnowledgeDeps {
|
||||
return {
|
||||
data: container.resolve(DATA_SOURCE),
|
||||
files: filesDeps(container),
|
||||
flags: flagsOf(container),
|
||||
}
|
||||
}
|
||||
|
||||
/** The effect handler's deps: the search service, bound in the processes that may leave the process. */
|
||||
export function knowledgeSearchDeps(container: Container): KnowledgeDeps {
|
||||
return { ...knowledgeDeps(container), search: container.resolve(KNOWLEDGE_SEARCH) }
|
||||
}
|
||||
|
||||
function settingsDeps(container: Container): SettingsDeps {
|
||||
return { data: container.resolve(DATA_SOURCE), platform: container.resolve(KNOWLEDGE_ENABLEMENT) }
|
||||
}
|
||||
|
||||
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 documentRemoveImpl = implement(documentRemove, ({ input, ctx }) =>
|
||||
requestRemove(knowledgeDeps(ctx.container), ctx.scope, input),
|
||||
)
|
||||
|
||||
export const knowledgeSearchImpl = implement(knowledgeSearch, ({ input, ctx }) =>
|
||||
searchKnowledge(knowledgeSearchDeps(ctx.container), ctx.scope, {
|
||||
collectionName: input.collectionName,
|
||||
query: input.query,
|
||||
...(input.limit === undefined ? {} : { limit: input.limit }),
|
||||
}),
|
||||
)
|
||||
|
||||
export const knowledgeStatusImpl = implement(knowledgeStatus, ({ ctx }) =>
|
||||
knowledgeStatusOf(knowledgeSearchDeps(ctx.container), ctx.scope),
|
||||
)
|
||||
|
||||
export const settingsGetImpl = implement(settingsGet, ({ ctx }) => getSettings(settingsDeps(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 ?? "vectors" }
|
||||
})
|
||||
|
||||
export const settingsDisableDocumentAiImpl = implement(settingsDisableDocumentAi, ({ ctx }) =>
|
||||
disableDocumentAi(settingsDeps(ctx.container), ctx.scope),
|
||||
)
|
||||
|
||||
export const KNOWLEDGE_PROCEDURE_IMPLS = [
|
||||
collectionListImpl,
|
||||
collectionCreateImpl,
|
||||
documentIndexImpl,
|
||||
documentListImpl,
|
||||
documentRemoveImpl,
|
||||
knowledgeSearchImpl,
|
||||
knowledgeStatusImpl,
|
||||
settingsGetImpl,
|
||||
settingsEnableDocumentAiImpl,
|
||||
settingsDisableDocumentAiImpl,
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
// The search service of a deployment whose vectors backend has no vector store (no Qdrant linked,
|
||||
// no explicit pgvector choice). It is not a fake: it is the honest answer of such a deployment.
|
||||
// Readiness says `unavailable` with the reason a person can act on; an index job fails its row with
|
||||
// the stable `vector_store_unavailable` code instead of retrying forever; a search or a removal is
|
||||
// refused with a typed problem. Nothing is written anywhere, so choosing a store later (enabling
|
||||
// document AI links a managed Qdrant) starts from a clean state.
|
||||
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import type { KnowledgeSearchService } from "../core/ports.js"
|
||||
|
||||
function unavailable(reason: string): AppError {
|
||||
return new AppError({
|
||||
type: "/problems/runtime_unavailable",
|
||||
title: "The knowledge search service is unavailable",
|
||||
status: 503,
|
||||
code: "runtime_unavailable",
|
||||
detail: reason,
|
||||
})
|
||||
}
|
||||
|
||||
export function unavailableKnowledgeSearch(reason: string): KnowledgeSearchService {
|
||||
return {
|
||||
async readiness() {
|
||||
return { state: "unavailable", reasons: [reason] }
|
||||
},
|
||||
|
||||
async index() {
|
||||
// A permanent failure: the row records why, and a retry of the same job cannot succeed until
|
||||
// the deployment binds a store (a new deployment, which re-runs the job).
|
||||
throw Object.assign(new Error(reason), { permanent: "vector_store_unavailable" })
|
||||
},
|
||||
|
||||
async search() {
|
||||
throw unavailable(reason)
|
||||
},
|
||||
|
||||
async remove() {
|
||||
throw unavailable(reason)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// The local vector search service: the knowledge module's own backend. Chunks are embedded
|
||||
// through the AI gateway's embeddings endpoint and stored in the app's vector adapter (Qdrant when
|
||||
// a Qdrant service is linked, pgvector otherwise); a query is embedded the same way and searched
|
||||
// with the collection as a metadata filter, so the tenant wall is the adapter's job, never the
|
||||
// caller's. Snippets and chunk positions ride in the metadata and are what a citation shows.
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import { internal } from "@conusai-kit/core"
|
||||
import type { VectorStore } from "@conusai-kit/data"
|
||||
import { chunkText } from "../core/chunk.js"
|
||||
import type { KnowledgeEmbeddings, KnowledgeHit, KnowledgeScope, KnowledgeSearchService } from "../core/ports.js"
|
||||
|
||||
const SUBJECT = "knowledge_document"
|
||||
const SNIPPET = 300
|
||||
|
||||
export interface VectorKnowledgeSearchOptions {
|
||||
embeddings: KnowledgeEmbeddings
|
||||
store: VectorStore
|
||||
}
|
||||
|
||||
export function vectorKnowledgeSearch(options: VectorKnowledgeSearchOptions): KnowledgeSearchService {
|
||||
const { embeddings, store } = options
|
||||
|
||||
return {
|
||||
async readiness() {
|
||||
// The local backend is ready when it can answer a search; the embedding model is the one
|
||||
// external piece, and an embeddings call is too expensive for a health probe — so readiness
|
||||
// here is the store's own, and a misconfigured embedding model fails the first real call.
|
||||
return { state: "ready", reasons: [] }
|
||||
},
|
||||
|
||||
async index(scope, input) {
|
||||
const text = new TextDecoder("utf-8", { fatal: false }).decode(input.bytes)
|
||||
const chunks = chunkText(text)
|
||||
const vectors = await embeddings.embed(
|
||||
chunks.map((chunk) => chunk.text),
|
||||
{ inputType: "passage" },
|
||||
)
|
||||
if (vectors.length !== chunks.length) {
|
||||
throw internal(`the embeddings answer held ${vectors.length} vectors for ${chunks.length} chunks`)
|
||||
}
|
||||
const vectorAt = (at: number): readonly number[] => {
|
||||
const vector = vectors[at]
|
||||
if (vector === undefined) throw internal("the embeddings answer held no vector for a chunk")
|
||||
return vector
|
||||
}
|
||||
await store.upsert(
|
||||
scope,
|
||||
chunks.map((chunk, at) => ({
|
||||
id: stableChunkId(input.fileId, chunk.position),
|
||||
vector: vectorAt(at),
|
||||
subject: { type: SUBJECT, id: input.fileId },
|
||||
metadata: {
|
||||
collection: input.collectionName,
|
||||
filename: input.filename,
|
||||
chunk: chunk.position,
|
||||
snippet: chunk.text.slice(0, SNIPPET),
|
||||
},
|
||||
})),
|
||||
)
|
||||
},
|
||||
|
||||
async search(scope, input) {
|
||||
const vectors = await embeddings.embed([input.query], { inputType: "query" })
|
||||
const vector = vectors[0]
|
||||
if (!vector) return []
|
||||
const matches = await store.search(scope, {
|
||||
vector,
|
||||
limit: input.limit,
|
||||
filter: { collection: input.collectionName },
|
||||
})
|
||||
const hits: KnowledgeHit[] = []
|
||||
for (const match of matches) {
|
||||
const metadata = match.metadata ?? {}
|
||||
const snippet = typeof metadata.snippet === "string" ? metadata.snippet : ""
|
||||
const chunk = typeof metadata.chunk === "number" ? metadata.chunk : 1
|
||||
const filename = typeof metadata.filename === "string" ? metadata.filename : match.subject.id
|
||||
const collectionName = typeof metadata.collection === "string" ? metadata.collection : input.collectionName
|
||||
hits.push({ fileId: match.subject.id, filename, collectionName, score: match.score, chunk, snippet })
|
||||
}
|
||||
return hits
|
||||
},
|
||||
|
||||
async remove(scope, input) {
|
||||
await store.deleteBySubject(scope, { type: SUBJECT, id: input.fileId })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A deterministic chunk id keeps a re-indexed chunk under one identity; the vector adapters
|
||||
// namespace it per organization themselves.
|
||||
function stableChunkId(fileId: string, position: number): string {
|
||||
const hex = createHash("sha256").update(`${fileId}:${position}`).digest("hex").slice(0, 32)
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
|
||||
}
|
||||
|
||||
// Types referenced by the composition root when it binds the adapter.
|
||||
export type { KnowledgeHit, KnowledgeScope }
|
||||
@@ -0,0 +1,36 @@
|
||||
// The constants of the knowledge module: its feature flag, jobs, events, audit actions and the
|
||||
// indexable content types.
|
||||
|
||||
/** The module flag: knowledge retrieval and indexing, off until an administrator enables it. */
|
||||
export const KNOWLEDGE_FLAG = "knowledge.enabled"
|
||||
|
||||
export const KNOWLEDGE_FLAG_DEFAULTS = {
|
||||
[KNOWLEDGE_FLAG]: false,
|
||||
} as const
|
||||
|
||||
/** The worker job that indexes one document's chunks into the vector store. */
|
||||
export const INDEX_JOB = "knowledge.index"
|
||||
|
||||
/** The worker job that removes one document's chunks from the vector store. */
|
||||
export const REMOVE_JOB = "knowledge.remove"
|
||||
|
||||
export const COLLECTION_CREATED = "knowledge.collection.created"
|
||||
export const DOCUMENT_INDEX_REQUESTED = "knowledge.document.index_requested"
|
||||
export const DOCUMENT_REMOVE_REQUESTED = "knowledge.document.remove_requested"
|
||||
export const DOCUMENT_AI_ENABLED = "knowledge.settings.document_ai_enabled"
|
||||
export const DOCUMENT_AI_DISABLED = "knowledge.settings.document_ai_disabled"
|
||||
|
||||
/**
|
||||
* What the indexer reads as text. PDFs and scans are the documents module's worked example; this
|
||||
* module indexes text-shaped files, and refuses anything else at the door with a named field.
|
||||
*/
|
||||
export const INDEXABLE_CONTENT_TYPES = ["text/plain", "text/markdown", "application/json", "text/csv"] as const
|
||||
|
||||
/** A search query's ceiling; a retrieval question is a sentence, not a document. */
|
||||
export const MAX_QUERY_LENGTH = 500
|
||||
|
||||
/** What one search returns at most, however much the caller asks (the retrieval limit contract). */
|
||||
export const MAX_SEARCH_RESULTS = 25
|
||||
|
||||
/** An indexed file's ceiling: one megabyte of text is already far past useful chunking. */
|
||||
export const MAX_INDEX_BYTES = 1_048_576
|
||||
@@ -0,0 +1,68 @@
|
||||
// The tables of the knowledge module. Three.
|
||||
//
|
||||
// `knowledge_collection` is one named collection of documents, project-owned; the collection name
|
||||
// is the slug procedures take (a procedure's input carries slugs, never ids). `knowledge_document`
|
||||
// is one file's indexing state in one collection — the row the UI's progress states read, and the
|
||||
// idempotency key of the index job (a file is indexed into a collection once, and re-indexing the
|
||||
// same pair resumes the one row). `knowledge_config` is the organization's enablement state: the
|
||||
// durable answer to "is document AI on", which backend serves it and where the managed vector
|
||||
// service was linked. No secret ever lands here: the Qdrant API key lives in the deployment
|
||||
// environment, not in a tenant row.
|
||||
|
||||
import { defineEntity, idSchema, instantSchema } from "@conusai-kit/data"
|
||||
import { z } from "zod"
|
||||
import { DOCUMENT_STATUSES } from "./statuses.js"
|
||||
|
||||
export const collections = defineEntity({
|
||||
name: "collection",
|
||||
table: "knowledge_collection",
|
||||
tenancy: "project",
|
||||
fields: {
|
||||
name: z.string().min(1).max(100),
|
||||
createdBy: z.string().min(1).max(128),
|
||||
},
|
||||
unique: [["name"]],
|
||||
})
|
||||
|
||||
export const documents = defineEntity({
|
||||
name: "document",
|
||||
table: "knowledge_document",
|
||||
tenancy: "project",
|
||||
fields: {
|
||||
collectionName: z.string().min(1).max(100),
|
||||
/** The row of the files module this document indexes: an opaque id, never a foreign key. */
|
||||
fileId: idSchema,
|
||||
filename: z.string().min(1).max(255),
|
||||
status: z.enum(DOCUMENT_STATUSES),
|
||||
/** A stable failure code when the indexing failed; null otherwise. */
|
||||
error: z.string().max(200).nullable(),
|
||||
indexedAt: z.string().nullable(),
|
||||
requestedBy: z.string().min(1).max(128),
|
||||
},
|
||||
// One row per (collection, file): a re-index resumes the same row instead of duplicating it.
|
||||
unique: [["collectionName", "fileId"]],
|
||||
indexes: [["collectionName"]],
|
||||
})
|
||||
|
||||
export const config = defineEntity({
|
||||
name: "config",
|
||||
table: "knowledge_config",
|
||||
tenancy: "org",
|
||||
fields: {
|
||||
enabled: z.boolean(),
|
||||
/** Which search backend serves this organization: the local vector store or the managed service. */
|
||||
backend: z.enum(["vectors", "platform"]),
|
||||
/** Where the vector backing came from: an operator-configured endpoint or a managed link. */
|
||||
source: z.enum(["operator", "managed"]),
|
||||
/** The linked managed service's id, when source is managed. */
|
||||
serviceId: z.string().max(128).nullable(),
|
||||
/** The operator- or link-configured endpoint, when there is one to show. Never a key. */
|
||||
url: z.string().max(500).nullable(),
|
||||
updatedBy: z.string().min(1).max(128),
|
||||
/** When the configuration last changed; the managed updated_at is the row's own bookkeeping. */
|
||||
configuredAt: instantSchema,
|
||||
},
|
||||
})
|
||||
|
||||
/** Every table this module owns, in the order a migration creates them. */
|
||||
export const KNOWLEDGE_ENTITIES = Object.freeze([collections, documents, config])
|
||||
@@ -0,0 +1,36 @@
|
||||
// The events of the knowledge module. A write procedure publishes the request; the module's own
|
||||
// subscriber (running in the worker's outbox relay) enqueues the job, which is how a web request
|
||||
// never waits on chunking, embeddings or a vector store.
|
||||
|
||||
import { defineEvent } from "@conusai-kit/core"
|
||||
import { z } from "zod"
|
||||
|
||||
const inProject = {
|
||||
orgId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
projectId: z.string(),
|
||||
}
|
||||
|
||||
/** A document was asked to be indexed: the subscriber enqueues the index job. */
|
||||
export const documentIndexRequested = defineEvent(
|
||||
"knowledge.documentIndexRequested",
|
||||
z.object({
|
||||
...inProject,
|
||||
documentId: z.string(),
|
||||
collectionName: z.string(),
|
||||
fileId: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
/** A document was asked to be removed: the subscriber enqueues the remove job. */
|
||||
export const documentRemoveRequested = defineEvent(
|
||||
"knowledge.documentRemoveRequested",
|
||||
z.object({
|
||||
...inProject,
|
||||
documentId: z.string(),
|
||||
collectionName: z.string(),
|
||||
fileId: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const KNOWLEDGE_EVENTS = [documentIndexRequested, documentRemoveRequested]
|
||||
@@ -0,0 +1,45 @@
|
||||
// The public API of the knowledge module: what a page, another module and the OpenAPI document may
|
||||
// import. Nothing here reaches core/, adapters/ or a driver.
|
||||
|
||||
export {
|
||||
COLLECTION_CREATED,
|
||||
DOCUMENT_AI_DISABLED,
|
||||
DOCUMENT_AI_ENABLED,
|
||||
DOCUMENT_INDEX_REQUESTED,
|
||||
DOCUMENT_REMOVE_REQUESTED,
|
||||
INDEX_JOB,
|
||||
INDEXABLE_CONTENT_TYPES,
|
||||
KNOWLEDGE_FLAG,
|
||||
KNOWLEDGE_FLAG_DEFAULTS,
|
||||
MAX_INDEX_BYTES,
|
||||
MAX_QUERY_LENGTH,
|
||||
MAX_SEARCH_RESULTS,
|
||||
REMOVE_JOB,
|
||||
} from "./constants.js"
|
||||
export { collections, config, documents, KNOWLEDGE_ENTITIES } from "./entities.js"
|
||||
export { documentIndexRequested, documentRemoveRequested, KNOWLEDGE_EVENTS } from "./events.js"
|
||||
export type { CollectionView, DocumentView, SearchResult } from "./procedures.js"
|
||||
export {
|
||||
collectionCreate,
|
||||
collectionList,
|
||||
collectionName,
|
||||
collectionViewSchema,
|
||||
documentIndex,
|
||||
documentList,
|
||||
documentRemove,
|
||||
documentViewSchema,
|
||||
KNOWLEDGE_PROCEDURES,
|
||||
knowledgeSearch,
|
||||
knowledgeStatus,
|
||||
searchResultSchema,
|
||||
settingsDisableDocumentAi,
|
||||
settingsEnableDocumentAi,
|
||||
settingsGet,
|
||||
} from "./procedures.js"
|
||||
export type { DocumentStatus, EnablementReason, IndexFailure, KnowledgeBackend, ServiceState } from "./statuses.js"
|
||||
export {
|
||||
ENABLEMENT_REASONS,
|
||||
INDEX_FAILURES,
|
||||
KNOWLEDGE_BACKENDS,
|
||||
SERVICE_STATES,
|
||||
} from "./statuses.js"
|
||||
@@ -0,0 +1,187 @@
|
||||
// The procedure contracts of the knowledge module: schemas and names, no handler and no port.
|
||||
//
|
||||
// The module is a retrieval layer over the app's documents: a collection of text files is indexed
|
||||
// (in the worker: chunk, embed through the AI gateway, upsert into the vector store), searched
|
||||
// with cited snippets, and managed through an explicit, idempotent enablement flow. The enablement
|
||||
// procedure is named enableDocumentAi per the Document AI enablement contract; its permission is the module's
|
||||
// manage permission, so the admin gate is one line in the manifest.
|
||||
|
||||
import { procedure } from "@conusai-kit/core"
|
||||
import { z } from "zod"
|
||||
import { COLLECTION_CREATED, DOCUMENT_AI_DISABLED, DOCUMENT_AI_ENABLED } from "./constants.js"
|
||||
import type { DocumentStatus, ServiceState } from "./statuses.js"
|
||||
|
||||
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
|
||||
const inOrg = { org: z.string() }
|
||||
|
||||
export const collectionName = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-z][a-z0-9-]*$/, "lower-case letters, digits and dashes")
|
||||
|
||||
export const collectionViewSchema = z.object({
|
||||
name: collectionName,
|
||||
documentCount: z.number().int(),
|
||||
indexedCount: z.number().int(),
|
||||
createdBy: z.string(),
|
||||
})
|
||||
export type CollectionView = z.output<typeof collectionViewSchema>
|
||||
|
||||
export const documentViewSchema = z.object({
|
||||
documentId: z.string(),
|
||||
collectionName: collectionName,
|
||||
fileId: z.string(),
|
||||
filename: z.string(),
|
||||
status: z.enum(["queued", "indexing", "indexed", "failed", "cancelled"]),
|
||||
error: z.string().nullable(),
|
||||
indexedAt: z.string().nullable(),
|
||||
requestedBy: z.string(),
|
||||
})
|
||||
export type DocumentView = z.output<typeof documentViewSchema>
|
||||
|
||||
export const searchResultSchema = z.object({
|
||||
fileId: z.string(),
|
||||
filename: z.string(),
|
||||
collectionName: collectionName,
|
||||
snippet: z.string(),
|
||||
/** 0..1; higher is closer. */
|
||||
score: z.number(),
|
||||
/** 1-based chunk position in the document: where a citation points. */
|
||||
chunk: z.number().int().min(1),
|
||||
})
|
||||
export type SearchResult = z.output<typeof searchResultSchema>
|
||||
|
||||
export const collectionList = procedure("knowledge.collection.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("knowledge.knowledge.search")
|
||||
.input(z.object({ ...inProject }))
|
||||
.output(z.object({ collections: z.array(collectionViewSchema) }))
|
||||
.contract()
|
||||
|
||||
export const collectionCreate = procedure("knowledge.collection.create")
|
||||
.kind("write")
|
||||
.scope("project")
|
||||
.permission("knowledge.collection.manage")
|
||||
.input(z.object({ ...inProject, name: collectionName }))
|
||||
.output(collectionViewSchema)
|
||||
.audit(COLLECTION_CREATED)
|
||||
.contract()
|
||||
|
||||
export const documentIndex = procedure("knowledge.document.index")
|
||||
.kind("write")
|
||||
.scope("project")
|
||||
.permission("knowledge.knowledge.manage")
|
||||
.input(z.object({ ...inProject, collectionName: collectionName, fileId: z.string() }))
|
||||
.output(documentViewSchema)
|
||||
.contract()
|
||||
|
||||
export const documentList = procedure("knowledge.document.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("knowledge.knowledge.search")
|
||||
.input(z.object({ ...inProject, collectionName: collectionName }))
|
||||
.output(z.object({ documents: z.array(documentViewSchema) }))
|
||||
.contract()
|
||||
|
||||
export const documentRemove = procedure("knowledge.document.remove")
|
||||
.kind("write")
|
||||
.scope("project")
|
||||
.permission("knowledge.knowledge.manage")
|
||||
.input(z.object({ ...inProject, collectionName: collectionName, fileId: z.string() }))
|
||||
.output(documentViewSchema)
|
||||
.contract()
|
||||
|
||||
/**
|
||||
* The retrieval call. It embeds the query through the AI gateway before it searches, so it is an
|
||||
* effect: a read procedure cannot leave the process. Exposed as an MCP tool on purpose (`.tool()`).
|
||||
*/
|
||||
export const knowledgeSearch = procedure("knowledge.search")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission("knowledge.knowledge.search")
|
||||
.tool()
|
||||
.input(
|
||||
z.object({
|
||||
...inProject,
|
||||
collectionName: collectionName,
|
||||
query: z.string().min(1).max(500),
|
||||
limit: z.number().int().min(1).max(25).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ results: z.array(searchResultSchema) }))
|
||||
.contract()
|
||||
|
||||
export const knowledgeStatus = procedure("knowledge.status")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("knowledge.knowledge.search")
|
||||
.input(z.object({ ...inProject }))
|
||||
.output(
|
||||
z.object({
|
||||
/** The module flag's answer for this project. */
|
||||
enabled: z.boolean(),
|
||||
/** The service readiness, kept separate from the indexing indicator below. */
|
||||
service: z.object({
|
||||
state: z.enum(["registered", "indexing", "ready", "degraded", "disabled", "unavailable"]),
|
||||
reasons: z.array(z.string()),
|
||||
}),
|
||||
documents: z.object({
|
||||
queued: z.number().int(),
|
||||
indexing: z.number().int(),
|
||||
indexed: z.number().int(),
|
||||
failed: z.number().int(),
|
||||
cancelled: z.number().int(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.contract()
|
||||
|
||||
export const settingsGet = procedure("knowledge.settings.get")
|
||||
.kind("read")
|
||||
.scope("org")
|
||||
.permission("knowledge.settings.manage")
|
||||
.input(z.object({ ...inOrg }))
|
||||
.output(
|
||||
z.object({
|
||||
enabled: z.boolean(),
|
||||
backend: z.enum(["vectors", "platform"]).nullable(),
|
||||
source: z.enum(["operator", "managed"]).nullable(),
|
||||
url: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.contract()
|
||||
|
||||
export const settingsEnableDocumentAi = procedure("knowledge.settings.enableDocumentAi")
|
||||
.kind("write")
|
||||
.scope("org")
|
||||
.permission("knowledge.settings.manage")
|
||||
.input(z.object({ ...inOrg }))
|
||||
.output(z.object({ enabled: z.boolean(), backend: z.enum(["vectors", "platform"]) }))
|
||||
.audit(DOCUMENT_AI_ENABLED)
|
||||
.contract()
|
||||
|
||||
export const settingsDisableDocumentAi = procedure("knowledge.settings.disableDocumentAi")
|
||||
.kind("write")
|
||||
.scope("org")
|
||||
.permission("knowledge.settings.manage")
|
||||
.input(z.object({ ...inOrg }))
|
||||
.output(z.object({ enabled: z.boolean() }))
|
||||
.audit(DOCUMENT_AI_DISABLED)
|
||||
.contract()
|
||||
|
||||
export const KNOWLEDGE_PROCEDURES = [
|
||||
collectionList,
|
||||
collectionCreate,
|
||||
documentIndex,
|
||||
documentList,
|
||||
documentRemove,
|
||||
knowledgeSearch,
|
||||
knowledgeStatus,
|
||||
settingsGet,
|
||||
settingsEnableDocumentAi,
|
||||
settingsDisableDocumentAi,
|
||||
]
|
||||
|
||||
export type { DocumentStatus, ServiceState }
|
||||
@@ -0,0 +1,22 @@
|
||||
// The statuses a knowledge document moves through. `unavailable` and `disabled` are not rows: they
|
||||
// are the module's and the service's answers, and the UI maps all eight states from what a page
|
||||
// reads (the module flag, the service readiness, the document row).
|
||||
|
||||
export const DOCUMENT_STATUSES = ["queued", "indexing", "indexed", "failed", "cancelled"] as const
|
||||
export type DocumentStatus = (typeof DOCUMENT_STATUSES)[number]
|
||||
|
||||
/** The search backend a deployment binds: the local vector adapters, or the managed service. */
|
||||
export const KNOWLEDGE_BACKENDS = ["vectors", "platform"] as const
|
||||
export type KnowledgeBackend = (typeof KNOWLEDGE_BACKENDS)[number]
|
||||
|
||||
/** The readiness states the search port answers (the managed toolset's readiness + unavailable). */
|
||||
export const SERVICE_STATES = ["registered", "indexing", "ready", "degraded", "disabled", "unavailable"] as const
|
||||
export type ServiceState = (typeof SERVICE_STATES)[number]
|
||||
|
||||
/** The enablement flow's typed refusal reasons, surfaced in the UI as states. */
|
||||
export const ENABLEMENT_REASONS = ["plan_required", "feature_disabled", "quota_exhausted"] as const
|
||||
export type EnablementReason = (typeof ENABLEMENT_REASONS)[number]
|
||||
|
||||
/** The indexing failure codes a document row records. */
|
||||
export const INDEX_FAILURES = ["not_indexable", "too_large", "not_text", "vector_store_unavailable"] as const
|
||||
export type IndexFailure = (typeof INDEX_FAILURES)[number]
|
||||
@@ -0,0 +1,38 @@
|
||||
// The chunker: one text into citation-sized pieces. Pure TypeScript, unit-tested without a store
|
||||
// or a model. Chunks split on blank lines where they can, so a snippet reads as a sentence and not
|
||||
// as a byte range; a chunk that fits stays whole.
|
||||
|
||||
import { validationFailed } from "@conusai-kit/core"
|
||||
import type { KnowledgeChunk } from "./ports.js"
|
||||
|
||||
export const CHUNK_SIZE = 2_000
|
||||
const CHUNK_OVERLAP = 200
|
||||
|
||||
/**
|
||||
* Splits one document's text into chunks of at most {@link CHUNK_SIZE} characters. Paragraphs
|
||||
* longer than the size are hard-split; the overlap keeps a sentence split across two chunks
|
||||
* retrievable from either side.
|
||||
*/
|
||||
export function chunkText(text: string): KnowledgeChunk[] {
|
||||
if (text.length === 0)
|
||||
throw validationFailed([{ path: "file", message: "the file has no text to index" }], "nothing to index")
|
||||
const paragraphs = text.split(/\r?\n\r?\n/).flatMap((paragraph) => {
|
||||
if (paragraph.length <= CHUNK_SIZE) return [paragraph]
|
||||
const pieces: string[] = []
|
||||
for (let at = 0; at < paragraph.length; at += CHUNK_SIZE - CHUNK_OVERLAP) {
|
||||
pieces.push(paragraph.slice(at, at + CHUNK_SIZE))
|
||||
}
|
||||
return pieces
|
||||
})
|
||||
const chunks: KnowledgeChunk[] = []
|
||||
let current = ""
|
||||
for (const paragraph of paragraphs) {
|
||||
if (current !== "" && current.length + paragraph.length + 2 > CHUNK_SIZE) {
|
||||
chunks.push({ position: chunks.length + 1, text: current })
|
||||
current = ""
|
||||
}
|
||||
current = current === "" ? paragraph : `${current}\n\n${paragraph}`
|
||||
}
|
||||
if (current !== "") chunks.push({ position: chunks.length + 1, text: current })
|
||||
return chunks
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// The collections: list them with their document counts, create one. A collection is a named
|
||||
// bucket; its name is the slug every procedure takes, so a page never handles an id.
|
||||
|
||||
import { conflict, notFound, validationFailed } from "@conusai-kit/core"
|
||||
import { type CollectionView, collections, documents } from "../contract/index.js"
|
||||
import type { KnowledgeDeps, KnowledgeScope } from "./ports.js"
|
||||
|
||||
const PAGE = 200
|
||||
|
||||
export async function listCollections(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
): Promise<{ collections: CollectionView[] }> {
|
||||
const found = []
|
||||
let cursor: string | undefined
|
||||
for (;;) {
|
||||
const page = await deps.data
|
||||
.forScope(scope)
|
||||
.of(collections)
|
||||
.list({
|
||||
order: [{ field: "createdAt", direction: "asc" }],
|
||||
limit: PAGE,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
})
|
||||
found.push(...page.items)
|
||||
if (!page.hasMore) break
|
||||
cursor = page.cursor
|
||||
}
|
||||
const views: CollectionView[] = []
|
||||
for (const row of found) {
|
||||
const counts = await documentCounts(deps, scope, row.name)
|
||||
views.push({
|
||||
name: row.name,
|
||||
documentCount: counts.total,
|
||||
indexedCount: counts.indexed,
|
||||
createdBy: row.createdBy,
|
||||
})
|
||||
}
|
||||
return { collections: views }
|
||||
}
|
||||
|
||||
async function documentCounts(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
collectionName: string,
|
||||
): Promise<{ total: number; indexed: number }> {
|
||||
let total = 0
|
||||
let indexed = 0
|
||||
let cursor: string | undefined
|
||||
for (;;) {
|
||||
const page = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.list({
|
||||
where: [{ field: "collectionName", op: "eq", value: collectionName }],
|
||||
limit: PAGE,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
})
|
||||
for (const row of page.items) {
|
||||
total += 1
|
||||
if (row.status === "indexed") indexed += 1
|
||||
}
|
||||
if (!page.hasMore) break
|
||||
cursor = page.cursor
|
||||
}
|
||||
return { total, indexed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a collection, idempotently: a name that already exists answers with the existing
|
||||
* collection and writes nothing, so a retried request never audits twice.
|
||||
*/
|
||||
export async function createCollection(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
name: string,
|
||||
): Promise<CollectionView> {
|
||||
const repos = deps.data.forScope(scope)
|
||||
const existing = (await repos.of(collections).list({ where: [{ field: "name", op: "eq", value: name }], limit: 1 }))
|
||||
.items[0]
|
||||
if (existing) {
|
||||
const counts = await documentCounts(deps, scope, name)
|
||||
return {
|
||||
name: existing.name,
|
||||
documentCount: counts.total,
|
||||
indexedCount: counts.indexed,
|
||||
createdBy: existing.createdBy,
|
||||
}
|
||||
}
|
||||
try {
|
||||
const row = await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const inserted = await tx.of(collections).insert({ name, createdBy: scope.actor.userId })
|
||||
return inserted
|
||||
})
|
||||
return { name: row.name, documentCount: 0, indexedCount: 0, createdBy: row.createdBy }
|
||||
} catch (error) {
|
||||
// A concurrent create with the same name lost the race: answer the winner's row.
|
||||
if ((error as { status?: number }).status === 409) {
|
||||
const winner = (await repos.of(collections).list({ where: [{ field: "name", op: "eq", value: name }], limit: 1 }))
|
||||
.items[0]
|
||||
if (winner) {
|
||||
const counts = await documentCounts(deps, scope, name)
|
||||
return {
|
||||
name: winner.name,
|
||||
documentCount: counts.total,
|
||||
indexedCount: counts.indexed,
|
||||
createdBy: winner.createdBy,
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** One collection by name, or NotFound: the resolver's own read is the membership check. */
|
||||
export async function requireCollection(deps: KnowledgeDeps, scope: KnowledgeScope, name: string) {
|
||||
const row = (
|
||||
await deps.data
|
||||
.forScope(scope)
|
||||
.of(collections)
|
||||
.list({ where: [{ field: "name", op: "eq", value: name }], limit: 1 })
|
||||
).items[0]
|
||||
if (!row) throw notFound("no such collection")
|
||||
return row
|
||||
}
|
||||
|
||||
export const unknownCollection = (name: string): never => {
|
||||
throw validationFailed([{ path: "collectionName", message: "no such collection" }], `no collection ${name}`)
|
||||
}
|
||||
export const takenCollection = (): never => {
|
||||
throw conflict("this collection name is taken")
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// The documents: request an index (a web write that records the row and publishes the outbox
|
||||
// event the worker's subscriber turns into the index job), request a removal the same way, and
|
||||
// list a collection's documents with their progress states.
|
||||
|
||||
import { forbidden, notFound, validationFailed } from "@conusai-kit/core"
|
||||
import { getFile } from "@conusai-kit/files"
|
||||
import {
|
||||
type DocumentView,
|
||||
documentIndexRequested,
|
||||
documentRemoveRequested,
|
||||
documents,
|
||||
INDEXABLE_CONTENT_TYPES,
|
||||
MAX_INDEX_BYTES,
|
||||
} from "../contract/index.js"
|
||||
import { requireCollection } from "./collections.js"
|
||||
import { type KnowledgeDeps, type KnowledgeScope, knowledgeEnabled } from "./ports.js"
|
||||
|
||||
const PAGE = 200
|
||||
|
||||
export const documentView = (row: {
|
||||
id: string
|
||||
collectionName: string
|
||||
fileId: string
|
||||
filename: string
|
||||
status: DocumentView["status"]
|
||||
error: string | null
|
||||
indexedAt: string | null
|
||||
requestedBy: string
|
||||
}): DocumentView => ({
|
||||
documentId: row.id,
|
||||
collectionName: row.collectionName,
|
||||
fileId: row.fileId,
|
||||
filename: row.filename,
|
||||
status: row.status,
|
||||
error: row.error,
|
||||
indexedAt: row.indexedAt,
|
||||
requestedBy: row.requestedBy,
|
||||
})
|
||||
|
||||
export async function listDocuments(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
collectionName: string,
|
||||
): Promise<{ documents: DocumentView[] }> {
|
||||
const found = []
|
||||
let cursor: string | undefined
|
||||
for (;;) {
|
||||
const page = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.list({
|
||||
where: [{ field: "collectionName", op: "eq", value: collectionName }],
|
||||
order: [{ field: "createdAt", direction: "desc" }],
|
||||
limit: PAGE,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
})
|
||||
found.push(...page.items)
|
||||
if (!page.hasMore) break
|
||||
cursor = page.cursor
|
||||
}
|
||||
return { documents: found.map((row) => documentView(row)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for a document's indexing. The write is three things in one transaction: the durable row
|
||||
* (the UI's progress states and the job's idempotency key), the outbox event the worker's
|
||||
* subscriber enqueues the index job from, and nothing else — chunking, embeddings and the vector
|
||||
* store all happen in the worker, never in a request (AGENTS.md: long work is a job).
|
||||
*
|
||||
* Idempotent by (collection, file): asking again for a pair that is queued or indexing answers
|
||||
* the same row and publishes nothing; asking for an indexed or failed pair re-queues the one row,
|
||||
* so a retry can never duplicate a document.
|
||||
*/
|
||||
export async function requestIndex(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
input: { collectionName: string; fileId: string },
|
||||
): Promise<DocumentView> {
|
||||
// The tenant-wall reads come before the flag check: another tenant's ids answer NotFound, never
|
||||
// a policy refusal, so ids do not leak existence behind a disabled flag.
|
||||
await requireCollection(deps, scope, input.collectionName)
|
||||
const file = await getFile(deps.files, scope, input.fileId)
|
||||
if (!(INDEXABLE_CONTENT_TYPES as readonly string[]).includes(file.contentType)) {
|
||||
throw validationFailed(
|
||||
[{ path: "fileId", message: `${file.contentType} cannot be indexed; index a text file` }],
|
||||
"this file type is not indexable",
|
||||
)
|
||||
}
|
||||
if (file.size > MAX_INDEX_BYTES) {
|
||||
throw validationFailed(
|
||||
[{ path: "fileId", message: `the file is larger than ${MAX_INDEX_BYTES} bytes` }],
|
||||
"the file is too large to index",
|
||||
)
|
||||
}
|
||||
if (!(await knowledgeEnabled(deps, scope))) throw forbidden("knowledge is switched off for this project")
|
||||
|
||||
return deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const existing = (
|
||||
await tx.of(documents).list({
|
||||
where: [
|
||||
{ field: "collectionName", op: "eq", value: input.collectionName },
|
||||
{ field: "fileId", op: "eq", value: input.fileId },
|
||||
],
|
||||
limit: 1,
|
||||
})
|
||||
).items[0]
|
||||
if (existing && (existing.status === "queued" || existing.status === "indexing")) {
|
||||
return documentView(existing)
|
||||
}
|
||||
const row =
|
||||
existing !== undefined
|
||||
? await tx
|
||||
.of(documents)
|
||||
.update(
|
||||
existing.id,
|
||||
{ status: "queued", error: null, indexedAt: null, requestedBy: scope.actor.userId },
|
||||
existing.version,
|
||||
)
|
||||
: await tx.of(documents).insert({
|
||||
collectionName: input.collectionName,
|
||||
fileId: input.fileId,
|
||||
filename: file.filename,
|
||||
status: "queued",
|
||||
error: null,
|
||||
indexedAt: null,
|
||||
requestedBy: scope.actor.userId,
|
||||
})
|
||||
await tx.publish(documentIndexRequested, {
|
||||
orgId: scope.orgId,
|
||||
workspaceId: scope.workspaceId,
|
||||
projectId: scope.projectId,
|
||||
documentId: row.id,
|
||||
collectionName: input.collectionName,
|
||||
fileId: input.fileId,
|
||||
})
|
||||
return documentView(row)
|
||||
})
|
||||
}
|
||||
|
||||
/** Asks for a document's removal: the row and the event in one transaction, the job in the worker. */
|
||||
export async function requestRemove(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
input: { collectionName: string; fileId: string },
|
||||
): Promise<DocumentView> {
|
||||
const current = (
|
||||
await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.list({
|
||||
where: [
|
||||
{ field: "collectionName", op: "eq", value: input.collectionName },
|
||||
{ field: "fileId", op: "eq", value: input.fileId },
|
||||
],
|
||||
limit: 1,
|
||||
})
|
||||
).items[0]
|
||||
// The tenant-wall read comes first, as in requestIndex: another tenant's ids answer NotFound.
|
||||
if (!current) throw notFound("no such document")
|
||||
if (current.status === "cancelled") return documentView(current)
|
||||
if (!(await knowledgeEnabled(deps, scope))) throw forbidden("knowledge is switched off for this project")
|
||||
return deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const row = (
|
||||
await tx.of(documents).list({
|
||||
where: [
|
||||
{ field: "collectionName", op: "eq", value: input.collectionName },
|
||||
{ field: "fileId", op: "eq", value: input.fileId },
|
||||
],
|
||||
limit: 1,
|
||||
})
|
||||
).items[0]
|
||||
if (!row) throw notFound("no such document")
|
||||
if (row.status === "cancelled") return documentView(row)
|
||||
const next = await tx.of(documents).update(row.id, { status: "cancelled", error: null }, row.version)
|
||||
await tx.publish(documentRemoveRequested, {
|
||||
orgId: scope.orgId,
|
||||
workspaceId: scope.workspaceId,
|
||||
projectId: scope.projectId,
|
||||
documentId: row.id,
|
||||
collectionName: input.collectionName,
|
||||
fileId: input.fileId,
|
||||
})
|
||||
return documentView(next)
|
||||
})
|
||||
}
|
||||
|
||||
/** The document row a job resumes by; NotFound when another tenant's id was named. */
|
||||
export async function documentById(deps: KnowledgeDeps, scope: KnowledgeScope, documentId: string) {
|
||||
const row = await deps.data.forScope(scope).of(documents).get(documentId)
|
||||
if (!row) throw notFound("no such document")
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// The index job's body: what the worker runs for one document. Read the file's bytes, chunk the
|
||||
// text, embed the chunks through the search service, and record the outcome on the durable row.
|
||||
// Transient failures (the vector store hiccups) throw, and the queue retries the job; the row the
|
||||
// retry resumes from makes the retry safe. Permanent failures are recorded on the row with a
|
||||
// stable code, so a person sees why a document failed and a retried request does not pay twice.
|
||||
|
||||
import { internal } from "@conusai-kit/core"
|
||||
import { downloadFile } from "@conusai-kit/files"
|
||||
import { type DocumentView, documents } from "../contract/index.js"
|
||||
import { documentById } from "./documents.js"
|
||||
import type { KnowledgeDeps, KnowledgeScope } from "./ports.js"
|
||||
|
||||
export async function indexDocument(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
documentId: string,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<DocumentView> {
|
||||
const row = await documentById(deps, scope, documentId)
|
||||
if (row.status === "indexing" || row.status === "indexed") {
|
||||
// A redelivered job or a crashed retry: the row says where the work stands.
|
||||
return {
|
||||
documentId: row.id,
|
||||
collectionName: row.collectionName,
|
||||
fileId: row.fileId,
|
||||
filename: row.filename,
|
||||
status: row.status,
|
||||
error: row.error,
|
||||
indexedAt: row.indexedAt,
|
||||
requestedBy: row.requestedBy,
|
||||
}
|
||||
}
|
||||
const service = deps.search
|
||||
if (!service) throw internal("the knowledge search service is not bound in this process")
|
||||
const indexing = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.update(row.id, { status: "indexing", error: null }, row.version)
|
||||
|
||||
try {
|
||||
const bytes = await readFileBytes(deps, scope, row.fileId)
|
||||
const text = decodeText(bytes)
|
||||
await service.index(scope, {
|
||||
collectionName: row.collectionName,
|
||||
fileId: row.fileId,
|
||||
filename: row.filename,
|
||||
bytes: new TextEncoder().encode(text),
|
||||
})
|
||||
const done = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.update(indexing.id, { status: "indexed", indexedAt: new Date().toISOString(), error: null }, indexing.version)
|
||||
return {
|
||||
documentId: done.id,
|
||||
collectionName: done.collectionName,
|
||||
fileId: done.fileId,
|
||||
filename: done.filename,
|
||||
status: done.status as DocumentView["status"],
|
||||
error: done.error,
|
||||
indexedAt: done.indexedAt,
|
||||
requestedBy: done.requestedBy,
|
||||
}
|
||||
} catch (error) {
|
||||
if (options.signal?.aborted) throw error
|
||||
const permanent = permanentFailure(error)
|
||||
if (permanent === null) throw error
|
||||
const failed = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.update(indexing.id, { status: "failed", error: permanent }, indexing.version)
|
||||
return {
|
||||
documentId: failed.id,
|
||||
collectionName: failed.collectionName,
|
||||
fileId: failed.fileId,
|
||||
filename: failed.filename,
|
||||
status: "failed",
|
||||
error: failed.error,
|
||||
indexedAt: failed.indexedAt,
|
||||
requestedBy: failed.requestedBy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDocument(deps: KnowledgeDeps, scope: KnowledgeScope, documentId: string): Promise<void> {
|
||||
const row = await documentById(deps, scope, documentId)
|
||||
const service = deps.search
|
||||
if (!service) throw internal("the knowledge search service is not bound in this process")
|
||||
await service.remove(scope, { collectionName: row.collectionName, fileId: row.fileId })
|
||||
await deps.data.forScope(scope).of(documents).delete(row.id)
|
||||
}
|
||||
|
||||
async function readFileBytes(deps: KnowledgeDeps, scope: KnowledgeScope, fileId: string): Promise<Uint8Array> {
|
||||
const response = await downloadFile(deps.files, scope, fileId)
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
function decodeText(bytes: Uint8Array): string {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
||||
} catch {
|
||||
throw Object.assign(new Error("the file is not valid UTF-8 text"), { permanent: "not_text" })
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps an indexing failure to the stable code recorded on the row; null when it is transient. */
|
||||
function permanentFailure(error: unknown): string | null {
|
||||
const code = (error as { permanent?: unknown }).permanent
|
||||
if (code === "not_text" || code === "not_indexable" || code === "too_large" || code === "vector_store_unavailable") {
|
||||
return String(code)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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, ServiceState } from "../contract/index.js"
|
||||
import { 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
|
||||
/**
|
||||
* The search service. Present for the effect procedure (search) and the status read; a write
|
||||
* procedure's deps never carry it, because resolving an effect port from a write is a kind
|
||||
* violation — the write only records the row and publishes the event.
|
||||
*/
|
||||
readonly search?: KnowledgeSearchService | undefined
|
||||
}
|
||||
|
||||
/** What a chunk knows about where it came from: the citation a search result carries. */
|
||||
export interface KnowledgeChunk {
|
||||
/** 1-based position of the chunk in the document. */
|
||||
position: number
|
||||
text: string
|
||||
}
|
||||
|
||||
/** One cited search answer. */
|
||||
export interface KnowledgeHit {
|
||||
fileId: string
|
||||
filename: string
|
||||
collectionName: string
|
||||
/** 0..1; higher is closer. */
|
||||
score: number
|
||||
chunk: number
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The search service: index a document's chunks, search a collection with citations, remove a
|
||||
* document, and report readiness. Two adapters ship with the module: the local vector store
|
||||
* (embeddings through the AI gateway, chunks in the bound vector adapter) and the platform's
|
||||
* managed documents-index service. Embedding leaves the process, so this port is an effect.
|
||||
*/
|
||||
export interface KnowledgeSearchService {
|
||||
readiness(scope: KnowledgeScope): Promise<{ state: ServiceState; reasons: string[] }>
|
||||
index(
|
||||
scope: KnowledgeScope,
|
||||
input: { collectionName: string; fileId: string; filename: string; bytes: Uint8Array },
|
||||
): Promise<void>
|
||||
search(
|
||||
scope: KnowledgeScope,
|
||||
input: { collectionName: string; query: string; limit: number; actorId: string },
|
||||
): Promise<readonly KnowledgeHit[]>
|
||||
remove(scope: KnowledgeScope, input: { collectionName: string; fileId: string }): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The enablement platform: the checks and the provisioning 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;
|
||||
* `linkManagedQdrant` provisions (or returns the existing) managed Qdrant backing service;
|
||||
* `probeQdrant` validates an operator-configured endpoint before its configuration is persisted.
|
||||
*/
|
||||
export interface DocumentAiPlatform {
|
||||
checkEligibility(
|
||||
scope: KnowledgeOrgScope,
|
||||
): Promise<{ ok: true } | { ok: false; reason: EnablementReason; detail?: string }>
|
||||
indexingQuota(scope: KnowledgeOrgScope): Promise<number>
|
||||
linkManagedQdrant(scope: KnowledgeOrgScope): Promise<{ serviceId: string; url: string | null }>
|
||||
probeQdrant(url: string, apiKey: string): Promise<{ ok: true } | { ok: false; detail: string }>
|
||||
/** The operator-configured endpoint+key the deployment carries, when it carries one. */
|
||||
operatorQdrant(): { url: string; apiKey: string } | null
|
||||
}
|
||||
|
||||
/** 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: the manifest default. */
|
||||
export const KNOWLEDGE_FLAGS = token<KnowledgeFlags>("knowledge.flags")
|
||||
|
||||
export const defaultFlags: KnowledgeFlags = { enabled: (flag) => KNOWLEDGE_FLAG_DEFAULTS[flag] }
|
||||
|
||||
export const flagsOf = (container: Container): KnowledgeFlags =>
|
||||
container.has(KNOWLEDGE_FLAGS) ? container.resolve(KNOWLEDGE_FLAGS) : defaultFlags
|
||||
|
||||
/** 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" })
|
||||
|
||||
/**
|
||||
* The embeddings the local vector backend chunks and queries with: the platform's embeddings
|
||||
* endpoint through the AI gateway. Declared here (not reached through another module's token) so
|
||||
* the module's boundary stays its own; the composition root binds it to the gateway client.
|
||||
*/
|
||||
export interface KnowledgeEmbeddings {
|
||||
embed(texts: readonly string[], options?: { inputType?: "passage" | "query" }): Promise<readonly number[][]>
|
||||
}
|
||||
|
||||
export const KNOWLEDGE_EMBEDDINGS = token<KnowledgeEmbeddings>("knowledge.embeddings", { access: "effect" })
|
||||
|
||||
/** Bound by the web shell (the enablement flow runs in a request). */
|
||||
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: KnowledgeDeps, scope: KnowledgeScope): Promise<boolean> =>
|
||||
deps.flags.enabled("knowledge.enabled", scope)
|
||||
@@ -0,0 +1,38 @@
|
||||
// The search: one collection, one query, cited answers. The embedding leaves the process (the
|
||||
// procedure is an effect), the ranking is the vector store's, and every answer carries where it
|
||||
// came from: the file, the chunk, and the snippet a person reads.
|
||||
|
||||
import { forbidden, internal } from "@conusai-kit/core"
|
||||
import { MAX_SEARCH_RESULTS, type SearchResult } from "../contract/index.js"
|
||||
import { requireCollection } from "./collections.js"
|
||||
import { type KnowledgeDeps, type KnowledgeScope, knowledgeEnabled } from "./ports.js"
|
||||
|
||||
export async function searchKnowledge(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
input: { collectionName: string; query: string; limit?: number | undefined },
|
||||
): Promise<{ results: SearchResult[] }> {
|
||||
if (!(await knowledgeEnabled(deps, scope))) throw forbidden("knowledge is switched off for this project")
|
||||
await requireCollection(deps, scope, input.collectionName)
|
||||
const service = deps.search
|
||||
if (!service) throw internal("the knowledge search service is not bound in this process")
|
||||
const limit = Math.min(input.limit ?? 10, MAX_SEARCH_RESULTS)
|
||||
const hits = await service.search(scope, {
|
||||
collectionName: input.collectionName,
|
||||
query: input.query,
|
||||
limit,
|
||||
actorId: scope.actor.userId,
|
||||
})
|
||||
return {
|
||||
results: hits.map((hit): SearchResult => {
|
||||
return {
|
||||
fileId: hit.fileId,
|
||||
filename: hit.filename,
|
||||
collectionName: hit.collectionName,
|
||||
snippet: hit.snippet,
|
||||
score: hit.score,
|
||||
chunk: hit.chunk,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// The enablement flow: one idempotent procedure pair an organization administrator runs, with the
|
||||
// platform's checks in front of any configuration change and every denial a typed reason code the
|
||||
// UI maps to an upgrade or policy state.
|
||||
//
|
||||
// enableDocumentAi: already enabled -> answers the current configuration, writes nothing.
|
||||
// Otherwise: eligibility (plan capability + rollout flags) -> quota -> the vector backing
|
||||
// (an operator-configured endpoint, validated before it is trusted, or a managed link through
|
||||
// the providers API) -> one transaction persists the configuration. A failure at any step
|
||||
// writes nothing and is retirable: the same call again resumes from the first failing check.
|
||||
//
|
||||
// disableDocumentAi: preserves everything — collections, documents, vectors and the recorded
|
||||
// configuration stay; only `enabled` flips, so re-enabling resumes without duplication.
|
||||
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { config, type EnablementReason } from "../contract/index.js"
|
||||
import type { DocumentAiPlatform, KnowledgeOrgScope } from "./ports.js"
|
||||
|
||||
/** A typed enablement denial: the problem's code is the stable reason the UI maps to a state. */
|
||||
export function enablementDenied(reason: EnablementReason, detail?: string): AppError {
|
||||
const status = reason === "quota_exhausted" ? 429 : 403
|
||||
const title =
|
||||
reason === "plan_required"
|
||||
? "The organization's plan does not include document AI"
|
||||
: reason === "feature_disabled"
|
||||
? "Document AI is disabled by policy"
|
||||
: "The indexing quota is exhausted"
|
||||
return new AppError({
|
||||
type: `/problems/${reason}`,
|
||||
title,
|
||||
status,
|
||||
code: reason,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface SettingsView {
|
||||
enabled: boolean
|
||||
backend: "vectors" | "platform" | null
|
||||
source: "operator" | "managed" | null
|
||||
url: string | null
|
||||
}
|
||||
|
||||
async function currentConfig(deps: { data: import("@conusai-kit/data").DataSource }, scope: KnowledgeOrgScope) {
|
||||
const row = (await deps.data.forScope(scope).of(config).list({ limit: 1 })).items[0]
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export async function getSettings(
|
||||
deps: { data: import("@conusai-kit/data").DataSource },
|
||||
scope: KnowledgeOrgScope,
|
||||
): Promise<SettingsView> {
|
||||
const row = await currentConfig(deps, scope)
|
||||
return {
|
||||
enabled: row?.enabled ?? false,
|
||||
backend: row?.backend ?? null,
|
||||
source: row?.source ?? null,
|
||||
url: row?.url ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsDeps {
|
||||
data: import("@conusai-kit/data").DataSource
|
||||
platform: DocumentAiPlatform
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export async function enableDocumentAi(deps: SettingsDeps, scope: KnowledgeOrgScope): Promise<SettingsView> {
|
||||
const existing = await currentConfig(deps, scope)
|
||||
if (existing?.enabled) {
|
||||
return { enabled: true, backend: existing.backend, source: existing.source, url: existing.url }
|
||||
}
|
||||
|
||||
const eligibility = await deps.platform.checkEligibility(scope)
|
||||
if (!eligibility.ok) throw enablementDenied(eligibility.reason, eligibility.detail)
|
||||
|
||||
const remaining = await deps.platform.indexingQuota(scope)
|
||||
if (remaining <= 0) throw enablementDenied("quota_exhausted", "the indexing volume for today is spent")
|
||||
|
||||
// The vector backing: an operator-configured endpoint+key when the deployment carries one
|
||||
// (validated before it is trusted), otherwise a managed link through the providers API.
|
||||
const operator = deps.platform.operatorQdrant()
|
||||
let source: "operator" | "managed"
|
||||
let serviceId: string | null
|
||||
let url: string | null
|
||||
if (operator !== null) {
|
||||
const probe = await deps.platform.probeQdrant(operator.url, operator.apiKey)
|
||||
if (!probe.ok) {
|
||||
throw new AppError({
|
||||
type: "/problems/validation_failed",
|
||||
title: "The configured Qdrant endpoint did not answer",
|
||||
status: 422,
|
||||
code: "validation_failed",
|
||||
detail: probe.detail,
|
||||
})
|
||||
}
|
||||
source = "operator"
|
||||
serviceId = null
|
||||
url = operator.url
|
||||
} else {
|
||||
const linked = await deps.platform.linkManagedQdrant(scope)
|
||||
source = "managed"
|
||||
serviceId = linked.serviceId
|
||||
url = linked.url
|
||||
}
|
||||
|
||||
await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const row = (await tx.of(config).list({ limit: 1 })).items[0]
|
||||
const patch = {
|
||||
enabled: true,
|
||||
backend: "vectors" as const,
|
||||
source,
|
||||
serviceId,
|
||||
url,
|
||||
updatedBy: scope.actor.userId,
|
||||
configuredAt: new Date((deps.now ?? Date.now)()).toISOString(),
|
||||
}
|
||||
if (row) await tx.of(config).update(row.id, patch, row.version)
|
||||
else await tx.of(config).insert(patch)
|
||||
})
|
||||
return { enabled: true, backend: "vectors", source, url }
|
||||
}
|
||||
|
||||
export async function disableDocumentAi(deps: SettingsDeps, scope: KnowledgeOrgScope): Promise<{ enabled: false }> {
|
||||
await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const row = (await tx.of(config).list({ limit: 1 })).items[0]
|
||||
if (!row?.enabled) return
|
||||
// Everything else is preserved on purpose: backend, source, the service id, the endpoint, and
|
||||
// every collection, document and vector. Disabling fences new work; it deletes nothing.
|
||||
await tx.of(config).update(
|
||||
row.id,
|
||||
{
|
||||
enabled: false,
|
||||
updatedBy: scope.actor.userId,
|
||||
configuredAt: new Date((deps.now ?? Date.now)()).toISOString(),
|
||||
},
|
||||
row.version,
|
||||
)
|
||||
})
|
||||
return { enabled: false }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// The status a page reads: the module flag, the search service's readiness (separate from the
|
||||
// indexing indicator, which is the per-document rows), and the document counts by progress state.
|
||||
|
||||
import { type DocumentStatus, documents } from "../contract/index.js"
|
||||
import { type KnowledgeDeps, type KnowledgeScope, knowledgeEnabled } from "./ports.js"
|
||||
|
||||
const PAGE = 200
|
||||
|
||||
export interface KnowledgeStatus {
|
||||
enabled: boolean
|
||||
service: { state: "registered" | "indexing" | "ready" | "degraded" | "disabled" | "unavailable"; reasons: string[] }
|
||||
documents: Record<DocumentStatus, number>
|
||||
}
|
||||
|
||||
export async function knowledgeStatusOf(deps: KnowledgeDeps, scope: KnowledgeScope): Promise<KnowledgeStatus> {
|
||||
const counts: Record<DocumentStatus, number> = { queued: 0, indexing: 0, indexed: 0, failed: 0, cancelled: 0 }
|
||||
let cursor: string | undefined
|
||||
for (;;) {
|
||||
const page = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.list({ limit: PAGE, ...(cursor === undefined ? {} : { cursor }) })
|
||||
for (const row of page.items) counts[row.status as DocumentStatus] += 1
|
||||
if (!page.hasMore) break
|
||||
cursor = page.cursor
|
||||
}
|
||||
const enabled = await knowledgeEnabled(deps, scope)
|
||||
const readiness =
|
||||
deps.search === undefined
|
||||
? { state: "unavailable" as const, reasons: ["the knowledge search service is not bound in this process"] }
|
||||
: await deps.search.readiness(scope).catch((error: unknown) => ({
|
||||
state: "unavailable" as const,
|
||||
reasons: [
|
||||
error instanceof Error
|
||||
? (error.message.split("\n")[0] ?? error.message)
|
||||
: "the search service did not answer",
|
||||
],
|
||||
}))
|
||||
return { enabled, service: readiness, documents: counts }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// English messages of the knowledge module. Every string a page of this module shows is a key here
|
||||
// and in lt.ts (spec §10.4); `__tests__/i18n.test.ts` fails when the two differ.
|
||||
|
||||
export const en = {
|
||||
title: "Knowledge",
|
||||
intro:
|
||||
"Search this project's documents by meaning. Files are indexed in the background; answers cite the file and the passage they came from.",
|
||||
status: {
|
||||
heading: "Service status",
|
||||
flagOff: "Knowledge is switched off for this project.",
|
||||
state: "Search service",
|
||||
stateReady: "Ready",
|
||||
stateIndexing: "Indexing",
|
||||
stateRegistered: "Registered",
|
||||
stateDegraded: "Degraded",
|
||||
stateDisabled: "Disabled by policy",
|
||||
stateUnavailable: "Unavailable",
|
||||
reasons: "Reasons",
|
||||
indexing: "Indexing in progress",
|
||||
documents: "{queued} queued, {indexing} indexing, {indexed} indexed, {failed} failed",
|
||||
},
|
||||
collections: {
|
||||
heading: "Collections",
|
||||
empty: "No collections yet.",
|
||||
name: "Name",
|
||||
documents: "Documents",
|
||||
indexed: "Indexed",
|
||||
createHeading: "New collection",
|
||||
nameLabel: "Name",
|
||||
nameHint: "Lower-case letters, digits and dashes.",
|
||||
create: "Create collection",
|
||||
creating: "Creating",
|
||||
pickerLabel: "Collection",
|
||||
pick: "Choose a collection",
|
||||
},
|
||||
upload: {
|
||||
heading: "Index a document",
|
||||
label: "File",
|
||||
hint: "Plain text, Markdown, JSON or CSV, up to 1 MB.",
|
||||
submit: "Index",
|
||||
busy: "Indexing requested",
|
||||
done: "The document is queued for indexing.",
|
||||
failed: "That did not work: {detail}",
|
||||
},
|
||||
documents: {
|
||||
heading: "Documents",
|
||||
empty: "Nothing is indexed in this collection yet.",
|
||||
file: "File",
|
||||
status: "Status",
|
||||
statusQueued: "Queued",
|
||||
statusIndexing: "Indexing",
|
||||
statusIndexed: "Indexed",
|
||||
statusFailed: "Failed",
|
||||
statusCancelled: "Removed",
|
||||
error: "Error",
|
||||
remove: "Remove {filename}",
|
||||
removeShort: "Remove",
|
||||
},
|
||||
search: {
|
||||
heading: "Search",
|
||||
label: "Question",
|
||||
placeholder: "Ask across the indexed documents",
|
||||
submit: "Search",
|
||||
busy: "Searching",
|
||||
empty: "No answers in this collection.",
|
||||
results: "{count, plural, one {# answer} other {# answers}}",
|
||||
snippet: "Passage",
|
||||
source: "Source",
|
||||
chunk: "chunk {chunk}",
|
||||
score: "relevance {score}",
|
||||
disclosure: "AI-assisted retrieval: answers are ranked by a model and must be checked against the source.",
|
||||
},
|
||||
settings: {
|
||||
heading: "Document AI",
|
||||
state: "State",
|
||||
stateOn: "Enabled",
|
||||
stateOff: "Disabled",
|
||||
backend: "Backend",
|
||||
backendVectors: "This app's vector store",
|
||||
source: "Source",
|
||||
sourceOperator: "Operator-configured endpoint",
|
||||
sourceManaged: "Managed service",
|
||||
endpoint: "Endpoint",
|
||||
enable: "Enable document AI",
|
||||
enabling: "Enabling",
|
||||
disable: "Disable",
|
||||
disabling: "Disabling",
|
||||
enabledDone: "Document AI is enabled.",
|
||||
disabledDone: "Document AI is disabled. Collections, documents and vectors are preserved.",
|
||||
planRequired: "The organization's plan does not include document AI.",
|
||||
featureDisabled: "Document AI is disabled by platform policy.",
|
||||
quotaExhausted: "The indexing quota is exhausted; try again later.",
|
||||
upgrade: "Upgrade the plan to enable document AI.",
|
||||
hint: "Enabling checks the plan and the rollout flags, links a managed vector service when none is configured, and records the configuration in this organization. Disabling keeps every collection, document and vector.",
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,2 @@
|
||||
export { en } from "./en.js"
|
||||
export { lt } from "./lt.js"
|
||||
@@ -0,0 +1,100 @@
|
||||
// Lietuviški knowledge modulio pranešimai. Kiekvienas puslapio tekstas yra raktas čia ir en.ts
|
||||
// faile; `__tests__/i18n.test.ts` žlunga, kai jie skiriasi.
|
||||
|
||||
import type { en } from "./en.js"
|
||||
|
||||
type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
|
||||
|
||||
export const lt: Messages<typeof en> = {
|
||||
title: "Žinios",
|
||||
intro:
|
||||
"Ieškokite šio projekto dokumentų pagal prasmę. Failai indeksuojami fone; atsakymai nurodo failą ir ištrauką, iš kurios paimti.",
|
||||
status: {
|
||||
heading: "Paslaugos būsena",
|
||||
flagOff: "Žinių paieška šiame projekte išjungta.",
|
||||
state: "Paieškos paslauga",
|
||||
stateReady: "Pasiruošusi",
|
||||
stateIndexing: "Indeksuoja",
|
||||
stateRegistered: "Registruota",
|
||||
stateDegraded: "Nukentėjusi",
|
||||
stateDisabled: "Išjungta politikos",
|
||||
stateUnavailable: "Neprieinama",
|
||||
reasons: "Priežastys",
|
||||
indexing: "Vyksta indeksavimas",
|
||||
documents: "{queued} laukia, {indexing} indeksuojama, {indexed} suindeksuota, {failed} nepavyko",
|
||||
},
|
||||
collections: {
|
||||
heading: "Kolekcijos",
|
||||
empty: "Kolekcijų dar nėra.",
|
||||
name: "Pavadinimas",
|
||||
documents: "Dokumentai",
|
||||
indexed: "Suindeksuota",
|
||||
createHeading: "Nauja kolekcija",
|
||||
nameLabel: "Pavadinimas",
|
||||
nameHint: "Mažosios raidės, skaičiai ir brūkšneliai.",
|
||||
create: "Sukurti kolekciją",
|
||||
creating: "Kuriama",
|
||||
pickerLabel: "Kolekcija",
|
||||
pick: "Pasirinkite kolekciją",
|
||||
},
|
||||
upload: {
|
||||
heading: "Indeksuoti dokumentą",
|
||||
label: "Failas",
|
||||
hint: "Grynasis tekstas, Markdown, JSON arba CSV, iki 1 MB.",
|
||||
submit: "Indeksuoti",
|
||||
busy: "Indeksavimas paprašytas",
|
||||
done: "Dokumentas eilėje indeksavimui.",
|
||||
failed: "Nepavyko: {detail}",
|
||||
},
|
||||
documents: {
|
||||
heading: "Dokumentai",
|
||||
empty: "Šioje kolekcijoje dar nieko suindeksuota.",
|
||||
file: "Failas",
|
||||
status: "Būsena",
|
||||
statusQueued: "Laukia",
|
||||
statusIndexing: "Indeksuojama",
|
||||
statusIndexed: "Suindeksuota",
|
||||
statusFailed: "Nepavyko",
|
||||
statusCancelled: "Pašalinta",
|
||||
error: "Klaida",
|
||||
remove: "Pašalinti: {filename}",
|
||||
removeShort: "Pašalinti",
|
||||
},
|
||||
search: {
|
||||
heading: "Paieška",
|
||||
label: "Klausimas",
|
||||
placeholder: "Klauskite per suindeksuotus dokumentus",
|
||||
submit: "Ieškoti",
|
||||
busy: "Ieškoma",
|
||||
empty: "Šioje kolekcijoje atsakymų nėra.",
|
||||
results: "{count, plural, one {# atsakymas} few {# atsakymai} many {# atsakymo} other {# atsakymų}}",
|
||||
snippet: "Ištrauka",
|
||||
source: "Šaltinis",
|
||||
chunk: "{chunk} ištrauka",
|
||||
score: "atitiktis {score}",
|
||||
disclosure: "Paieška su DI pagalba: atsakymus rikiuoja modelis, juos būtina patikrinti pagal šaltinį.",
|
||||
},
|
||||
settings: {
|
||||
heading: "Dokumentų DI",
|
||||
state: "Būsena",
|
||||
stateOn: "Įjungta",
|
||||
stateOff: "Išjungta",
|
||||
backend: "Backend'as",
|
||||
backendVectors: "Šios programos vektorinė saugykla",
|
||||
source: "Šaltinis",
|
||||
sourceOperator: "Operatoriaus sukonfigūruotas adresas",
|
||||
sourceManaged: "Valdoma paslauga",
|
||||
endpoint: "Adresas",
|
||||
enable: "Įjungti dokumentų DI",
|
||||
enabling: "Įjungiama",
|
||||
disable: "Išjungti",
|
||||
disabling: "Išjungiama",
|
||||
enabledDone: "Dokumentų DI įjungta.",
|
||||
disabledDone: "Dokumentų DI išjungta. Kolekcijos, dokumentai ir vektoriai išsaugoti.",
|
||||
planRequired: "Organizacijos planas dokumentų DI neapima.",
|
||||
featureDisabled: "Dokumentų DI išjungta platformos politikos.",
|
||||
quotaExhausted: "Indeksavimo kvota išsemta; bandykite vėliau.",
|
||||
upgrade: "Atnaujinkite planą, kad įjungtumėte dokumentų DI.",
|
||||
hint: "Įjungdama tikrina planą ir išleidimo požymius, prireikus prijungia valdomą vektorių paslaugą ir įrašo konfigūraciją šioje organizacijoje. Išjungdama viską išsaugo.",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// The knowledge module's public surface: the manifest and what the composition roots bind.
|
||||
|
||||
export type { SdkDocumentAiPlatformOptions } from "./adapters/enablement.js"
|
||||
export { AGENT_DOCUMENT_TOOLS_FLAG, AGENT_KNOWLEDGE_TOOLS_FLAG, sdkDocumentAiPlatform } from "./adapters/enablement.js"
|
||||
export { platformKnowledgeSearch } from "./adapters/platform-knowledge.js"
|
||||
export { unavailableKnowledgeSearch } from "./adapters/unavailable.js"
|
||||
export { vectorKnowledgeSearch } from "./adapters/vectors.js"
|
||||
export * from "./contract/index.js"
|
||||
export { CHUNK_SIZE, chunkText } from "./core/chunk.js"
|
||||
export type {
|
||||
DocumentAiPlatform,
|
||||
KnowledgeChunk,
|
||||
KnowledgeDeps,
|
||||
KnowledgeEmbeddings,
|
||||
KnowledgeFlags,
|
||||
KnowledgeHit,
|
||||
KnowledgeOrgScope,
|
||||
KnowledgeScope,
|
||||
KnowledgeSearchService,
|
||||
} from "./core/ports.js"
|
||||
export {
|
||||
KNOWLEDGE_EMBEDDINGS,
|
||||
KNOWLEDGE_ENABLEMENT,
|
||||
KNOWLEDGE_FLAGS,
|
||||
KNOWLEDGE_SCOPE,
|
||||
KNOWLEDGE_SEARCH,
|
||||
} from "./core/ports.js"
|
||||
export { convexKnowledgeModule } from "./module.convex.js"
|
||||
export { knowledgeModule } from "./module.js"
|
||||
export { KNOWLEDGE_MODULE_BASE } from "./module.shared.js"
|
||||
@@ -0,0 +1,68 @@
|
||||
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
create table "knowledge_collection" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"project_id" uuid not null,
|
||||
"name" text not null,
|
||||
"created_by" text not null
|
||||
);
|
||||
|
||||
alter table "knowledge_collection" add constraint "knowledge_collection_org_id_workspace_id_project_id_name_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "name");
|
||||
|
||||
create table "knowledge_config" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"enabled" boolean not null,
|
||||
"backend" text not null,
|
||||
"source" text not null,
|
||||
"service_id" text,
|
||||
"url" text,
|
||||
"updated_by" text not null,
|
||||
"configured_at" text not null
|
||||
);
|
||||
|
||||
create table "knowledge_document" (
|
||||
"id" uuid primary key,
|
||||
"version" integer not null,
|
||||
"created_at" timestamptz not null,
|
||||
"updated_at" timestamptz not null,
|
||||
"org_id" uuid not null,
|
||||
"workspace_id" uuid not null,
|
||||
"project_id" uuid not null,
|
||||
"collection_name" text not null,
|
||||
"file_id" uuid not null,
|
||||
"filename" text not null,
|
||||
"status" text not null,
|
||||
"error" text,
|
||||
"indexed_at" text,
|
||||
"requested_by" text not null
|
||||
);
|
||||
|
||||
alter table "knowledge_document" add constraint "knowledge_document_org_id_workspace_id_project_id_12648b24_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "collection_name", "file_id");
|
||||
|
||||
create index "knowledge_document_org_id_workspace_id_project_id_ec03a581_idx" on "knowledge_document" ("org_id", "workspace_id", "project_id", "collection_name");
|
||||
|
||||
alter table "knowledge_collection" enable row level security;
|
||||
|
||||
alter table "knowledge_collection" force row level security;
|
||||
|
||||
create policy "knowledge_collection_tenant" on "knowledge_collection" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
|
||||
alter table "knowledge_config" enable row level security;
|
||||
|
||||
alter table "knowledge_config" force row level security;
|
||||
|
||||
create policy "knowledge_config_tenant" on "knowledge_config" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
|
||||
alter table "knowledge_document" enable row level security;
|
||||
|
||||
alter table "knowledge_document" force row level security;
|
||||
|
||||
create policy "knowledge_document_tenant" on "knowledge_document" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
|
||||
@@ -0,0 +1,24 @@
|
||||
// The Convex profile binding of the knowledge module, shipped fail-closed for a future wiring:
|
||||
// the integration review kept the module Postgres-only because the search effect, the
|
||||
// index/remove jobs and the enablement flow all leave the process and have no composed Convex
|
||||
// Node actions yet (see README.md). Knowledge indexing needs the Node runtime boundary (the AI
|
||||
// gateway SDK, the vector adapter), exactly like documents' OCR: the job handlers here fail
|
||||
// closed so the work can only reach the platform SDKs through a composed Node action, never the
|
||||
// transaction dispatcher.
|
||||
|
||||
import { defineModule, internal, type JobDef } from "@conusai-kit/core"
|
||||
import { type DocumentJobPayload, documentPayload } from "./adapters/jobs.js"
|
||||
import { INDEX_JOB, REMOVE_JOB } from "./contract/index.js"
|
||||
import { KNOWLEDGE_MODULE_BASE } from "./module.shared.js"
|
||||
|
||||
const actionOnly = (name: string): JobDef<DocumentJobPayload> => ({
|
||||
payload: documentPayload,
|
||||
async handler() {
|
||||
throw internal(`${name} must run through the Convex Node action boundary`)
|
||||
},
|
||||
})
|
||||
|
||||
export const convexKnowledgeModule = defineModule({
|
||||
...KNOWLEDGE_MODULE_BASE,
|
||||
jobs: { [INDEX_JOB]: actionOnly(INDEX_JOB), [REMOVE_JOB]: actionOnly(REMOVE_JOB) },
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
// Shared, runtime-neutral knowledge manifest data. The normal app binds its worker implementations
|
||||
// of the index/remove jobs; the Convex binding file (module.convex.ts) ships fail-closed for a
|
||||
// future wiring — the module stays Postgres-only until the search effect and the jobs have their
|
||||
// composed Node actions (see README.md).
|
||||
|
||||
import type { ModuleManifest } from "@conusai-kit/core"
|
||||
import { repositoryBindings } from "@conusai-kit/data"
|
||||
import { KNOWLEDGE_PROCEDURE_IMPLS } from "./adapters/procedures.js"
|
||||
import {
|
||||
COLLECTION_CREATED,
|
||||
DOCUMENT_AI_DISABLED,
|
||||
DOCUMENT_AI_ENABLED,
|
||||
KNOWLEDGE_ENTITIES,
|
||||
KNOWLEDGE_FLAG_DEFAULTS,
|
||||
} from "./contract/index.js"
|
||||
import { en, lt } from "./i18n/index.js"
|
||||
|
||||
const tables = repositoryBindings(...KNOWLEDGE_ENTITIES)
|
||||
|
||||
export const KNOWLEDGE_MODULE_BASE: ModuleManifest = {
|
||||
id: "knowledge",
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy", "files"],
|
||||
permissions: {
|
||||
// Searching and reading the status is a member's act; the kit's member maps onto "editor".
|
||||
"knowledge.knowledge.search": "editor",
|
||||
// Managing collections, indexing and removing documents, and the enablement flow are
|
||||
// administrative acts.
|
||||
"knowledge.collection.manage": "admin",
|
||||
"knowledge.knowledge.manage": "admin",
|
||||
"knowledge.settings.manage": "admin",
|
||||
},
|
||||
procedures: KNOWLEDGE_PROCEDURE_IMPLS,
|
||||
repositories: { postgres: tables },
|
||||
migrations: { postgres: "migrations" },
|
||||
// The module ships the Postgres profile; the Convex binding file (module.convex.ts) ships
|
||||
// fail-closed for a future wiring and is deliberately NOT registered in convex/ — the search
|
||||
// effect, the jobs and the enablement flow all leave the process and have no composed Convex
|
||||
// Node actions yet (see README.md).
|
||||
profiles: ["postgres"],
|
||||
audit: [COLLECTION_CREATED, DOCUMENT_AI_ENABLED, DOCUMENT_AI_DISABLED],
|
||||
flags: Object.fromEntries(Object.entries(KNOWLEDGE_FLAG_DEFAULTS).map(([flag, value]) => [flag, { default: value }])),
|
||||
ui: {
|
||||
nav: [{ scope: "project", label: "knowledge.title", href: "knowledge", permission: "knowledge.knowledge.search" }],
|
||||
},
|
||||
i18n: { en, lt },
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// The knowledge module's manifest (spec §4.1): the one place it registers anything. The normal
|
||||
// (Postgres) app binds the worker implementations of the index/remove jobs; the module's own
|
||||
// outbox subscribers enqueue them, so a web request writes the row and answers, never waiting on
|
||||
// chunking, embeddings or the vector store.
|
||||
|
||||
import { defineModule } from "@conusai-kit/core"
|
||||
import { KNOWLEDGE_JOBS, KNOWLEDGE_SUBSCRIPTIONS } from "./adapters/jobs.js"
|
||||
import { KNOWLEDGE_EVENTS } from "./contract/index.js"
|
||||
import { KNOWLEDGE_MODULE_BASE } from "./module.shared.js"
|
||||
|
||||
export const knowledgeModule = defineModule({
|
||||
...KNOWLEDGE_MODULE_BASE,
|
||||
jobs: KNOWLEDGE_JOBS,
|
||||
events: { publishes: [...KNOWLEDGE_EVENTS], subscribes: KNOWLEDGE_SUBSCRIPTIONS },
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
// Upload and index: the browser sends the file to the files module (the kit's `uploadFile`), then
|
||||
// asks this module to index it (`knowledge.document.index`). The indexing itself is a worker job;
|
||||
// this form only ever waits on the request.
|
||||
|
||||
import { type ProjectSlugs, uploadFile, useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, useState } from "react"
|
||||
import { INDEXABLE_CONTENT_TYPES, MAX_INDEX_BYTES } from "../contract/index.js"
|
||||
|
||||
export interface IndexFormLabels {
|
||||
readonly heading: string
|
||||
readonly label: string
|
||||
readonly hint: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly done: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
type State = { kind: "idle" } | { kind: "busy" } | { kind: "done" } | { kind: "failed"; detail: string }
|
||||
|
||||
export function IndexForm({
|
||||
slugs,
|
||||
collectionName,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
collectionName: string
|
||||
labels: IndexFormLabels
|
||||
}): ReactElement {
|
||||
const transport = useTransport()
|
||||
const { call, describe } = transport
|
||||
const inputId = useId()
|
||||
const hintId = useId()
|
||||
const [state, setState] = useState<State>({ kind: "idle" })
|
||||
|
||||
const index = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const form = event.currentTarget
|
||||
const file = new FormData(form).get("file")
|
||||
if (!(file instanceof File) || file.size === 0) return
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const stored = await uploadFile(transport, slugs, file)
|
||||
await call("knowledge.document.index", { ...slugs, collectionName, fileId: stored.fileId })
|
||||
form.reset()
|
||||
setState({ kind: "done" })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`${inputId}-heading`}>
|
||||
<h2 id={`${inputId}-heading`}>{labels.heading}</h2>
|
||||
<form onSubmit={(event) => void index(event)}>
|
||||
<label htmlFor={inputId}>{labels.label}</label>
|
||||
<input
|
||||
id={inputId}
|
||||
name="file"
|
||||
type="file"
|
||||
required
|
||||
aria-describedby={hintId}
|
||||
accept={INDEXABLE_CONTENT_TYPES.join(",")}
|
||||
/>
|
||||
<p id={hintId}>
|
||||
{labels.hint} · {Math.round(MAX_INDEX_BYTES / 1024)} KB.
|
||||
</p>
|
||||
<button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</button>
|
||||
</form>
|
||||
{state.kind === "done" ? <p role="status">{labels.done}</p> : null}
|
||||
{state.kind === "failed" ? <p role="alert">{labels.failed.replace("{detail}", state.detail)}</p> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { IndexFormLabels } from "./index-form.js"
|
||||
export { IndexForm } from "./index-form.js"
|
||||
export type { SearchFormLabels, StatusLabels } from "./labels.js"
|
||||
export { knowledgePage } from "./pages.js"
|
||||
export { SearchForm } from "./search-form.js"
|
||||
export type { SettingsSectionLabels } from "./settings-section.js"
|
||||
export { SettingsSection } from "./settings-section.js"
|
||||
@@ -0,0 +1,79 @@
|
||||
// The labels each component takes, built from a message lookup. Every key this module's pages use
|
||||
// is named here and nowhere else, so `__tests__/i18n.test.ts` can run these builders against the
|
||||
// catalogues and fail on a key either language lacks.
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
/** The status shape the labels render, declared here: ui reads no core/. */
|
||||
interface StatusCounts {
|
||||
documents: { queued: number; indexing: number; indexed: number; failed: number; cancelled: number }
|
||||
}
|
||||
|
||||
export interface StatusLabels {
|
||||
readonly heading: string
|
||||
readonly flagOff: string
|
||||
readonly state: string
|
||||
readonly reasons: string
|
||||
readonly indexing: string
|
||||
readonly documents: string
|
||||
}
|
||||
|
||||
export function statusLabels(t: Translate, status: StatusCounts): StatusLabels {
|
||||
return {
|
||||
heading: t("status.heading"),
|
||||
flagOff: t("status.flagOff"),
|
||||
state: t("status.state"),
|
||||
reasons: t("status.reasons"),
|
||||
indexing: t("status.indexing"),
|
||||
documents: t("status.documents", status.documents),
|
||||
}
|
||||
}
|
||||
|
||||
export const serviceStateLabel = (t: Translate, state: string): string => {
|
||||
switch (state) {
|
||||
case "ready":
|
||||
return t("status.stateReady")
|
||||
case "indexing":
|
||||
return t("status.stateIndexing")
|
||||
case "registered":
|
||||
return t("status.stateRegistered")
|
||||
case "degraded":
|
||||
return t("status.stateDegraded")
|
||||
case "disabled":
|
||||
return t("status.stateDisabled")
|
||||
default:
|
||||
return t("status.stateUnavailable")
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchFormLabels {
|
||||
readonly heading: string
|
||||
readonly label: string
|
||||
readonly placeholder: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly empty: string
|
||||
readonly results: string
|
||||
readonly snippet: string
|
||||
readonly source: string
|
||||
readonly chunk: string
|
||||
readonly disclosure: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
export function searchFormLabels(t: Translate, failed: string): SearchFormLabels {
|
||||
return {
|
||||
heading: t("search.heading"),
|
||||
label: t("search.label"),
|
||||
placeholder: t("search.placeholder"),
|
||||
submit: t("search.submit"),
|
||||
busy: t("search.busy"),
|
||||
empty: t("search.empty"),
|
||||
results: t("search.results", { count: "{count}" }),
|
||||
snippet: t("search.snippet"),
|
||||
source: t("search.source"),
|
||||
chunk: t("search.chunk", { chunk: "{chunk}" }),
|
||||
disclosure: t("search.disclosure"),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// The knowledge module's page, as the one server component the web shell mounts under
|
||||
// `/<org>/<workspace>/<project>/knowledge`. Every read is a procedure through the same caller the
|
||||
// API uses; every write is a client component that calls a procedure through the shell's
|
||||
// transport.
|
||||
//
|
||||
// The page renders three things separately on purpose: the service's health (the module flag and
|
||||
// the search service's readiness), the indexing indicator (the per-document progress states), and
|
||||
// the search itself — "indexing" is not "down", and the page never conflates them.
|
||||
|
||||
import type { Container, TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import type { ProjectPage } from "@conusai-kit/ui"
|
||||
import type { ReactElement } from "react"
|
||||
import {
|
||||
type CollectionView,
|
||||
collectionList,
|
||||
type DocumentView,
|
||||
documentList,
|
||||
knowledgeStatus,
|
||||
} from "../contract/index.js"
|
||||
import { IndexForm, type IndexFormLabels } from "./index-form.js"
|
||||
import { searchFormLabels, serviceStateLabel, statusLabels } from "./labels.js"
|
||||
import { SearchForm } from "./search-form.js"
|
||||
|
||||
export type KnowledgePage = ProjectPage<TransportCaller, I18n, Container>
|
||||
|
||||
const indexFormLabels = (
|
||||
t: (key: string, values?: Record<string, string | number>) => string,
|
||||
failed: string,
|
||||
): IndexFormLabels => ({
|
||||
heading: t("upload.heading"),
|
||||
label: t("upload.label"),
|
||||
hint: t("upload.hint"),
|
||||
submit: t("upload.submit"),
|
||||
busy: t("upload.busy"),
|
||||
done: t("upload.done"),
|
||||
failed,
|
||||
})
|
||||
|
||||
const documentStatusKey: Record<DocumentView["status"], string> = {
|
||||
queued: "documents.statusQueued",
|
||||
indexing: "documents.statusIndexing",
|
||||
indexed: "documents.statusIndexed",
|
||||
failed: "documents.statusFailed",
|
||||
cancelled: "documents.statusCancelled",
|
||||
}
|
||||
|
||||
export const knowledgePage: KnowledgePage = async ({ slugs, caller, i18n }): Promise<ReactElement> => {
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`knowledge.${key}`, values)
|
||||
const failed = i18n.message("app.failed")
|
||||
|
||||
const [{ collections }, status] = await Promise.all([
|
||||
caller.call(collectionList, slugs),
|
||||
caller.call(knowledgeStatus, slugs),
|
||||
])
|
||||
const documentsByName = new Map<string, DocumentView[]>()
|
||||
await Promise.all(
|
||||
collections.map(async (collection: CollectionView) => {
|
||||
const answer = await caller.call(documentList, { ...slugs, collectionName: collection.name })
|
||||
documentsByName.set(collection.name, answer.documents)
|
||||
}),
|
||||
)
|
||||
|
||||
const labels = statusLabels(t, status)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{t("title")}</h1>
|
||||
<p>{t("intro")}</p>
|
||||
<section aria-labelledby="knowledge-status">
|
||||
<h2 id="knowledge-status">{labels.heading}</h2>
|
||||
{status.enabled ? (
|
||||
<dl>
|
||||
<dt>{labels.state}</dt>
|
||||
<dd>{serviceStateLabel(t, status.service.state)}</dd>
|
||||
{status.service.reasons.length > 0 ? (
|
||||
<>
|
||||
<dt>{labels.reasons}</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
{status.service.reasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt>{labels.indexing}</dt>
|
||||
<dd>{labels.documents}</dd>
|
||||
</dl>
|
||||
) : (
|
||||
<p role="status">{labels.flagOff}</p>
|
||||
)}
|
||||
</section>
|
||||
{status.enabled
|
||||
? collections.map((collection) => (
|
||||
<section key={collection.name} aria-labelledby={`collection-${collection.name}`}>
|
||||
<h2 id={`collection-${collection.name}`}>{collection.name}</h2>
|
||||
<IndexForm slugs={slugs} collectionName={collection.name} labels={indexFormLabels(t, failed)} />
|
||||
<SearchForm slugs={slugs} collectionName={collection.name} labels={searchFormLabels(t, failed)} />
|
||||
<table>
|
||||
<caption>{t("documents.heading")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("documents.file")}</th>
|
||||
<th scope="col">{t("documents.status")}</th>
|
||||
<th scope="col">{t("documents.error")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(documentsByName.get(collection.name) ?? []).map((document) => (
|
||||
<tr key={document.documentId}>
|
||||
<td>{document.filename}</td>
|
||||
<td>{t(documentStatusKey[document.status])}</td>
|
||||
<td>{document.error ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
// The search form: one question, cited answers. The disclosure the EU AI Act Article 50 asks for
|
||||
// rides with the results, and every answer names its source and chunk — a person checks a passage
|
||||
// against the file, never a model's word.
|
||||
|
||||
import { AiDisclosureBadge, type ProjectSlugs, useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, useState } from "react"
|
||||
import type { SearchResult } from "../contract/index.js"
|
||||
import type { SearchFormLabels } from "./labels.js"
|
||||
|
||||
type State =
|
||||
| { kind: "idle" }
|
||||
| { kind: "busy" }
|
||||
| { kind: "done"; results: SearchResult[] }
|
||||
| { kind: "failed"; detail: string }
|
||||
|
||||
export function SearchForm({
|
||||
slugs,
|
||||
collectionName,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
collectionName: string
|
||||
labels: SearchFormLabels
|
||||
}): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const inputId = useId()
|
||||
const [state, setState] = useState<State>({ kind: "idle" })
|
||||
|
||||
const search = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const query = String(new FormData(event.currentTarget).get("query") ?? "").trim()
|
||||
if (query === "") return
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const answer = (await call("knowledge.search", { ...slugs, collectionName, query })) as {
|
||||
results: SearchResult[]
|
||||
}
|
||||
setState({ kind: "done", results: answer.results })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`${inputId}-heading`}>
|
||||
<h2 id={`${inputId}-heading`}>{labels.heading}</h2>
|
||||
<AiDisclosureBadge label={labels.disclosure} />
|
||||
<form onSubmit={(event) => void search(event)}>
|
||||
<label htmlFor={inputId}>{labels.label}</label>
|
||||
<input id={inputId} name="query" type="search" placeholder={labels.placeholder} required maxLength={500} />
|
||||
<button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</button>
|
||||
</form>
|
||||
{state.kind === "failed" ? <p role="alert">{labels.failed.replace("{detail}", state.detail)}</p> : null}
|
||||
{state.kind === "done" ? (
|
||||
state.results.length === 0 ? (
|
||||
<p>{labels.empty}</p>
|
||||
) : (
|
||||
<ol aria-label={labels.results.replace("{count}", String(state.results.length))}>
|
||||
{state.results.map((result, at) => (
|
||||
<li key={`${result.fileId}-${result.chunk}-${at}`}>
|
||||
<blockquote>{result.snippet}</blockquote>
|
||||
<p>
|
||||
{labels.source}: {result.filename} · {labels.chunk.replace("{chunk}", String(result.chunk))}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client"
|
||||
|
||||
// The enablement controls of the organization's settings page: the current state, the enable
|
||||
// button with its typed refusal reasons (plan, policy, quota), and disable — which preserves
|
||||
// every collection, document and vector.
|
||||
|
||||
import { useTransport } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useId, useState } from "react"
|
||||
|
||||
export interface SettingsSectionLabels {
|
||||
readonly heading: string
|
||||
readonly state: string
|
||||
readonly stateOn: string
|
||||
readonly stateOff: string
|
||||
readonly backend: string
|
||||
readonly backendVectors: string
|
||||
readonly source: string
|
||||
readonly sourceOperator: string
|
||||
readonly sourceManaged: string
|
||||
readonly endpoint: string
|
||||
readonly enable: string
|
||||
readonly enabling: string
|
||||
readonly disable: string
|
||||
readonly disabling: string
|
||||
readonly enabledDone: string
|
||||
readonly disabledDone: string
|
||||
readonly planRequired: string
|
||||
readonly featureDisabled: string
|
||||
readonly quotaExhausted: string
|
||||
readonly upgrade: string
|
||||
readonly hint: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
type View = { kind: "idle" } | { kind: "busy" } | { kind: "message"; text: string; alert: boolean }
|
||||
|
||||
export function SettingsSection({
|
||||
org,
|
||||
initial,
|
||||
labels,
|
||||
}: {
|
||||
org: string
|
||||
initial: { enabled: boolean; backend: string | null; source: string | null; url: string | null }
|
||||
labels: SettingsSectionLabels
|
||||
}): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const id = useId()
|
||||
const [enabled, setEnabled] = useState(initial.enabled)
|
||||
const [view, setView] = useState<View>({ kind: "idle" })
|
||||
|
||||
const enable = async (): Promise<void> => {
|
||||
setView({ kind: "busy" })
|
||||
try {
|
||||
await call("knowledge.settings.enableDocumentAi", { org })
|
||||
setEnabled(true)
|
||||
setView({ kind: "message", text: labels.enabledDone, alert: false })
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string }).code ?? "unknown"
|
||||
if (code === "plan_required") {
|
||||
setView({ kind: "message", text: `${labels.planRequired} ${labels.upgrade}`, alert: true })
|
||||
} else if (code === "quota_exhausted") {
|
||||
setView({ kind: "message", text: labels.quotaExhausted, alert: true })
|
||||
} else if (code === "feature_disabled") {
|
||||
setView({ kind: "message", text: labels.featureDisabled, alert: true })
|
||||
} else {
|
||||
setView({ kind: "message", text: describe(error), alert: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const disable = async (): Promise<void> => {
|
||||
setView({ kind: "busy" })
|
||||
try {
|
||||
await call("knowledge.settings.disableDocumentAi", { org })
|
||||
setEnabled(false)
|
||||
setView({ kind: "message", text: labels.disabledDone, alert: false })
|
||||
} catch (error) {
|
||||
setView({ kind: "message", text: describe(error), alert: true })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`${id}-heading`}>
|
||||
<h2 id={`${id}-heading`}>{labels.heading}</h2>
|
||||
<p>{labels.hint}</p>
|
||||
<dl>
|
||||
<dt>{labels.state}</dt>
|
||||
<dd>{enabled ? labels.stateOn : labels.stateOff}</dd>
|
||||
{initial.backend !== null ? (
|
||||
<>
|
||||
<dt>{labels.backend}</dt>
|
||||
<dd>{initial.backend === "vectors" ? labels.backendVectors : initial.backend}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{initial.source !== null ? (
|
||||
<>
|
||||
<dt>{labels.source}</dt>
|
||||
<dd>{initial.source === "operator" ? labels.sourceOperator : labels.sourceManaged}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{initial.url !== null ? (
|
||||
<>
|
||||
<dt>{labels.endpoint}</dt>
|
||||
<dd>{initial.url}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
{view.kind === "message" ? <p role={view.alert ? "alert" : "status"}>{view.text}</p> : null}
|
||||
{enabled ? (
|
||||
<button type="button" disabled={view.kind === "busy"} onClick={() => void disable()}>
|
||||
{view.kind === "busy" ? labels.disabling : labels.disable}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" disabled={view.kind === "busy"} onClick={() => void enable()}>
|
||||
{view.kind === "busy" ? labels.enabling : labels.enable}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user