diff --git a/kbdb/src/routes/embed.ts b/kbdb/src/routes/embed.ts index 1792221..42b251b 100644 --- a/kbdb/src/routes/embed.ts +++ b/kbdb/src/routes/embed.ts @@ -8,7 +8,7 @@ // base 對內容語意無知:只認通用 metadata.embed===true 旗標,不知 triplet/wiki(解耦)。 import { Hono } from 'hono'; import type { Bindings } from '../types'; -import { embedEnabled, backfillEmbeddings, backfillStatus } from '../embed'; +import { embedEnabled, backfillEmbeddings, backfillStatus, semanticSearch } from '../embed'; export const embedRoutes = new Hono<{ Bindings: Bindings }>(); @@ -49,4 +49,35 @@ embedRoutes.get('/backfill/status', async (c) => { return c.json({ success: true, ...status }); }); +// POST /embed/query — 語義查詢(薄殼暴露既有 semanticSearch 能力,不重寫任何 embedding/query 邏輯,rule 07)。 +// body:{ q:string(必填), owner_id?, source?, entry_type?, topK?(預設20,上限100,由 semanticSearch 收斂) }。 +// base 對內容語意無知:owner_id/source/entry_type 皆通用 metadata filter(不寫死 triplet/wiki 語意)。 +// 模組未開 → 409 + capability_hint(比照 backfill route,不假綠,mindset §7)。q 缺/空 → 400。 +// (與 GET /entries/search?mode=semantic 同一底層能力;此為 issue「POST /search 或 /embed/query」的獨立端點暴露。) +embedRoutes.post('/query', async (c) => { + if (!embedEnabled(c.env)) { + return c.json( + { success: false, error: 'embed module not enabled (need VECTORIZE + AI bindings)', capability_hint: OFF_HINT }, + 409, + ); + } + const body = (await c.req.json().catch(() => ({}))) as { + q?: string; + owner_id?: string; + source?: string; + entry_type?: string; + topK?: number | string; + }; + const q = typeof body.q === 'string' ? body.q.trim() : ''; + if (!q) return c.json({ success: false, error: 'q required' }, 400); + + const hits = await semanticSearch(c.env, q, { + owner_id: body.owner_id || undefined, + source: body.source || undefined, + entry_type: body.entry_type || undefined, + topK: body.topK !== undefined ? Number(body.topK) : undefined, + }); + return c.json({ success: true, query: q, hits: hits ?? [] }); +}); + export default embedRoutes; diff --git a/kbdb/tests/embed-query.test.ts b/kbdb/tests/embed-query.test.ts new file mode 100644 index 0000000..3b651c3 --- /dev/null +++ b/kbdb/tests/embed-query.test.ts @@ -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); + }); +}); diff --git a/system-dev/docs/3-specs/arcrun/kbdb-base/tasks.md b/system-dev/docs/3-specs/arcrun/kbdb-base/tasks.md index 10253e4..c3de418 100644 --- a/system-dev/docs/3-specs/arcrun/kbdb-base/tasks.md +++ b/system-dev/docs/3-specs/arcrun/kbdb-base/tasks.md @@ -200,6 +200,11 @@ 當 next-step 回給 AI(語義/關鍵字同一 KBDB MCP,D17 邊界)。薄殼模式不變(kbdbFetch)。mcp tsc exit 0。 - [x] 12.5 **CC 幫開 vectorize(T2.4d,第一版)**:路徑=CC 寫 config `kbdb_embed:true` + `acr update`(已接 kbdbEmbed → 建 index + 注入 binding redeploy)。base 查詢回應的 `capability_hint` 是發現入口。Pages 設定頁不做(leo 排未來)。 +- [x] 12.6 **語義查詢 HTTP 端點(薄殼暴露,issue#7「POST /search 或 /embed/query」)**:`kbdb/src/routes/embed.ts` + 加 `POST /embed/query`(body `{q必填, owner_id?, source?, entry_type?, topK?(預設20上限100)}`)→ 呼叫既有 + `semanticSearch`(rule 07 薄殼:route 不重寫任何 embedding/query 邏輯);模組未開→409+OFF_HINT(不假綠); + q 缺/空→400;回 `{success, query, hits}`。與 GET /entries/search?mode=semantic 同底層能力、另一薄殼暴露。 + 測試 `kbdb/tests/embed-query.test.ts`(模組開回 hits/未開 409/缺 q 400/空白 q 400)。kbdb tsc exit 0、vitest 9 綠。 - [ ] 12.V **端到端驗收 ⏳ 待 leo21c 部署驗**(需官方/leo21c 帳號開 Vectorize index):開 kbdb_embed → acr update → 寫一筆帶 `metadata.embed:true` 的 entry → `?mode=semantic` 搜回;未開時 `?mode=semantic` 回 keyword+capability_hint。 本次只到 **tsc exit 0(kbdb/cypher/cli/mcp 全綠)+ toml 注入 dry-run 驗證**,不假裝端到端綠(mindset §7)。