fix(portal): 檢修孔 library_count/triplet_count 恆 0 的根因+加統計自我檢查

真因:/portal/data/diagnostics 讀 GET /map,那是 library_map 快取 block,只能
靠 POST /map/recompute 產生;核實過整個 repo 沒有任何呼叫點會打 /map/recompute
(library-map SDD 的 ingest 自動重算 M3 從沒接上,status: draft)。⇒ /map 對任何
租戶恆回空 libraries,與實際資料量無關——2026-08-07 leo 實測抓到:3 庫、大量
triplets,診斷檔卻回 0/0。

修法:改走 GET /portal/admin/libraries 已在用、驗證過的即時查詢組合(不依賴任何
快取):listRecordsByTemplate(portal_library) + /entries/libraries(t52 蓋章即
現身)+ /records/triplet-stats(t142 即時聚合 SQL)。

附帶:加 library_scope_check 統計自我檢查(呼應 embedding.self_test 的精神)。
兩個計數都是 0 時,用完全不同的查詢路徑(不分庫/模板,只問這個租戶底下有沒有
任何 entries)交叉驗證,區分「真的是空」與「查詢方式或 owner_id 對不上」
(2026-08-01 t161 前科同型病:手動補的 record owner_id 存成 None,全量查得到、
按 owner_id 過濾的畫面永遠空)。

