ceb7638d74
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。 模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」 - 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數 (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+ 每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列 (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。 - record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留, 驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。 - entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry 接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。 - 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列, LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀), GET /maintenance/relation-orphans 唯讀巡檢。 - cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS, 整檔送 /query 會在重跑時假紅)。 - 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描; 釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。 遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型 與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
16 KiB
TypeScript
291 lines
16 KiB
TypeScript
// daemon-beta t24(總管 0.971 親復現、t11 斷點①②)——/entries/search 服務端濾 deprecated。
|
||
// 背景:rag_takedown_direct 下架只把 metadata_json.status 標 'deprecated'(軟刪,append-only,
|
||
// KBDB 表不變鐵律),從不砍列、也不刪 Vectorize 向量。過去唯一的濾層在
|
||
// cypher-executor/src/routes/portal-data.ts(客端治標,Arcrun#46),沒部署 rag_chat 的實例
|
||
// (如 leo21c)等於完全沒濾——MCP/raw /entries/search 直接把已下架內容當現役吐出。semantic
|
||
// 甚至最高分照吐(t11 實測 0.971)。本測試覆蓋三案:keyword 濾、semantic 濾+補位、
|
||
// include_deprecated 開關。
|
||
//
|
||
// 測試手法同 search-source-and-score.test.ts:fake D1 捕 SQL 形狀 + getEntry 依 id 回可控
|
||
// metadata_json;mock VECTORIZE 捕 query opts(驗補位 topK)並回混合 active/deprecated 命中。
|
||
// 真 SQL 語意(json_extract 對 status 欄的實際判等)由本機 miniflare/wrangler d1 跑驗(PR 驗收證據)。
|
||
import { describe, it, expect } from 'vitest';
|
||
import { Hono } from 'hono';
|
||
import { entryRoutes } from '../src/routes/entries';
|
||
import { searchEntries, isDeprecatedEntry } from '../src/actions/entry-crud';
|
||
import type { Bindings, Entry } from '../src/types';
|
||
|
||
const NOT_DEPRECATED_PREDICATE =
|
||
"(json_extract(metadata_json, '$.status') IS NULL OR json_extract(metadata_json, '$.status') != 'deprecated')";
|
||
|
||
// ── fake D1:捕捉 prepared SQL 與 bound params;getEntry(SELECT … WHERE id = ?)依 id 從
|
||
// ENTRY_META 查表回可控 metadata_json,讓 semantic hydrate 路徑能測到 deprecated 過濾 ──
|
||
interface Captured { sql: string; params: unknown[] }
|
||
|
||
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, src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
|
||
};
|
||
}
|
||
|
||
function makeCaptureDB(captured: Captured[], entryMeta: Record<string, string | null> = {}) {
|
||
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>() {
|
||
if (sql.includes('WHERE id = ?')) {
|
||
const id = String(rec.params[0]);
|
||
const meta = id in entryMeta ? entryMeta[id] : null;
|
||
return mkEntry(id, meta) as unknown as T;
|
||
}
|
||
return { total: 0, c: 0 } as unknown as T;
|
||
},
|
||
async run() { return { success: true }; },
|
||
};
|
||
return stmt;
|
||
};
|
||
return { prepare } as unknown as D1Database;
|
||
}
|
||
|
||
function makeApp(captured: Captured[], extraEnv: Record<string, unknown> = {}) {
|
||
const app = new Hono<{ Bindings: Bindings }>();
|
||
app.route('/entries', entryRoutes);
|
||
const env = { DB: makeCaptureDB(captured, (extraEnv._entryMeta as Record<string, string | null>) ?? {}), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings;
|
||
return { app, env };
|
||
}
|
||
|
||
// ══ 案①:keyword 濾 ══════════════════════════════════════════════════════
|
||
|
||
describe('t24 案① — searchEntries(keyword)預設濾 deprecated', () => {
|
||
it('預設(不帶 includeDeprecated)→ SQL 含 NOT_DEPRECATED_PREDICATE', async () => {
|
||
const captured: Captured[] = [];
|
||
await searchEntries(makeCaptureDB(captured), '靛藍', 'tenant1');
|
||
expect(captured[0].sql).toContain(NOT_DEPRECATED_PREDICATE);
|
||
});
|
||
|
||
it('includeDeprecated=true → SQL 不含濾 deprecated 謂詞(管理面查殘留用)', async () => {
|
||
const captured: Captured[] = [];
|
||
await searchEntries(makeCaptureDB(captured), '靛藍', 'tenant1', undefined, undefined, undefined, undefined, true);
|
||
expect(captured[0].sql).not.toContain(NOT_DEPRECATED_PREDICATE);
|
||
});
|
||
|
||
it('route GET /entries/search(keyword,不帶 include_deprecated)→ 濾謂詞下傳', async () => {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured);
|
||
const res = await app.request('/entries/search?q=靛藍', {}, env);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { mode: string };
|
||
expect(body.mode).toBe('keyword');
|
||
expect(captured[0].sql).toContain(NOT_DEPRECATED_PREDICATE);
|
||
});
|
||
|
||
it('route GET /entries/search?include_deprecated=true(keyword)→ 濾謂詞不下傳', async () => {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured);
|
||
const res = await app.request('/entries/search?q=靛藍&include_deprecated=true', {}, env);
|
||
expect(res.status).toBe(200);
|
||
expect(captured[0].sql).not.toContain(NOT_DEPRECATED_PREDICATE);
|
||
});
|
||
|
||
it('semantic 模組未開+降級 keyword → 仍套濾(不因降級洩下架內容)', async () => {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured); // 無 VECTORIZE/AI → semanticSearch 回 null
|
||
const res = await app.request('/entries/search?q=靛藍&mode=semantic', {}, env);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { mode: string };
|
||
expect(body.mode).toBe('keyword');
|
||
expect(captured[0].sql).toContain(NOT_DEPRECATED_PREDICATE);
|
||
});
|
||
|
||
it('semantic 模組未開+include_deprecated=true 降級 → 濾謂詞不下傳', async () => {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured);
|
||
const res = await app.request('/entries/search?q=靛藍&mode=semantic&include_deprecated=true', {}, env);
|
||
expect(res.status).toBe(200);
|
||
expect(captured[0].sql).not.toContain(NOT_DEPRECATED_PREDICATE);
|
||
});
|
||
});
|
||
|
||
// ══ isDeprecatedEntry 單元測試(JS 側判準,semantic 路徑用) ══════════════
|
||
|
||
describe('t24 — isDeprecatedEntry(JS 側判準)', () => {
|
||
it('status:"deprecated" → true', () => {
|
||
expect(isDeprecatedEntry({ metadata_json: JSON.stringify({ status: 'deprecated' }) })).toBe(true);
|
||
});
|
||
it('status 缺欄 / null metadata_json / 空字串 → false(未下架,保留)', () => {
|
||
expect(isDeprecatedEntry({ metadata_json: JSON.stringify({ embed: true }) })).toBe(false);
|
||
expect(isDeprecatedEntry({ metadata_json: null })).toBe(false);
|
||
expect(isDeprecatedEntry({ metadata_json: '' })).toBe(false);
|
||
});
|
||
it('status 是其他值(非 deprecated)→ false', () => {
|
||
expect(isDeprecatedEntry({ metadata_json: JSON.stringify({ status: 'active' }) })).toBe(false);
|
||
});
|
||
it('metadata_json parse 失敗(壞 JSON)→ false(治標不誤殺)', () => {
|
||
expect(isDeprecatedEntry({ metadata_json: '{not valid json' })).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ══ 案②:semantic 濾+補位 ═══════════════════════════════════════════════
|
||
|
||
// mock VECTORIZE:捕 query opts(驗補位 topK);命中組合可控(含 deprecated id 前綴 dep- 供辨識)。
|
||
function makeSemanticEnv(
|
||
queryCalls: { opts: Record<string, unknown> }[],
|
||
matches: { id: string; score: number }[],
|
||
) {
|
||
return {
|
||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||
VECTORIZE: {
|
||
async query(_vec: number[], opts: Record<string, unknown>) {
|
||
queryCalls.push({ opts });
|
||
return { matches: matches.map((m) => ({ id: m.id, score: m.score, metadata: {} })) };
|
||
},
|
||
async upsert(v: unknown[]) { return { count: (v as unknown[]).length }; },
|
||
},
|
||
};
|
||
}
|
||
|
||
describe('t24 案② — semantic 濾 deprecated + 補位(t11 斷點②:0.971 最高分照吐的洞)', () => {
|
||
it('命中含已下架(最高分)→ 回應濾掉,只留現役(覆現 t11 0.971 復現案)', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const entryMeta = {
|
||
'dep-highest': JSON.stringify({ status: 'deprecated' }), // 0.971 最高分但已下架
|
||
'e-active': null,
|
||
};
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured, {
|
||
...makeSemanticEnv(calls, [
|
||
{ id: 'dep-highest', score: 0.971 },
|
||
{ id: 'e-active', score: 0.6 },
|
||
]),
|
||
_entryMeta: entryMeta,
|
||
});
|
||
const res = await app.request('/entries/search?q=靛藍風鈴石的硬度&mode=semantic', {}, env);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { mode: string; count: number; entries: (Entry & { score?: number })[] };
|
||
expect(body.mode).toBe('semantic');
|
||
expect(body.entries.map((e) => e.id)).toEqual(['e-active']); // dep-highest 被濾掉
|
||
expect(body.count).toBe(1);
|
||
});
|
||
|
||
it('補位:預設過濾生效時,Vectorize 查詢的 topK 大於 caller 要求(避免整頁被下架品吃光)', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, []));
|
||
await app.request('/entries/search?q=x&mode=semantic&top_k=10', {}, env);
|
||
expect(calls[0].opts.topK).toBeGreaterThan(10); // 補位餘量(實作=×3 封頂 100)
|
||
expect(calls[0].opts.topK).toBe(30);
|
||
});
|
||
|
||
it('補位 topK 封頂 100(不因 top_k 大就超過 Vectorize 上限)', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, []));
|
||
await app.request('/entries/search?q=x&mode=semantic&top_k=50', {}, env);
|
||
expect(calls[0].opts.topK).toBe(100);
|
||
});
|
||
|
||
it('補位後截斷:濾掉部分下架品後,回應筆數不超過 caller 要求的 top_k', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
// 6 筆命中,3 筆已下架 → 濾完剩 3 筆現役,均少於 top_k=5,應原樣回(不會硬湊出更多)
|
||
const matches = [
|
||
{ id: 'a1', score: 0.9 }, { id: 'dep1', score: 0.85 }, { id: 'a2', score: 0.8 },
|
||
{ id: 'dep2', score: 0.7 }, { id: 'a3', score: 0.6 }, { id: 'dep3', score: 0.5 },
|
||
];
|
||
const entryMeta: Record<string, string | null> = {
|
||
dep1: JSON.stringify({ status: 'deprecated' }),
|
||
dep2: JSON.stringify({ status: 'deprecated' }),
|
||
dep3: JSON.stringify({ status: 'deprecated' }),
|
||
};
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured, { ...makeSemanticEnv(calls, matches), _entryMeta: entryMeta });
|
||
// 顯式帶 min_score:本案要測的是「濾下架+不硬湊」,不是分數門檻。
|
||
// 2026-08-05 起未帶 min_score 會套相對門檻(top×0.8),0.6 的 a3 會被砍掉
|
||
// ⇒ 那會把這個測試變成在測門檻。帶一個寬鬆的絕對值,把門檻這個變因移開。
|
||
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5&min_score=0.4', {}, env);
|
||
const body = (await res.json()) as { entries: Entry[]; count: number };
|
||
expect(body.entries.map((e) => e.id)).toEqual(['a1', 'a2', 'a3']);
|
||
expect(body.count).toBe(3);
|
||
});
|
||
|
||
it('include_deprecated=true → 不補位(topK=請求值)、不過濾(下架品也回傳,管理面查殘留)', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const entryMeta = { 'dep-highest': JSON.stringify({ status: 'deprecated' }) };
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured, {
|
||
...makeSemanticEnv(calls, [{ id: 'dep-highest', score: 0.971 }]),
|
||
_entryMeta: entryMeta,
|
||
});
|
||
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=10&include_deprecated=true', {}, env);
|
||
expect(calls[0].opts.topK).toBe(10); // 不補位
|
||
const body = (await res.json()) as { entries: Entry[]; count: number };
|
||
expect(body.entries.map((e) => e.id)).toEqual(['dep-highest']); // 保留
|
||
expect(body.count).toBe(1);
|
||
});
|
||
});
|
||
|
||
// ── 相對門檻(2026-08-05,leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關)────────────
|
||
//
|
||
// 這組鎖住兩件事:
|
||
// ① 門檻跟著「這次查詢的最高分」走,不是固定值
|
||
// (固定 0.5 放太多雜訊;固定 0.6 會把「閉環機」那種整體偏低的查詢砍成 0 命中)
|
||
// ② 🔴 **門檻必須在濾掉下架之後才算**——否則一筆 0.971 的下架殘影會把 0.6 的正解一起帶走,
|
||
// 那正是 leo 08-05 早上撞的「0 命中」的翻版。t24 的 0.971 復現案就是這種殘影。
|
||
describe('相對門檻(08-05)— 跟著最高分走,且在濾下架之後才算', () => {
|
||
it('低分尾被砍:0.77/0.74/0.64 留下,0.55 以下砍掉(門檻 0.77×0.8=0.616)', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const matches = [
|
||
{ id: 'hit1', score: 0.77 }, { id: 'hit2', score: 0.74 }, { id: 'hit3', score: 0.64 },
|
||
{ id: 'noise1', score: 0.55 }, { id: 'noise2', score: 0.53 }, { id: 'noise3', score: 0.52 },
|
||
];
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
|
||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||
const body = (await res.json()) as { entries: Entry[]; count: number };
|
||
expect(body.entries.map((e) => e.id)).toEqual(['hit1', 'hit2', 'hit3']);
|
||
});
|
||
|
||
it('整體偏低的查詢不會被砍光:0.638/0.603/0.588/0.552 全留(門檻 0.638×0.8=0.510)', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const matches = [
|
||
{ id: 'l1', score: 0.638 }, { id: 'l2', score: 0.603 },
|
||
{ id: 'l3', score: 0.588 }, { id: 'l4', score: 0.552 }, { id: 'noise', score: 0.446 },
|
||
];
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
|
||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||
const body = (await res.json()) as { entries: Entry[] };
|
||
expect(body.entries.map((e) => e.id)).toEqual(['l1', 'l2', 'l3', 'l4']);
|
||
});
|
||
|
||
it('🔴 下架殘影不得決定門檻:0.971 已下架 → 門檻要用倖存者的 0.6 算,正解不被帶走', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const matches = [
|
||
{ id: 'dep-ghost', score: 0.971 }, // 下架殘影,分數卻最高
|
||
{ id: 'real1', score: 0.60 }, { id: 'real2', score: 0.52 },
|
||
];
|
||
const entryMeta: Record<string, string | null> = { 'dep-ghost': JSON.stringify({ status: 'deprecated' }) };
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured, { ...makeSemanticEnv(calls, matches), _entryMeta: entryMeta });
|
||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||
const body = (await res.json()) as { entries: Entry[] };
|
||
// 若拿 0.971 算門檻=0.777 ⇒ real1/real2 全被砍 ⇒ 0 命中(就是那個病)。
|
||
// 正解:殘影先被濾掉,門檻用 0.6×0.8=0.48 算 ⇒ 兩筆都留。
|
||
expect(body.entries.map((e) => e.id)).toEqual(['real1', 'real2']);
|
||
});
|
||
|
||
it('caller 顯式帶 min_score → 尊重絕對值,不再加碼相對門檻', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const matches = [{ id: 'a', score: 0.9 }, { id: 'b', score: 0.5 }, { id: 'c', score: 0.3 }];
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
|
||
const res = await app.request('/entries/search?q=x&mode=semantic&min_score=0.4', {}, env);
|
||
const body = (await res.json()) as { entries: Entry[] };
|
||
expect(body.entries.map((e) => e.id)).toEqual(['a', 'b']); // 0.3 被絕對門檻砍,0.5 留著
|
||
});
|
||
});
|