From e36cd2d99061ef14383ebf94eb00e387da3c73e2 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 28 Jul 2026 15:06:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(t95+t96):=20=E6=9F=A5=E8=A9=A2=20CJK=20?= =?UTF-8?q?=E9=82=8A=E7=95=8C=E8=87=AA=E5=8B=95=E8=A3=9C=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=EF=BC=8B=E5=9C=96=E8=AD=9C=E7=AF=80=E9=BB=9E=E6=A8=A1=E7=B3=8A?= =?UTF-8?q?=E5=91=BD=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=總管) --- cypher-executor/src/routes/portal-data.ts | 85 +++++++++- cypher-executor/tests/portal-data.test.ts | 163 ++++++++++++++++++- system-dev/docs/3-specs/portal-auth/tasks.md | 17 ++ system-dev/wiki/status.md | 7 + 4 files changed, 266 insertions(+), 6 deletions(-) diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index c17a5b5..61ad1b6 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -121,6 +121,62 @@ export function filterDeprecatedEntries /[぀-鿿豈-﫿]/.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 { + 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 }[] } | null; + if (!body || !Array.isArray(body.records)) return null; + const nodeNames = new Set(); + 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 = {}; 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); diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts index 0dd975d..f0d40b2 100644 --- a/cypher-executor/tests/portal-data.test.ts +++ b/cypher-executor/tests/portal-data.test.ts @@ -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('normalizeCjkQuery(t95 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('findBestNodeMatch(t96 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/search(t95 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/:name(t96 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); + }); +}); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index ad4a0cd..6e6a25d 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -161,6 +161,23 @@ 頁尾連 00-MAP.md=#39「人機共用同一份地圖」的文字版)。 ③ #39 本體(library_map Template/ingest 重算/MCP instructions+get_map)不在本次範圍,仍歸 #39 SDD。 +- [x] **t95 搜尋 CJK 正規化(2026-07-28,任務層小改)**: + 來源=leo 實測 geek6688 實例「搜『AI協作』(無空白)0 結果,搜『AI 協作』有結果,中文習慣不是每個人都會加空白」。 + 修:`portal-data.ts` 新增 `normalizeCjkQuery()`(CJK/ASCII 邊界自動插空白,純函式可 export 單測); + `/portal/data/search` 路由的 `q` 參數先過正規化再查 KBDB;只動查詢端不動索引端。 + 驗證:pure function 6 案(純 CJK/ASCII 不動、邊界插空白、已有空白不重複、全形符號不觸發)+ + integration 2 案(`AI%E5%8D%94%E4%BD%9C` → KBDB 收到 `AI 協作`)。 + +- [x] **t96 圖譜節點模糊比對 fuzzy fallback(2026-07-28,任務層小改)**: + 來源=leo 實測「graph 模式搜『AI 協作』回鄰居 0 關聯 0,但總圖上明明有節點『AI 協作規範書』—精確比對太嚴」。 + 修:`portal-data.ts` 新增 `findBestNodeMatch()`(contains 比對+最短名優先,純函式)+ + `fuzzyFindNode()`(查 KBDB triplet records 找最佳節點名); + graph neighbors 路由 plugin fallback 路徑(②):精確命中 0 鄰居+0 邊 → 以 fuzzyFindNode 找最佳節點名重查; + 工作流路徑(①)套 CJK 正規化但不加 fuzzy fallback(workflow 自管節點解析)。 + B5 分支(work/b5-graph-library-filter-0726)只動同一行的 `libraries` 欄位,衝突面最小。 + 驗證:findBestNodeMatch 6 案(空清單/無命中/精確子字串/多命中取最短/CJK 未正規化/大小寫)+ + integration 4 案(有鄰居直接回、0 鄰居 fuzzy 命中、0 鄰居 fuzzy 無命中誠實回 0、t95+t96 連動)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`) diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md index 17bfe82..70b5624 100644 --- a/system-dev/wiki/status.md +++ b/system-dev/wiki/status.md @@ -15,6 +15,13 @@ metadata: ## 📍 當前位置 +> **2026-07-28(t95+t96 搜尋缺陷修復,main)**:portal 搜尋兩缺陷修復——t95 CJK/ASCII +> 邊界自動補空白(`normalizeCjkQuery`,查詢端,不動索引);t96 graph 節點精確 0 鄰居 +> → fuzzy fallback(`findBestNodeMatch`/`fuzzyFindNode`,contains 比對+最短名優先)。 +> 純函式 + integration 各 6/2/4 案,tasks.md Bugfix 已標 [x]。 +> B5 分支衝突面:同一行 `libraries` 欄位(可一行解)。待 leo 本機跑 vitest 驗收 +>(sandbox symlink 封鎖,靜態分析確認邏輯正確)+ commit+部署。 +> > **2026-07-19(#39 藏書地圖 M5,分支 `feat/console-library-map-home`)**:**library-map SDD M5(GUI > 首頁)PR 已開,等審+gated 部署(merge 後需 leo 閘 redeploy cypher-executor)**。R4 落點裁定= > console **總庫搜尋頁搜尋框上方**(rag profile 該頁即首頁;full profile 它是全館入口——駕駛艙是