Merge branch 'dhk-1' into 'master'

fix(dashboard): 抽取 report-json 前剥离 <think> 思考过程做容错

See merge request knowledge-base-bj/deerflow-server!137
This commit is contained in:
董宏凯 committed 2026-06-19 07:42:29 +00:00
commit a6dd0fdd85
4 files changed
+72 -5

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true
VITE_MOCK_COP_TASK_DETAIL=true
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_SQ_REPORT=false
VITE_MOCK_SQ_REPORT=true
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
@@ -67,6 +67,32 @@ test("extractReportData:dependsOn 只保留真实存在的席位 id(剔除
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);
@@ -34,6 +34,7 @@
*/
import darkBgSvgRaw from "@/assets/img/layout-bg-theme-dark-blue.svg?raw";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
// ── 契约类型 ─────────────────────────────────────────────────────────────────
@@ -207,18 +208,23 @@ function arr(v: unknown): unknown[] {
*/
export function extractReportData(text: string | null | undefined): ReportData | null {
if (!text) return null;
// 容错:先剥离模型 `<think>...</think>` 思考过程(兼容完整/多段/流式未闭合/只吐闭合等
// 形态,见 reasoning.ts)。否则思考里出现的 ```json / 裸 {…} 会被误当候选,抽到错误或
// 残缺数据(与「抽取结构化流程」同款容错)。无 think 标记时原样返回,仅去首尾空白。
const cleaned = stripReasoning(text);
if (!cleaned) return null;
const candidates: string[] = [];
const rank = (lang: string) =>
lang === "report-json" ? 0 : lang === "json" || lang === "jsonc" ? 1 : lang === "" ? 2 : 3;
collectFencedBlocks(text)
collectFencedBlocks(cleaned)
.map((b, i) => ({ b, i }))
.sort((x, y) => rank(x.b.lang) - rank(y.b.lang) || y.i - x.i)
.forEach(({ b }) => candidates.push(b.body));
// 未闭合围栏(流式截断):取 report-json fence 之后到结尾。
const f = text.lastIndexOf(FENCE);
if (f >= 0) candidates.push(text.slice(f + FENCE.length).trim());
const f = cleaned.lastIndexOf(FENCE);
if (f >= 0) candidates.push(cleaned.slice(f + FENCE.length).trim());
// 裸 JSON(无围栏 / 夹在文字里):所有顶层平衡 {…} 对象,靠后的先试,契约形状的优先。
const objs = balancedJsonObjects(text);
const objs = balancedJsonObjects(cleaned);
for (const o of objs.filter(looksLikeReportJson).reverse()) candidates.push(o);
for (const o of objs.reverse()) candidates.push(o);
@@ -28,6 +28,37 @@ _LOOKS_LIKE_RE = re.compile(r'"(seats|consensus|kpis|charts|timeline)"\s*:')
# 收集所有 ```lang\n...``` 代码块(lang 可空)。
_FENCE_RE = re.compile(r"```([^\n`]*)\r?\n(.*?)```", re.DOTALL)
# 推理模型的 <think>...</think> 思考块(剥离后再抽取,避免思考里的 ```json/裸 {} 被误当数据)。
_THINK_BLOCK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
_ANY_THINK_TAG_RE = re.compile(r"</?think>", re.IGNORECASE)
_OPEN_TAG = "<think>"
_CLOSE_TAG = "</think>"
def _strip_reasoning(text: str) -> str:
"""剥离模型 ``<think>...</think>`` 思考过程,只留正文(对齐前端 ``reasoning.ts::stripReasoning``)。
兼容四种形态:完整、多段、流式未闭合(尾段只有开标签)、只吐闭合(chat template 把开
标签注入 prompt、模型只生成 ``</think>``,DeepSeek-R1 / QwQ 等推理模型常见)。无 think
标记时仅去首尾空白。
"""
if not text:
return ""
working = text
# 形态4:首个 </think> 出现在任何 <think> 之前(或无 <think>)→ 其前内容整体算推理。
first_close = working.find(_CLOSE_TAG)
first_open = working.find(_OPEN_TAG)
if first_close >= 0 and (first_open < 0 or first_close < first_open):
working = working[first_close + len(_CLOSE_TAG) :]
# 形态1/2:移除所有完整 <think>...</think> 块。
closed = _THINK_BLOCK_RE.sub("", working)
# 形态3:尾段未闭合 <think>(流式截断)→ 截到 <think> 之前。
open_idx = closed.rfind(_OPEN_TAG)
if open_idx >= 0:
closed = closed[:open_idx]
# 兜底:正文不该残留任何 think 标签字面量。
return _ANY_THINK_TAG_RE.sub("", closed).strip()
# ── 标量/容器归一化小工具(对齐前端 str/num/arr) ───────────────────────────────
@@ -231,6 +262,10 @@ def extract_report_json(text: str | None) -> str | None:
3. 夹在文字里的所有平衡 ``{…}`` 对象,契约形状的优先、靠后的先试。
每个候选 ``json.loads`` 成功后过 :func:`coerce_report_data`,命中即返回归一化 JSON。
"""
if not text:
return None
# 先剥离 <think>...</think> 思考过程,否则思考里的 ```json / 裸 {…} 会被误当候选。
text = _strip_reasoning(text)
if not text:
return None