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>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-19 15:36:34 +08:00
1 parent ef615bbfb6
commit 30cf0fed91
4 files changed
+72 -5

No files matched your search

@@ -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