Files
Arcrun/kbdb/src/actions/library-backfill.ts
T
uncle6me-web 674e1b4fa2 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>
2026-08-11 18:39:55 +08:00

169 lines
9.4 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.
// 標庫 backfillArcrun#85 二次裁決,2026-08-11;相關票 Arcrun#87「藏書地圖是空的」)。
//
// 背景:leo 把向量化優先序講成一句話後又補一刀:「它是兩件事?其實是一件——沒有庫就
// 剩一個,但你把庫標好以後,原來的判定要修改對吧」。⇒ 判定標準(embed.ts 的
// SelectionCriteria)必須從第一天就同時容納時間與庫,本檔提供「庫」這一半真正的資料。
//
// base 的 write path 早就支援(`createEntry` 的 `metadata_json.$.library`t52:「庫由
// ingest 蓋章決定」)——既有的搜尋/embeddeprecate-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 節流:與 reconcileEmbedGenerationembed.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 >= sinceunix seconds
until?: number; // created_at < untilunix 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<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
opts: { library: string; owner_id: string; limit?: number } & Omit<LibraryBackfillCriteria, 'owner_id'>,
): Promise<LibraryBackfillResult> {
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 };
}