55a47d7c18
embed 模組原本只有 embedOnWrite(寫入即嵌),對「開 Vectorize binding 之前就寫入」或 embed-on-write 當時漏掉的既有 entry 沒有回填路徑 → is_embedded=0 永遠補不回,語義查詢回 0 筆。 新增(base,對 entries 做;embedding 是 base 唯一職責,非 graph 插件): - embed.ts: backfillEmbeddings()——找 is_embedded=0 且 isEmbeddable(metadata.embed===true) 的 entry, 批次補嵌(單次 AI.run 陣列 + 單次 VECTORIZE.upsert 陣列 + 單次 UPDATE IN,一批≈3 subrequest)、 設 is_embedded=1,冪等、分批(limit 1-100,回傳 processed/remaining,可重複呼叫直到清零)。 模組未開誠實回 enabled:false(不假綠)。backfillStatus() 回 pending/embedded 計數。 - routes/embed.ts: POST /embed/backfill、GET /embed/backfill/status;模組未開回 409 + capability_hint。 - index.ts: mount /embed。 - tests/embed-backfill.test.ts: 5 vitest(off no-op / 補嵌+標記 / 冪等 / 分批 remaining / status)。 base 維持對內容語意無知(只認通用 embed 旗標,不知 triplet/wiki)。tsc exit 0、vitest 5/5。 端到端(leo21c,wrangler 直推、非 acr update):pending 5→processed 5→remaining 0, Vectorize vectorCount 0→5,/entries/search?mode=semantic 由 0 筆→5 筆(語義排序命中)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDtnGPJpAzp8UqHfAo8o1s
138 lines
5.8 KiB
TypeScript
138 lines
5.8 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { backfillEmbeddings, backfillStatus, embedEnabled } from '../src/embed';
|
|
import type { Bindings, Entry } 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 → candidate rows (embeddable & is_embedded=0 & non-empty content)
|
|
// UPDATE ... IN (...) → flip is_embedded=1 for the bound ids
|
|
// SELECT COUNT(*) → count of remaining candidates
|
|
function isCandidate(e: Entry): boolean {
|
|
if (e.is_embedded !== 0) return false;
|
|
if (!e.content || e.content.trim() === '') return false;
|
|
try {
|
|
const m = JSON.parse(e.metadata_json ?? 'null');
|
|
return m?.embed === true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function makeFakeDB(store: Entry[]) {
|
|
const prepare = (sql: string) => {
|
|
let bound: unknown[] = [];
|
|
const stmt = {
|
|
bind(...args: unknown[]) { bound = args; return stmt; },
|
|
async all<T>() {
|
|
// SELECT * ... LIMIT ? (limit is the last bound param)
|
|
const limit = Number(bound[bound.length - 1]);
|
|
const results = store.filter(isCandidate).slice(0, limit) as unknown as T[];
|
|
return { results };
|
|
},
|
|
async first<T>() {
|
|
// SELECT COUNT(*) as c ...
|
|
const c = store.filter(isCandidate).length;
|
|
return { c } as unknown as T;
|
|
},
|
|
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 };
|
|
},
|
|
};
|
|
return stmt;
|
|
};
|
|
return { prepare } 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,
|
|
};
|
|
}
|
|
|
|
function makeEnv(store: Entry[], withBindings: boolean): Bindings {
|
|
const upserts: { id: string }[] = [];
|
|
const aiCalls: string[][] = [];
|
|
const env = {
|
|
DB: makeFakeDB(store),
|
|
ENVIRONMENT: 'test',
|
|
...(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 }; } },
|
|
}
|
|
: {}),
|
|
} as unknown as Bindings;
|
|
(env as unknown as { __upserts: unknown[]; __ai: unknown[] }).__upserts = upserts;
|
|
(env as unknown as { __upserts: unknown[]; __ai: unknown[] }).__ai = aiCalls;
|
|
return env;
|
|
}
|
|
|
|
describe('backfillEmbeddings', () => {
|
|
it('module off → enabled:false, no-op (誠實不假綠)', async () => {
|
|
const store = [mkEntry('e1', 'hello', true)];
|
|
const env = makeEnv(store, 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
|
|
});
|
|
|
|
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);
|
|
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);
|
|
});
|
|
|
|
it('idempotent: re-run after all embedded processes nothing', async () => {
|
|
const store = [mkEntry('e1', 'x', true)];
|
|
const env = makeEnv(store, true);
|
|
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);
|
|
const r1 = await backfillEmbeddings(env, { limit: 2 });
|
|
expect(r1.processed).toBe(2);
|
|
expect(r1.remaining).toBe(1);
|
|
const r2 = await backfillEmbeddings(env, { limit: 2 });
|
|
expect(r2.processed).toBe(1);
|
|
expect(r2.remaining).toBe(0);
|
|
});
|
|
|
|
it('status reports pending/embedded counts', async () => {
|
|
const store = [mkEntry('e1', 'x', true), mkEntry('e2', 'y', true, 1)];
|
|
const env = makeEnv(store, true);
|
|
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');
|
|
});
|
|
});
|