From ccb86481aec16af1ad7deb5c939bd614c95e9338 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 29 Jul 2026 14:11:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(t128+t129):=20=E5=9C=96=E6=90=9C=E5=B0=8B?= =?UTF-8?q?=E8=A3=9C=20template:triplet=EF=BC=8BAI=20=E5=95=8F=E7=AD=94?= =?UTF-8?q?=E5=87=BA=E8=99=95=E6=8C=89=E9=A0=81=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t128 真因(總管實測定罪):t116 只補 kbdb_base,同一行 URL 還吃 {{input.template}} ⇒ /records/by-template/?owner_id=... 查不到;手動補 template 即 count=1 (企業版功能解鎖→控制→授權系統)。**同種病三犯,已記 mistakes。** t129:一卡切 3-5 block 每段都算一筆命中 ⇒ dedupeSourcesByPage 後端去重+hit_count。 vitest 52 passed(1 紅=console HTML 搬遷陳舊測試,非本案)。 (實作=子 CC;驗證+commit=總管) --- cypher-executor/src/routes/portal-data.ts | 33 ++++- cypher-executor/tests/portal-data.test.ts | 133 ++++++++++++++++++- system-dev/docs/3-specs/portal-auth/tasks.md | 20 +++ 3 files changed, 183 insertions(+), 3 deletions(-) diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 61ad1b6..dabaf9d 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -73,6 +73,32 @@ export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; e return { neighbors, edges, count: neighbors.length }; } +/** + * 出處清單按 page_name 去重(t129): + * rag_chat workflow 把同一張卡拆成多個 block,每個 block 各回一筆 source(同頁名)→ 前端列一整頁重複。 + * 後端去重:同一個 page_name / page 只保留第一筆,hit_count > 1 時附計數。 + * page_name 優先;page 備用;兩者皆無 → key 為空字串(歸為同一「無頁名」組)。 + * 純函式,單測用 export。 + */ +export function dedupeSourcesByPage(sources: unknown[]): unknown[] { + const seen = new Map; count: number }>(); + for (const s of sources) { + if (!s || typeof s !== 'object') continue; + const item = s as Record; + const page = typeof item.page_name === 'string' ? item.page_name : + typeof item.page === 'string' ? item.page : ''; + const existing = seen.get(page); + if (existing) { + existing.count += 1; + } else { + seen.set(page, { item, count: 1 }); + } + } + return [...seen.values()].map(({ item, count }) => + count > 1 ? { ...item, hit_count: count } : item, + ); +} + /** 越庫/不存在 一律同一句 404(不洩存在性)。 */ function notFound(c: Context<{ Bindings: Bindings }>): Response { return c.json({ error: '找不到這筆資料' }, 404); @@ -274,7 +300,8 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => const result = await executeWebhookGraph( c.env, wfGraph, - { node: nodeName, depth, namespace: tenant, owner: tenant }, + // t116: 補傳 kbdb_base;t128: 補傳 template(workflow fetch_triplets.url 用 {{input.template}}) + { node: nodeName, depth, namespace: tenant, owner: tenant, kbdb_base: c.env.KBDB_BASE_URL ?? '', template: 'triplet' }, 'graph_neighbors', tenant, c.executionCtx, @@ -390,10 +417,12 @@ portalDataRouter.get('/portal/data/chat', (c) => return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502); } // 回 workflow 回應內層 data:{answer, sources, graph_facts}(缺欄位誠實回空,不編造) + // t129: sources 按 page_name 去重——同一卡拆多 block 每個各一筆,前端列一整頁重複;後端去重後乾淨。 const inner = unwrapWorkflowData(result.data, 'answer'); + const rawSources = Array.isArray(inner.sources) ? inner.sources : []; return c.json({ answer: typeof inner.answer === 'string' ? inner.answer : '', - sources: Array.isArray(inner.sources) ? inner.sources : [], + sources: dedupeSourcesByPage(rawSources), graph_facts: inner.graph_facts ?? null, }); }), diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts index f0d40b2..271c32a 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, normalizeCjkQuery, findBestNodeMatch } from '../src/routes/portal-data'; +import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch, dedupeSourcesByPage } from '../src/routes/portal-data'; import type { Bindings } from '../src/types'; const KBDB = 'https://kbdb.test'; @@ -578,3 +578,134 @@ describe('GET /portal/data/graph/neighbors/:name(t96 fuzzy fallback)', () => expect(data.neighbors.length).toBe(1); }); }); + +// ═══════════════ 12. t116: graph_neighbors workflow 補傳 kbdb_base ═══════════════ + +describe('GET /portal/data/graph/neighbors/:name(t116 kbdb_base 補傳)', () => { + it('tenant 有 graph_neighbors workflow → portal 傳入 kbdb_base,workflow 正常執行不崩', async () => { + // 設定 session(["*"] 全庫,放行 graph 粗閘) + await seedSession('tok-t116', 'rec_t116'); + mockGetRecord('rec_t116', userValues({ libraries: '["*"]', role: 'admin' })); + + // 在 WEBHOOKS KV 放 graph_neighbors workflow(Input→Output 直通) + // 這個 workflow 不用 {{input.kbdb_base}},只驗工作流路徑正常執行(不走 graphBase fallback) + // 若沒補傳 kbdb_base 但 workflow 內有 {{input.kbdb_base}} 的節點,URL 解析失敗 → executeWebhookGraph 回 error + // 此測試退而求其次:用無外部依賴的直通圖確認整個路徑都通(workflow 取代 plugin fallback) + const wfKey = `${TENANT}:wf:graph_neighbors`; + await env.WEBHOOKS.put(wfKey, JSON.stringify({ + graph: { + id: 'gn-t116', + name: 'graph_neighbors', + nodes: [ + { id: 'input', type: 'Input' }, + // comp_passthrough 是內建零件,不需外部 fetch,直接回傳 context + { id: 'pass', type: 'Component', componentId: 'comp_passthrough' }, + { id: 'output', type: 'Output' }, + ], + edges: [ + { from: 'input', to: 'pass', type: 'PIPE' }, + { from: 'pass', to: 'output', type: 'PIPE' }, + ], + }, + description: 't116 test', + created_at: '2026-07-29T00:00:00.000Z', + })); + + const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t116' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[]; count: number; kbdb_base?: string }; + // workflow 走 comp_passthrough,output = 整個 context(含 kbdb_base) + // mapGraphWorkflowOutput 只取 neighbors/edges,其他欄位不影響回應 + expect(Array.isArray(data.neighbors)).toBe(true); + expect(Array.isArray(data.edges)).toBe(true); + // 確認不是 502(graph_neighbors workflow 執行失敗) + expect(res.status).not.toBe(502); + + await env.WEBHOOKS.delete(wfKey); + }); +}); + +// ═══════════════ 13. t128: graph_neighbors workflow 補傳 template ═══════════════ + +describe('GET /portal/data/graph/neighbors/:name(t128 template 補傳)', () => { + it('tenant 有 graph_neighbors workflow → portal 傳入 template=triplet,workflow 不崩', async () => { + await seedSession('tok-t128', 'rec_t128'); + mockGetRecord('rec_t128', userValues({ libraries: '["*"]', role: 'admin' })); + + const wfKey = `${TENANT}:wf:graph_neighbors`; + await env.WEBHOOKS.put(wfKey, JSON.stringify({ + graph: { + id: 'gn-t128', + name: 'graph_neighbors', + nodes: [ + { id: 'input', type: 'Input' }, + { id: 'pass', type: 'Component', componentId: 'comp_passthrough' }, + { id: 'output', type: 'Output' }, + ], + edges: [ + { from: 'input', to: 'pass', type: 'PIPE' }, + { from: 'pass', to: 'output', type: 'PIPE' }, + ], + }, + })); + + const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t128' }); + // template 有進 context → workflow 執行不崩(非 502) + expect(res.status).toBe(200); + const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] }; + expect(Array.isArray(data.neighbors)).toBe(true); + + await env.WEBHOOKS.delete(wfKey); + }); +}); + +// ═══════════════ 14. t129: dedupeSourcesByPage 純函式 ═══════════════ + +describe('dedupeSourcesByPage(t129 出處去重)', () => { + it('同 page_name 合併,hit_count 標計數', () => { + const srcs = [ + { page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' }, + { page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' }, + { page_name: '企業版功能', mode: 'keyword', source: 'gitea://docs/enterprise.md' }, + ]; + const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[]; + expect(out.length).toBe(1); // 3 筆→1 筆 + expect(out[0].page_name).toBe('企業版功能'); + expect(out[0].hit_count).toBe(3); + }); + + it('不同 page_name 各保留一筆;單筆無 hit_count', () => { + const srcs = [ + { page_name: 'A 頁', mode: 'semantic' }, + { page_name: 'B 頁', mode: 'keyword' }, + ]; + const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[]; + expect(out.length).toBe(2); + expect(out.every(s => s.hit_count === undefined)).toBe(true); + }); + + it('page 欄(備用)也能去重', () => { + const srcs = [ + { page: '備用頁', mode: 'semantic' }, + { page: '備用頁', mode: 'keyword' }, + ]; + const out = dedupeSourcesByPage(srcs) as { page?: string; hit_count?: number }[]; + expect(out.length).toBe(1); + expect(out[0].hit_count).toBe(2); + }); + + it('空陣列 → 空陣列;非物件條目跳過', () => { + expect(dedupeSourcesByPage([])).toEqual([]); + const out = dedupeSourcesByPage([null, 'oops', { page_name: 'X' }]); + expect(out.length).toBe(1); + }); + + it('page_name 優先於 page', () => { + const srcs = [ + { page_name: '優先頁', page: '備用頁' }, + { page_name: '優先頁', page: '備用頁' }, + ]; + const out = dedupeSourcesByPage(srcs) as { hit_count?: number }[]; + expect(out.length).toBe(1); // 同 page_name → 合為一筆 + }); +}); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index 66a471e..2407c69 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -242,6 +242,26 @@ ④ `console-ui/public/portal/index.html` 設定頁新增「萃取引擎」區塊(引擎選擇+金鑰輸入+提示重連)。 測試:vitest 新增 3 案(未設定→下發 gemma 無金鑰;設定後→下發含金鑰;GET 不回明文)。 +- [x] **t128 圖搜尋仍回 0——graph_neighbors 補傳 template(2026-07-29,任務層小改)**: + 診斷:t116 只補了 kbdb_base,但 fetch_triplets.url 同時含 `{{input.template}}`; + portal 呼叫 executeWebhookGraph 未傳 template → URL 變 `/records/by-template/?owner_id=...` → count=0。 + 總管實測:手動補 `"template":"triplet"` → count=1(企業版功能解鎖)。 + 修:portal-data.ts graph_neighbors 呼叫補 `template: 'triplet'`。 + 執行範圍:`cypher-executor/src/routes/portal-data.ts` 一行。 + 測試:portal-data.test.ts 新增整合案(t128 describe,workflow 執行不崩即代表 template 進到 context)。 + workflow input 完整核對清單(portal-data.ts 所有 executeWebhookGraph 呼叫): + - graph_neighbors:node ✅、depth ✅、namespace ✅、owner ✅、kbdb_base ✅(t116)、template ✅(本次) + - rag_chat:question ✅;namespace/kbdb_base 未核實(rag_chat workflow yaml 在 arcrun-rag,非本 repo) + - takedown:不在 portal-data.ts,在 arcrun-rag 端(dashboard/安裝器),超出本 repo 範圍 + +- [x] **t129 AI 問答出處重複一整頁——後端按 page_name 去重(2026-07-29,任務層小改)**: + 診斷:一張卡拆成 3-5 block,每 block 各回一筆 source(同 page_name)→ 前端列一整頁重複。 + 選後端修(/portal/data/chat)理由:出處去重是資料清潔,跟前端渲染無關;改後端不需動 HTML。 + 修:portal-data.ts 新增純函式 `dedupeSourcesByPage()`(Map 按 page_name/page 去重,hit_count > 1 時附計數); + chat 路由 rawSources 先過去重再回。 + 執行範圍:`cypher-executor/src/routes/portal-data.ts`(新增 export 函式 + chat route)。 + 測試:portal-data.test.ts 新增 5 案(t129 describe:合併計數/各保一筆/page 備用/空陣列/page_name 優先)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)