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:
@@ -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_id+library;回應照 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 路徑(存在才走;input:node=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);
|
||||
|
||||
Reference in New Issue
Block a user