推理模型(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>
187 lines
7.9 KiB
TypeScript
187 lines
7.9 KiB
TypeScript
/**
|
||
* 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("<script>bad()</script>"));
|
||
});
|
||
|
||
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"));
|
||
});
|