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>
231 lines
12 KiB
TypeScript
231 lines
12 KiB
TypeScript
// 標庫 backfill(Arcrun#85 二次裁決,2026-08-11)測試。
|
||
//
|
||
// 測試策略比照 embed-backfill.test.ts:真 SQLite(node:sqlite)套 migrations/0001_base.sql
|
||
// 原檔,驗真實 SQL 語意(json_set/WHERE/LIMIT),不是「以為 SQL 長這樣」。
|
||
//
|
||
// 覆蓋:
|
||
// 1. 只補「符合條件、目前未標記 library」的候選;已標記的不動(冪等)
|
||
// 2. owner_id 必填(缺了要拋錯,防「補錯 owner 等於白做」——2026-08-11 leo 直令)
|
||
// 3. source_prefix/page_name_prefix/since/until 篩選條件真的在篩
|
||
// 4. 與 reconcileEmbedGeneration 共用同一顆每日 D1 寫入額度(D69 的核心訴求:
|
||
// 補標不能把世代核對的閘繞過去,反之亦然)
|
||
//
|
||
// 本檔在 kbdb/tests/(牆外),依 D38 kbdb-api-wall-guard 規則,直接對 SQLite 治具下 SQL
|
||
// 的行集中在 helper(測試治具本身,非牆外業務邏輯繞過 API,每行標 kbdb-sql-ok 留痕)。
|
||
import { describe, it, expect } from 'vitest';
|
||
import { DatabaseSync } from 'node:sqlite';
|
||
import { readFileSync } from 'node:fs';
|
||
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../src/actions/library-backfill';
|
||
import { reconcileEmbedGeneration } from '../src/embed';
|
||
import type { Bindings, Entry, EntryType } from '../src/types';
|
||
|
||
// ── node:sqlite → D1 介面最小 adapter(同 embed-backfill.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<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||
async run() {
|
||
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
|
||
return { success: true, meta: { changes: r.changes } };
|
||
},
|
||
};
|
||
return s;
|
||
}
|
||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||
}
|
||
|
||
function insertEntry(db: D1Database, e: Partial<Entry> & { id: string; created_at: number }): void {
|
||
const sql = `INSERT INTO entries (id, content, entry_type, owner_id, content_hash, is_embedded, metadata_json, page_name, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||
db.prepare(sql).bind( // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)灌測試資料
|
||
e.id,
|
||
e.content === undefined ? 'x' : e.content,
|
||
(e.entry_type ?? 'block') as EntryType,
|
||
e.owner_id ?? 'bfezv28v',
|
||
e.content_hash ?? null,
|
||
e.is_embedded ?? 0,
|
||
e.metadata_json === undefined ? null : e.metadata_json,
|
||
e.page_name ?? null,
|
||
e.created_at,
|
||
e.created_at,
|
||
).run();
|
||
}
|
||
|
||
async function getLibrary(db: D1Database, id: string): Promise<string | null> {
|
||
const row = await db.prepare("SELECT json_extract(metadata_json, '$.library') AS library FROM entries WHERE id = ?").bind(id).first<{ library: string | null }>(); // kbdb-sql-ok:測試治具讀回斷言用
|
||
return row?.library ?? null;
|
||
}
|
||
|
||
function makeEnv(db: D1Database, opts: { maintenanceLimit?: string } = {}): Bindings {
|
||
return {
|
||
DB: db,
|
||
ENVIRONMENT: 'test',
|
||
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
|
||
} as unknown as Bindings;
|
||
}
|
||
|
||
describe('backfillEntryLibraryTags — 基本行為', () => {
|
||
it('只標記符合條件、目前未標記 library 的候選;已標記的不動', async () => {
|
||
const db = makeSqliteD1();
|
||
insertEntry(db, { id: 'a', created_at: 1 }); // 無 metadata_json → 未標記
|
||
insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({}) }); // 有 metadata_json 但無 library
|
||
insertEntry(db, { id: 'c', created_at: 3, metadata_json: JSON.stringify({ library: 'hr' }) }); // 已標記,不該被動
|
||
const env = makeEnv(db);
|
||
const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||
expect(r.tagged).toBe(2);
|
||
expect(r.remaining).toBe(0);
|
||
expect(await getLibrary(db, 'a')).toBe('finance');
|
||
expect(await getLibrary(db, 'b')).toBe('finance');
|
||
expect(await getLibrary(db, 'c')).toBe('hr'); // 未被覆寫
|
||
});
|
||
|
||
it('冪等:全部標記完後重跑不再處理', async () => {
|
||
const db = makeSqliteD1();
|
||
insertEntry(db, { id: 'a', created_at: 1 });
|
||
const env = makeEnv(db);
|
||
await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||
const r2 = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||
expect(r2.tagged).toBe(0);
|
||
expect(r2.remaining).toBe(0);
|
||
});
|
||
|
||
it('owner_id 缺了要拋錯(防補錯 owner 等於白做,2026-08-11 leo 直令)', async () => {
|
||
const db = makeSqliteD1();
|
||
insertEntry(db, { id: 'a', created_at: 1 });
|
||
const env = makeEnv(db);
|
||
await expect(
|
||
backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: '' }),
|
||
).rejects.toThrow(/owner_id/);
|
||
});
|
||
|
||
it('library 缺了要拋錯', async () => {
|
||
const db = makeSqliteD1();
|
||
const env = makeEnv(db);
|
||
await expect(
|
||
backfillEntryLibraryTags(db, env, { library: '', owner_id: 'bfezv28v' }),
|
||
).rejects.toThrow(/library/);
|
||
});
|
||
|
||
it('owner_id 篩選:只動指定租戶的資料,其他租戶不受影響(跨租戶隔離)', async () => {
|
||
const db = makeSqliteD1();
|
||
insertEntry(db, { id: 'mine', created_at: 1, owner_id: 'bfezv28v' });
|
||
insertEntry(db, { id: 'theirs', created_at: 2, owner_id: 'someone-else' });
|
||
const env = makeEnv(db);
|
||
const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||
expect(r.tagged).toBe(1);
|
||
expect(await getLibrary(db, 'mine')).toBe('finance');
|
||
expect(await getLibrary(db, 'theirs')).toBe(null); // 別的租戶完全沒被動到
|
||
});
|
||
|
||
it('source_prefix/page_name_prefix/since/until 篩選真的在篩', async () => {
|
||
const db = makeSqliteD1();
|
||
insertEntry(db, { id: 'match-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/kb/foo.md' }) });
|
||
insertEntry(db, { id: 'other-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/other/bar.md' }) });
|
||
const r1 = await backfillEntryLibraryTags(db, makeEnv(db), {
|
||
library: 'kb', owner_id: 'bfezv28v', source_prefix: 'gitea://Leo/kb/',
|
||
});
|
||
expect(r1.tagged).toBe(1);
|
||
expect(await getLibrary(db, 'match-source')).toBe('kb');
|
||
expect(await getLibrary(db, 'other-source')).toBe(null);
|
||
|
||
const db2 = makeSqliteD1();
|
||
insertEntry(db2, { id: 'in-window', created_at: 500, page_name: 'wiki/foo' });
|
||
insertEntry(db2, { id: 'out-window', created_at: 5000, page_name: 'wiki/bar' });
|
||
const r2 = await backfillEntryLibraryTags(db2, makeEnv(db2), {
|
||
library: 'wiki', owner_id: 'bfezv28v', page_name_prefix: 'wiki/', since: 0, until: 1000,
|
||
});
|
||
expect(r2.tagged).toBe(1);
|
||
expect(await getLibrary(db2, 'in-window')).toBe('wiki');
|
||
expect(await getLibrary(db2, 'out-window')).toBe(null);
|
||
});
|
||
|
||
it('page_names 精準比對(leo 定案的正解:拿 Gitea 原稿卡名逐批遍歷點名)', async () => {
|
||
const db = makeSqliteD1();
|
||
insertEntry(db, { id: 'a', created_at: 1, page_name: 'card-alpha' });
|
||
insertEntry(db, { id: 'b', created_at: 2, page_name: 'card-beta' });
|
||
insertEntry(db, { id: 'c', created_at: 3, page_name: 'card-gamma' }); // 不在點名清單內
|
||
const r = await backfillEntryLibraryTags(db, makeEnv(db), {
|
||
library: 'kb', owner_id: 'bfezv28v', page_names: ['card-alpha', 'card-beta'],
|
||
});
|
||
expect(r.tagged).toBe(2);
|
||
expect(await getLibrary(db, 'a')).toBe('kb');
|
||
expect(await getLibrary(db, 'b')).toBe('kb');
|
||
expect(await getLibrary(db, 'c')).toBe(null); // 沒被點名,不動
|
||
});
|
||
|
||
it('libraryBackfillStatus 回報待補標筆數', async () => {
|
||
const db = makeSqliteD1();
|
||
insertEntry(db, { id: 'a', created_at: 1 });
|
||
insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({ library: 'hr' }) });
|
||
const s = await libraryBackfillStatus(db, { owner_id: 'bfezv28v' });
|
||
expect(s.pending).toBe(1); // 只有 'a' 未標記
|
||
});
|
||
});
|
||
|
||
describe('Arcrun#85 D69:標庫 backfill 與 reconcile 共用同一顆 D1 每日寫入額度', () => {
|
||
it('reconcile 先消耗額度 → 標庫 backfill 看到的剩餘額度真的變少', async () => {
|
||
const db = makeSqliteD1();
|
||
// reconcile 的候選:is_embedded=1 且 content_hash 非現行世代
|
||
// library 已標記('hr')→ 不會被下面的標庫 backfill 選中,讓兩種候選池互不重疊,
|
||
// 才能單純驗證「額度共用」本身,不被「標庫候選也吃到 reconcile 資料」干擾。
|
||
insertEntry(db, { id: 'reconcile-1', created_at: 1, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
|
||
insertEntry(db, { id: 'reconcile-2', created_at: 2, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
|
||
// 標庫的候選:未標記 library
|
||
insertEntry(db, { id: 'tag-1', created_at: 3 });
|
||
insertEntry(db, { id: 'tag-2', created_at: 4 });
|
||
insertEntry(db, { id: 'tag-3', created_at: 5 });
|
||
|
||
const maintenanceLimit = '3'; // 5 個候選(2 reconcile + 3 tag),額度只夠 3 個
|
||
const reconcileEnv = {
|
||
DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
|
||
AI: { async run() { return { data: [] }; } },
|
||
VECTORIZE: {
|
||
async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); }, // 全部視為現行 index 已有
|
||
async upsert() { return { count: 0 }; },
|
||
},
|
||
} as unknown as Bindings;
|
||
|
||
// 先跑 reconcile:吃掉 2 筆額度(3 - 2 = 1 剩)
|
||
const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
|
||
expect(rc.checked).toBe(2);
|
||
expect(rc.quota_used_today).toBe(2);
|
||
|
||
// 標庫 backfill 用同一顆 DB/同一個每日上限:只剩 1 筆額度可用,即使候選有 3 筆
|
||
const tagEnv = makeEnv(db, { maintenanceLimit });
|
||
const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
|
||
expect(tagResult.scanned).toBe(3); // 3 筆候選都掃到了
|
||
expect(tagResult.tagged).toBe(1); // 但只剩 1 筆額度,只標了 1 筆
|
||
expect(tagResult.quota_exceeded).toBe(true);
|
||
expect(tagResult.quota_used_today).toBe(3); // 2(reconcile)+ 1(本次)= 3,額度用滿
|
||
});
|
||
|
||
it('反過來也一樣:標庫 backfill 先消耗額度 → reconcile 看到的剩餘額度真的變少', async () => {
|
||
const db = makeSqliteD1();
|
||
insertEntry(db, { id: 'tag-1', created_at: 1 });
|
||
insertEntry(db, { id: 'tag-2', created_at: 2 });
|
||
insertEntry(db, { id: 'reconcile-1', created_at: 3, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true }) });
|
||
|
||
const maintenanceLimit = '2';
|
||
const tagEnv = makeEnv(db, { maintenanceLimit });
|
||
const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
|
||
expect(tagResult.tagged).toBe(2); // 額度剛好夠標完兩筆
|
||
|
||
const reconcileEnv = {
|
||
DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
|
||
AI: { async run() { return { data: [] }; } },
|
||
VECTORIZE: {
|
||
async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); },
|
||
async upsert() { return { count: 0 }; },
|
||
},
|
||
} as unknown as Bindings;
|
||
const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
|
||
expect(rc.scanned).toBe(1); // 有 1 筆候選
|
||
expect(rc.checked).toBe(0); // 但額度已被標庫 backfill 用光,reconcile 一筆都動不了
|
||
expect(rc.quota_exceeded).toBe(true);
|
||
});
|
||
});
|