65d85eb08f
#66:/entries/search keyword 路徑 source 解析後丟棄(#5.1 只接了 listEntries 那半)—— searchEntries 尾端加 source?(既有 positional caller 全不用改),conds 補與 listEntries 同款 json_extract(metadata_json,'$.source') 謂詞;route keyword 分支與 semantic 降級 分支兩處傳入。 #67:semantic 固定 topK=20、零分數閾值、低分尾硬湊數——route 曝 top_k(預設 20、封頂 100)與 min_score(預設 0=不過濾)query 參數;semanticSearch 依 min_score 截低分尾; semantic 回應 entry 附 score 欄(加欄不改形)。壞值(非數字/非正)視同沒帶,不 400。 向後相容:不帶新參數時輸出與現況一致(semantic 僅多 score 資訊);不動表(D6)、 不動 D1 結構(API-as-Wall)。測試:新增 search-source-and-score.test.ts 13 條 (source 謂詞形狀/route 下傳/降級不洩 filter/min_score 截斷/topK 透傳封頂/壞值防呆/ 不帶參數行為不變),kbdb vitest 33/33 綠、tsc 0。 關聯 #66 #67。merge 後需 gated redeploy kbdb worker(leo 閘)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUmjwkHLVBHM3ydhT1WSW3
144 lines
7.6 KiB
TypeScript
144 lines
7.6 KiB
TypeScript
// Entries route — atomic data + tree (project/workflow). Base; embed is OPTIONAL (issue #7).
|
||
import { Hono } from 'hono';
|
||
import type { Bindings } from '../types';
|
||
import {
|
||
createEntry,
|
||
getEntry,
|
||
listEntries,
|
||
updateEntry,
|
||
deleteEntry,
|
||
searchEntries,
|
||
} from '../actions/entry-crud';
|
||
import { embedEnabled, embedOnWrite, semanticSearch } from '../embed';
|
||
|
||
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
|
||
|
||
// library 多值參數(逗號分隔,portal-auth P1,design §3.3)。空值/全空白 → undefined(=不過濾,
|
||
// 行為與未帶參數一字不變——向後相容硬驗收)。
|
||
function parseLibraryParam(raw: string | undefined): string[] | undefined {
|
||
if (!raw) return undefined;
|
||
const libs = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||
return libs.length > 0 ? libs : undefined;
|
||
}
|
||
|
||
// POST /entries — create (entry_type=block/value/project/workflow/...)
|
||
entryRoutes.post('/', async (c) => {
|
||
const body = await c.req.json().catch(() => null);
|
||
if (!body || !body.entry_type) return c.json({ success: false, error: 'entry_type required' }, 400);
|
||
const entry = await createEntry(c.env.DB, body);
|
||
// embed-on-write (#7 / #5 第4點):模組開 + entry 標 embed:true 才做;fire-and-forget,不阻塞回應、失敗不致命。
|
||
if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
|
||
return c.json({ success: true, entry });
|
||
});
|
||
|
||
// GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search)
|
||
// e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow
|
||
// e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun
|
||
// e.g. filter by ingest source: ?source=logseq://vault/foo.md (issue #5.1)
|
||
// e.g. filter by library(多值逗號分隔,portal-auth P1): ?library=finance,hr(未標記舊資料歸 general)
|
||
// e.g. keyword filter: ?q=遷移 或 ?search=遷移(別名,Arcrun#3 發現①:caller 實測時打的是 search=,
|
||
// 舊版完全不接這個 filter;q 與 search 兩個名字都認,避免同一個坑再踩一次)。
|
||
// count = 本頁筆數(受 limit 影響);total = 符合條件全部筆數(不受 limit 影響,見 total 欄位)。
|
||
entryRoutes.get('/', async (c) => {
|
||
const { entries, total } = await listEntries(c.env.DB, {
|
||
entry_type: c.req.query('entry_type') || undefined,
|
||
owner_id: c.req.query('owner_id') || undefined,
|
||
parent_id: c.req.query('parent_id') || undefined,
|
||
page_name: c.req.query('page_name') || undefined,
|
||
source: c.req.query('source') || undefined,
|
||
library: parseLibraryParam(c.req.query('library')),
|
||
q: c.req.query('q') || c.req.query('search') || undefined,
|
||
limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
|
||
offset: c.req.query('offset') ? Number(c.req.query('offset')) : undefined,
|
||
});
|
||
return c.json({ success: true, entries, count: entries.length, total });
|
||
});
|
||
|
||
// GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&library=...&mode=keyword|semantic
|
||
// - mode=keyword(預設):D1 LIKE(base,永遠可用)。
|
||
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword + capability_hint 告知缺能力(#7 發現閉環)。
|
||
// - entry_type:base 通用 filter(caller 傳任意 type,如 workflow;base 不寫死語意,workflow-discovery Q4)。
|
||
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extract+NULL→general;
|
||
// semantic 走 Vectorize $in。未帶=全庫(行為不變)。
|
||
// - source:keyword 走 json_extract 謂詞(#66——#5.1 只接了 list 那半,這裡原本解析完即丟);
|
||
// semantic 走 Vectorize metadata filter(原本就有)。
|
||
// - top_k / min_score(#67,semantic 專用):topK 可調(預設 20、上限 100)+分數閾值
|
||
// (預設 0=不過濾)。未帶=行為與舊版一致(向後相容);semantic 回應的 entry 另附 score
|
||
// 欄讓 caller 自裁(加欄不改形,keyword 路徑不受影響)。
|
||
entryRoutes.get('/search', async (c) => {
|
||
const q = c.req.query('q');
|
||
if (!q) return c.json({ success: false, error: 'q required' }, 400);
|
||
const owner_id = c.req.query('owner_id') || undefined;
|
||
const source = c.req.query('source') || undefined;
|
||
const entry_type = c.req.query('entry_type') || undefined;
|
||
const library = parseLibraryParam(c.req.query('library'));
|
||
const mode = c.req.query('mode') === 'semantic' ? 'semantic' : 'keyword';
|
||
// 數字參數防呆:非數字/非正 → 當沒帶(回預設),不 400——與其他 filter「壞值靜默忽略」一致。
|
||
const topKNum = Number(c.req.query('top_k'));
|
||
const top_k = Number.isFinite(topKNum) && topKNum > 0 ? Math.floor(topKNum) : undefined;
|
||
const minScoreNum = Number(c.req.query('min_score'));
|
||
const min_score = Number.isFinite(minScoreNum) && minScoreNum > 0 ? minScoreNum : undefined;
|
||
|
||
if (mode === 'semantic') {
|
||
const hits = await semanticSearch(c.env, q, {
|
||
owner_id, source, entry_type, library, topK: top_k, min_score,
|
||
});
|
||
if (hits === null) {
|
||
// 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。
|
||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source);
|
||
return c.json({
|
||
success: true,
|
||
entries,
|
||
count: entries.length,
|
||
mode: 'keyword',
|
||
requested_mode: 'semantic',
|
||
capability_hint:
|
||
'語義查詢需先開 vectorize(embed 模組)。叫 CC「幫我開語義查詢」即可(設 kbdb_embed:true + redeploy)。本次已降級關鍵字搜尋。',
|
||
});
|
||
}
|
||
// hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。
|
||
// #67:entry 附 score(相似分數)——加欄不改形,既有 caller 不解析多的欄位不受影響。
|
||
const entries = (
|
||
await Promise.all(
|
||
hits.map(async (h) => {
|
||
const e = await getEntry(c.env.DB, h.id);
|
||
return e ? { ...e, score: h.score } : null;
|
||
}),
|
||
)
|
||
).filter((e): e is NonNullable<typeof e> => e !== null);
|
||
return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
|
||
}
|
||
|
||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source);
|
||
return c.json({ success: true, entries, count: entries.length, mode: 'keyword' });
|
||
});
|
||
|
||
// GET /entries/:id
|
||
entryRoutes.get('/:id', async (c) => {
|
||
const entry = await getEntry(c.env.DB, c.req.param('id'));
|
||
if (!entry) return c.json({ success: false, error: 'not found' }, 404);
|
||
return c.json({ success: true, entry });
|
||
});
|
||
|
||
// PATCH /entries/:id
|
||
entryRoutes.patch('/:id', async (c) => {
|
||
const body = await c.req.json().catch(() => ({}));
|
||
const entry = await updateEntry(c.env.DB, c.req.param('id'), body);
|
||
if (!entry) return c.json({ success: false, error: 'not found' }, 404);
|
||
// 內容改了 → 重 embed(保持向量新鮮)。embedOnWrite 內部自會檢查模組開 + entry 是否 embeddable。
|
||
if (embedEnabled(c.env) && body.content !== undefined) {
|
||
c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
|
||
}
|
||
return c.json({ success: true, entry });
|
||
});
|
||
|
||
// DELETE /entries/:id
|
||
entryRoutes.delete('/:id', async (c) => {
|
||
// 模組開 → 連帶刪向量(避免孤兒向量)。失敗不致命。
|
||
if (embedEnabled(c.env)) {
|
||
c.executionCtx.waitUntil(c.env.VECTORIZE!.deleteByIds([c.req.param('id')]).then(() => {}).catch(() => {}));
|
||
}
|
||
await deleteEntry(c.env.DB, c.req.param('id'));
|
||
return c.json({ success: true });
|
||
});
|