Files
template-app/modules/knowledge/adapters/platform-knowledge.ts
T

117 lines
4.3 KiB
TypeScript

// 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)
}
},
}
}