// Entries route — atomic data + tree (project/workflow). Base; embed is OPTIONAL (issue #7). import { Hono } from 'hono'; import type { Bindings } from '../types'; import { createEntry, deprecateEntriesByLibrary, getEntry, listEntries, updateEntry, deleteEntry, searchEntries, isDeprecatedEntry, } 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/libraries?owner_id=... — 這個租戶的資料裡實際出現過哪些庫(distinct)。 // t52(leo 2026-07-26:地端幾個資料夾=雲端幾個庫):庫由 ingest 蓋章決定,這裡直接從 // 資料反查,讓「蓋了章的庫」一定看得到,不必依賴任何登記動作。未蓋章的舊資料=general。 // 註冊在 '/' 之前——Hono 路由先到先比,放後面會被 '/:id' 之類的樣式吃掉。 entryRoutes.get('/libraries', async (c) => { const owner = c.req.query('owner_id') || ''; const rows = await c.env.DB.prepare( `SELECT DISTINCT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library FROM entries WHERE (?1 = '' OR owner_id = ?1) AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated' ORDER BY library`, ) .bind(owner) .all<{ library: string }>(); const libraries = (rows.results ?? []).map((r) => r.library).filter(Boolean); return c.json({ success: true, libraries, count: libraries.length }); }); // GET /entries/library-stats?owner_id=... — 每個庫的知識卡數(distinct page_name,非 block 數)。 // t142(2026-07-29):政府驗收用——一眼看出每個庫有幾張卡(page 粒度,不是 block 粒度, // 一張卡通常對應 3-5 個 block;不含 deprecated entries)。 // 只計 entry_type='block' 的條目,因為 block 才對應知識卡的一個段落(page_name 標記所屬頁面)。 entryRoutes.get('/library-stats', async (c) => { const owner = c.req.query('owner_id') || ''; const rows = await c.env.DB.prepare( `SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library, COUNT(DISTINCT page_name) AS card_count FROM entries WHERE (?1 = '' OR owner_id = ?1) AND entry_type = 'block' AND page_name IS NOT NULL AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated' GROUP BY library ORDER BY library`, ) .bind(owner) .all<{ library: string; card_count: number }>(); const stats = (rows.results ?? []).map((r) => ({ library: r.library, card_count: r.card_count })); return c.json({ success: true, stats }); }); // 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 路徑不受影響)。 // - include_deprecated(daemon-beta t24,預設 false):兩 mode 預設都濾掉已下架 // (metadata_json.status==='deprecated')的 entry——這是本次修的洞(t11 斷點①②,總管 // 0.971 親復現:下架後 keyword/semantic 都照樣回傳)。傳 `include_deprecated=true` // 保留給管理面查殘留(驗證下架有沒有真的生效、盤點待清的向量殘留),一般搜尋不帶。 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'; const include_deprecated = c.req.query('include_deprecated') === 'true'; // 數字參數防呆:非數字/非正 → 當沒帶(回預設),不 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') { // 補位(daemon-beta t24):Vectorize 的 indexed metadata 沒存 status(見 embed.ts upsert, // 只有 owner_id/entry_type/source/library),下架與否只能在 hydrate 回完整 entry 後才知道 // ——換句話說 Vectorize 端沒辦法直接濾掉已下架向量,濾一定發生在 hydrate 之後。 // 若濾完才截斷到請求的 topK,遇到「這頁命中大半已下架」(t11 ZZ-T10 實測案例:命中 // 25 顆全下架)就會整頁被吃光、回傳筆數遠低於 caller 要的量。故过濾生效時(非 // include_deprecated)**先多撈一批再濾再截斷**:單次 Vectorize query 成本不變(同一次 // query 只是 topK 參數變大,非多一次 subrequest),用查詢端的餘量換掉「整頁被下架品吃光」 // 的體驗劣化。這是單輪補位(非重試迴圈到湊滿為止)——若下架比例極高仍可能不足額, // 已在 PR 描述向 leo 說明這個 trade-off(多倍 margin vs 迴圈重撈的取捨)。 const requestedTopK = top_k ?? 20; // 與 embed.ts semanticSearch 的預設 topK 對齊 const fetchTopK = include_deprecated ? requestedTopK : Math.min(requestedTopK * 3, 100); const hits = await semanticSearch(c.env, q, { owner_id, source, entry_type, library, topK: fetchTopK, min_score, }); if (hits === null) { // 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。 const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated); 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 不解析多的欄位不受影響。 let 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 => e !== null); if (!include_deprecated) { entries = entries.filter((e) => !isDeprecatedEntry(e)); } // 補位後截斷回 caller 實際要的量(多撈的餘量只用來墊背,不多回傳超過請求的筆數)。 entries = entries.slice(0, requestedTopK); 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, include_deprecated); 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/deprecate-by-library — body {owner_id, library}。 // t135:把某租戶某庫的所有 entries 標 deprecated,讓庫從 auto 清單消失。 // 此路由必須在 '/:id' 之前,否則 'deprecate-by-library' 會被當成 id 參數。 entryRoutes.patch('/deprecate-by-library', async (c) => { const body = (await c.req.json().catch(() => null)) as { owner_id?: string; library?: string } | null; const ownerId = String(body?.owner_id ?? '').trim(); const library = String(body?.library ?? '').trim(); if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400); const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library); return c.json({ success: true, deprecated_count: count }); }); // 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 }); });