: Gradio Markdown drops bare
+ # text /
siblings inside HTML blocks.
+ body = re.sub(
+ r"(?m)^[ \t]*\*{0,2}置信度:[高中低][。.]?\*{0,2}[ \t]*$"
+ r"|\*{0,2}置信度:[高中低][。.]?\*{0,2}",
+ "",
+ body,
+ ).strip()
+ answer = body.strip() if body.strip() else "(结论正文缺失,请展开过程或重试)"
+ # Collapse excessive blank lines but keep readable line breaks as
.
+ answer_html = html.escape(answer, quote=False).replace("\n", "
")
+ return (
+ f'
'
+ f'
'
+ f'结论'
+ f"{conf}"
+ f"
"
+ f'
{answer_html}
'
+ f"
\n\n"
+ )
+
+ text = re.sub(
+ r"(?ms)^##\s*结论\s*\n+(.*?)(?=^##\s|\Z)",
+ _wrap_glance,
+ text,
+ count=1,
+ )
+
+ # 2) Legacy TL;DR callout (skip if already glance-wrapped; exclude bare 结论)
+ def _wrap_tldr(match: "re.Match[str]") -> str:
+ title = html.escape(match.group(1).strip(), quote=False)
+ body = (match.group(2) or "").strip()
+ conf = ""
+ conf_m = re.search(
+ r"(高|中|低)\s*置信|confidence\s*[:=]?\s*(high|medium|low|\d+%?)",
+ match.group(1) + "\n" + body,
+ re.I,
+ )
+ if conf_m:
+ label = html.escape(conf_m.group(0), quote=False)
+ level = "mid"
+ if re.search(r"高|high", label, re.I):
+ level = "high"
+ elif re.search(r"低|low", label, re.I):
+ level = "low"
+ conf = f'
{label}'
+ return (
+ f'
\n\n'
+ f'
{title}{conf}
\n\n'
+ f"{body}\n\n"
+ f"
\n\n"
+ )
+
+ if 'class="report-glance"' not in text:
+ text = re.sub(
+ r"(?ms)^##\s*([^\n]*(?:TL;?DR|总览|Executive Summary)[^\n]*)\n+(.*?)(?=^##\s|\Z)",
+ _wrap_tldr,
+ text,
+ count=1,
+ )
+
+ # 3) Conflict tag on heading (keep open, short)
+ text = re.sub(
+ r"(?m)^(##\s*[^\n]*(?:争议与不确定|冲突|不确定|Conflicts?|Uncertainties?)[^\n]*)$",
+ r'\1
冲突/不确定',
+ text,
+ count=1,
+ )
+
+ # 4) Fold heavy sections (evidence / deep dive). Nested headings are ###.
+ def _fold_section(match: "re.Match[str]") -> str:
+ heading = match.group(1).strip()
+ body = (match.group(2) or "").strip()
+ if not body:
+ return "" # never show an empty fold
+ n_links = len(re.findall(r"https?://", body))
+ n_items = len(re.findall(r"(?m)^\s*(?:\d+\.|[-*•])\s+", body))
+ n = n_links or n_items
+ count_hint = f"({n})" if n else ""
+ title = html.escape(re.sub(r"^##\s*", "", heading), quote=False)
+ # Mermaid card inside fold body
+ body = re.sub(
+ r"(?ms)(###\s*[^\n]*(?:关系拓扑|Relationship Map|拓扑)[^\n]*\n+)(```mermaid\n.*?```)",
+ r'
\n\n\1\2\n\n
\n\n',
+ body,
+ count=1,
+ )
+ return (
+ f'
\n'
+ f"{title}{count_hint}
\n\n"
+ f"{body}\n\n"
+ f" \n\n"
+ )
+
+ text = re.sub(
+ r"(?ms)^(##\s*[^\n]*(?:证据与来源|证据和来源|深入了解|深入分析)[^\n]*)\n+(.*?)(?=^##\s|\Z)",
+ _fold_section,
+ text,
+ )
+
+ text = text.replace('class="ref-citation"', 'class="ref-citation ref-chip"')
+ return text
+
+
+def _keep_last_report_glance(decorated_blocks: List[str]) -> List[str]:
+ """Keep a single consumer glance card when streamed summaries differ only by confidence.
+
+ Balanced/detailed merge uses ``all_unique``, so an early mid-confidence reshape and a
+ later high-confidence reshape can both survive. Rendering both produces duplicate
+ 「结论 / 置信度」 chrome in the a11y tree. Prefer the latest glance-bearing block.
+ """
+ if len(decorated_blocks) <= 1:
+ return decorated_blocks
+ glance_indexes = [
+ idx
+ for idx, block in enumerate(decorated_blocks)
+ if 'class="report-glance"' in str(block or "")
+ ]
+ if len(glance_indexes) <= 1:
+ return decorated_blocks
+ drop = set(glance_indexes[:-1])
+ return [block for idx, block in enumerate(decorated_blocks) if idx not in drop]
+
+
+_REPORT_SOURCE_TOOL_NAMES = {"google_search", "sogou_search"}
+_REPORT_SCRAPE_TOOL_NAMES = {
+ "scrape_url",
+ "scrape",
+ "scrape_website",
+ "scrape_webpage",
+ "scrape_and_extract_info",
+}
+# 参考来源上限:论文式文末列表,太多反而淹没阅读
+MAX_REPORT_SOURCES = 30
+
+
+def _normalize_source_url(url: Any) -> str:
+ candidate = str(url or "").strip()
+ if not candidate:
+ return ""
+ if not candidate.lower().startswith(("http://", "https://")):
+ return ""
+ return candidate
+
+
+def _source_display_title(title: Any, url: str) -> str:
+ # 搜索结果标题常带
等高亮标签,剥掉避免破坏 Markdown 链接文本
+ text = re.sub(r"<[^>]+>", "", str(title or ""))
+ text = re.sub(r"\s+", " ", text).strip()
+ if text:
+ return text[:120]
+ host = urlparse(url).netloc or url
+ return host
+
+
+def _collect_report_sources(state: dict) -> List[Dict[str, str]]:
+ """汇总本轮研究实际命中/访问过的来源,按首次出现顺序去重。"""
+ sources: List[Dict[str, str]] = []
+ seen_urls = set()
+
+ def _add(url: Any, title: Any = "") -> None:
+ normalized = _normalize_source_url(url)
+ if not normalized or normalized in seen_urls:
+ return
+ seen_urls.add(normalized)
+ sources.append(
+ {"url": normalized, "title": _source_display_title(title, normalized)}
+ )
+
+ for agent_id in (state or {}).get("agent_order", []):
+ agent = (state or {}).get("agents", {}).get(agent_id, {})
+ for call_id in agent.get("tool_call_order", []):
+ call = agent.get("tools", {}).get(call_id, {})
+ tool_name = call.get("tool_name", "")
+ tool_input = call.get("input", {})
+ tool_output = call.get("output", {})
+ if tool_name in _REPORT_SOURCE_TOOL_NAMES:
+ result_data: Dict[str, Any] = {}
+ result_payload = (
+ tool_output.get("result", "")
+ if isinstance(tool_output, dict)
+ else ""
+ )
+ if isinstance(result_payload, str) and result_payload.strip():
+ try:
+ parsed = json.loads(result_payload)
+ if isinstance(parsed, dict):
+ result_data = parsed
+ except json.JSONDecodeError:
+ result_data = {}
+ elif isinstance(result_payload, dict):
+ result_data = result_payload
+ if not result_data and isinstance(tool_output, dict):
+ result_data = tool_output
+ organic = result_data.get("organic")
+ if not isinstance(organic, list):
+ organic = result_data.get("Pages")
+ if isinstance(organic, list):
+ for item in organic:
+ if not isinstance(item, dict):
+ continue
+ _add(
+ item.get("link") or item.get("url"),
+ item.get("title") or item.get("siteName"),
+ )
+ elif tool_name in _REPORT_SCRAPE_TOOL_NAMES and isinstance(
+ tool_input, dict
+ ):
+ _add(tool_input.get("url") or tool_input.get("link"))
+ if len(sources) >= MAX_REPORT_SOURCES:
+ return sources
+ return sources
+
+
+def _build_references_section(sources: List[Dict[str, str]]) -> List[str]:
+ if not sources:
+ return []
+ heading = _progress_copy("references_heading")
+ lines = ["", "\n---\n", f"### {heading}\n"]
+ for idx, src in enumerate(sources, 1):
+ title = str(src.get("title") or src["url"]).replace("[", "(").replace("]", ")")
+ lines.append(f"[{idx}] [{title}]({src['url']})")
+ # 条目间空行:相邻行会被 Markdown 合并成一段,来源挤在一起无法逐条阅读
+ lines.append("")
+ return lines
+
+
+def _build_summary_section(
+ final_summary_blocks: List[str],
+ output_detail_level: Optional[str] = None,
+) -> List[str]:
if not final_summary_blocks:
return []
# 前置空字符串项:确保和上一个 HTML block(如 search-step-board 的 )之间
# 有一个空行,否则 CommonMark 会把 `## 📋 研究总结` 视为 HTML block 的延续,
# 导致 `## ` 字面显示而非作为标题渲染。
- lines = ["", "## 📋 研究总结\n\n"]
+ # Glance card already leads with 结论; keep a quieter label.
+ lines = ["", "### 研究报告\n\n"]
+ resolved_detail = _normalize_output_detail_level(output_detail_level)
# 先剥离 OutputFormatter 注入的调试分段标记,再做 LaTeX → Markdown 规范化
- sanitized = (_strip_diagnostic_markers(block) for block in final_summary_blocks)
+ sanitized = (
+ _prepare_user_facing_report_safe(block, detail_level=resolved_detail)
+ for block in final_summary_blocks
+ )
normalized = (_normalize_latex_like_markup(block) for block in sanitized)
rewritten = (_humanize_pipeline_fallback(block) for block in normalized)
- lines.extend(_linkify_reference_citations(block) for block in rewritten)
+ # linkify first, then decorate so ref-chip class lands on citation anchors
+ linkified = [_linkify_reference_citations(block) for block in rewritten]
+ decorated = [
+ _decorate_report_for_web(block, detail_level=resolved_detail)
+ for block in linkified
+ ]
+ lines.extend(_keep_last_report_glance(decorated))
return lines
-def _build_process_details_section(process_lines: List[str]) -> List[str]:
+def _set_thought_cards_expanded(
+ process_lines: List[str], *, expanded: bool
+) -> List[str]:
+ """Streaming: keep thoughts open. Finished: fold them shut inside process panel."""
+ out: List[str] = []
+ for line in process_lines:
+ text = str(line or "")
+ if expanded:
+ text = text.replace(
+ '