fix(kbdb): D69 節流世代核對+標庫共用 D1 額度,補「挑哪一批」的統一 SelectionCriteria(Arcrun#85)

leo 逐行複核 fix/embed-backfill-d68 後點出的破口+二次裁決(票上全文見 Leo/Arcrun#85):

一、向量化優先序(今天寫的立刻/本週在跑的先跑/有查詢紀錄的庫優先/半年前慢慢跑)表達
不出來——策略要能從外面(工作流)指定,不能焊死在資料層。新增 embed.ts 的
`SelectionCriteria`(owner_id/source/library/since/until),backfillEmbeddings 與
reconcileEmbedGeneration 共用同一套形狀;「按庫」那一層現在有資料可用即可運作(見下)。

二、世代核對(reconcileEmbedGeneration)不打 AI 但逐筆寫 D1,47 萬筆候選 ≈ 4.7 倍 D1
100,000 rows/日免費額度,先前零保護。新增 actions/maintenance-quota.ts(單一 entries
列/日的共用計數器,精神同 execution-log.ts/embed.ts 既有慣例,不新增表)。

三、leo 二度裁決:「標庫」與「時間分層」其實是一件事,判定標準要從第一天同時容納兩者,
不能先做一半再回頭改。新增 actions/library-backfill.ts 的 backfillEntryLibraryTags——
呼叫端(ingest/daemon/Arcrun#87)決定要貼哪個庫、用 page_names(Gitea 原稿卡名精準
點名,leo 定案的正解)或 source_prefix/page_name_prefix 過渡 fallback 篩選候選,base
只負責安全、節流地寫入。owner_id 刻意必填(leo 點出「補錯 owner 等於白做」——實查卡片
掛在 owner_id=bfezv28v,換成 'leo' 查卻是空的)。

D69:reconcile 與標庫 backfill 共用同一顆「今天還剩多少 D1 寫入額度」計數器(不共用的話
其中一個會把另一個的閘繞過去);新增 POST /entries/backfill-library + GET .../status,
擴充 POST /embed/backfill 與 /embed/reconcile 吃 library/since/until 參數。

測試:92 → 39 個新增/擴充案例覆蓋 since/until/library 篩選、reconcile 額度真的擋
(含「拿掉 cap 會變紅」反向驗證)、標庫 backfill 冪等/owner_id 必填/page_names 精準比對、
以及兩個操作共用同一顆額度計數器的跨模組驗證(雙向:先 reconcile 耗盡再標庫、反之亦然)。
kbdb 全套 192 個測試綠燈,tsc --noEmit 除既有 auth.test.ts 舊缺陷外無新增錯誤。

紅線:未併 main、未部署、未動任何實例的 is_embedded 旗標(只在本地 SQLite 測試治具跑過)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-11 18:39:55 +08:00
parent 1d6dde4a01
commit 674e1b4fa2
8 changed files with 787 additions and 23 deletions
+25 -4
View File
@@ -16,11 +16,14 @@ const OFF_HINT =
'語義補嵌需先開 embed 模組(Vectorize+AI binding)。叫 CC「幫我開語義查詢」(設 kbdb_embed:true + redeploy 注入 binding)後再呼叫本端點。';
// POST /embed/backfill — batch-embed existing embeddable entries with is_embedded=0.
// body(皆選填):{ limit?:1-100(預設25, owner_id?, source?, reindex?, offset? }。
// body(皆選填):{ limit?:1-100(預設25, owner_id?, source?, library?, since?, until?, reindex?, offset? }。
// 冪等:重跑不會重複嵌(已 is_embedded=1 的不再入選;upsert 同 id 冪等)。
// 分批:單次最多 limit 筆;回傳 remaining>0 表示還有 → 重複呼叫直到 remaining=0。
// reindex:trueArcrun#11):改重推「所有 embeddable」既有向量(含 is_embedded=1),
// 讓事後建立的 Vectorize metadata index 收錄它們(否則帶過濾語意查詢回 0);配 offset 分頁。
// librarysinceuntilArcrun#852026-08-11):「挑哪一批」從外面指定——時間分層
// (今天/本週/半年前)與庫分層(有查詢紀錄的庫優先)共用同一套 SelectionCriteria
// 由呼叫端(工作流)決定這次要補的是哪一批,不是資料層焊死單一排序(見 embed.ts 檔頭說明)。
// 模組未開 → 409 + capability_hint(不假綠)。
embedRoutes.post('/backfill', async (c) => {
if (!embedEnabled(c.env)) {
@@ -33,6 +36,9 @@ embedRoutes.post('/backfill', async (c) => {
limit?: number | string;
owner_id?: string;
source?: string;
library?: string;
since?: number | string;
until?: number | string;
reindex?: boolean;
offset?: number | string;
};
@@ -40,6 +46,9 @@ embedRoutes.post('/backfill', async (c) => {
limit: body.limit !== undefined ? Number(body.limit) : undefined,
owner_id: body.owner_id || undefined,
source: body.source || undefined,
library: body.library || undefined,
since: body.since !== undefined ? Number(body.since) : undefined,
until: body.until !== undefined ? Number(body.until) : undefined,
// reindexArcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。
reindex: body.reindex === true,
offset: body.offset !== undefined ? Number(body.offset) : undefined,
@@ -56,11 +65,14 @@ embedRoutes.get('/backfill/status', async (c) => {
return c.json({ success: true, ...status });
});
// POST /embed/reconcile — 世代核對(D68 配套修復,2026-08-11):
// POST /embed/reconcile — 世代核對(D68 配套修復,2026-08-11D69 額度節流同日補上):
// 對「is_embedded=1 但 content_hash 非現行模型」的候選,問現行 Vectorize index 是否真的收錄;
// 真的在 → 補標 content_hash(不打 AI);不在 → 重置 is_embedded=0,回到正常 /embed/backfill 佇列。
// 解「從備份整批灌回、帶著對已退役索引的 is_embedded=1,永遠不被 backfill 碰到」這個坑。
// body(皆選填):{ limit?:1-200(預設50, owner_id? }。重複呼叫直到 remaining=0。
// body(皆選填):{ limit?:1-200(預設50, owner_id?, library?, since?, until? }。重複呼叫直到 remaining=0。
// D69:每筆候選最多消耗一次 D1 row write,與 POST /entries/backfill-library 共用同一顆每日
// 「背景維護 D1 寫入」額度(見 actions/maintenance-quota.ts)——額度用完會誠實回
// quota_exceeded:true 並停手,不會把當天 D1 免費額度燒穿(2026-08-11 leo 逐行複核找到的破口)。
embedRoutes.post('/reconcile', async (c) => {
if (!embedEnabled(c.env)) {
return c.json(
@@ -68,10 +80,19 @@ embedRoutes.post('/reconcile', async (c) => {
409,
);
}
const body = (await c.req.json().catch(() => ({}))) as { limit?: number | string; owner_id?: string };
const body = (await c.req.json().catch(() => ({}))) as {
limit?: number | string;
owner_id?: string;
library?: string;
since?: number | string;
until?: number | string;
};
const result = await reconcileEmbedGeneration(c.env, {
limit: body.limit !== undefined ? Number(body.limit) : undefined,
owner_id: body.owner_id || undefined,
library: body.library || undefined,
since: body.since !== undefined ? Number(body.since) : undefined,
until: body.until !== undefined ? Number(body.until) : undefined,
});
return c.json({ success: true, ...result });
});