Files
Arcrun/kbdb/src/routes/entries.ts
T
Leo 5b983c47b8 Merge branch 'main' into fix/merge-main-into-batch-t173
# Conflicts:
#	console-ui/public/portal/index.html
#	cypher-executor/src/routes/health.ts
#	cypher-executor/src/routes/portal.ts
#	registry/components/kbdb_upsert_block/component.contract.yaml
#	registry/examples/km-wiki-ingest/workflow.yaml
2026-08-02 23:43:16 +08:00

222 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 P1design §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)。
// t52leo 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 數)。
// t1422026-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 LIKEbase,永遠可用)。
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword + capability_hint 告知缺能力(#7 發現閉環)。
// - entry_typebase 通用 filtercaller 傳任意 type,如 workflowbase 不寫死語意,workflow-discovery Q4)。
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extractNULL→general
// semantic 走 Vectorize $in。未帶=全庫(行為不變)。
// - sourcekeyword 走 json_extract 謂詞(#66——#5.1 只接了 list 那半,這裡原本解析完即丟);
// semantic 走 Vectorize metadata filter(原本就有)。
// - top_k / min_score#67semantic 專用):topK 可調(預設 20、上限 100)+分數閾值
// (預設 0=不過濾)。未帶=行為與舊版一致(向後相容);semantic 回應的 entry 另附 score
// 欄讓 caller 自裁(加欄不改形,keyword 路徑不受影響)。
// - include_deprecateddaemon-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:
'語義查詢需先開 vectorizeembed 模組)。叫 CC「幫我開語義查詢」即可(設 kbdb_embed:true + redeploy)。本次已降級關鍵字搜尋。',
});
}
// hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。
// #67entry 附 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<typeof e> => 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 });
});