This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
// The chunker: one text into citation-sized pieces. Pure TypeScript, unit-tested without a store
|
||||
// or a model. Chunks split on blank lines where they can, so a snippet reads as a sentence and not
|
||||
// as a byte range; a chunk that fits stays whole.
|
||||
|
||||
import { validationFailed } from "@conusai-kit/core"
|
||||
import type { KnowledgeChunk } from "./ports.js"
|
||||
|
||||
export const CHUNK_SIZE = 2_000
|
||||
const CHUNK_OVERLAP = 200
|
||||
|
||||
/**
|
||||
* Splits one document's text into chunks of at most {@link CHUNK_SIZE} characters. Paragraphs
|
||||
* longer than the size are hard-split; the overlap keeps a sentence split across two chunks
|
||||
* retrievable from either side.
|
||||
*/
|
||||
export function chunkText(text: string): KnowledgeChunk[] {
|
||||
if (text.length === 0)
|
||||
throw validationFailed([{ path: "file", message: "the file has no text to index" }], "nothing to index")
|
||||
const paragraphs = text.split(/\r?\n\r?\n/).flatMap((paragraph) => {
|
||||
if (paragraph.length <= CHUNK_SIZE) return [paragraph]
|
||||
const pieces: string[] = []
|
||||
for (let at = 0; at < paragraph.length; at += CHUNK_SIZE - CHUNK_OVERLAP) {
|
||||
pieces.push(paragraph.slice(at, at + CHUNK_SIZE))
|
||||
}
|
||||
return pieces
|
||||
})
|
||||
const chunks: KnowledgeChunk[] = []
|
||||
let current = ""
|
||||
for (const paragraph of paragraphs) {
|
||||
if (current !== "" && current.length + paragraph.length + 2 > CHUNK_SIZE) {
|
||||
chunks.push({ position: chunks.length + 1, text: current })
|
||||
current = ""
|
||||
}
|
||||
current = current === "" ? paragraph : `${current}\n\n${paragraph}`
|
||||
}
|
||||
if (current !== "") chunks.push({ position: chunks.length + 1, text: current })
|
||||
return chunks
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// The collections: list them with their document counts, create one. A collection is a named
|
||||
// bucket; its name is the slug every procedure takes, so a page never handles an id.
|
||||
|
||||
import { conflict, notFound, validationFailed } from "@conusai-kit/core"
|
||||
import { type CollectionView, collections, documents } from "../contract/index.js"
|
||||
import type { KnowledgeDeps, KnowledgeScope } from "./ports.js"
|
||||
|
||||
const PAGE = 200
|
||||
|
||||
export async function listCollections(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
): Promise<{ collections: CollectionView[] }> {
|
||||
const found = []
|
||||
let cursor: string | undefined
|
||||
for (;;) {
|
||||
const page = await deps.data
|
||||
.forScope(scope)
|
||||
.of(collections)
|
||||
.list({
|
||||
order: [{ field: "createdAt", direction: "asc" }],
|
||||
limit: PAGE,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
})
|
||||
found.push(...page.items)
|
||||
if (!page.hasMore) break
|
||||
cursor = page.cursor
|
||||
}
|
||||
const views: CollectionView[] = []
|
||||
for (const row of found) {
|
||||
const counts = await documentCounts(deps, scope, row.name)
|
||||
views.push({
|
||||
name: row.name,
|
||||
documentCount: counts.total,
|
||||
indexedCount: counts.indexed,
|
||||
createdBy: row.createdBy,
|
||||
})
|
||||
}
|
||||
return { collections: views }
|
||||
}
|
||||
|
||||
async function documentCounts(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
collectionName: string,
|
||||
): Promise<{ total: number; indexed: number }> {
|
||||
let total = 0
|
||||
let indexed = 0
|
||||
let cursor: string | undefined
|
||||
for (;;) {
|
||||
const page = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.list({
|
||||
where: [{ field: "collectionName", op: "eq", value: collectionName }],
|
||||
limit: PAGE,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
})
|
||||
for (const row of page.items) {
|
||||
total += 1
|
||||
if (row.status === "indexed") indexed += 1
|
||||
}
|
||||
if (!page.hasMore) break
|
||||
cursor = page.cursor
|
||||
}
|
||||
return { total, indexed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a collection, idempotently: a name that already exists answers with the existing
|
||||
* collection and writes nothing, so a retried request never audits twice.
|
||||
*/
|
||||
export async function createCollection(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
name: string,
|
||||
): Promise<CollectionView> {
|
||||
const repos = deps.data.forScope(scope)
|
||||
const existing = (await repos.of(collections).list({ where: [{ field: "name", op: "eq", value: name }], limit: 1 }))
|
||||
.items[0]
|
||||
if (existing) {
|
||||
const counts = await documentCounts(deps, scope, name)
|
||||
return {
|
||||
name: existing.name,
|
||||
documentCount: counts.total,
|
||||
indexedCount: counts.indexed,
|
||||
createdBy: existing.createdBy,
|
||||
}
|
||||
}
|
||||
try {
|
||||
const row = await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const inserted = await tx.of(collections).insert({ name, createdBy: scope.actor.userId })
|
||||
return inserted
|
||||
})
|
||||
return { name: row.name, documentCount: 0, indexedCount: 0, createdBy: row.createdBy }
|
||||
} catch (error) {
|
||||
// A concurrent create with the same name lost the race: answer the winner's row.
|
||||
if ((error as { status?: number }).status === 409) {
|
||||
const winner = (await repos.of(collections).list({ where: [{ field: "name", op: "eq", value: name }], limit: 1 }))
|
||||
.items[0]
|
||||
if (winner) {
|
||||
const counts = await documentCounts(deps, scope, name)
|
||||
return {
|
||||
name: winner.name,
|
||||
documentCount: counts.total,
|
||||
indexedCount: counts.indexed,
|
||||
createdBy: winner.createdBy,
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** One collection by name, or NotFound: the resolver's own read is the membership check. */
|
||||
export async function requireCollection(deps: KnowledgeDeps, scope: KnowledgeScope, name: string) {
|
||||
const row = (
|
||||
await deps.data
|
||||
.forScope(scope)
|
||||
.of(collections)
|
||||
.list({ where: [{ field: "name", op: "eq", value: name }], limit: 1 })
|
||||
).items[0]
|
||||
if (!row) throw notFound("no such collection")
|
||||
return row
|
||||
}
|
||||
|
||||
export const unknownCollection = (name: string): never => {
|
||||
throw validationFailed([{ path: "collectionName", message: "no such collection" }], `no collection ${name}`)
|
||||
}
|
||||
export const takenCollection = (): never => {
|
||||
throw conflict("this collection name is taken")
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// The documents: request an index (a web write that records the row and publishes the outbox
|
||||
// event the worker's subscriber turns into the index job), request a removal the same way, and
|
||||
// list a collection's documents with their progress states.
|
||||
|
||||
import { forbidden, notFound, validationFailed } from "@conusai-kit/core"
|
||||
import { getFile } from "@conusai-kit/files"
|
||||
import {
|
||||
type DocumentView,
|
||||
documentIndexRequested,
|
||||
documentRemoveRequested,
|
||||
documents,
|
||||
INDEXABLE_CONTENT_TYPES,
|
||||
MAX_INDEX_BYTES,
|
||||
} from "../contract/index.js"
|
||||
import { requireCollection } from "./collections.js"
|
||||
import { type KnowledgeDeps, type KnowledgeScope, knowledgeEnabled } from "./ports.js"
|
||||
|
||||
const PAGE = 200
|
||||
|
||||
export const documentView = (row: {
|
||||
id: string
|
||||
collectionName: string
|
||||
fileId: string
|
||||
filename: string
|
||||
status: DocumentView["status"]
|
||||
error: string | null
|
||||
indexedAt: string | null
|
||||
requestedBy: string
|
||||
}): DocumentView => ({
|
||||
documentId: row.id,
|
||||
collectionName: row.collectionName,
|
||||
fileId: row.fileId,
|
||||
filename: row.filename,
|
||||
status: row.status,
|
||||
error: row.error,
|
||||
indexedAt: row.indexedAt,
|
||||
requestedBy: row.requestedBy,
|
||||
})
|
||||
|
||||
export async function listDocuments(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
collectionName: string,
|
||||
): Promise<{ documents: DocumentView[] }> {
|
||||
const found = []
|
||||
let cursor: string | undefined
|
||||
for (;;) {
|
||||
const page = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.list({
|
||||
where: [{ field: "collectionName", op: "eq", value: collectionName }],
|
||||
order: [{ field: "createdAt", direction: "desc" }],
|
||||
limit: PAGE,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
})
|
||||
found.push(...page.items)
|
||||
if (!page.hasMore) break
|
||||
cursor = page.cursor
|
||||
}
|
||||
return { documents: found.map((row) => documentView(row)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for a document's indexing. The write is three things in one transaction: the durable row
|
||||
* (the UI's progress states and the job's idempotency key), the outbox event the worker's
|
||||
* subscriber enqueues the index job from, and nothing else — chunking, embeddings and the vector
|
||||
* store all happen in the worker, never in a request (AGENTS.md: long work is a job).
|
||||
*
|
||||
* Idempotent by (collection, file): asking again for a pair that is queued or indexing answers
|
||||
* the same row and publishes nothing; asking for an indexed or failed pair re-queues the one row,
|
||||
* so a retry can never duplicate a document.
|
||||
*/
|
||||
export async function requestIndex(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
input: { collectionName: string; fileId: string },
|
||||
): Promise<DocumentView> {
|
||||
// The tenant-wall reads come before the flag check: another tenant's ids answer NotFound, never
|
||||
// a policy refusal, so ids do not leak existence behind a disabled flag.
|
||||
await requireCollection(deps, scope, input.collectionName)
|
||||
const file = await getFile(deps.files, scope, input.fileId)
|
||||
if (!(INDEXABLE_CONTENT_TYPES as readonly string[]).includes(file.contentType)) {
|
||||
throw validationFailed(
|
||||
[{ path: "fileId", message: `${file.contentType} cannot be indexed; index a text file` }],
|
||||
"this file type is not indexable",
|
||||
)
|
||||
}
|
||||
if (file.size > MAX_INDEX_BYTES) {
|
||||
throw validationFailed(
|
||||
[{ path: "fileId", message: `the file is larger than ${MAX_INDEX_BYTES} bytes` }],
|
||||
"the file is too large to index",
|
||||
)
|
||||
}
|
||||
if (!(await knowledgeEnabled(deps, scope))) throw forbidden("knowledge is switched off for this project")
|
||||
|
||||
return deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const existing = (
|
||||
await tx.of(documents).list({
|
||||
where: [
|
||||
{ field: "collectionName", op: "eq", value: input.collectionName },
|
||||
{ field: "fileId", op: "eq", value: input.fileId },
|
||||
],
|
||||
limit: 1,
|
||||
})
|
||||
).items[0]
|
||||
if (existing && (existing.status === "queued" || existing.status === "indexing")) {
|
||||
return documentView(existing)
|
||||
}
|
||||
const row =
|
||||
existing !== undefined
|
||||
? await tx
|
||||
.of(documents)
|
||||
.update(
|
||||
existing.id,
|
||||
{ status: "queued", error: null, indexedAt: null, requestedBy: scope.actor.userId },
|
||||
existing.version,
|
||||
)
|
||||
: await tx.of(documents).insert({
|
||||
collectionName: input.collectionName,
|
||||
fileId: input.fileId,
|
||||
filename: file.filename,
|
||||
status: "queued",
|
||||
error: null,
|
||||
indexedAt: null,
|
||||
requestedBy: scope.actor.userId,
|
||||
})
|
||||
await tx.publish(documentIndexRequested, {
|
||||
orgId: scope.orgId,
|
||||
workspaceId: scope.workspaceId,
|
||||
projectId: scope.projectId,
|
||||
documentId: row.id,
|
||||
collectionName: input.collectionName,
|
||||
fileId: input.fileId,
|
||||
})
|
||||
return documentView(row)
|
||||
})
|
||||
}
|
||||
|
||||
/** Asks for a document's removal: the row and the event in one transaction, the job in the worker. */
|
||||
export async function requestRemove(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
input: { collectionName: string; fileId: string },
|
||||
): Promise<DocumentView> {
|
||||
const current = (
|
||||
await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.list({
|
||||
where: [
|
||||
{ field: "collectionName", op: "eq", value: input.collectionName },
|
||||
{ field: "fileId", op: "eq", value: input.fileId },
|
||||
],
|
||||
limit: 1,
|
||||
})
|
||||
).items[0]
|
||||
// The tenant-wall read comes first, as in requestIndex: another tenant's ids answer NotFound.
|
||||
if (!current) throw notFound("no such document")
|
||||
if (current.status === "cancelled") return documentView(current)
|
||||
if (!(await knowledgeEnabled(deps, scope))) throw forbidden("knowledge is switched off for this project")
|
||||
return deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const row = (
|
||||
await tx.of(documents).list({
|
||||
where: [
|
||||
{ field: "collectionName", op: "eq", value: input.collectionName },
|
||||
{ field: "fileId", op: "eq", value: input.fileId },
|
||||
],
|
||||
limit: 1,
|
||||
})
|
||||
).items[0]
|
||||
if (!row) throw notFound("no such document")
|
||||
if (row.status === "cancelled") return documentView(row)
|
||||
const next = await tx.of(documents).update(row.id, { status: "cancelled", error: null }, row.version)
|
||||
await tx.publish(documentRemoveRequested, {
|
||||
orgId: scope.orgId,
|
||||
workspaceId: scope.workspaceId,
|
||||
projectId: scope.projectId,
|
||||
documentId: row.id,
|
||||
collectionName: input.collectionName,
|
||||
fileId: input.fileId,
|
||||
})
|
||||
return documentView(next)
|
||||
})
|
||||
}
|
||||
|
||||
/** The document row a job resumes by; NotFound when another tenant's id was named. */
|
||||
export async function documentById(deps: KnowledgeDeps, scope: KnowledgeScope, documentId: string) {
|
||||
const row = await deps.data.forScope(scope).of(documents).get(documentId)
|
||||
if (!row) throw notFound("no such document")
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// The index job's body: what the worker runs for one document. Read the file's bytes, chunk the
|
||||
// text, embed the chunks through the search service, and record the outcome on the durable row.
|
||||
// Transient failures (the vector store hiccups) throw, and the queue retries the job; the row the
|
||||
// retry resumes from makes the retry safe. Permanent failures are recorded on the row with a
|
||||
// stable code, so a person sees why a document failed and a retried request does not pay twice.
|
||||
|
||||
import { internal } from "@conusai-kit/core"
|
||||
import { downloadFile } from "@conusai-kit/files"
|
||||
import { type DocumentView, documents } from "../contract/index.js"
|
||||
import { documentById } from "./documents.js"
|
||||
import type { KnowledgeDeps, KnowledgeScope } from "./ports.js"
|
||||
|
||||
export async function indexDocument(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
documentId: string,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<DocumentView> {
|
||||
const row = await documentById(deps, scope, documentId)
|
||||
if (row.status === "indexing" || row.status === "indexed") {
|
||||
// A redelivered job or a crashed retry: the row says where the work stands.
|
||||
return {
|
||||
documentId: row.id,
|
||||
collectionName: row.collectionName,
|
||||
fileId: row.fileId,
|
||||
filename: row.filename,
|
||||
status: row.status,
|
||||
error: row.error,
|
||||
indexedAt: row.indexedAt,
|
||||
requestedBy: row.requestedBy,
|
||||
}
|
||||
}
|
||||
const service = deps.search
|
||||
if (!service) throw internal("the knowledge search service is not bound in this process")
|
||||
const indexing = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.update(row.id, { status: "indexing", error: null }, row.version)
|
||||
|
||||
try {
|
||||
const bytes = await readFileBytes(deps, scope, row.fileId)
|
||||
const text = decodeText(bytes)
|
||||
await service.index(scope, {
|
||||
collectionName: row.collectionName,
|
||||
fileId: row.fileId,
|
||||
filename: row.filename,
|
||||
bytes: new TextEncoder().encode(text),
|
||||
})
|
||||
const done = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.update(indexing.id, { status: "indexed", indexedAt: new Date().toISOString(), error: null }, indexing.version)
|
||||
return {
|
||||
documentId: done.id,
|
||||
collectionName: done.collectionName,
|
||||
fileId: done.fileId,
|
||||
filename: done.filename,
|
||||
status: done.status as DocumentView["status"],
|
||||
error: done.error,
|
||||
indexedAt: done.indexedAt,
|
||||
requestedBy: done.requestedBy,
|
||||
}
|
||||
} catch (error) {
|
||||
if (options.signal?.aborted) throw error
|
||||
const permanent = permanentFailure(error)
|
||||
if (permanent === null) throw error
|
||||
const failed = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.update(indexing.id, { status: "failed", error: permanent }, indexing.version)
|
||||
return {
|
||||
documentId: failed.id,
|
||||
collectionName: failed.collectionName,
|
||||
fileId: failed.fileId,
|
||||
filename: failed.filename,
|
||||
status: "failed",
|
||||
error: failed.error,
|
||||
indexedAt: failed.indexedAt,
|
||||
requestedBy: failed.requestedBy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDocument(deps: KnowledgeDeps, scope: KnowledgeScope, documentId: string): Promise<void> {
|
||||
const row = await documentById(deps, scope, documentId)
|
||||
const service = deps.search
|
||||
if (!service) throw internal("the knowledge search service is not bound in this process")
|
||||
await service.remove(scope, { collectionName: row.collectionName, fileId: row.fileId })
|
||||
await deps.data.forScope(scope).of(documents).delete(row.id)
|
||||
}
|
||||
|
||||
async function readFileBytes(deps: KnowledgeDeps, scope: KnowledgeScope, fileId: string): Promise<Uint8Array> {
|
||||
const response = await downloadFile(deps.files, scope, fileId)
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
function decodeText(bytes: Uint8Array): string {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
||||
} catch {
|
||||
throw Object.assign(new Error("the file is not valid UTF-8 text"), { permanent: "not_text" })
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps an indexing failure to the stable code recorded on the row; null when it is transient. */
|
||||
function permanentFailure(error: unknown): string | null {
|
||||
const code = (error as { permanent?: unknown }).permanent
|
||||
if (code === "not_text" || code === "not_indexable" || code === "too_large" || code === "vector_store_unavailable") {
|
||||
return String(code)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// The ports the knowledge module's use cases are written against, and the tokens the composition
|
||||
// roots bind. Core imports no SDK and no adapter (spec §2): the search service and the enablement
|
||||
// platform are shapes, and adapters/ is the only place an SDK exists.
|
||||
|
||||
import { type Container, type TenantScope, token } from "@conusai-kit/core"
|
||||
import type { DataSource } from "@conusai-kit/data"
|
||||
import type { FilesDeps } from "@conusai-kit/files"
|
||||
import type { EnablementReason, ServiceState } from "../contract/index.js"
|
||||
import { KNOWLEDGE_FLAG_DEFAULTS } from "../contract/index.js"
|
||||
|
||||
/** A project scope: collections and documents live at this level. */
|
||||
export type KnowledgeScope = TenantScope & { workspaceId: string; projectId: string }
|
||||
|
||||
/** An org scope: the enablement configuration is the organization's own. */
|
||||
export type KnowledgeOrgScope = TenantScope & { orgId: string }
|
||||
|
||||
export interface KnowledgeDeps {
|
||||
readonly data: DataSource
|
||||
readonly files: FilesDeps
|
||||
readonly flags: KnowledgeFlags
|
||||
/**
|
||||
* The search service. Present for the effect procedure (search) and the status read; a write
|
||||
* procedure's deps never carry it, because resolving an effect port from a write is a kind
|
||||
* violation — the write only records the row and publishes the event.
|
||||
*/
|
||||
readonly search?: KnowledgeSearchService | undefined
|
||||
}
|
||||
|
||||
/** What a chunk knows about where it came from: the citation a search result carries. */
|
||||
export interface KnowledgeChunk {
|
||||
/** 1-based position of the chunk in the document. */
|
||||
position: number
|
||||
text: string
|
||||
}
|
||||
|
||||
/** One cited search answer. */
|
||||
export interface KnowledgeHit {
|
||||
fileId: string
|
||||
filename: string
|
||||
collectionName: string
|
||||
/** 0..1; higher is closer. */
|
||||
score: number
|
||||
chunk: number
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The search service: index a document's chunks, search a collection with citations, remove a
|
||||
* document, and report readiness. Two adapters ship with the module: the local vector store
|
||||
* (embeddings through the AI gateway, chunks in the bound vector adapter) and the platform's
|
||||
* managed documents-index service. Embedding leaves the process, so this port is an effect.
|
||||
*/
|
||||
export interface KnowledgeSearchService {
|
||||
readiness(scope: KnowledgeScope): Promise<{ state: ServiceState; reasons: string[] }>
|
||||
index(
|
||||
scope: KnowledgeScope,
|
||||
input: { collectionName: string; fileId: string; filename: string; bytes: Uint8Array },
|
||||
): Promise<void>
|
||||
search(
|
||||
scope: KnowledgeScope,
|
||||
input: { collectionName: string; query: string; limit: number; actorId: string },
|
||||
): Promise<readonly KnowledgeHit[]>
|
||||
remove(scope: KnowledgeScope, input: { collectionName: string; fileId: string }): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The enablement platform: the checks and the provisioning the enablement flow needs, over the
|
||||
* platform's own APIs. `checkEligibility` is where the plan capability and the rollout flags
|
||||
* surface as typed reasons; `indexingQuota` answers today's remaining indexing volume;
|
||||
* `linkManagedQdrant` provisions (or returns the existing) managed Qdrant backing service;
|
||||
* `probeQdrant` validates an operator-configured endpoint before its configuration is persisted.
|
||||
*/
|
||||
export interface DocumentAiPlatform {
|
||||
checkEligibility(
|
||||
scope: KnowledgeOrgScope,
|
||||
): Promise<{ ok: true } | { ok: false; reason: EnablementReason; detail?: string }>
|
||||
indexingQuota(scope: KnowledgeOrgScope): Promise<number>
|
||||
linkManagedQdrant(scope: KnowledgeOrgScope): Promise<{ serviceId: string; url: string | null }>
|
||||
probeQdrant(url: string, apiKey: string): Promise<{ ok: true } | { ok: false; detail: string }>
|
||||
/** The operator-configured endpoint+key the deployment carries, when it carries one. */
|
||||
operatorQdrant(): { url: string; apiKey: string } | null
|
||||
}
|
||||
|
||||
/** What a flag service answers for the project a call runs in. */
|
||||
export interface KnowledgeFlags {
|
||||
enabled(flag: keyof typeof KNOWLEDGE_FLAG_DEFAULTS, scope: KnowledgeScope): boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
/** Bound by an app that evaluates flags (the platform's flag service). Unbound: the manifest default. */
|
||||
export const KNOWLEDGE_FLAGS = token<KnowledgeFlags>("knowledge.flags")
|
||||
|
||||
export const defaultFlags: KnowledgeFlags = { enabled: (flag) => KNOWLEDGE_FLAG_DEFAULTS[flag] }
|
||||
|
||||
export const flagsOf = (container: Container): KnowledgeFlags =>
|
||||
container.has(KNOWLEDGE_FLAGS) ? container.resolve(KNOWLEDGE_FLAGS) : defaultFlags
|
||||
|
||||
/** Bound by the worker and the web shell with the module's search backend adapter. */
|
||||
export const KNOWLEDGE_SEARCH = token<KnowledgeSearchService>("knowledge.search", { access: "effect" })
|
||||
|
||||
/**
|
||||
* The embeddings the local vector backend chunks and queries with: the platform's embeddings
|
||||
* endpoint through the AI gateway. Declared here (not reached through another module's token) so
|
||||
* the module's boundary stays its own; the composition root binds it to the gateway client.
|
||||
*/
|
||||
export interface KnowledgeEmbeddings {
|
||||
embed(texts: readonly string[], options?: { inputType?: "passage" | "query" }): Promise<readonly number[][]>
|
||||
}
|
||||
|
||||
export const KNOWLEDGE_EMBEDDINGS = token<KnowledgeEmbeddings>("knowledge.embeddings", { access: "effect" })
|
||||
|
||||
/** Bound by the web shell (the enablement flow runs in a request). */
|
||||
export const KNOWLEDGE_ENABLEMENT = token<DocumentAiPlatform>("knowledge.enablement", { access: "effect" })
|
||||
|
||||
/** The per-run scope, under the module's own name (decision AT-F6). */
|
||||
export const KNOWLEDGE_SCOPE = token<TenantScope>("knowledge.scope")
|
||||
|
||||
export const knowledgeEnabled = async (deps: KnowledgeDeps, scope: KnowledgeScope): Promise<boolean> =>
|
||||
deps.flags.enabled("knowledge.enabled", scope)
|
||||
@@ -0,0 +1,38 @@
|
||||
// The search: one collection, one query, cited answers. The embedding leaves the process (the
|
||||
// procedure is an effect), the ranking is the vector store's, and every answer carries where it
|
||||
// came from: the file, the chunk, and the snippet a person reads.
|
||||
|
||||
import { forbidden, internal } from "@conusai-kit/core"
|
||||
import { MAX_SEARCH_RESULTS, type SearchResult } from "../contract/index.js"
|
||||
import { requireCollection } from "./collections.js"
|
||||
import { type KnowledgeDeps, type KnowledgeScope, knowledgeEnabled } from "./ports.js"
|
||||
|
||||
export async function searchKnowledge(
|
||||
deps: KnowledgeDeps,
|
||||
scope: KnowledgeScope,
|
||||
input: { collectionName: string; query: string; limit?: number | undefined },
|
||||
): Promise<{ results: SearchResult[] }> {
|
||||
if (!(await knowledgeEnabled(deps, scope))) throw forbidden("knowledge is switched off for this project")
|
||||
await requireCollection(deps, scope, input.collectionName)
|
||||
const service = deps.search
|
||||
if (!service) throw internal("the knowledge search service is not bound in this process")
|
||||
const limit = Math.min(input.limit ?? 10, MAX_SEARCH_RESULTS)
|
||||
const hits = await service.search(scope, {
|
||||
collectionName: input.collectionName,
|
||||
query: input.query,
|
||||
limit,
|
||||
actorId: scope.actor.userId,
|
||||
})
|
||||
return {
|
||||
results: hits.map((hit): SearchResult => {
|
||||
return {
|
||||
fileId: hit.fileId,
|
||||
filename: hit.filename,
|
||||
collectionName: hit.collectionName,
|
||||
snippet: hit.snippet,
|
||||
score: hit.score,
|
||||
chunk: hit.chunk,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// The enablement flow: one idempotent procedure pair an organization administrator runs, with the
|
||||
// platform's checks in front of any configuration change and every denial a typed reason code the
|
||||
// UI maps to an upgrade or policy state.
|
||||
//
|
||||
// enableDocumentAi: already enabled -> answers the current configuration, writes nothing.
|
||||
// Otherwise: eligibility (plan capability + rollout flags) -> quota -> the vector backing
|
||||
// (an operator-configured endpoint, validated before it is trusted, or a managed link through
|
||||
// the providers API) -> one transaction persists the configuration. A failure at any step
|
||||
// writes nothing and is retirable: the same call again resumes from the first failing check.
|
||||
//
|
||||
// disableDocumentAi: preserves everything — collections, documents, vectors and the recorded
|
||||
// configuration stay; only `enabled` flips, so re-enabling resumes without duplication.
|
||||
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { config, type EnablementReason } from "../contract/index.js"
|
||||
import type { DocumentAiPlatform, KnowledgeOrgScope } from "./ports.js"
|
||||
|
||||
/** A typed enablement denial: the problem's code is the stable reason the UI maps to a state. */
|
||||
export function enablementDenied(reason: EnablementReason, detail?: string): AppError {
|
||||
const status = reason === "quota_exhausted" ? 429 : 403
|
||||
const title =
|
||||
reason === "plan_required"
|
||||
? "The organization's plan does not include document AI"
|
||||
: reason === "feature_disabled"
|
||||
? "Document AI is disabled by policy"
|
||||
: "The indexing quota is exhausted"
|
||||
return new AppError({
|
||||
type: `/problems/${reason}`,
|
||||
title,
|
||||
status,
|
||||
code: reason,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface SettingsView {
|
||||
enabled: boolean
|
||||
backend: "vectors" | "platform" | null
|
||||
source: "operator" | "managed" | null
|
||||
url: string | null
|
||||
}
|
||||
|
||||
async function currentConfig(deps: { data: import("@conusai-kit/data").DataSource }, scope: KnowledgeOrgScope) {
|
||||
const row = (await deps.data.forScope(scope).of(config).list({ limit: 1 })).items[0]
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export async function getSettings(
|
||||
deps: { data: import("@conusai-kit/data").DataSource },
|
||||
scope: KnowledgeOrgScope,
|
||||
): Promise<SettingsView> {
|
||||
const row = await currentConfig(deps, scope)
|
||||
return {
|
||||
enabled: row?.enabled ?? false,
|
||||
backend: row?.backend ?? null,
|
||||
source: row?.source ?? null,
|
||||
url: row?.url ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsDeps {
|
||||
data: import("@conusai-kit/data").DataSource
|
||||
platform: DocumentAiPlatform
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export async function enableDocumentAi(deps: SettingsDeps, scope: KnowledgeOrgScope): Promise<SettingsView> {
|
||||
const existing = await currentConfig(deps, scope)
|
||||
if (existing?.enabled) {
|
||||
return { enabled: true, backend: existing.backend, source: existing.source, url: existing.url }
|
||||
}
|
||||
|
||||
const eligibility = await deps.platform.checkEligibility(scope)
|
||||
if (!eligibility.ok) throw enablementDenied(eligibility.reason, eligibility.detail)
|
||||
|
||||
const remaining = await deps.platform.indexingQuota(scope)
|
||||
if (remaining <= 0) throw enablementDenied("quota_exhausted", "the indexing volume for today is spent")
|
||||
|
||||
// The vector backing: an operator-configured endpoint+key when the deployment carries one
|
||||
// (validated before it is trusted), otherwise a managed link through the providers API.
|
||||
const operator = deps.platform.operatorQdrant()
|
||||
let source: "operator" | "managed"
|
||||
let serviceId: string | null
|
||||
let url: string | null
|
||||
if (operator !== null) {
|
||||
const probe = await deps.platform.probeQdrant(operator.url, operator.apiKey)
|
||||
if (!probe.ok) {
|
||||
throw new AppError({
|
||||
type: "/problems/validation_failed",
|
||||
title: "The configured Qdrant endpoint did not answer",
|
||||
status: 422,
|
||||
code: "validation_failed",
|
||||
detail: probe.detail,
|
||||
})
|
||||
}
|
||||
source = "operator"
|
||||
serviceId = null
|
||||
url = operator.url
|
||||
} else {
|
||||
const linked = await deps.platform.linkManagedQdrant(scope)
|
||||
source = "managed"
|
||||
serviceId = linked.serviceId
|
||||
url = linked.url
|
||||
}
|
||||
|
||||
await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const row = (await tx.of(config).list({ limit: 1 })).items[0]
|
||||
const patch = {
|
||||
enabled: true,
|
||||
backend: "vectors" as const,
|
||||
source,
|
||||
serviceId,
|
||||
url,
|
||||
updatedBy: scope.actor.userId,
|
||||
configuredAt: new Date((deps.now ?? Date.now)()).toISOString(),
|
||||
}
|
||||
if (row) await tx.of(config).update(row.id, patch, row.version)
|
||||
else await tx.of(config).insert(patch)
|
||||
})
|
||||
return { enabled: true, backend: "vectors", source, url }
|
||||
}
|
||||
|
||||
export async function disableDocumentAi(deps: SettingsDeps, scope: KnowledgeOrgScope): Promise<{ enabled: false }> {
|
||||
await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const row = (await tx.of(config).list({ limit: 1 })).items[0]
|
||||
if (!row?.enabled) return
|
||||
// Everything else is preserved on purpose: backend, source, the service id, the endpoint, and
|
||||
// every collection, document and vector. Disabling fences new work; it deletes nothing.
|
||||
await tx.of(config).update(
|
||||
row.id,
|
||||
{
|
||||
enabled: false,
|
||||
updatedBy: scope.actor.userId,
|
||||
configuredAt: new Date((deps.now ?? Date.now)()).toISOString(),
|
||||
},
|
||||
row.version,
|
||||
)
|
||||
})
|
||||
return { enabled: false }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// The status a page reads: the module flag, the search service's readiness (separate from the
|
||||
// indexing indicator, which is the per-document rows), and the document counts by progress state.
|
||||
|
||||
import { type DocumentStatus, documents } from "../contract/index.js"
|
||||
import { type KnowledgeDeps, type KnowledgeScope, knowledgeEnabled } from "./ports.js"
|
||||
|
||||
const PAGE = 200
|
||||
|
||||
export interface KnowledgeStatus {
|
||||
enabled: boolean
|
||||
service: { state: "registered" | "indexing" | "ready" | "degraded" | "disabled" | "unavailable"; reasons: string[] }
|
||||
documents: Record<DocumentStatus, number>
|
||||
}
|
||||
|
||||
export async function knowledgeStatusOf(deps: KnowledgeDeps, scope: KnowledgeScope): Promise<KnowledgeStatus> {
|
||||
const counts: Record<DocumentStatus, number> = { queued: 0, indexing: 0, indexed: 0, failed: 0, cancelled: 0 }
|
||||
let cursor: string | undefined
|
||||
for (;;) {
|
||||
const page = await deps.data
|
||||
.forScope(scope)
|
||||
.of(documents)
|
||||
.list({ limit: PAGE, ...(cursor === undefined ? {} : { cursor }) })
|
||||
for (const row of page.items) counts[row.status as DocumentStatus] += 1
|
||||
if (!page.hasMore) break
|
||||
cursor = page.cursor
|
||||
}
|
||||
const enabled = await knowledgeEnabled(deps, scope)
|
||||
const readiness =
|
||||
deps.search === undefined
|
||||
? { state: "unavailable" as const, reasons: ["the knowledge search service is not bound in this process"] }
|
||||
: await deps.search.readiness(scope).catch((error: unknown) => ({
|
||||
state: "unavailable" as const,
|
||||
reasons: [
|
||||
error instanceof Error
|
||||
? (error.message.split("\n")[0] ?? error.message)
|
||||
: "the search service did not answer",
|
||||
],
|
||||
}))
|
||||
return { enabled, service: readiness, documents: counts }
|
||||
}
|
||||
Reference in New Issue
Block a user