fix(t128+t129): 圖搜尋補 template:triplet+AI 問答出處按頁去重
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=總管)
This commit is contained in:
@@ -73,6 +73,32 @@ export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; e
|
|||||||
return { neighbors, edges, count: neighbors.length };
|
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<string, { item: Record<string, unknown>; count: number }>();
|
||||||
|
for (const s of sources) {
|
||||||
|
if (!s || typeof s !== 'object') continue;
|
||||||
|
const item = s as Record<string, unknown>;
|
||||||
|
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(不洩存在性)。 */
|
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
|
||||||
function notFound(c: Context<{ Bindings: Bindings }>): Response {
|
function notFound(c: Context<{ Bindings: Bindings }>): Response {
|
||||||
return c.json({ error: '找不到這筆資料' }, 404);
|
return c.json({ error: '找不到這筆資料' }, 404);
|
||||||
@@ -274,7 +300,8 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
|||||||
const result = await executeWebhookGraph(
|
const result = await executeWebhookGraph(
|
||||||
c.env,
|
c.env,
|
||||||
wfGraph,
|
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',
|
'graph_neighbors',
|
||||||
tenant,
|
tenant,
|
||||||
c.executionCtx,
|
c.executionCtx,
|
||||||
@@ -390,10 +417,12 @@ portalDataRouter.get('/portal/data/chat', (c) =>
|
|||||||
return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
|
return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
|
||||||
}
|
}
|
||||||
// 回 workflow 回應內層 data:{answer, sources, graph_facts}(缺欄位誠實回空,不編造)
|
// 回 workflow 回應內層 data:{answer, sources, graph_facts}(缺欄位誠實回空,不編造)
|
||||||
|
// t129: sources 按 page_name 去重——同一卡拆多 block 每個各一筆,前端列一整頁重複;後端去重後乾淨。
|
||||||
const inner = unwrapWorkflowData(result.data, 'answer');
|
const inner = unwrapWorkflowData(result.data, 'answer');
|
||||||
|
const rawSources = Array.isArray(inner.sources) ? inner.sources : [];
|
||||||
return c.json({
|
return c.json({
|
||||||
answer: typeof inner.answer === 'string' ? inner.answer : '',
|
answer: typeof inner.answer === 'string' ? inner.answer : '',
|
||||||
sources: Array.isArray(inner.sources) ? inner.sources : [],
|
sources: dedupeSourcesByPage(rawSources),
|
||||||
graph_facts: inner.graph_facts ?? null,
|
graph_facts: inner.graph_facts ?? null,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||||
import { workflowsVisible } from '../src/routes/portal';
|
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';
|
import type { Bindings } from '../src/types';
|
||||||
|
|
||||||
const KBDB = 'https://kbdb.test';
|
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);
|
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 → 合為一筆
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -242,6 +242,26 @@
|
|||||||
④ `console-ui/public/portal/index.html` 設定頁新增「萃取引擎」區塊(引擎選擇+金鑰輸入+提示重連)。
|
④ `console-ui/public/portal/index.html` 設定頁新增「萃取引擎」區塊(引擎選擇+金鑰輸入+提示重連)。
|
||||||
測試:vitest 新增 3 案(未設定→下發 gemma 無金鑰;設定後→下發含金鑰;GET 不回明文)。
|
測試: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 動工範圍,掛號)
|
## 第二波(不在本 SDD 動工範圍,掛號)
|
||||||
|
|
||||||
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
|
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
|
||||||
|
|||||||
Reference in New Issue
Block a user