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
+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 }
}