feat(kbdb): POST /embed/query 語義查詢端點(薄殼暴露既有 semanticSearch)——Arcrun#7 A2

- routes/embed.ts 加 /embed/query:q 必填、owner_id/source/entry_type/topK 通用 filter
- 模組未開回 409+capability_hint(不假綠)、缺q回400;能力不重寫(rule07 薄殼)
- tests/embed-query.test.ts 4例、tasks 12.6 標x;tsc 0 error、vitest 9/9
This commit is contained in:
Claude
2026-07-05 08:59:05 +00:00
parent ed2e42e007
commit c9e44bb9a3
3 changed files with 108 additions and 1 deletions
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { embedRoutes } from '../src/routes/embed';
import type { Bindings } from '../src/types';
// ── Minimal in-memory fakes (no Workers runtime) ─────────────────────────────
// POST /embed/query is a thin shell over semanticSearch (rule 07). We drive the real
// route through the real semanticSearch by faking only AI + VECTORIZE bindings.
// - module off = no AI/VECTORIZE bindings → semanticSearch returns null → route 409.
// - module on = fake AI (query→vector) + fake VECTORIZE.query (returns matches).
function makeEnv(withBindings: boolean, matches: unknown[] = []): Bindings {
const env = {
ENVIRONMENT: 'test',
...(withBindings
? {
AI: { async run(_m: string, i: { text: string[] }) { return { data: i.text.map(() => [0.1, 0.2, 0.3]) }; } },
VECTORIZE: { async query(_v: number[], _o: unknown) { return { matches }; } },
}
: {}),
} as unknown as Bindings;
return env;
}
async function post(env: Bindings, body: unknown): Promise<{ status: number; json: any }> {
// embedRoutes is the standalone sub-app; parent mounts it at '/embed', so here the path is '/query'.
const req = new Request('http://kbdb/query', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const res = await embedRoutes.fetch(req, env);
return { status: res.status, json: await res.json() };
}
describe('POST /embed/query', () => {
it('module on → returns semantic hits', async () => {
const matches = [
{ id: 'e1', score: 0.9, metadata: { owner_id: 'leo', entry_type: 'workflow', source: 'wiki' } },
{ id: 'e2', score: 0.7, metadata: { owner_id: 'leo', entry_type: 'workflow', source: 'wiki' } },
];
const env = makeEnv(true, matches);
const { status, json } = await post(env, { q: 'doorbell', owner_id: 'leo', topK: 5 });
expect(status).toBe(200);
expect(json.success).toBe(true);
expect(json.query).toBe('doorbell');
expect(json.hits.map((h: { id: string }) => h.id)).toEqual(['e1', 'e2']);
expect(json.hits[0]).toMatchObject({ id: 'e1', score: 0.9, owner_id: 'leo', entry_type: 'workflow', source: 'wiki' });
});
it('module off → 409 + capability_hint (誠實不假綠)', async () => {
const env = makeEnv(false);
const { status, json } = await post(env, { q: 'doorbell' });
expect(status).toBe(409);
expect(json.success).toBe(false);
expect(typeof json.capability_hint).toBe('string');
});
it('missing q → 400', async () => {
const env = makeEnv(true);
const { status, json } = await post(env, { owner_id: 'leo' });
expect(status).toBe(400);
expect(json.success).toBe(false);
expect(json.error).toContain('q');
});
it('empty/blank q → 400', async () => {
const env = makeEnv(true);
const { status, json } = await post(env, { q: ' ' });
expect(status).toBe(400);
expect(json.success).toBe(false);
});
});