ConusAI vendor v0.1.0-dev.20261027+9b1131fb
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-dev.20261027+9b1131fb
2026-10-27 18:05:17 +00:00
commit 3102273ec7
538 changed files with 80203 additions and 0 deletions
+50
View File
@@ -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.
+33
View File
@@ -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([])
})
})
+30
View File
@@ -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")
})
})
+53
View File
@@ -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)
})
})
+89
View File
@@ -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"])
})
})
+141
View File
@@ -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 }
},
}
}
+102
View File
@@ -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)
}
},
}
}
+100
View File
@@ -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,
]
+41
View File
@@ -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)
},
}
}
+98
View File
@@ -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 }
+36
View File
@@ -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
+68
View File
@@ -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])
+36
View File
@@ -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]
+45
View File
@@ -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"
+187
View File
@@ -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 }
+22
View File
@@ -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]
+38
View File
@@ -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
}
+132
View File
@@ -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")
}
+192
View File
@@ -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
}
+112
View File
@@ -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
}
+118
View File
@@ -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)
+38
View File
@@ -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,
}
}),
}
}
+140
View File
@@ -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 }
}
+40
View File
@@ -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 }
}
+96
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
export { en } from "./en.js"
export { lt } from "./lt.js"
+100
View File
@@ -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.",
},
}
+30
View File
@@ -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);
+24
View File
@@ -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) },
})
+48
View File
@@ -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 },
}
+15
View File
@@ -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 },
})
+79
View File
@@ -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>
)
}
+7
View File
@@ -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"
+79
View File
@@ -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,
}
}
+125
View File
@@ -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}
</>
)
}
+76
View File
@@ -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>
)
}
+120
View File
@@ -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>
)
}