三個 diagnostics 測試全綠:即時查對出正確 library_count/triplet_count(且不再
外流庫名/內容,只回數字)、真空情境自我探測誠實回空、t161 同型病情境自我探測抓到
「查得到但統計回 0」的矛盾。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-08 00:12:54 +08:00
parent d779a11958
commit 7dbd4f59e7
3 changed files with 152 additions and 24 deletions
+83 -15
View File
@@ -720,7 +720,10 @@ describe('GET /portal/data/diagnostics', () => {
expect(res.status).toBe(401);
});
it('登入 → 200,聚合 embed 健康狀態+規模統計+版本;只含數字/布林/字串狀態', async () => {
it('登入 → 200,聚合 embed 健康狀態+規模統計(即時查,非 /map 快取)+版本;只含數字/布林/字串狀態', async () => {
// 2026-08-08 修復對應測試:library_count/triplet_count 改走 listRecordsByTemplate(portal_library)
// /entries/libraries /records/triplet-stats(與 GET /portal/admin/libraries 同一套即時查),
// 不再靠 /maplibrary_map 快取,recompute 從未被呼叫,恆回空——這正是 08-07 leo 實測抓到的病根)。
await seedSession('tok-diag1', 'rec_diag1');
mockGetRecord('rec_diag1', userValues());
fetchMock
@@ -731,41 +734,47 @@ describe('GET /portal/data/diagnostics', () => {
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
.reply(200, { success: true, enabled: true, tested: true, passed: false, note: '搜不到自己' });
// 已登記庫:1 筆(kb),values 帶不該外流的內容欄位(display_name/description)驗紅線。
mockLibraryList([
{ record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '不該出現在診斷檔', description: '密卡內容' } },
]);
// 資料裡實際蓋章出現過的庫:kb(與登記簿重複,去重)+notes(未登記但蓋章過,t52「蓋章即現身」)+general(fallback 桶,排除不算庫)。
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map'), method: 'GET' })
.reply(200, {
success: true,
libraries: [
{ name: 'general', triplet_count: 67, narrative: '不該出現在診斷檔', top_entities: ['密卡', '內容'] },
],
count: 1,
});
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { success: true, libraries: ['general', 'kb', 'notes'], count: 3 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 40 }, { library: 'notes', triplet_count: 27 }] });
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag1' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
library_count: number;
triplet_count: number;
library_scope_check: { ran: boolean };
embedding: { module_enabled: boolean; cards_embedded: number; cards_pending: number; self_test: { ran: boolean; found_itself: boolean | null } };
instance_url: string;
bundle_version: string | null;
};
expect(body.library_count).toBe(1);
expect(body.triplet_count).toBe(67);
expect(body.library_count).toBe(2); // kb(登記簿+資料面重複,去重)+notes;general 不算
expect(body.triplet_count).toBe(67); // 40+27,實際聚合 SQL 算出,非快取
expect(body.library_scope_check.ran).toBe(false); // 數字不是 0,不需要自我探測
expect(body.embedding.module_enabled).toBe(true);
expect(body.embedding.cards_embedded).toBe(80);
expect(body.embedding.cards_pending).toBe(3);
expect(body.embedding.self_test.ran).toBe(true);
expect(body.embedding.self_test.found_itself).toBe(false);
expect(body.instance_url).toBe('http://localhost');
// 紅線斷言:整份回應不含知識卡內容本體(/map 回應裡的 narrativetop_entities 沒被轉發
// 紅線斷言:整份回應不含知識卡內容本體(登記簿 values 裡的 display_name/description 沒被轉發,只取了 name 算數
const raw = JSON.stringify(body);
expect(raw).not.toContain('不該出現在診斷檔');
expect(raw).not.toContain('密卡');
expect(raw).not.toContain('"kb"'); // 連庫名本身都不外流,只回數字
});
it('embed 模組未開(自架未開語義搜尋)→ 誠實回 module_enabled:false,不是假裝有 index', async () => {
it('embed 模組未開(自架未開語義搜尋)→ 誠實回 module_enabled:false,不是假裝有 index;庫/三元組真的是空 → 自我探測也回空,不誤判為查詢錯誤', async () => {
await seedSession('tok-diag2', 'rec_diag2');
mockGetRecord('rec_diag2', userValues());
fetchMock
@@ -776,16 +785,75 @@ describe('GET /portal/data/diagnostics', () => {
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
mockLibraryList([]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map'), method: 'GET' })
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { success: true, libraries: [], count: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
// library_count/triplet_count 都是 0 → 觸發自我探測;這裡探測也回真的空(total:0)。
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
.reply(200, { success: true, entries: [], count: 0, total: 0 });
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag2' });
expect(res.status).toBe(200);
const body = (await res.json()) as { embedding: { module_enabled: boolean; self_test: { ran: boolean; found_itself: boolean | null } } };
const body = (await res.json()) as {
library_count: number;
triplet_count: number;
library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
embedding: { module_enabled: boolean; self_test: { ran: boolean; found_itself: boolean | null } };
};
expect(body.library_count).toBe(0);
expect(body.triplet_count).toBe(0);
expect(body.library_scope_check.ran).toBe(true);
expect(body.library_scope_check.any_entries_found).toBe(false);
expect(body.embedding.module_enabled).toBe(false);
expect(body.embedding.self_test.ran).toBe(false);
expect(body.embedding.self_test.found_itself).toBeNull();
});
it('統計自我檢查抓到 t161 同型病:庫/三元組回 0,但這個租戶底下其實查得到其他資料 → 標「像是查詢方式或租戶對不上」而非誤判成真的沒有資料', async () => {
await seedSession('tok-diag3', 'rec_diag3');
mockGetRecord('rec_diag3', userValues());
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
mockLibraryList([]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { success: true, libraries: [], count: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
// 自我探測:這個租戶底下其實有 12 筆 entries——庫/三元組統計卻回 0,兩者矛盾,該被標記。
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
.reply(200, { success: true, entries: [{ id: 'e1' }], count: 1, total: 12 });
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag3' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
library_count: number;
triplet_count: number;
library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
};
expect(body.library_count).toBe(0);
expect(body.triplet_count).toBe(0);
expect(body.library_scope_check.ran).toBe(true);
expect(body.library_scope_check.any_entries_found).toBe(true);
expect(body.library_scope_check.note).toContain('查詢方式或租戶對不上');
});
});