portal-auth P1(#24 #25):KBDB library filter 地基

- /entries 與 /entries/search 加 library 多值參數(逗號分隔);不帶=行為一字不變(向後相容)
- D1:COALESCE(json_extract(metadata_json,'$.library'),'general') IN (…)(≡ design §3.3 OR 形狀;
  未標記舊資料歸 general)
- semantic:Vectorize upsert metadata 加 library(寫入端正規化 general);查詢 filter $in 主路徑
  (官方文件+workers-types 4.20260702.1 核實支援,fan-out fallback 不需)
- 測試:library-filter.test.ts 11 項+既有 6 項全綠;tsc exit 0;本機 miniflare local D1 十案例實跑全過
- semantic 線上路徑(metadata index+reindex backfill)=部署清單,待雲端排練驗(本機無 Vectorize)
- cypher kbdb-proxy 透傳順延(P1 派工紅線只動 kbdb,tasks.md 已註)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-14 11:45:08 +08:00
parent 18c0846af0
commit e788c52dd2
6 changed files with 274 additions and 14 deletions
+189
View File
@@ -0,0 +1,189 @@
// portal-auth P1 — 「庫」filter 地基(design §3.2/§3.3Gitea #24/#25)。
// 覆蓋:D1 filter SQL 形狀(單值/多值/NULL→general fallback 謂詞)、route 參數解析(含向後相容:
// 不帶 library SQL 一字不變)、semantic 路徑 Vectorize $in filtermock VECTORIZE)、
// embed 寫入端 library metadata 正規化(未標記→'general')。
// D1 真實 SQL 語意(COALESCE/json_extract 實際執行)由本機 miniflare + wrangler d1 驗證(PR 驗收證據)。
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import { listEntries, searchEntries } from '../src/actions/entry-crud';
import { semanticSearch, embedOnWrite } from '../src/embed';
import type { Bindings, Entry } from '../src/types';
const LIB_PREDICATE = "COALESCE(json_extract(metadata_json, '$.library'), 'general') IN";
// ── fake D1:只捕捉 prepared SQL 與 bound params(不解讀語意——真語意交給 miniflare 實跑)──
interface Captured { sql: string; params: unknown[] }
function makeCaptureDB(captured: Captured[]) {
const prepare = (sql: string) => {
const rec: Captured = { sql, params: [] };
captured.push(rec);
const stmt = {
bind(...args: unknown[]) { rec.params = args; return stmt; },
async all<T>() { return { results: [] as T[] }; },
async first<T>() { return { total: 0, c: 0 } as unknown as T; },
async run() { return { success: true }; },
};
return stmt;
};
return { prepare } as unknown as D1Database;
}
function mkEntry(id: string, metadata_json: string | null): Entry {
return {
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 0, confidence: null, metadata_json, created_at: 1, updated_at: 1,
};
}
describe('D1 library filter — SQL 形狀(entry-crud', () => {
it('listEntries 帶 library 多值 → COALESCE…IN (?,?) 謂詞+參數', async () => {
const captured: Captured[] = [];
await listEntries(makeCaptureDB(captured), { library: ['finance', 'hr'] });
const select = captured.find((c) => c.sql.startsWith('SELECT *'))!;
expect(select.sql).toContain(`${LIB_PREDICATE} (?,?)`);
expect(select.params.slice(0, 2)).toEqual(['finance', 'hr']);
// COUNT 查詢同謂詞(total 與分頁一致)
const count = captured.find((c) => c.sql.includes('COUNT(*)'))!;
expect(count.sql).toContain(`${LIB_PREDICATE} (?,?)`);
});
it('listEntries 不帶 library → SQL 無庫謂詞(向後相容:行為一字不變)', async () => {
const captured: Captured[] = [];
await listEntries(makeCaptureDB(captured), { owner_id: 'tenant1' });
for (const c of captured) expect(c.sql).not.toContain('$.library');
});
it('searchEntries 帶 library → LIKE+庫謂詞;不帶 → 原樣', async () => {
const withLib: Captured[] = [];
await searchEntries(makeCaptureDB(withLib), '遷移', 'tenant1', undefined, undefined, ['general']);
expect(withLib[0].sql).toContain('content LIKE ?');
expect(withLib[0].sql).toContain(`${LIB_PREDICATE} (?)`);
expect(withLib[0].params).toContain('general');
const without: Captured[] = [];
await searchEntries(makeCaptureDB(without), '遷移', 'tenant1');
expect(without[0].sql).not.toContain('$.library');
});
});
describe('route 參數解析(GET /entries、/entries/search', () => {
function makeApp(captured: Captured[]) {
const app = new Hono<{ Bindings: Bindings }>();
app.route('/entries', entryRoutes);
const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test' } as unknown as Bindings;
return { app, env };
}
it('GET /entries?library=finance,hr →(含空白容忍)庫謂詞+兩參數', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured);
const res = await app.request('/entries?library=finance,%20hr', {}, env);
expect(res.status).toBe(200);
const select = captured.find((c) => c.sql.startsWith('SELECT *'))!;
expect(select.sql).toContain(`${LIB_PREDICATE} (?,?)`);
expect(select.params).toContain('finance');
expect(select.params).toContain('hr');
});
it('GET /entries 不帶 library / library=空 → SQL 無庫謂詞(向後相容)', async () => {
for (const qs of ['', '?library=', '?library=%20,%20']) {
const captured: Captured[] = [];
const { app, env } = makeApp(captured);
const res = await app.request(`/entries${qs}`, {}, env);
expect(res.status).toBe(200);
for (const c of captured) expect(c.sql).not.toContain('$.library');
}
});
it('GET /entries/search?q=x&library=financekeyword 模式)→ 庫謂詞下到 searchEntries', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured);
const res = await app.request('/entries/search?q=x&library=finance', {}, env);
expect(res.status).toBe(200);
expect(captured[0].sql).toContain(`${LIB_PREDICATE} (?)`);
expect(captured[0].params).toContain('finance');
});
it('semantic 模組未開+帶 library → 誠實降級 keyword 仍套庫 filter(不因降級洩庫)', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured); // 無 VECTORIZE/AI binding → semanticSearch 回 null
const res = await app.request('/entries/search?q=x&mode=semantic&library=finance', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { mode: string; requested_mode?: string };
expect(body.mode).toBe('keyword');
expect(body.requested_mode).toBe('semantic');
expect(captured[0].sql).toContain(`${LIB_PREDICATE} (?)`);
expect(captured[0].params).toContain('finance');
});
});
describe('semantic 路徑 — Vectorize $in filtermock VECTORIZE', () => {
function makeSemanticEnv(queryCalls: { vec: number[]; opts: Record<string, unknown> }[]) {
return {
DB: makeCaptureDB([]),
ENVIRONMENT: 'test',
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
VECTORIZE: {
async query(vec: number[], opts: Record<string, unknown>) {
queryCalls.push({ vec, opts });
return { matches: [{ id: 'e1', score: 0.9, metadata: { library: 'finance' } }] };
},
async upsert(v: unknown[]) { return { count: (v as unknown[]).length }; },
},
} as unknown as Bindings;
}
it('帶 library 多值 → filter.library = { $in: [...] }(主路徑,不 fan-out', async () => {
const calls: { vec: number[]; opts: Record<string, unknown> }[] = [];
const env = makeSemanticEnv(calls);
const hits = await semanticSearch(env, 'query', { owner_id: 'tenant1', library: ['finance', 'hr'] });
expect(calls.length).toBe(1); // 單次 query(非每庫 fan-out
const filter = calls[0].opts.filter as Record<string, unknown>;
expect(filter.owner_id).toBe('tenant1');
expect(filter.library).toEqual({ $in: ['finance', 'hr'] });
expect(hits?.[0]?.library).toBe('finance'); // hit 帶回 library metadata
});
it('不帶 library → filter 無 library 鍵(行為不變)', async () => {
const calls: { vec: number[]; opts: Record<string, unknown> }[] = [];
const env = makeSemanticEnv(calls);
await semanticSearch(env, 'query', { owner_id: 'tenant1' });
const filter = calls[0].opts.filter as Record<string, unknown>;
expect('library' in filter).toBe(false);
});
});
describe('embed 寫入端 — library metadata 正規化', () => {
function makeUpsertEnv(upserts: { id: string; metadata: Record<string, unknown> }[]) {
const db = {
prepare: () => {
const stmt = { bind: () => stmt, run: async () => ({ success: true }), all: async () => ({ results: [] }), first: async () => null };
return stmt;
},
} as unknown as D1Database;
return {
DB: db,
ENVIRONMENT: 'test',
AI: { async run(_m: string, i: { text: string[] }) { return { data: i.text.map(() => [0.1, 0.2]) }; } },
VECTORIZE: { async upsert(v: { id: string; metadata: Record<string, unknown> }[]) { upserts.push(...v); return { count: v.length }; } },
} as unknown as Bindings;
}
it('metadata_json 有 library → upsert metadata.library 原值', async () => {
const upserts: { id: string; metadata: Record<string, unknown> }[] = [];
const env = makeUpsertEnv(upserts);
await embedOnWrite(env, mkEntry('e1', JSON.stringify({ embed: true, library: 'finance' })));
expect(upserts[0].metadata.library).toBe('finance');
});
it('未標記 / 空字串 / 非字串 → 正規化為 generaldesign §3.2 舊資料歸 general', async () => {
for (const meta of [{ embed: true }, { embed: true, library: '' }, { embed: true, library: 42 }]) {
const upserts: { id: string; metadata: Record<string, unknown> }[] = [];
const env = makeUpsertEnv(upserts);
await embedOnWrite(env, mkEntry('e1', JSON.stringify(meta)));
expect(upserts[0].metadata.library).toBe('general');
}
});
});