fix(t95+t96): 查詢 CJK 邊界自動補空白+圖譜節點模糊命中

leo 07-28 實測:「AI協作」(無空白)搜不到;圖譜搜「AI 協作」0 鄰居但總圖有
「AI 協作規範書」節點(「這個搜尋詞來自 Graph View 的一部分,居然搜不到?」)。
- normalizeCjkQuery:CJK↔ASCII 邊界插空白,search q 與 graph 節點名都過
- fuzzyFindNode:精確 0 鄰居時 fallback contains 比對(取最短命中)重查
測試 +18 全綠(vitest 197 passed;9 個既有紅=console HTML 搬遷陳舊測試,與本案無關,
stash 基線對照確認)。B5 分支衝突面已查:僅 line 274 一行。
(實作=子 CC;驗證+commit=總管)
This commit is contained in:
uncle6me-web
2026-07-28 15:06:07 +08:00
parent ba92d10a3f
commit e36cd2d990
4 changed files with 266 additions and 6 deletions
+162 -1
View File
@@ -19,7 +19,7 @@
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { workflowsVisible } from '../src/routes/portal';
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput } from '../src/routes/portal-data';
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
@@ -417,3 +417,164 @@ describe('mapGraphWorkflowOutput#57 workflow 輸出 → plugin 形狀)', ()
expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 });
});
});
// ═══════════════ 8. t95: normalizeCjkQuery 純函式 ═══════════════
describe('normalizeCjkQueryt95 CJK/ASCII 邊界補空白)', () => {
it('純中文 → 不動', () => {
expect(normalizeCjkQuery('中文')).toBe('中文');
expect(normalizeCjkQuery('AI 協作')).toBe('AI 協作'); // 已有空白不重複
});
it('純 ASCII/數字 → 不動', () => {
expect(normalizeCjkQuery('ABC123')).toBe('ABC123');
expect(normalizeCjkQuery('')).toBe('');
});
it('CJK→ASCII 邊界插空白', () => {
expect(normalizeCjkQuery('協作AI')).toBe('協作 AI');
expect(normalizeCjkQuery('中文1234')).toBe('中文 1234');
});
it('ASCII→CJK 邊界插空白', () => {
expect(normalizeCjkQuery('AI協作')).toBe('AI 協作');
expect(normalizeCjkQuery('1234中文')).toBe('1234 中文');
});
it('已有空白不重複插', () => {
expect(normalizeCjkQuery('AI 協作規範書')).toBe('AI 協作規範書');
});
it('全形符號(非 ASCII alnum)不觸發插空白', () => {
expect(normalizeCjkQuery('全形:中文')).toBe('全形:中文');
});
});
// ═══════════════ 9. t96: findBestNodeMatch 純函式 ═══════════════
describe('findBestNodeMatcht96 fuzzy 節點比對)', () => {
it('空清單 → null', () => {
expect(findBestNodeMatch('AI 協作', [])).toBeNull();
});
it('完全不包含 → null', () => {
expect(findBestNodeMatch('量子運算', ['AI 協作規範書', '工作流'])).toBeNull();
});
it('精確子字串命中 → 返回', () => {
expect(findBestNodeMatch('AI 協作', ['AI 協作規範書'])).toBe('AI 協作規範書');
});
it('多命中 → 取最短(最精確優先)', () => {
const result = findBestNodeMatch('AI', ['AI 協作規範書', 'AI 知識管理', 'AI']);
expect(result).toBe('AI'); // 最短
});
it('CJK 未正規化的搜尋詞也能比對(normalizeCjkQuery 先處理)', () => {
// 搜「AI協作」→ 正規化成「AI 協作」→ 能命中「AI 協作規範書」
expect(findBestNodeMatch('AI協作', ['AI 協作規範書', '工作流'])).toBe('AI 協作規範書');
});
it('大小寫不敏感', () => {
expect(findBestNodeMatch('ai', ['AI 協作規範書'])).toBe('AI 協作規範書');
});
});
// ═══════════════ 10. t95: 搜尋 CJK 正規化整合測試 ═══════════════
describe('GET /portal/data/searcht95 CJK 正規化)', () => {
it('無空白中英混搜尋詞「AI協作」→ KBDB 收到「AI 協作」', async () => {
await seedSession('tok-cn1', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
const cap = captureSearch();
await get('/portal/data/search?q=AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn1' });
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('q')).toBe('AI 協作'); // 已補空白
});
it('已有空白的搜尋詞「AI 協作」→ KBDB 收到同樣不重複補', async () => {
await seedSession('tok-cn2', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
const cap = captureSearch();
await get('/portal/data/search?q=AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn2' });
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('q')).toBe('AI 協作'); // 無重複空白
});
});
// ═══════════════ 11. t96: graph neighbors fuzzy fallback 整合測試 ═══════════════
describe('GET /portal/data/graph/neighbors/:namet96 fuzzy fallback', () => {
it('plugin 精確命中有鄰居 → 直接回,不觸發 fallback', async () => {
await seedSession('tok-gf1', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }], count: 1 });
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8', { Authorization: 'Bearer tok-gf1' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[] };
expect(data.neighbors.length).toBe(1); // 有鄰居直接回
});
it('plugin 精確命中 0 鄰居 → fuzzy fallback 找到更長節點名並以它重查', async () => {
await seedSession('tok-gf2', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
// 精確命中「AI 協作」→ 0 鄰居
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
.reply(200, { neighbors: [], edges: [] });
// KBDB triplets → 含「AI 協作規範書」
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, {
records: [
{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } },
{ values: { subject: '工作流', predicate: '使用', object: 'Arcrun' } },
],
});
// fallback 以「AI 協作規範書」重查 → 有鄰居
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf2' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[] };
expect(data.neighbors.length).toBe(1); // fallback 帶出鄰居
});
it('plugin 精確命中 0 鄰居且 fuzzy 無匹配 → 誠實回 0 鄰居', async () => {
await seedSession('tok-gf3', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
.reply(200, { neighbors: [], edges: [] });
// KBDB triplets → 完全沒有能比對的節點
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, { records: [{ values: { subject: '量子運算', predicate: '屬於', object: '物理學' } }] });
const res = await get('/portal/data/graph/neighbors/%E6%B2%92%E6%9C%89%E9%80%99%E5%80%8B%E7%AF%80%E9%BB%9E', { Authorization: 'Bearer tok-gf3' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
expect(data.neighbors.length).toBe(0); // 誠實回 0,不偽造
expect(data.edges.length).toBe(0);
});
it('t95+t96: 無空白「AI協作」→ 正規化成「AI 協作」→ fuzzy 命中「AI 協作規範書」', async () => {
await seedSession('tok-gf4', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
// plugin 收到的是正規化後的「AI 協作」(%20 分隔)
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
.reply(200, { neighbors: [], edges: [] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, { records: [{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } }] });
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
// 前端傳「AI協作」(無空白,URL encoded
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf4' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[] };
expect(data.neighbors.length).toBe(1);
});
});