feat(knowledge): deepen Knowledge/RAG — search, stats, delete endpoints
- repository.ts: add searchChunks (term-based text search with scoring), getBaseStats (source/chunk/token counts), deleteSource, deleteBase - routes.ts: 4 new endpoints — GET /knowledge/bases/:id/stats, DELETE /knowledge/bases/:id (draft only), DELETE /knowledge/bases/:id/sources/:sourceId, POST /knowledge/bases/:id/search (text search with previews) - Existing 4 tests unchanged, typecheck clean
This commit is contained in:
parent
ae87371b3a
commit
20663d7078
@ -105,3 +105,65 @@ export async function listChunks(
|
||||
limit: filter.limit ?? 500,
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchChunks(
|
||||
knowledgeBaseId: string,
|
||||
query: string,
|
||||
options: { sourceId?: string; limit?: number } = {}
|
||||
): Promise<KnowledgeChunkDoc[]> {
|
||||
const allChunks = await listChunks(knowledgeBaseId, {
|
||||
sourceId: options.sourceId,
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
const terms = queryLower.split(/\s+/).filter(Boolean);
|
||||
|
||||
const scored = allChunks
|
||||
.map(chunk => {
|
||||
const text = chunk.contentText.toLowerCase();
|
||||
let score = 0;
|
||||
for (const term of terms) {
|
||||
if (text.includes(term)) score++;
|
||||
}
|
||||
return { chunk, score };
|
||||
})
|
||||
.filter(s => s.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, options.limit ?? 10);
|
||||
|
||||
return scored.map(s => s.chunk);
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseStats {
|
||||
knowledgeBaseId: string;
|
||||
sourceCount: number;
|
||||
chunkCount: number;
|
||||
totalTokens: number;
|
||||
indexedSources: number;
|
||||
pendingSources: number;
|
||||
failedSources: number;
|
||||
}
|
||||
|
||||
export async function getBaseStats(knowledgeBaseId: string): Promise<KnowledgeBaseStats> {
|
||||
const sources = await listSources(knowledgeBaseId);
|
||||
const chunks = await listChunks(knowledgeBaseId, { limit: 10000 });
|
||||
|
||||
return {
|
||||
knowledgeBaseId,
|
||||
sourceCount: sources.length,
|
||||
chunkCount: chunks.length,
|
||||
totalTokens: chunks.reduce((sum, c) => sum + (c.tokenCount ?? 0), 0),
|
||||
indexedSources: sources.filter(s => s.status === 'indexed').length,
|
||||
pendingSources: sources.filter(s => s.status === 'pending').length,
|
||||
failedSources: sources.filter(s => s.status === 'failed').length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteSource(id: string, knowledgeBaseId: string): Promise<void> {
|
||||
await sourceCollection().delete(id, knowledgeBaseId);
|
||||
}
|
||||
|
||||
export async function deleteBase(id: string, productId: string): Promise<void> {
|
||||
await baseCollection().delete(id, productId);
|
||||
}
|
||||
|
||||
@ -209,4 +209,61 @@ export async function knowledgeRoutes(app: FastifyInstance) {
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// ── Knowledge base stats ───────────────────────────────
|
||||
app.get('/knowledge/bases/:id/stats', async req => {
|
||||
const access = requireAdmin(req);
|
||||
const { id } = req.params as { id: string };
|
||||
await repo.getBase(id, access.productId);
|
||||
return repo.getBaseStats(id);
|
||||
});
|
||||
|
||||
// ── Delete knowledge base (draft only) ─────────────────
|
||||
app.delete('/knowledge/bases/:id', async req => {
|
||||
const access = requireAdmin(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const base = await repo.getBase(id, access.productId);
|
||||
if (base.status !== 'draft') {
|
||||
throw new BadRequestError('Only draft knowledge bases can be deleted');
|
||||
}
|
||||
await repo.deleteBase(id, access.productId);
|
||||
return { deleted: true };
|
||||
});
|
||||
|
||||
// ── Delete knowledge source ────────────────────────────
|
||||
app.delete('/knowledge/bases/:id/sources/:sourceId', async req => {
|
||||
const access = requireAdmin(req);
|
||||
const { id, sourceId } = req.params as { id: string; sourceId: string };
|
||||
await repo.getBase(id, access.productId);
|
||||
await repo.getSource(sourceId, id);
|
||||
await repo.deleteSource(sourceId, id);
|
||||
return { deleted: true };
|
||||
});
|
||||
|
||||
// ── Search chunks (text search) ────────────────────────
|
||||
app.post('/knowledge/bases/:id/search', async req => {
|
||||
requireAdmin(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = RetrievalQuerySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
validationError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||
}
|
||||
const results = await repo.searchChunks(id, parsed.data.query, {
|
||||
sourceId: parsed.data.sourceId,
|
||||
limit: parsed.data.limit,
|
||||
});
|
||||
return {
|
||||
query: parsed.data.query,
|
||||
count: results.length,
|
||||
chunks: results.map(c => ({
|
||||
id: c.id,
|
||||
sourceId: c.sourceId,
|
||||
ordinal: c.ordinal,
|
||||
preview: c.contentText.slice(0, 240),
|
||||
tokenCount: c.tokenCount,
|
||||
citations: c.citations,
|
||||
tags: c.tags,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user