66 lines
2.7 KiB
TypeScript
66 lines
2.7 KiB
TypeScript
// The search: one collection, one query, cited answers. The query leaves the process (the procedure
|
|
// is an effect), the ranking is the platform's service, and every answer carries where it came from:
|
|
// the file, the chunk, and the snippet a person reads.
|
|
|
|
import { AppError, forbidden, internal } from "@conusai-kit/core"
|
|
import { getFile } from "@conusai-kit/files"
|
|
import { documents, 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,
|
|
})
|
|
const results: SearchResult[] = []
|
|
for (const hit of hits) {
|
|
// The current scoped source of truth revokes a citation before the remote deletion job finishes.
|
|
const current = (
|
|
await deps.data
|
|
.forScope(scope)
|
|
.of(documents)
|
|
.list({
|
|
where: [
|
|
{ field: "collectionName", op: "eq", value: input.collectionName },
|
|
{ field: "fileId", op: "eq", value: hit.fileId },
|
|
{ field: "status", op: "eq", value: "indexed" },
|
|
],
|
|
limit: 1,
|
|
})
|
|
).items[0]
|
|
if (!current || hit.collectionName !== input.collectionName) continue
|
|
// The original file can be deleted before its outbox cleanup is delivered.
|
|
// Current scoped file visibility is mandatory, independent of stale index rows.
|
|
try {
|
|
await getFile(deps.files, scope, hit.fileId)
|
|
} catch (error) {
|
|
if (error instanceof AppError && error.status === 404) continue
|
|
throw error
|
|
}
|
|
results.push({
|
|
fileId: hit.fileId,
|
|
filename: hit.filename,
|
|
collectionName: hit.collectionName,
|
|
snippet: hit.snippet,
|
|
score: hit.score,
|
|
chunk: hit.chunk,
|
|
...(hit.page === undefined ? {} : { page: hit.page }),
|
|
...(hit.blockId === undefined ? {} : { blockId: hit.blockId }),
|
|
...(hit.chunkId === undefined ? {} : { chunkId: hit.chunkId }),
|
|
})
|
|
}
|
|
return { results }
|
|
}
|