fix(kbdb/embed): D68 補算向量照新到舊排序+每日額度上限+修復舊世代 is_embedded 誤判

leo 2026-08-11 拍板(D68,system-dev/wiki/decisions-summary.md):補算向量要照時間
由新到舊、且每天有額度上限,不能一次把 Workers AI 每日免費 10,000 neurons 燒光
(與萃取共用同一份額度,見 ops-facts.md)。對應 Leo/Arcrun#85 列出的三個缺口:
① 補算是由舊到新(ORDER BY created_at ASC)② 沒有每日額度上限 ③ 沒有任何自動觸發。

改動:
- backfillEmbeddings:ORDER BY created_at DESC(新到舊),並在打 AI 前依
  env.EMBED_BACKFILL_DAILY_LIMIT(未設用推導出的預設值 1800,算式見 embed.ts 註解)
  截斷候選、額度用完即停手不再打 AI。額度用量存在 entries 表單一列
  (entry_type='embed_backfill_usage',UTC 日期切),不新增表(D38)。
- embedOnWrite / backfillEmbeddings 成功嵌入後在既有 content_hash 欄位蓋上
  現行模型名(世代戳記),修復 leo21c 資料還原案:從備份整批灌回的列帶著對已退役
  768 維索引的 is_embedded=1,現行 1024 維索引永遠不會補到它們。
- 新增 reconcileEmbedGeneration + POST /embed/reconcile:對 is_embedded=1 但
  content_hash 非現行世代的候選,問 Vectorize.getByIds 是否真的在現行 index——
  在→只補 content_hash 不打 AI;不在→重置 is_embedded=0 交回正常 backfill 佇列。
- 新增 kbdb/tests/embed-backfill.test.ts(改走真 SQLite,比舊版手刻假 DB 更硬):
  14 個測試涵蓋新到舊排序、額度真的擋(含「拿掉 cap 會變紅」的反向驗證)、
  世代核對端到端(reconcile → 重置 → backfill 真的補回來)、既有行為不迴歸。

現況誠實回報:目前沒有任何東西會自動觸發補算(無 cron/scheduled handler)——
唯一的「自動」路徑是 entries.ts 的語意搜尋回 0 命中時 fire-and-forget 觸發一次
(既有行為,本次未改動),仍需人或 CC 主動呼叫 /embed/backfill 或掛排程。

紅線:未動 leo 正式實例 leo21c;未動資料層形狀(三表不變,仍走既有 content_hash
bookkeeping 欄);未 push main,本 commit 在獨立分支 fix/embed-backfill-d68。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-11 16:42:54 +08:00
parent 507620e313
commit 1d6dde4a01
4 changed files with 566 additions and 118 deletions
+21 -1
View File
@@ -8,7 +8,7 @@
// base 對內容語意無知:只認通用 metadata.embed===true 旗標,不知 triplet/wiki(解耦)。
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest } from '../embed';
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest, reconcileEmbedGeneration } from '../embed';
export const embedRoutes = new Hono<{ Bindings: Bindings }>();
@@ -56,6 +56,26 @@ embedRoutes.get('/backfill/status', async (c) => {
return c.json({ success: true, ...status });
});
// POST /embed/reconcile — 世代核對(D68 配套修復,2026-08-11):
// 對「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。
embedRoutes.post('/reconcile', 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 { limit?: number | string; owner_id?: string };
const result = await reconcileEmbedGeneration(c.env, {
limit: body.limit !== undefined ? Number(body.limit) : undefined,
owner_id: body.owner_id || undefined,
});
return c.json({ success: true, ...result });
});
// GET /embed/selftest?owner_id= — 語義自我檢查(檢修孔,2026-08-07):
// 挑一筆已嵌入的卡片,拿它自己的內容查自己,只回布林診斷(不回卡片內容、不回 entry id)。
// 計數(backfill/status)看不出「嵌了但查不到」這種故障模式(Arcrun#11 撞過的真實案例),