Files
deerflow-code/frontend-web/src/roundtable-planning/lib/report-template.test.ts
T
董宏凯andClaude Opus 4.8 30cf0fed91 fix(dashboard): 抽取 report-json 前剥离 <think> 思考过程做容错
推理模型(DeepSeek-R1/QwQ 等)输出的 <think>...</think> 思考里常含
```json/裸 {…},会被误当候选导致抽到错误/残缺数据。抽取前先剥离 think:
- 前端 report-template.ts::extractReportData 开头 stripReasoning(复用 reasoning.ts,
  兼容完整/多段/流式未闭合/只吐闭合四形态)
- 后端 report_json.py 新增 _strip_reasoning 在 extract_report_json 开头调用
- report-template.test.ts +3 例(均通过)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:36:34 +08:00

187 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* report-template.ts 纯函数单测(大屏多页模式的契约层)。
* 运行方式(项目无 vitest,对齐 flow-graph.test.ts):
* npx esbuild src/roundtable-planning/lib/report-template.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/report-template.test.mjs
* node --test .tmp-tests/report-template.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
buildDashboardPrompt,
extractReportData,
isDashboardHtml,
renderDashboardHtml,
RT_DASHBOARD_MARKER,
stripReportFence,
} from "./report-template";
const SAMPLE = [
"下面是大屏数据。",
"```report-json",
JSON.stringify({
title: "智能机房选址方案大屏",
subtitle: "三地候选 · 综合评审",
kpis: [
{ label: "总投资", value: "1.2", unit: "亿元", trend: "up" },
{ label: "工期", value: "14", unit: "个月" },
],
consensus: { headline: "推荐 B 地块方案", points: ["供电冗余最高", "扩容空间充足"] },
charts: [
{ type: "bar", title: "候选地块评分", items: [{ label: "A", value: 72 }, { label: "B", value: 89 }] },
{ type: "donut", title: "投资构成", items: [{ label: "土建", value: 40 }, { label: "设备", value: 60 }] },
],
timeline: [{ label: "阶段一", desc: "勘察与立项" }],
seats: [
{ id: "seat-1", name: "情报收集", summary: "完成三地基础数据采集", sections: [{ heading: "数据来源", bullets: ["政务公开", "实地走访"] }], metrics: [{ label: "覆盖率", value: 92 }], risks: [], dependsOn: [] },
{ id: "seat-2", name: "方案设计", summary: "B 地块双回路供电方案", sections: [], metrics: [], risks: [{ label: "供电瓶颈", level: "high" }], dependsOn: ["seat-1"] },
],
}),
"```",
].join("\n");
test("extractReportData:完整块解析 + 字段归一化", () => {
const d = extractReportData(SAMPLE)!;
assert.equal(d.title, "智能机房选址方案大屏");
assert.equal(d.kpis.length, 2);
assert.equal(d.consensus.headline, "推荐 B 地块方案");
assert.equal(d.charts.length, 2);
assert.equal(d.seats.length, 2);
assert.deepEqual(d.seats[1].dependsOn, ["seat-1"]);
});
test("extractReportData:dependsOn 只保留真实存在的席位 id(剔除模型编造/自引用)", () => {
const text = [
"```report-json",
JSON.stringify({
title: "t",
seats: [
{ id: "s1", name: "甲", dependsOn: ["s1", "s2", "ghost"] },
{ id: "s2", name: "乙", dependsOn: [] },
],
}),
"```",
].join("\n");
const d = extractReportData(text)!;
assert.deepEqual(d.seats[0].dependsOn, ["s2"]);
});
test("extractReportData:剥离 <think> 思考过程后再抽取(思考里的假 JSON 不干扰)", () => {
// 思考块里故意混入一个会「像」契约但不该被采用的裸 JSON,正文才是真数据。
const withThink = [
"<think>",
'我先草拟一版:{"title":"草稿不要用","seats":[{"id":"x","name":"废弃"}]}',
"再想想……",
"</think>",
SAMPLE,
].join("\n");
const d = extractReportData(withThink)!;
assert.equal(d.title, "智能机房选址方案大屏");
assert.equal(d.seats.length, 2);
});
test("extractReportData:只吐闭合 </think>(chat template 注入式)也能抽到正文", () => {
const onlyClose = ["模型在此思考成本与风险……", "</think>", SAMPLE].join("\n");
const d = extractReportData(onlyClose)!;
assert.equal(d.title, "智能机房选址方案大屏");
});
test("extractReportData:流式未闭合 <think> 尾段不被误抽(返回 null)", () => {
// 正文还没出,模型还在思考里写了半个 JSON —— 不应抽出数据。
const streaming = '正文前导\n<think>我打算输出 {"title":"在想","seats":[{"id":"a","name":"甲"';
assert.equal(extractReportData(streaming), null);
});
test("extractReportData:无 fence / 坏 JSON / 空数据返回 null", () => {
assert.equal(extractReportData("没有数据块"), null);
assert.equal(extractReportData("```report-json\n{broken\n```"), null);
assert.equal(extractReportData("```report-json\n{}\n```"), null);
assert.equal(extractReportData(null), null);
});
test("renderDashboardHtml:完整文档 + marker + 总览/席位多页 + :target 导航", () => {
const d = extractReportData(SAMPLE)!;
const html = renderDashboardHtml(d, { generatedAt: "2026-06-13 10:00" });
assert.ok(html.startsWith("<!doctype html>"));
assert.ok(html.includes(RT_DASHBOARD_MARKER));
assert.ok(html.trimEnd().endsWith("</html>"));
assert.ok(isDashboardHtml(html));
// 多页结构:总览 + 每席位一页
assert.ok(html.includes('id="overview"'));
assert.ok(html.includes('id="seat-1"'));
assert.ok(html.includes('id="seat-2"'));
// 纯 CSS :target 导航(零 JS:无 allow-scripts 沙箱也能切页)
assert.ok(html.includes(".page:target"));
assert.ok(!html.includes("<script"));
// 席位卡片跳转 + 依赖互相跳转(方案设计 依赖→ 情报收集)
assert.ok(html.includes('href="#seat-2"'));
assert.ok(html.includes('class="dep" href="#seat-1"'));
// 反向「被依赖」chip(情报收集页里出现 方案设计 ← 承接)
assert.ok(html.includes("承接本席位"));
// 共识详情纯 CSS 弹窗
assert.ok(html.includes('id="consensus-modal"'));
// 图表 SVG 由模板生成
assert.ok(html.includes("<svg"));
});
test("renderDashboardHtml:主题跟随 —— dark=深蓝规范 / light=浅红规范", () => {
const d = extractReportData(SAMPLE)!;
const dark = renderDashboardHtml(d, { theme: "dark" });
// 深蓝规范:页面 #0C1836 / 卡片 #122347 / 主色 #1677FF(tokens.css §1)
assert.ok(dark.includes("#0C1836"));
assert.ok(dark.includes("#122347"));
assert.ok(dark.includes("#1677FF"));
const light = renderDashboardHtml(d, { theme: "light" });
// 蓝图工程图风(参考设计图):页面 #F8FAFC / 玫瑰红主色 #E11D48 / slate 文字
assert.ok(light.includes("#F8FAFC"));
assert.ok(light.includes("#E11D48"));
assert.ok(light.includes("#1E293B"));
assert.ok(!light.includes("#0C1836"));
// 缺省 = dark(向后兼容)
const def = renderDashboardHtml(d);
assert.ok(def.includes("#0C1836"));
});
test("renderDashboardHtml:模型文本全部 HTML 转义(防注入/破版)", () => {
const text = [
"```report-json",
JSON.stringify({
title: '<img src=x onerror=alert(1)>',
seats: [{ id: "s1", name: "甲<b>", summary: 'a"b<c>', dependsOn: [] }],
consensus: { headline: "<script>bad()</script>", points: [] },
}),
"```",
].join("\n");
const d = extractReportData(text)!;
const html = renderDashboardHtml(d);
assert.ok(!html.includes("<img src=x"));
assert.ok(!html.includes("<script>bad"));
assert.ok(html.includes("&lt;script&gt;bad()&lt;/script&gt;"));
});
test("stripReportFence:闭合块替换为引导语;未闭合(流式中)用「正在输出」措辞", () => {
const done = stripReportFence(SAMPLE, false);
assert.ok(done.includes("已生成大屏数据"));
assert.ok(!done.includes("```report-json"));
const streaming = stripReportFence('说明\n```report-json\n{"title":', true);
assert.ok(streaming.includes("正在输出大屏结构化数据"));
assert.ok(!streaming.includes("```report-json"));
// 无 fence 原样返回
assert.equal(stripReportFence("普通文字", false), "普通文字");
});
test("buildDashboardPrompt:禁工具声明 + 原料齐全 + 契约说明", () => {
const prompt = buildDashboardPrompt({
intentText: "选址决策",
seatDeliveries: [{ sender: "情报收集", content: "三地数据" }],
consensusText: "推荐 B 地块",
});
assert.ok(prompt.includes("不要调用任何工具"));
assert.ok(prompt.includes("选址决策"));
assert.ok(prompt.includes("情报收集") && prompt.includes("三地数据"));
assert.ok(prompt.includes("推荐 B 地块"));
assert.ok(prompt.includes("```report-json"));
assert.ok(prompt.includes("dependsOn"));
});