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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 出處清單按 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(不洩存在性)。 */
|
||||
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,
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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 → 合為一筆
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user