From 1d6dde4a013add79d317ecd96f68b2b7391fb718 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 11 Aug 2026 16:42:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(kbdb/embed):=20D68=20=E8=A3=9C=E7=AE=97?= =?UTF-8?q?=E5=90=91=E9=87=8F=E7=85=A7=E6=96=B0=E5=88=B0=E8=88=8A=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=EF=BC=8B=E6=AF=8F=E6=97=A5=E9=A1=8D=E5=BA=A6=E4=B8=8A?= =?UTF-8?q?=E9=99=90=EF=BC=8B=E4=BF=AE=E5=BE=A9=E8=88=8A=E4=B8=96=E4=BB=A3?= =?UTF-8?q?=20is=5Fembedded=20=E8=AA=A4=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- kbdb/src/embed.ts | 233 ++++++++++++++++- kbdb/src/routes/embed.ts | 22 +- kbdb/src/types.ts | 8 +- kbdb/tests/embed-backfill.test.ts | 421 ++++++++++++++++++++++-------- 4 files changed, 566 insertions(+), 118 deletions(-) diff --git a/kbdb/src/embed.ts b/kbdb/src/embed.ts index 9f0e0ce..75334c7 100644 --- a/kbdb/src/embed.ts +++ b/kbdb/src/embed.ts @@ -132,7 +132,12 @@ export async function embedOnWrite(env: Bindings, entry: Entry): Promise | null { const BACKFILL_PREDICATE = "is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1"; +// ── 每日額度上限(D68,2026-08-11:leo「補算向量照時間新到舊、且每天有額度上限」)───────── +// +// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日免費 10,000 neurons(UTC 午夜重置, +// 見頂層 wiki ops-facts.md「萃取與向量化吃同一份 Workers AI 額度」)。backfill 是背景低優先 +// 動作,不該把當天額度燒光讓萃取/今天的新寫入整天卡死(embedOnWrite 不受此上限——「新寫的 +// 立刻算」是 D68 三條之一,不能被 backfill 的節制連坐)。自設「軟上限」,非 Cloudflare 硬限制, +// 可用 env.EMBED_BACKFILL_DAILY_LIMIT 覆寫(精神比照 execution-log.ts 的 DEFAULT_DAILY_LIMIT)。 +// +// 預設值怎麼選(不是拍腦袋,2026-08-11 查證 Cloudflare 官方定價後回推): +// bge-m3 定價:1,075 neurons / 1,000,000 input tokens(無輸出 token 成本,embedding 只有輸入)。 +// 保守估計每筆中文知識卡片 ~800 tokens(寧可高估——CJK tokenizer 密度通常高於英文, +// 高估 token 數 ⇒ 算出的「每日可嵌筆數」偏保守,不會撞真的 CF 額度): +// 800 tokens × 1,075 / 1,000,000 ≈ 0.86 neurons/entry +// backfill 分到日配額 20%(比照 execution-log.ts「自我節制、留大部分給主流程」的既有慣例): +// 10,000 × 20% = 2,000 neurons/日 +// 2,000 ÷ 0.86 ≈ 2,325 entries/日,再打八折留緩衝(token 估計誤差/其他背景消耗): +// 2,325 × 0.8 ≈ 1,860 → 取整數 1,800。 +const DEFAULT_BACKFILL_DAILY_LIMIT = 1800; + +function backfillDailyLimit(env: Pick): number { + const raw = env.EMBED_BACKFILL_DAILY_LIMIT; + const n = raw ? parseInt(raw, 10) : NaN; + return Number.isFinite(n) && n > 0 ? n : DEFAULT_BACKFILL_DAILY_LIMIT; +} + +function utcDay(): string { + return new Date().toISOString().slice(0, 10); +} + +/** 額度計數器 entries id(單一列/日,UTC 日期字串,換日自然歸零;不分租戶——Workers AI 額度是帳號級)。 */ +function backfillUsageId(): string { + return `embed-backfill-usage:${utcDay()}`; +} + +/** + * 今天 backfill 已消耗的筆數。儲存精神完全比照 execution-log.ts 的 checkUsage:單一 entries 列/日 + * (entry_type='embed_backfill_usage',計數包進 metadata_json),不新增表。 + * 讀取失敗(含壞資料)誠實視為 0(caller 決定是否 fail-open)。 + */ +async function getBackfillUsageToday(db: D1Database): Promise { + const row = await db + .prepare('SELECT metadata_json FROM entries WHERE id = ?') + .bind(backfillUsageId()) + .first<{ metadata_json: string | null }>(); + if (!row) return 0; + try { + const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { embedded?: number }) : {}; + return Number(parsed.embedded) || 0; + } catch { + return 0; // 壞資料誠實視為 0,不讓損毀的計數器卡死額度機制 + } +} + +/** 今天 backfill 額度用量 +by(upsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */ +async function addBackfillUsage(db: D1Database, by: number): Promise { + if (by <= 0) return; + const id = backfillUsageId(); + const existing = await db + .prepare('SELECT metadata_json FROM entries WHERE id = ?') + .bind(id) + .first<{ metadata_json: string | null }>(); + let prev = 0; + if (existing) { + try { + const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { embedded?: number }) : {}; + prev = Number(parsed.embedded) || 0; + } catch { + prev = 0; + } + await db + .prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?') + .bind(JSON.stringify({ day: utcDay(), embedded: prev + by }), id) + .run(); + } else { + await db + .prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'embed_backfill_usage', ?)`) + .bind(id, JSON.stringify({ day: utcDay(), embedded: by })) + .run(); + } +} + export interface BackfillResult { enabled: boolean; // 模組是否開(false → 什麼都沒做,caller 該誠實回錯,不假裝)。 processed: number; // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。 - skipped: number; // 掃到但沒嵌(例如 embedText 回 null)的筆數。 - remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0)。 + skipped: number; // 掃到但沒嵌(例如 embedText 回 null,或本批被額度擋下)的筆數。 + remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0,與額度無關——單純候選總量)。 scanned: number; // 本批掃出的候選筆數(受 limit 限制)。 + quota_limit: number; // 今日 backfill 額度上限(env.EMBED_BACKFILL_DAILY_LIMIT 或預設值)。 + quota_used_today: number; // 本次呼叫後,今日累積已消耗的 backfill 額度。 + quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有可嵌的候選但今天不再打 AI,等明天/調高上限)。 } /** @@ -195,7 +284,12 @@ export async function backfillEmbeddings( env: Bindings, opts: { limit?: number; owner_id?: string; source?: string; reindex?: boolean; offset?: number } = {}, ): Promise { - if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 }; + if (!embedEnabled(env)) { + return { + enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0, + quota_limit: 0, quota_used_today: 0, quota_exceeded: false, + }; + } const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100); const offset = Math.max(opts.offset ?? 0, 0); @@ -216,15 +310,31 @@ export async function backfillEmbeddings( if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); } const where = conds.join(' AND '); + // D68:由新到舊——最可能被查到的最先補回來(見檔頭 DEFAULT_BACKFILL_DAILY_LIMIT 段的決策脈絡)。 const res = await env.DB - .prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`) + .prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`) .bind(...params, limit, offset) .all(); const rows = res.results ?? []; const scanned = rows.length; + // D68:每日額度上限。額度是「這次呼叫要不要打 AI」的唯一守門——reindex 一樣要打 AI.run, + // 同樣受限(不因為是 reindex 就例外,會打 Workers AI 的動作都算)。 + const dailyCap = backfillDailyLimit(env); + let usedToday = 0; + try { + usedToday = await getBackfillUsageToday(env.DB); + } catch { + usedToday = 0; // fail-open:計數器本身故障(含 D1 額度打滿)不該連 backfill 都不做 + } + const remainingQuota = Math.max(0, dailyCap - usedToday); + let processed = 0; - const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0); + const candidates = rows.filter((e) => (e.content ?? '').trim().length > 0); + // 額度截斷:candidates 已按 created_at DESC 排序,取前 remainingQuota 筆=優先保留最新的。 + const embeddable = candidates.slice(0, remainingQuota); + const quotaExceeded = candidates.length > embeddable.length; + if (embeddable.length > 0 && env.AI && env.VECTORIZE) { const texts = embeddable.map((e) => (e.content ?? '').trim()); const out = (await env.AI.run(embedModel(env), { text: texts })) as { data: number[][] }; @@ -246,8 +356,18 @@ export async function backfillEmbeddings( await env.VECTORIZE.upsert(vectors); const ids = vectors.map((v) => v.id); const placeholders = ids.map(() => '?').join(','); - await env.DB.prepare(`UPDATE entries SET is_embedded = 1 WHERE id IN (${placeholders})`).bind(...ids).run(); + // content_hash 順手蓋成現行模型(世代戳記,見 reconcileEmbedGeneration)。 + await env.DB + .prepare(`UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id IN (${placeholders})`) + .bind(embedModel(env), ...ids) + .run(); processed = vectors.length; + try { + await addBackfillUsage(env.DB, processed); + } catch { + // fail-open:額度計數寫入失敗不影響已經完成的嵌入(別讓 bookkeeping 故障吞掉已做的工); + // 代價是下次呼叫可能少算一點用量——比「明明做了卻沒生效」安全(誠實限制,mindset §7)。 + } } } @@ -259,7 +379,16 @@ export async function backfillEmbeddings( // 非 reindex:predicate 含 is_embedded=0,處理後該筆變 1 → COUNT 自然遞減(重呼直到 0)。 // reindex:predicate 不含 is_embedded,COUNT 恆等於總數 → 改用 offset 分頁計 remaining(否則永不終止)。 const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching; - return { enabled: true, processed, skipped: scanned - processed, remaining, scanned }; + return { + enabled: true, + processed, + skipped: scanned - processed, + remaining, + scanned, + quota_limit: dailyCap, + quota_used_today: usedToday + processed, + quota_exceeded: quotaExceeded, + }; } /** 補嵌進度統計(回報用;模組未開仍可查 pending 數,誠實標 enabled:false)。 */ @@ -283,6 +412,94 @@ export async function backfillStatus( return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 }; } +export interface ReconcileResult { + enabled: boolean; + checked: number; // 本批檢查筆數(is_embedded=1 且 content_hash 非現行世代的候選)。 + confirmed_current: number; // 核對後確認已在現行 Vectorize index:只補標 content_hash,未打 AI。 + reset_to_pending: number; // 核對後確認不在現行 index:重置 is_embedded=0,回到正常 backfill 佇列。 + remaining: number; // 本次之後仍待核對的筆數(可重複呼叫直到 0)。 +} + +/** + * 世代核對(Generation reconciliation,D68 配套修復,2026-08-11)。 + * + * 背景:`is_embedded=1` 只代表「曾經對某個 Vectorize index 嵌過」,不保證是**現行**的 + * index/模型(見檔頭 2026-08-03 換代註解:換模型必須換 index,舊向量收不進新 index、也刪不掉)。 + * 從備份整批灌回的資料尤其會帶著對**已退役索引**(例:768 維 `arcrun-kbdb-embed`)的 + * `is_embedded=1`——現行 backfill 的預設路徑(只補 `is_embedded=0`)永遠不會碰它們, + * 語意搜尋對現行(1024 維 `arcrun-kbdb-embed-m3`)索引而言永遠搜不到那批東西,畫面不會說壞掉。 + * + * 做法:不猜(`is_embedded` 本身此刻不可信),直接問現行 Vectorize index「這些 id 真的在你這嗎」 + * (`env.VECTORIZE.getByIds`,ground truth,而非比對 content_hash 字串本身——後者在這次修復 + * 之前從未被寫過,所有既有 is_embedded=1 的列 content_hash 皆為 NULL,無法只憑字串判斷「哪些是 + * 這次修復前的正常資料、哪些是真正的舊世代殘留」,必須問 Vectorize 本身): + * - 真的在現行 index → 只是這次修復之前的正常資料,沒補寫過 content_hash。補標記,不重打 AI + * (不浪費額度在已經正確的資料上)。 + * - 不在現行 index → 對現行 index 而言等於沒嵌過,重置 is_embedded=0、清空 content_hash, + * 交回正常 backfill 佇列(下一輪照樣受「新到舊」排序+每日額度上限保護,不特別優待)。 + * + * 不消耗 Workers AI 額度:零 AI.run,只有一次 D1 掃描 + 一次 Vectorize.getByIds + D1 寫回。 + */ +export async function reconcileEmbedGeneration( + env: Bindings, + opts: { limit?: number; owner_id?: string } = {}, +): Promise { + if (!embedEnabled(env)) return { enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0 }; + const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); + const currentModel = embedModel(env); + + const conds = [ + 'is_embedded = 1', + '(content_hash IS NULL OR content_hash != ?)', + "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", + ]; + const params: unknown[] = [currentModel]; + if (opts.owner_id) { + conds.push('owner_id = ?'); + params.push(opts.owner_id); + } + const where = conds.join(' AND '); + + const res = await env.DB + .prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ?`) + .bind(...params, limit) + .all<{ id: string }>(); + const ids = (res.results ?? []).map((r) => r.id); + const checked = ids.length; + + let confirmed_current = 0; + let reset_to_pending = 0; + if (ids.length > 0 && env.VECTORIZE) { + const found = await env.VECTORIZE.getByIds(ids); + const foundIds = new Set(found.map((v) => v.id)); + const presentIds = ids.filter((id) => foundIds.has(id)); + const missingIds = ids.filter((id) => !foundIds.has(id)); + + if (presentIds.length > 0) { + const ph = presentIds.map(() => '?').join(','); + await env.DB + .prepare(`UPDATE entries SET content_hash = ? WHERE id IN (${ph})`) + .bind(currentModel, ...presentIds) + .run(); + confirmed_current = presentIds.length; + } + if (missingIds.length > 0) { + const ph = missingIds.map(() => '?').join(','); + await env.DB + .prepare(`UPDATE entries SET is_embedded = 0, content_hash = NULL WHERE id IN (${ph})`) + .bind(...missingIds) + .run(); + reset_to_pending = missingIds.length; + } + } + + const remRow = await env.DB + .prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`) + .bind(...params) + .first<{ c: number }>(); + return { enabled: true, checked, confirmed_current, reset_to_pending, remaining: remRow?.c ?? 0 }; +} + export interface SelfTestResult { enabled: boolean; // embed 模組是否開(binding 都在) tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗) diff --git a/kbdb/src/routes/embed.ts b/kbdb/src/routes/embed.ts index 55c6cee..87cf145 100644 --- a/kbdb/src/routes/embed.ts +++ b/kbdb/src/routes/embed.ts @@ -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 撞過的真實案例), diff --git a/kbdb/src/types.ts b/kbdb/src/types.ts index f11c0f7..cbbe1b3 100644 --- a/kbdb/src/types.ts +++ b/kbdb/src/types.ts @@ -24,6 +24,11 @@ export type Bindings = { // kbdb/src/actions/execution-log.ts DEFAULT_DAILY_LIMIT 說明)。未設 → 20000 // (D1 100,000 rows written/日的 20%,留 80% 給知識卡 entries)。 EXECUTION_LOG_DAILY_WRITE_LIMIT?: string; + // embed backfill 每日軟上限(D68,2026-08-11:補算向量照時間新到舊、且每天有額度上限)。 + // backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日 10,000 免費 neurons(見頂層 + // wiki ops-facts.md);backfill 是背景低優先動作,自設軟上限不把當天額度燒光。未設 → 見 + // kbdb/src/embed.ts DEFAULT_BACKFILL_DAILY_LIMIT 說明(含選值算式,非拍腦袋)。 + EMBED_BACKFILL_DAILY_LIMIT?: string; }; export type EntryType = @@ -35,7 +40,8 @@ export type EntryType = | 'workflow' | 'recipe_stat' | 'execution_log' - | 'execution_log_usage'; + | 'execution_log_usage' + | 'embed_backfill_usage'; export interface Entry { id: string; diff --git a/kbdb/tests/embed-backfill.test.ts b/kbdb/tests/embed-backfill.test.ts index 09df14f..4040a27 100644 --- a/kbdb/tests/embed-backfill.test.ts +++ b/kbdb/tests/embed-backfill.test.ts @@ -1,130 +1,195 @@ +// embed backfill — D68(2026-08-11 leo 拍板:補算向量照時間新到舊、且每天有額度上限)測試。 +// +// 測試策略比照 execution-log.test.ts/library-map.test.ts:真 SQLite(node:sqlite)套 +// migrations/0001_base.sql 原檔,比手刻假 DB 更硬——驗的是真實 SQL 語意(ORDER BY/WHERE/ +// JSON 函式),不是「以為 SQL 長這樣」。AI/VECTORIZE 仍是輕量假物件(Cloudflare binding, +// 不是 SQL,沒有真 runtime 可套)。 +// +// 覆蓋 D68 三條 + is_embedded 世代旗標坑,四項都要有實測輸出: +// 1. 由新到舊:造 created_at 跨時間的候選,證明先被處理的是最新那幾筆 +// 2. 每日額度上限真的擋:cap 設小,跑到撞上限,證明它停手不再打 AI(不是繼續打) +// 3. 帶著舊世代旗標(is_embedded=1 但對應已退役索引)的列補得回來 +// 4. 現有 idempotent/batching/reindex 行為不因本次改動而壞掉 +// +// 本檔在 kbdb/tests/(牆外,非 kbdb/src|migrations),依 D38 kbdb-api-wall-guard 規則, +// 所有直接對 SQLite 治具下 SQL 的行都集中在下面幾個 helper(每行標 kbdb-sql-ok 留痕)—— +// 這是**測試治具本身**(node:sqlite→D1 shim,模擬 D1 binding),不是牆外業務邏輯繞過 API。 import { describe, it, expect } from 'vitest'; -import { backfillEmbeddings, backfillStatus, embedEnabled } from '../src/embed'; -import type { Bindings, Entry } from '../src/types'; +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync } from 'node:fs'; +import { + backfillEmbeddings, + backfillStatus, + embedEnabled, + reconcileEmbedGeneration, +} from '../src/embed'; +import type { Bindings, Entry, EntryType } from '../src/types'; -// ── Minimal in-memory fakes (no Workers runtime) ───────────────────────────── -// The fake DB interprets only the 3 statement shapes backfill issues, by keyword: -// SELECT * ... LIMIT ? OFFSET ? → candidate rows (embeddable & non-empty content; -// +is_embedded=0 for normal backfill, any for reindex) -// UPDATE ... IN (...) → flip is_embedded=1 for the bound ids -// SELECT COUNT(*) → count of matching candidates -// embeddable = metadata.embed===true & non-empty content(reindex predicate)。 -function isEmbeddable(e: Entry): boolean { - if (!e.content || e.content.trim() === '') return false; - try { - const m = JSON.parse(e.metadata_json ?? 'null'); - return m?.embed === true; - } catch { - return false; - } -} -// normal backfill 額外要求 is_embedded=0(漏網補嵌)。 -function isCandidate(e: Entry): boolean { - return e.is_embedded === 0 && isEmbeddable(e); -} +const CURRENT_MODEL = '@cf/baai/bge-m3'; // embed.ts DEFAULT_EMBED_MODEL(未 export,測試按文件字面核對) -function makeFakeDB(store: Entry[]) { - const prepare = (sql: string) => { - // reindex predicate 不含 "is_embedded = 0" → 依 SQL 判斷該用哪個 filter(對齊 embed.ts)。 - const pred = /is_embedded = 0/.test(sql) ? isCandidate : isEmbeddable; - let bound: unknown[] = []; - const stmt = { - bind(...args: unknown[]) { bound = args; return stmt; }, - async all() { - // SELECT * ... LIMIT ? OFFSET ? (bound tail = [..., limit, offset]) - const offset = Number(bound[bound.length - 1]); - const limit = Number(bound[bound.length - 2]); - const results = store.filter(pred).slice(offset, offset + limit) as unknown as T[]; - return { results }; - }, - async first() { - // SELECT COUNT(*) as c ... - const c = store.filter(pred).length; - return { c } as unknown as T; - }, +// ── node:sqlite → D1 介面最小 adapter(同 execution-log.test.ts/library-map.test.ts 手法)── +function makeSqliteD1(): D1Database { + const raw = new DatabaseSync(':memory:'); + raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔 + function stmt(sql: string, params: unknown[]) { + const s = { + bind(...args: unknown[]) { return stmt(sql, args); }, + async all() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim) + async first() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim) async run() { - // UPDATE entries SET is_embedded = 1 WHERE id IN (...) → bound = ids - const ids = new Set(bound.map(String)); - for (const e of store) if (ids.has(e.id)) e.is_embedded = 1; - return { success: true }; + const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API + return { success: true, meta: { changes: r.changes } }; }, }; - return stmt; - }; - return { prepare } as unknown as D1Database; + return s; + } + return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database; } -function mkEntry(id: string, content: string | null, embed: boolean, is_embedded = 0): Entry { - return { - id, content, entry_type: 'workflow', owner_id: 'leo', parent_id: null, page_name: null, - refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded, - confidence: null, metadata_json: JSON.stringify({ embed }), created_at: 1, updated_at: 1, - }; +// ── 測試專用資料存取 helper:把所有直接下 SQL 的呼叫收斂到這裡(每行標記留痕)────────── +function insertEntry(db: D1Database, e: Partial & { id: string; created_at: number }): void { + const sql = `INSERT INTO entries (id, content, entry_type, owner_id, content_hash, is_embedded, metadata_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; + db.prepare(sql).bind( // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)灌測試資料 + e.id, + e.content === undefined ? 'x' : e.content, // 區分「沒提供」(undefined→預設'x') 與「顯式 null」(保留 null) + + (e.entry_type ?? 'workflow') as EntryType, + e.owner_id ?? 'leo', + e.content_hash ?? null, + e.is_embedded ?? 0, + e.metadata_json ?? JSON.stringify({ embed: true }), + e.created_at, + e.created_at, + ).run(); } -function makeEnv(store: Entry[], withBindings: boolean): Bindings { +async function getRow(db: D1Database, id: string): Promise<{ id: string; is_embedded: number; content_hash: string | null } | null> { + return db.prepare('SELECT id, is_embedded, content_hash FROM entries WHERE id = ?').bind(id).first(); // kbdb-sql-ok:測試治具讀回斷言用 +} + +async function listAllRows(db: D1Database): Promise<{ id: string; is_embedded: number; content_hash: string | null }[]> { + const res = await db.prepare('SELECT id, is_embedded, content_hash FROM entries').all<{ id: string; is_embedded: number; content_hash: string | null }>(); // kbdb-sql-ok:測試治具讀回斷言用 + return res.results; +} + +async function listEmbeddedIds(db: D1Database): Promise { + const res = await db.prepare("SELECT id FROM entries WHERE is_embedded = 1").all<{ id: string }>(); // kbdb-sql-ok:測試治具讀回斷言用 + return res.results.map((r) => r.id); +} + +async function countUsageRows(db: D1Database): Promise<{ id: string; entry_type: string }[]> { + const res = await db.prepare("SELECT id, entry_type FROM entries WHERE entry_type = 'embed_backfill_usage'").all<{ id: string; entry_type: string }>(); // kbdb-sql-ok:測試治具驗證「不新增表、單列 upsert」 + return res.results; +} + +function makeEnv(db: D1Database, opts: { withBindings?: boolean; dailyLimit?: string } = {}): Bindings { + const withBindings = opts.withBindings ?? true; const upserts: { id: string }[] = []; const aiCalls: string[][] = []; + const getByIdsCalls: string[][] = []; + const vectorizeStore = new Set(); // ids "present" in the current (fake) Vectorize index const env = { - DB: makeFakeDB(store), + DB: db, ENVIRONMENT: 'test', + EMBED_BACKFILL_DAILY_LIMIT: opts.dailyLimit, ...(withBindings ? { - AI: { async run(_m: string, i: { text: string[] }) { aiCalls.push(i.text); return { data: i.text.map(() => [0.1, 0.2, 0.3]) }; } }, - VECTORIZE: { async upsert(v: { id: string }[]) { upserts.push(...v); return { count: v.length }; } }, + AI: { + async run(_m: string, i: { text: string[] }) { + aiCalls.push(i.text); + return { data: i.text.map(() => [0.1, 0.2, 0.3]) }; + }, + }, + VECTORIZE: { + async upsert(v: { id: string }[]) { + upserts.push(...v); + for (const x of v) vectorizeStore.add(x.id); + return { count: v.length }; + }, + async getByIds(ids: string[]) { + getByIdsCalls.push(ids); + return ids.filter((id) => vectorizeStore.has(id)).map((id) => ({ id, values: [0.1] })); + }, + }, } : {}), } as unknown as Bindings; - (env as unknown as { __upserts: unknown[]; __ai: unknown[] }).__upserts = upserts; - (env as unknown as { __upserts: unknown[]; __ai: unknown[] }).__ai = aiCalls; + const bag = env as unknown as { + __upserts: unknown[]; __ai: unknown[]; __getByIds: unknown[]; + __seedVectorized: (ids: string[]) => void; + }; + bag.__upserts = upserts; + bag.__ai = aiCalls; + bag.__getByIds = getByIdsCalls; + bag.__seedVectorized = (ids: string[]) => { for (const id of ids) vectorizeStore.add(id); }; return env; } -describe('backfillEmbeddings', () => { - it('module off → enabled:false, no-op (誠實不假綠)', async () => { - const store = [mkEntry('e1', 'hello', true)]; - const env = makeEnv(store, false); +function aiCallsOf(env: Bindings): string[][] { + return (env as unknown as { __ai: string[][] }).__ai; +} +function upsertsOf(env: Bindings): { id: string }[] { + return (env as unknown as { __upserts: { id: string }[] }).__upserts; +} +function seedVectorized(env: Bindings, ids: string[]): void { + (env as unknown as { __seedVectorized: (ids: string[]) => void }).__seedVectorized(ids); +} + +describe('backfillEmbeddings — 模組未開', () => { + it('誠實不假綠:no-op,含新增的 quota 欄位', async () => { + const db = makeSqliteD1(); + insertEntry(db, { id: 'e1', created_at: 1 }); + const env = makeEnv(db, { withBindings: false }); expect(embedEnabled(env)).toBe(false); const r = await backfillEmbeddings(env); - expect(r).toEqual({ enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 }); - expect(store[0].is_embedded).toBe(0); // untouched + expect(r).toEqual({ + enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0, + quota_limit: 0, quota_used_today: 0, quota_exceeded: false, + }); }); +}); - it('embeds embeddable+is_embedded=0 entries, marks is_embedded=1, batches AI+upsert', async () => { - const store = [ - mkEntry('e1', 'doorbell workflow', true), - mkEntry('e2', 'notify workflow', true), - mkEntry('e3', 'not tagged', false), // embed:false → not a candidate - mkEntry('e4', 'already done', true, 1), // is_embedded=1 → not a candidate - mkEntry('e5', ' ', true), // empty content → not embeddable - ]; - const env = makeEnv(store, true); +describe('backfillEmbeddings — 基本行為(沿用既有覆蓋,改動後仍要綠)', () => { + it('embeds embeddable+is_embedded=0 entries, marks is_embedded=1 + content_hash,批次 AI+upsert', async () => { + const db = makeSqliteD1(); + insertEntry(db, { id: 'e1', content: 'doorbell workflow', created_at: 1 }); + insertEntry(db, { id: 'e2', content: 'notify workflow', created_at: 2 }); + insertEntry(db, { id: 'e3', content: 'not tagged', created_at: 3, metadata_json: JSON.stringify({ embed: false }) }); + insertEntry(db, { id: 'e4', content: 'already done', created_at: 4, is_embedded: 1 }); + insertEntry(db, { id: 'e5', content: null, created_at: 5 }); // NULL content → 排除,非本次改動範圍的既有行為 + const env = makeEnv(db); const r = await backfillEmbeddings(env, { limit: 100 }); expect(r.enabled).toBe(true); - expect(r.processed).toBe(2); // only e1,e2 - expect(r.remaining).toBe(0); // nothing left embeddable - expect(store.find((e) => e.id === 'e1')!.is_embedded).toBe(1); - expect(store.find((e) => e.id === 'e2')!.is_embedded).toBe(1); - expect(store.find((e) => e.id === 'e3')!.is_embedded).toBe(0); - const upserts = (env as unknown as { __upserts: { id: string }[] }).__upserts; - expect(upserts.map((u) => u.id).sort()).toEqual(['e1', 'e2']); - const ai = (env as unknown as { __ai: string[][] }).__ai; - expect(ai.length).toBe(1); // single batched AI.run for the whole batch - expect(ai[0].length).toBe(2); + expect(r.processed).toBe(2); // only e1, e2 + expect(r.remaining).toBe(0); + const rows = await listAllRows(db); + const byId = Object.fromEntries(rows.map((x) => [x.id, x])); + expect(byId.e1.is_embedded).toBe(1); + expect(byId.e1.content_hash).toBe(CURRENT_MODEL); // 世代戳記有寫 + expect(byId.e2.is_embedded).toBe(1); + expect(byId.e3.is_embedded).toBe(0); + expect(upsertsOf(env).map((u) => u.id).sort()).toEqual(['e1', 'e2']); + expect(aiCallsOf(env).length).toBe(1); + expect(aiCallsOf(env)[0].length).toBe(2); }); - it('idempotent: re-run after all embedded processes nothing', async () => { - const store = [mkEntry('e1', 'x', true)]; - const env = makeEnv(store, true); + it('idempotent:全部嵌完後重跑不再處理', async () => { + const db = makeSqliteD1(); + insertEntry(db, { id: 'e1', content: 'x', created_at: 1 }); + const env = makeEnv(db, { dailyLimit: '100' }); await backfillEmbeddings(env); const r2 = await backfillEmbeddings(env); expect(r2.processed).toBe(0); expect(r2.remaining).toBe(0); }); - it('batches via limit → remaining reported so caller can loop to zero', async () => { - const store = [mkEntry('a', 'x', true), mkEntry('b', 'y', true), mkEntry('c', 'z', true)]; - const env = makeEnv(store, true); + it('batches via limit → remaining 讓 caller 可重複呼叫到 0', async () => { + const db = makeSqliteD1(); + insertEntry(db, { id: 'a', content: 'x', created_at: 1 }); + insertEntry(db, { id: 'b', content: 'y', created_at: 2 }); + insertEntry(db, { id: 'c', content: 'z', created_at: 3 }); + const env = makeEnv(db, { dailyLimit: '100' }); const r1 = await backfillEmbeddings(env, { limit: 2 }); expect(r1.processed).toBe(2); expect(r1.remaining).toBe(1); @@ -133,34 +198,174 @@ describe('backfillEmbeddings', () => { expect(r2.remaining).toBe(0); }); - it('reindex: 重推所有 embeddable(含 is_embedded=1),offset 分頁到 remaining=0(Arcrun#11)', async () => { - // 三筆皆已 is_embedded=1(既有向量):正常 backfill 不會碰(pending=0),reindex 要全部重推 - // 讓事後建立的 Vectorize metadata index 收錄。 - const store = [ - mkEntry('a', 'x', true, 1), mkEntry('b', 'y', true, 1), mkEntry('c', 'z', true, 1), - ]; - const env = makeEnv(store, true); - // 正常 backfill:沒有 is_embedded=0 → 什麼都不做(證明「不重推就補不到」)。 + it('reindex:重推所有 embeddable(含 is_embedded=1),offset 分頁到 remaining=0(Arcrun#11)', async () => { + const db = makeSqliteD1(); + insertEntry(db, { id: 'a', content: 'x', created_at: 1, is_embedded: 1 }); + insertEntry(db, { id: 'b', content: 'y', created_at: 2, is_embedded: 1 }); + insertEntry(db, { id: 'c', content: 'z', created_at: 3, is_embedded: 1 }); + const env = makeEnv(db, { dailyLimit: '100' }); const normal = await backfillEmbeddings(env, { limit: 100 }); - expect(normal.processed).toBe(0); - // reindex 分頁:第一批 2 筆、remaining=1;第二批 1 筆、remaining=0。 + expect(normal.processed).toBe(0); // 沒有 is_embedded=0 → 什麼都不做 const r1 = await backfillEmbeddings(env, { reindex: true, limit: 2, offset: 0 }); expect(r1.processed).toBe(2); expect(r1.remaining).toBe(1); const r2 = await backfillEmbeddings(env, { reindex: true, limit: 2, offset: 2 }); expect(r2.processed).toBe(1); expect(r2.remaining).toBe(0); - const upserts = (env as unknown as { __upserts: { id: string }[] }).__upserts; - expect(upserts.map((u) => u.id).sort()).toEqual(['a', 'b', 'c']); + expect(upsertsOf(env).map((u) => u.id).sort()).toEqual(['a', 'b', 'c']); }); - it('status reports pending/embedded counts', async () => { - const store = [mkEntry('e1', 'x', true), mkEntry('e2', 'y', true, 1)]; - const env = makeEnv(store, true); + it('status 回報 pending/embedded 計數', async () => { + const db = makeSqliteD1(); + insertEntry(db, { id: 'e1', content: 'x', created_at: 1 }); + insertEntry(db, { id: 'e2', content: 'y', created_at: 2, is_embedded: 1 }); + const env = makeEnv(db); const s = await backfillStatus(env); - // fake first() returns candidate count for pending; embedded query also runs through - // the same COUNT fake, so this asserts the call path works (enabled:true). expect(s.enabled).toBe(true); expect(typeof s.pending).toBe('number'); }); }); + +describe('D68①:由新到舊排序(實測,不是推論)', () => { + it('候選跨時間分佈時,先被嵌入的是 created_at 最新的那幾筆', async () => { + const db = makeSqliteD1(); + // 刻意亂序插入,證明排序看的是 created_at 不是插入順序 / id 字母序 + insertEntry(db, { id: 'old-2024', content: 'half year ago', created_at: 1_000 }); + insertEntry(db, { id: 'today', content: 'written today', created_at: 100_000 }); + insertEntry(db, { id: 'mid-2025', content: 'a few months ago', created_at: 50_000 }); + const env = makeEnv(db, { dailyLimit: '100' }); + // limit=1:一次只能處理一筆,若排序正確,該筆必須是 'today'(created_at 最大) + const r = await backfillEmbeddings(env, { limit: 1 }); + expect(r.processed).toBe(1); + const embedded = await listEmbeddedIds(db); + expect(embedded).toEqual(['today']); + expect(aiCallsOf(env)[0]).toEqual(['written today']); + }); +}); + +describe('D68②:每日額度上限真的擋(把上限設小,跑到撞上限)', () => { + it('額度耗盡後停手,不再繼續打 AI;未耗盡的仍優先保留最新的(額度截斷 + 排序疊加)', async () => { + const db = makeSqliteD1(); + insertEntry(db, { id: 'e-oldest', content: 'c1', created_at: 1 }); + insertEntry(db, { id: 'e-old', content: 'c2', created_at: 2 }); + insertEntry(db, { id: 'e-new', content: 'c3', created_at: 3 }); + insertEntry(db, { id: 'e-newest', content: 'c4', created_at: 4 }); + const env = makeEnv(db, { dailyLimit: '2' }); // 上限設得比候選數(4)小 + const r = await backfillEmbeddings(env, { limit: 100 }); + + // 停手,不是繼續打:AI 只被叫過一次,且只帶 2 筆文字(不是全部 4 筆) + expect(aiCallsOf(env).length).toBe(1); + expect(aiCallsOf(env)[0].length).toBe(2); + expect(r.processed).toBe(2); + expect(r.quota_limit).toBe(2); + expect(r.quota_used_today).toBe(2); + expect(r.quota_exceeded).toBe(true); // 還有候選但今天不再打 AI + + // 被留下處理的兩筆是最新的(e-newest, e-new),不是隨機或最舊的 + const embedded = await listEmbeddedIds(db); + expect(embedded.sort()).toEqual(['e-new', 'e-newest']); + + // 再跑一次(同一天):額度已用完,processed=0,AI 呼叫次數仍是 1(沒有再打) + const r2 = await backfillEmbeddings(env, { limit: 100 }); + expect(r2.processed).toBe(0); + expect(r2.quota_exceeded).toBe(true); + expect(aiCallsOf(env).length).toBe(1); // 沒有新增呼叫 + }); + + it('額度上限被拿掉時本測試會變紅(反向驗證:測試真的在測東西,不是恆真)', async () => { + const db = makeSqliteD1(); + for (let i = 1; i <= 5; i++) insertEntry(db, { id: `e${i}`, content: `c${i}`, created_at: i }); + // 不設 dailyLimit(用預設 1800,遠大於 5)→ 全部應被處理,模擬「上限被拿掉」的行為 + const env = makeEnv(db); + const r = await backfillEmbeddings(env, { limit: 100 }); + expect(r.processed).toBe(5); + expect(r.quota_exceeded).toBe(false); + // 對照組:把上限設到比候選數小,行為必須不同(證明上一組「額度=2」的測試不是巧合) + const db2 = makeSqliteD1(); + for (let i = 1; i <= 5; i++) insertEntry(db2, { id: `e${i}`, content: `c${i}`, created_at: i }); + const env2 = makeEnv(db2, { dailyLimit: '2' }); + const r2 = await backfillEmbeddings(env2, { limit: 100 }); + expect(r2.processed).toBe(2); + expect(r2.processed).not.toBe(r.processed); // 有 cap vs 沒 cap 必須不同,否則 cap 沒在起作用 + }); + + it('額度計數跨呼叫累加,換日字串變動即歸零(不新增表,entries 單列 upsert)', async () => { + const db = makeSqliteD1(); + insertEntry(db, { id: 'e1', content: 'c1', created_at: 1 }); + insertEntry(db, { id: 'e2', content: 'c2', created_at: 2 }); + const env = makeEnv(db, { dailyLimit: '10' }); + const r1 = await backfillEmbeddings(env, { limit: 1 }); + expect(r1.quota_used_today).toBe(1); + const r2 = await backfillEmbeddings(env, { limit: 1 }); + expect(r2.quota_used_today).toBe(2); // 累加,不是每次重算成當批數 + // 驗證只有一列計數器,且落在既有三表(entries),沒有新表 + const usageRows = await countUsageRows(db); + expect(usageRows.length).toBe(1); + expect(usageRows[0].id).toMatch(/^embed-backfill-usage:\d{4}-\d{2}-\d{2}$/); + }); +}); + +describe('D68③:leo21c 資料還原情境——is_embedded=1 但對應已退役索引的列補得回來', () => { + it('reconcile:確認在現行 index 的只補 content_hash,不打 AI', async () => { + const db = makeSqliteD1(); + // 模擬「這次修復之前」就已經正確嵌入現行 index 的資料:is_embedded=1、content_hash 從未寫過(NULL) + insertEntry(db, { id: 'ok-legacy', content: 'x', created_at: 1, is_embedded: 1, content_hash: null }); + const env = makeEnv(db); + seedVectorized(env, ['ok-legacy']); // 現行 index 真的有它 + + const r = await reconcileEmbedGeneration(env, { limit: 100 }); + expect(r.checked).toBe(1); + expect(r.confirmed_current).toBe(1); + expect(r.reset_to_pending).toBe(0); + expect(aiCallsOf(env).length).toBe(0); // 沒有打 AI + + const row = await getRow(db, 'ok-legacy'); + expect(row!.is_embedded).toBe(1); // 沒被誤重置 + expect(row!.content_hash).toBe(CURRENT_MODEL); // 補標記 + }); + + it('reconcile 揪出真正對舊索引的殘留 → 重置回 pending → 正常 backfill 真的把它補回來(端到端)', async () => { + const db = makeSqliteD1(); + // leo21c 情境:從備份整批灌回,is_embedded=1 但這是對已退役 768 維索引說的; + // 現行(1024 維)Vectorize index 裡沒有這個向量(不呼叫 seedVectorized)。 + insertEntry(db, { id: 'restored-stale', content: '從備份還原的舊卡片', created_at: 999, is_embedded: 1, content_hash: null }); + const env = makeEnv(db); + + // step 1:reconcile 應該發現它不在現行 index,重置成 pending + const r1 = await reconcileEmbedGeneration(env, { limit: 100 }); + expect(r1.checked).toBe(1); + expect(r1.confirmed_current).toBe(0); + expect(r1.reset_to_pending).toBe(1); + const midRow = await getRow(db, 'restored-stale'); + expect(midRow!.is_embedded).toBe(0); + expect(midRow!.content_hash).toBe(null); + expect(r1.remaining).toBe(0); // 處理完,沒有更多待核對的了 + + // step 2:正常 backfill 現在會撿到它(因為 is_embedded=0 了),真的打 AI 補回來 + const r2 = await backfillEmbeddings(env, { limit: 100 }); + expect(r2.processed).toBe(1); + expect(aiCallsOf(env).length).toBe(1); + expect(aiCallsOf(env)[0]).toEqual(['從備份還原的舊卡片']); + + const finalRow = await getRow(db, 'restored-stale'); + expect(finalRow!.is_embedded).toBe(1); // 補回來了 + expect(finalRow!.content_hash).toBe(CURRENT_MODEL); // 蓋上現行世代戳記,下次 reconcile 不會再選到它 + }); + + it('已經是現行世代(content_hash 等於現行模型)的列不會被 reconcile 重複選中', async () => { + const db = makeSqliteD1(); + insertEntry(db, { id: 'fresh', content: 'x', created_at: 1, is_embedded: 1, content_hash: CURRENT_MODEL }); + const env = makeEnv(db); + const r = await reconcileEmbedGeneration(env, { limit: 100 }); + expect(r.checked).toBe(0); + expect(r.remaining).toBe(0); + }); + + it('模組未開 → 誠實回 enabled:false,不假裝', async () => { + const db = makeSqliteD1(); + insertEntry(db, { id: 'e1', content: 'x', created_at: 1, is_embedded: 1 }); + const env = makeEnv(db, { withBindings: false }); + const r = await reconcileEmbedGeneration(env); + expect(r).toEqual({ enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0 }); + }); +});