// 標庫 backfill(Arcrun#85 二次裁決,2026-08-11;相關票 Arcrun#87「藏書地圖是空的」)。 // // 背景:leo 把向量化優先序講成一句話後又補一刀:「它是兩件事?其實是一件——沒有庫就 // 剩一個,但你把庫標好以後,原來的判定要修改對吧」。⇒ 判定標準(embed.ts 的 // SelectionCriteria)必須從第一天就同時容納時間與庫,本檔提供「庫」這一半真正的資料。 // // base 的 write path 早就支援(`createEntry` 的 `metadata_json.$.library`,t52:「庫由 // ingest 蓋章決定」)——既有的搜尋/embed/deprecate-by-library 全都讀這個欄位, // **缺的不是機制,是既有資料沒被蓋章**(library-map.ts 檔頭 2026-07-19 對 prod 核實: // 既有 entries 的 metadata.library 是空的)。「源頭寫入就貼標」是呼叫端(ingest)的事, // base 這裡管不到、也不該猜(base 對內容語意無知的既有原則);triplet 的實際寫入更是 // 在另一個 repo(見 kbdb/src/index.ts 檔頭「triplet (separate repo)」)。 // // 這個模組只做「補存量」那一半,且刻意設計成呼叫端驅動: // - base 不猜「這筆該屬於哪個庫」——那是語意判斷。呼叫端給一個 target library 值 // +一組篩選條件,base 只負責把符合條件、目前未標記的 entries 安全、節流地蓋上這個值。 // - 篩選條件有兩種精度(2026-08-11 leo 定案的做法後補上): // ① 精準比對 `page_names`(IN 清單)——leo 定的正解:「有 2 份原稿,在 gitea 和我的 // Mac……去 gitea 把每個庫有哪些的卡名列出,跑來遍歷應該就搞定了」。呼叫端(daemon/ // #87)從 Gitea repo 列出卡名,逐批把「這些卡名屬於庫 X」精準地寫進來,不必猜。 // ② `source_prefix`/`page_name_prefix` 前綴 fallback(同 library-map.ts // recomputeLibraryMap 的 source_prefix 精神,只是這裡是寫入不是聚合)——沒有精準 // 清單時的過渡手段,精度不如①,兩者可並用(AND)縮小範圍。 // - 冪等:已標記的 entries 不會再入選(WHERE 帶「library 為空」)。 // // D69 節流:與 reconcileEmbedGeneration(embed.ts)共用 maintenance-quota.ts 的同一顆 // 每日 D1 寫入計數器——兩者都是「多筆 D1 row write、不打 AI」的背景維護操作,不共用 // 計數器的話,補存量時會把世代核對的閘繞過去(leo 2026-08-11 二次裁決原話:「每日上限 // 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。 import type { Bindings } from '../types'; import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota'; // IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用, // 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。 const MAX_PAGE_NAMES = 300; export interface LibraryBackfillCriteria { owner_id?: string; entry_type?: string; page_names?: string[]; // 精準比對 page_name(IN 清單)——leo 定案的正解:從 Gitea repo // 列出卡名,逐批精準點名「這些卡名屬於庫 X」(見檔頭說明①)。 source_prefix?: string; // metadata_json.$.source LIKE prefix%(過渡 fallback,見檔頭②) page_name_prefix?: string; // page_name LIKE prefix%(過渡 fallback,見檔頭②) since?: number; // created_at >= since(unix seconds) until?: number; // created_at < until(unix seconds) } export interface LibraryBackfillResult { library: string; scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。 tagged: number; // 本次真的寫入 metadata_json.$.library 的筆數。 remaining: number; // 本次之後仍待補標(符合條件、仍未標記)的筆數,不受額度影響。 quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與 reconcile 共用)。 quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。 quota_exceeded: boolean; // 本批是否因額度不足被截斷。 } // 單次呼叫候選上限(避開 subrequest/CPU/timeout;一批只有 1 次 SELECT + 1 次 UPDATE, // 比 reconcile 多一次 Vectorize 呼叫的成本低,故上限可以放寬一些)。 const HARD_LIMIT_CAP = 500; function criteriaPredicate(c: LibraryBackfillCriteria): { conds: string[]; params: unknown[] } { // 冪等的核心:只選「目前沒有 library 值」的候選,已標記過的(含標成 'general' 的)不會再入選。 const conds: string[] = [ "(json_extract(metadata_json, '$.library') IS NULL OR json_extract(metadata_json, '$.library') = '')", ]; const params: unknown[] = []; if (c.owner_id) { conds.push('owner_id = ?'); params.push(c.owner_id); } if (c.entry_type) { conds.push('entry_type = ?'); params.push(c.entry_type); } if (c.page_names && c.page_names.length > 0) { const names = c.page_names.slice(0, MAX_PAGE_NAMES); conds.push(`page_name IN (${names.map(() => '?').join(',')})`); params.push(...names); } if (c.source_prefix) { conds.push("json_extract(metadata_json, '$.source') LIKE ? || '%'"); params.push(c.source_prefix); } if (c.page_name_prefix) { conds.push("page_name LIKE ? || '%'"); params.push(c.page_name_prefix); } if (typeof c.since === 'number') { conds.push('created_at >= ?'); params.push(c.since); } if (typeof c.until === 'number') { conds.push('created_at < ?'); params.push(c.until); } return { conds, params }; } /** * 對「符合條件、目前未標記 library」的既有 entries 批次蓋上 target library 值。 * 冪等 + 分批(單次 limit 上限)+ budget(與 reconcile 共用每日 D1 寫入額度,見檔頭)。 * 呼叫端(ingest / Arcrun#87)決定「這批是誰、該貼哪個庫」,本函式只負責安全、節流地 * 把值寫進去——base 不猜語意,也因此不假裝「這樣就把 #87 做完了」(mindset §7)。 * * `owner_id` 刻意設成**必填**(不同於 LibraryBackfillCriteria 其餘欄位皆選填): * 2026-08-11 leo 在票上點出「補標補在錯的 owner 底下等於白做」(實查發現卡片實際掛在 * `owner_id=bfezv28v`,換成 `owner_id='leo'` 查卻是空的——兩個候選 owner 已經在互相打架)。 * 跟既有的 `deprecateEntriesByLibrary`(同樣是「依 library 批次改一大片既有資料」的操作) * 同一個防線:不給不知道自己在改誰的資料的呼叫端一個「忘記帶 owner_id 就變成跨租戶全庫掃」 * 的後門,逼呼叫端明確想清楚「這批是哪個 owner」再動手。 */ export async function backfillEntryLibraryTags( db: D1Database, env: Pick, opts: { library: string; owner_id: string; limit?: number } & Omit, ): Promise { const library = (opts.library ?? '').trim(); if (!library) throw new Error('library required'); const ownerId = (opts.owner_id ?? '').trim(); if (!ownerId) throw new Error('owner_id required(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)'); const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP); const sel = criteriaPredicate({ ...opts, owner_id: ownerId }); const where = sel.conds.join(' AND '); const params = sel.params; const res = await db .prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`) .bind(...params, limit) .all<{ id: string }>(); const scannedIds = (res.results ?? []).map((r) => r.id); const scanned = scannedIds.length; // D69:額度截斷——每個候選最多 1 次 D1 write,與 reconcile 共用同一顆計數器。 const budget = await maintenanceBudgetToday(env, db); const ids = scannedIds.slice(0, budget.remaining); const quotaExceeded = scanned > ids.length; let tagged = 0; if (ids.length > 0) { const ph = ids.map(() => '?').join(','); await db .prepare( `UPDATE entries SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.library', ?), updated_at = unixepoch() WHERE id IN (${ph})`, ) .bind(library, ...ids) .run(); tagged = ids.length; } try { await addMaintenanceUsage(db, tagged); } catch { // fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。 } const remRow = await db .prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`) .bind(...params) .first<{ c: number }>(); return { library, scanned, tagged, remaining: remRow?.c ?? 0, quota_limit: budget.limit, quota_used_today: budget.used + tagged, quota_exceeded: quotaExceeded, }; } /** 待補標統計(回報用):符合條件、目前未標記 library 的筆數。 */ export async function libraryBackfillStatus( db: D1Database, opts: LibraryBackfillCriteria = {}, ): Promise<{ pending: number }> { const sel = criteriaPredicate(opts); const where = sel.conds.join(' AND '); const row = await db .prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`) .bind(...sel.params) .first<{ c: number }>(); return { pending: row?.c ?? 0 }; }