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
+80 -5
View File
@@ -121,6 +121,62 @@ export function filterDeprecatedEntries<T extends { metadata_json?: string | nul
});
}
/**
* CJK/ASCII 邊界插空白正規化(t95):
* 「AI協作」→「AI 協作」;「協作AI」→「協作 AI」;已有空白不重複插。
* 只動查詢端,不動索引端。純函式,單測用 export。
*/
export function normalizeCjkQuery(q: string): string {
// U+3040-U+9FFF: Hiragana/Katakana/CJK Ext.A/CJK main; U+F900-U+FAFF: CJK Compat.
const isCjk = (c: string) => /[぀-鿿豈-﫿]/.test(c);
const isAsciiAlnum = (c: string) => /[぀-鿿豈-﫿]/.test(c);
let result = '';
for (let i = 0; i < q.length; i++) {
const ch = q[i];
if (result.length > 0) {
const prev = result[result.length - 1];
if (prev !== ' ' && ch !== ' ' &&
((isCjk(prev) && /[A-Za-z0-9]/.test(ch)) || (/[A-Za-z0-9]/.test(prev) && isCjk(ch)))) {
result += ' ';
}
}
result += ch;
}
return result;
}
/**
* 從三元組節點名清單找最佳比對(t96 fuzzy fallback 用):
* 正規化後做 contains 比對;多命中取最短名(前綴/最精確優先)。純函式,單測用 export。
*/
export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): string | null {
const term = normalizeCjkQuery(searchTerm).toLowerCase();
if (!term) return null;
const hits = nodeNames.filter(n => normalizeCjkQuery(n).toLowerCase().includes(term));
if (hits.length === 0) return null;
return hits.reduce((a, b) => a.length <= b.length ? a : b);
}
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
if (!body || !Array.isArray(body.records)) return null;
const nodeNames = new Set<string>();
for (const r of body.records) {
const v = r?.values;
if (!v || typeof v !== 'object') continue;
if (typeof v.subject === 'string' && v.subject.trim()) nodeNames.add(v.subject.trim());
if (typeof v.object === 'string' && v.object.trim()) nodeNames.add(v.object.trim());
}
return findBestNodeMatch(searchTerm, [...nodeNames]);
} catch {
return null; // fallback 失敗靜默略過,原本 0 結果直接回
}
}
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
//graph 走 /portal/data/graph/*)。server 注入 owner_idlibrary;回應照 KBDB 原形
//entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
@@ -129,8 +185,9 @@ portalDataRouter.get('/portal/data/search', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const q = c.req.query('q');
if (!q) return c.json({ error: 'q 必填' }, 400);
const qRaw = c.req.query('q');
if (!qRaw) return c.json({ error: 'q 必填' }, 400);
const q = normalizeCjkQuery(qRaw); // t95: CJK/ASCII 邊界補空白(只動查詢端)
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
@@ -205,6 +262,9 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
// t95/t96: CJK 正規化後再用(避免「AI協作」找不到「AI 協作」節點)
const nodeName = normalizeCjkQuery(c.req.param('name'));
// ① tenant workflow 路徑(存在才走;inputnode=path、depth=query 預設 2、namespace/owner=tenant
const tenant = portalTenant(c.env);
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
@@ -214,7 +274,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
const result = await executeWebhookGraph(
c.env,
wfGraph,
{ node: c.req.param('name'), depth, namespace: tenant, owner: tenant },
{ node: nodeName, depth, namespace: tenant, owner: tenant },
'graph_neighbors',
tenant,
c.executionCtx,
@@ -231,8 +291,23 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
// t96: 精確命中 0 鄰居 → 試 substring fallback 找最佳節點名(如「AI 協作」→「AI 協作規範書」)
const resText = await res.text().catch(() => '');
let data: { neighbors?: unknown[]; edges?: unknown[] } | null = null;
try { data = JSON.parse(resText) as typeof data; } catch { /* 非 JSON → 直接透傳 */ }
if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 &&
Array.isArray(data.edges) && data.edges.length === 0) {
const fallbackName = await fuzzyFindNode(c.env, tenant, nodeName);
if (fallbackName && fallbackName !== nodeName) {
const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers });
return new Response(res2.body, { status: res2.status, headers: { 'Content-Type': 'application/json' } });
}
}
return new Response(resText, { status: res.status, headers: { 'Content-Type': 'application/json' } });
} catch (e) {
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
+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);
});
});