674e1b4fa2
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>
169 lines
9.4 KiB
TypeScript
169 lines
9.4 KiB
TypeScript
// 標庫 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<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 };
|
||
}
|