Procházet zdrojové kódy

feat: add Academic-Data-Agent graduation project

captain před 3 měsíci
rodič
revize
7742827cc9
25 změnil soubory, kde provedl 5094 přidání a 0 odebrání
  1. 13 0
      Co-creation-projects/healer-666-Academic-Data-Agent/.env.example
  2. 5 0
      Co-creation-projects/healer-666-Academic-Data-Agent/.gitignore
  3. 147 0
      Co-creation-projects/healer-666-Academic-Data-Agent/README.md
  4. binární
      Co-creation-projects/healer-666-Academic-Data-Agent/data/sample_paper.pdf
  5. binární
      Co-creation-projects/healer-666-Academic-Data-Agent/data/sample_table.xlsx
  6. binární
      Co-creation-projects/healer-666-Academic-Data-Agent/images/image1.png
  7. binární
      Co-creation-projects/healer-666-Academic-Data-Agent/images/image2.png
  8. binární
      Co-creation-projects/healer-666-Academic-Data-Agent/images/image3.png
  9. 537 0
      Co-creation-projects/healer-666-Academic-Data-Agent/main.ipynb
  10. 19 0
      Co-creation-projects/healer-666-Academic-Data-Agent/requirements.txt
  11. 24 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/__init__.py
  12. 1809 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/agent_runner.py
  13. 105 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/config.py
  14. 258 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/data_context.py
  15. 497 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/document_ingestion.py
  16. 18 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/llm.py
  17. 255 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/plotting.py
  18. 134 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/presentation.py
  19. 350 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/prompts.py
  20. 194 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/reporting.py
  21. 61 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/tool_protocol.py
  22. 6 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/tools/__init__.py
  23. 169 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/tools/python_interpreter.py
  24. 94 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/tools/tavily_search.py
  25. 399 0
      Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/vision_review.py

+ 13 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/.env.example

@@ -0,0 +1,13 @@
+LLM_MODEL_ID=deepseek-chat
+LLM_BASE_URL=https://api.deepseek.com/v1
+LLM_API_KEY=your_api_key_here
+LLM_TIMEOUT=120
+
+# Optional: online domain background search
+TAVILY_API_KEY=your_tavily_api_key_here
+
+# Optional: visual reviewer
+VISION_LLM_MODEL_ID=your_vision_model
+VISION_LLM_BASE_URL=https://your-openai-compatible-vision-endpoint/v1
+VISION_LLM_API_KEY=your_vision_api_key_here
+VISION_LLM_TIMEOUT=120

+ 5 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/.gitignore

@@ -0,0 +1,5 @@
+.env
+outputs/
+__pycache__/
+*.pyc
+.ipynb_checkpoints/

+ 147 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/README.md

@@ -0,0 +1,147 @@
+# Academic-Data-Agent
+
+> 一个面向科研场景的智能数据分析 Agent,支持表格分析与 PDF 文献中的主表提取分析。
+
+## 📝 项目简介
+
+Academic-Data-Agent 是一个基于 Hello-Agents 思路构建的科研数据分析工作台。它把数据接入、分析执行、报告生成和审稿治理串成了一条完整工作流,既能处理 `csv / xls / xlsx` 结构化表格,也能处理文本型 PDF 文献。
+
+对于 PDF 输入,项目并不是简单抽一张表就结束,而是采用“主表 + 文献背景 + 候选表摘要”的综合模式:
+
+- 自动抽取论文正文摘要或前文背景
+- 自动识别候选表,并选择一张主表做正式定量分析
+- 其余候选表作为上下文证据参与报告解释
+
+当前社区版是一个精简演示包,目标是便于在 Hello-Agents 社区中快速复现核心能力;完整版仓库保留了 Gradio 工作台、历史记录浏览和更完整的工程能力:
+
+- 完整版仓库:[My-Academic-Data-Agent](https://github.com/healer-666/My-Academic-Data-Agent)
+
+## ✨ 核心功能
+
+- [x] 表格数据自动分析:支持 `csv / xls / xlsx`
+- [x] PDF 文献解析:提取候选表、自动选择主表、注入文献背景
+- [x] 结构化报告生成:输出 Markdown 报告、图表和 trace
+- [x] 轻量审稿治理:支持 `draft / standard / publication`
+- [x] PDF 小表强约束:避免模型对结果汇总表误用统计检验
+
+## 🛠️ 技术栈
+
+- Hello-Agents
+- 自定义 Scientific ReAct 控制流
+- PythonInterpreterTool / TavilySearchTool
+- pandas / numpy / scipy / matplotlib / seaborn / statsmodels
+- pdfplumber / pypdf
+- Jupyter Notebook 演示
+
+## 🚀 快速开始
+
+### 环境要求
+
+- Python 3.10+
+- 建议使用虚拟环境
+
+### 安装依赖
+
+```bash
+pip install -r requirements.txt
+```
+
+### 配置 API Key
+
+```bash
+cp .env.example .env
+```
+
+然后编辑 `.env`,至少填入以下项目:
+
+```env
+LLM_MODEL_ID=your_model_id
+LLM_BASE_URL=https://your-openai-compatible-endpoint/v1
+LLM_API_KEY=your_api_key
+```
+
+如果需要联网背景检索或视觉审稿,也可以继续补充:
+
+```env
+TAVILY_API_KEY=your_tavily_api_key
+VISION_LLM_MODEL_ID=your_vision_model
+VISION_LLM_BASE_URL=https://your-vision-endpoint/v1
+VISION_LLM_API_KEY=your_vision_api_key
+```
+
+### 运行项目
+
+```bash
+jupyter lab
+```
+
+打开 `main.ipynb`,按顺序运行即可。
+
+## 📖 使用示例
+
+本项目在 Notebook 中内置了两段演示:
+
+1. 一个 Excel 表格案例,展示标准表格分析流程
+2. 一个 PDF 文献案例,展示候选表识别、主表选择和综合报告生成
+
+演示默认使用轻量配置:
+
+- `quality_mode="draft"`
+- `latency_mode="auto"`
+
+这样更适合社区评审快速复现。
+
+## 🖼️ 界面展示
+
+虽然本次提交以 Notebook 为主,但完整版项目还提供了 Gradio 工作台。下面保留三张界面截图供展示:
+
+![主界面](images/image1.png)
+---
+
+![历史记录](images/image2.png)
+---
+![运行日志与结果工作台](images/image3.png)
+
+## 🎯 项目亮点
+
+- 把科研数据分析从“单次脚本”升级为“可追踪工作流”
+- 支持 PDF 文献中的主表分析,而不是只吃干净表格
+- 对小样本结果表加入方法边界约束,减少统计跑偏
+- 支持报告、图表、trace 的完整工件输出
+
+## 📊 性能评估
+
+当前完整版仓库已经具备较完整的自动化测试覆盖,核心链路包括:
+
+- 文档解析
+- 数据上下文构建
+- 分析主流程
+- 审稿与视觉审稿
+- Web 工作台与历史记录
+
+本社区版默认采用轻量演示参数,重点关注可复现性和工作流完整性,而不是追求极限性能。
+
+## 🔮 未来计划
+
+- [ ] 增强 PDF 多表路由能力
+- [ ] 支持扫描版 PDF / OCR
+- [ ] 提升视觉审稿的图表理解深度
+- [ ] 提供更完整的社区版 Web Demo
+
+## 🤝 贡献指南
+
+欢迎提出 Issue 和 Pull Request。
+
+如果你想体验完整工程版,建议直接访问完整版仓库。
+
+## 📄 许可证
+
+MIT License
+
+## 👤 作者
+
+- GitHub: [@healer-666](https://github.com/healer-666)
+
+## 🙏 致谢
+
+感谢 Datawhale 社区和 Hello-Agents 项目提供的学习与共创机会。

binární
Co-creation-projects/healer-666-Academic-Data-Agent/data/sample_paper.pdf


binární
Co-creation-projects/healer-666-Academic-Data-Agent/data/sample_table.xlsx


binární
Co-creation-projects/healer-666-Academic-Data-Agent/images/image1.png


binární
Co-creation-projects/healer-666-Academic-Data-Agent/images/image2.png


binární
Co-creation-projects/healer-666-Academic-Data-Agent/images/image3.png


+ 537 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/main.ipynb

@@ -0,0 +1,537 @@
+{
+  "cells": [
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "# Academic-Data-Agent\n",
+        "\n",
+        "## 项目简介\n",
+        "\n",
+        "这是一个面向 Hello-Agents 社区展示的精简演示 Notebook,用来快速说明 Academic-Data-Agent 如何处理两类输入:\n",
+        "\n",
+        "- 结构化表格数据\n",
+        "- 文本型 PDF 文献\n",
+        "\n",
+        "Notebook 默认使用轻量模式,便于社区评审快速复现。\n",
+        "\n",
+        "## 作者信息\n",
+        "\n",
+        "- GitHub: @healer-666\n"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## 环境配置\n",
+        "\n",
+        "首次运行前,请先在项目目录执行:\n",
+        "\n",
+        "```bash\n",
+        "pip install -r requirements.txt\n",
+        "```\n",
+        "\n",
+        "并根据 `.env.example` 创建 `.env`,填写可用的模型配置。\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": 1,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "from __future__ import annotations\n",
+        "\n",
+        "from pathlib import Path\n",
+        "import sys\n",
+        "\n",
+        "from IPython.display import Markdown, display\n",
+        "\n",
+        "PROJECT_ROOT = Path.cwd()\n",
+        "SRC_PATH = PROJECT_ROOT / \"src\"\n",
+        "OUTPUT_ROOT = PROJECT_ROOT / \"outputs\"\n",
+        "OUTPUT_ROOT.mkdir(exist_ok=True)\n",
+        "\n",
+        "if str(SRC_PATH) not in sys.path:\n",
+        "    sys.path.insert(0, str(SRC_PATH))\n",
+        "\n",
+        "from data_analysis_agent.agent_runner import run_analysis\n",
+        "from data_analysis_agent.presentation import render_trace_table, render_full_report, render_diagnostics\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": 2,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "def render_run_summary(result, title: str):\n",
+        "    methods = \", \".join(result.methods_used) if result.methods_used else \"unknown\"\n",
+        "    tools = \", \".join(result.tools_used) if result.tools_used else \"unknown\"\n",
+        "    summary_md = f\"\"\"\n",
+        "## {title}\n",
+        "\n",
+        "- 输入类型: `{result.input_kind}`\n",
+        "- 数据路径: `{result.data_context.absolute_path.as_posix()}`\n",
+        "- 数据规模: `{result.data_context.shape[0]} x {result.data_context.shape[1]}`\n",
+        "- 识别领域: `{result.detected_domain}`\n",
+        "- 使用工具: `{tools}`\n",
+        "- 分析方法: `{methods}`\n",
+        "- 文档解析状态: `{result.document_ingestion_status}`\n",
+        "- 候选表数量: `{result.candidate_table_count}`\n",
+        "- 选中主表: `{result.selected_table_id or 'not_applicable'}`\n",
+        "- PDF 多表综合: `{result.pdf_multi_table_mode}`\n",
+        "- 报告路径: `{result.report_path.as_posix()}`\n",
+        "- Trace 路径: `{result.trace_path.as_posix()}`\n",
+        "- 审稿状态: `{result.review_status}`\n",
+        "- 总耗时: `{result.total_duration_ms / 1000:.2f}s`\n",
+        "\"\"\"\n",
+        "    display(Markdown(summary_md))\n"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## 示例 1:表格数据分析\n",
+        "\n",
+        "这里使用一个轻量的 Excel 样例,演示标准表格分析路径。社区版默认使用 `draft + auto`,尽量减少等待时间。\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": 3,
+      "metadata": {},
+      "outputs": [
+        {
+          "name": "stdout",
+          "output_type": "stream",
+          "text": [
+            "[1/7] Loading runtime configuration...\n",
+            "      Model: deepseek-chat | Tavily credential: detected\n",
+            "      Latency mode: auto\n",
+            "      Vision review: configured\n",
+            "[2/7] Created production run directory...\n",
+            "      Run root: c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130434\n",
+            "[3/7] Running input document ingestion...\n",
+            "      Input kind: tabular\n",
+            "      Document ingestion skipped: input is already tabular.\n",
+            "[4/7] Building compact dataset metadata context...\n",
+            "      Data shape: 1070 rows x 31 columns\n",
+            "[5/7] Tool registry ready: PythonInterpreterTool, TavilySearchTool\n",
+            "      Fast path: True | effective max steps: 3\n",
+            "[6/7] Advanced Data Analyst started reasoning (max steps = 3)\n",
+            "      Analysis round: 1\n",
+            "      Step 1/3: thinking...\n",
+            "      Calling PythonInterpreterTool | Stage 1: Load the raw Excel file, inspect data quality, clean missing values and column names, and save cleaned dataset.\n",
+            "      Completed PythonInterpreterTool | status = success\n",
+            "        Observation: Loading raw Excel file... Raw shape: (1070, 31) Raw columns: ['序号', '孕妇代码', '年龄', '身高', '体重', '末次月经', 'IVF妊娠', '检测日期', '检测抽血次数', '检测孕周', '孕妇BMI', '原始读段数', '在参考基因组上比对的比例', '重复读段的比例', '唯一比对的读段数 ', 'GC含量', '13号染色体的Z值', '18号\n",
+            "      Step 2/3: thinking...\n",
+            "      Calling PythonInterpreterTool | Stage 2: Load cleaned data, perform statistical analysis, generate visualizations, and save figures.\n"
+          ]
+        },
+        {
+          "name": "stderr",
+          "output_type": "stream",
+          "text": [
+            "WARNING:matplotlib.legend:No artists with labels found to put in legend.  Note that artists whose label start with an underscore are ignored when legend() is called with no argument.\n"
+          ]
+        },
+        {
+          "name": "stdout",
+          "output_type": "stream",
+          "text": [
+            "      Completed PythonInterpreterTool | status = success\n",
+            "        Observation: Loaded cleaned data shape: (1070, 31) Columns: ['序号', '孕妇代码', '年龄', '身高', '体重', '末次月经', 'IVF妊娠', '检测日期', '检测抽血次数', '检测孕周', '孕妇BMI', '原始读段数', '在参考基因组上比对的比例', '重复读段的比例', '唯一比对的读段数', 'GC含量', '13号染色体的Z值', '18号染色体的Z值', '21号染色\n",
+            "      Step 3/3: thinking...\n",
+            "      Final report generated successfully.\n",
+            "[7/7] Saving Markdown report and run trace...\n",
+            "      Final report: c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130434/final_report.md\n",
+            "      Agent trace: c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130434/logs/agent_trace.json\n",
+            "[8/8] Production artifact validation passed.\n"
+          ]
+        },
+        {
+          "data": {
+            "text/markdown": [
+              "\n",
+              "## 表格案例运行摘要\n",
+              "\n",
+              "- 输入类型: `tabular`\n",
+              "- 数据路径: `C:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/data/sample_table.xlsx`\n",
+              "- 数据规模: `1070 x 31`\n",
+              "- 识别领域: `生物医学/无创产前检测(NIPT)`\n",
+              "- 使用工具: `PythonInterpreterTool`\n",
+              "- 分析方法: `数据清洗(缺失值处理、类型转换), 描述性统计, Shapiro-Wilk正态性检验, Mann-Whitney U检验, 效应量计算(秩二列相关系数), Bootstrap置信区间估计, Pearson相关性分析, 箱线图, 热力图, 分布直方图`\n",
+              "- 文档解析状态: `not_needed`\n",
+              "- 候选表数量: `0`\n",
+              "- 选中主表: `not_applicable`\n",
+              "- PDF 多表综合: `False`\n",
+              "- 报告路径: `c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130434/final_report.md`\n",
+              "- Trace 路径: `c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130434/logs/agent_trace.json`\n",
+              "- 审稿状态: `skipped`\n",
+              "- 总耗时: `110.79s`\n"
+            ],
+            "text/plain": [
+              "<IPython.core.display.Markdown object>"
+            ]
+          },
+          "metadata": {},
+          "output_type": "display_data"
+        }
+      ],
+      "source": [
+        "tabular_result = run_analysis(\n",
+        "    data_path=PROJECT_ROOT / \"data\" / \"sample_table.xlsx\",\n",
+        "    output_dir=OUTPUT_ROOT,\n",
+        "    quality_mode=\"draft\",\n",
+        "    latency_mode=\"auto\",\n",
+        "    verbose=True,\n",
+        ")\n",
+        "render_run_summary(tabular_result, \"表格案例运行摘要\")\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": 4,
+      "metadata": {},
+      "outputs": [
+        {
+          "data": {
+            "text/html": [
+              "\n",
+              "    <h2>Agent 推理轨迹表</h2>\n",
+              "    <table style=\"width:100%; border-collapse:collapse; font-size:14px;\">\n",
+              "      <thead>\n",
+              "        <tr style=\"background:#f3f4f6;\">\n",
+              "          <th style=\"border:1px solid #d1d5db; padding:8px;\">Step</th>\n",
+              "          <th style=\"border:1px solid #d1d5db; padding:8px;\">Stage / Tool</th>\n",
+              "          <th style=\"border:1px solid #d1d5db; padding:8px;\">Decision</th>\n",
+              "          <th style=\"border:1px solid #d1d5db; padding:8px;\">Status</th>\n",
+              "          <th style=\"border:1px solid #d1d5db; padding:8px;\">Short Observation</th>\n",
+              "          <th style=\"border:1px solid #d1d5db; padding:8px;\">Notes</th>\n",
+              "        </tr>\n",
+              "      </thead>\n",
+              "      <tbody>\n",
+              "        \n",
+              "            <tr>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">1</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">本地 Python 分析 (PythonInterpreterTool)</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">Stage 1: Load the raw Excel file, inspect data quality, clean missing values and column names, and save cleaned dataset.</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">成功</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">Loading raw Excel file... Raw shape: (1070, 31) Raw columns: [&#x27;序号&#x27;, &#x27;孕妇代码&#x27;, &#x27;年龄&#x27;, &#x27;身高&#x27;, &#x27;体重&#x27;, &#x27;末次月经&#x27;, &#x27;IVF妊娠&#x27;, &#x27;检测日期&#x27;, &#x27;检测抽血次数&#x27;, &#x27;检测孕周&#x27;, &#x27;孕妇BMI&#x27;, &#x27;原始读段数&#x27;, &#x27;在参考基因组上比对的比例&#x27;, &#x27;重复读段的比例&#x27;, &#x27;唯一比对的读段数 &#x27;, &#x27;GC含量&#x27;, &#x27;13号染色体的Z值&#x27;, &#x27;18号</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">Local Python execution | status=success | decision=Stage 1: Load the raw Excel file, inspect data quality, clean missing values and column names, and save cleaned dataset. | observation=Loading raw Excel file... Raw shape: (1070, 31) Raw columns: [&#x27;序号&#x27;, &#x27;孕妇代码&#x27;, &#x27;年龄&#x27;, &#x27;身高&#x27;, &#x27;体重&#x27;, &#x27;末次月经&#x27;, &#x27;IVF妊娠&#x27;, &#x27;检测日期&#x27;, &#x27;检测抽血次数&#x27;, &#x27;检测孕周&#x27;, &#x27;孕妇BMI&#x27;, &#x27;原始读段数&#x27;, &#x27;在参考基因组上比对的比例&#x27;, &#x27;重复读段的比例&#x27;, &#x27;唯一比对的读段数 &#x27;, &#x27;GC含量&#x27;, &#x27;13号染色体的Z值&#x27;, &#x27;18号</td>\n",
+              "            </tr>\n",
+              "            \n",
+              "            <tr>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">2</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">本地 Python 分析 (PythonInterpreterTool)</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">Stage 2: Load cleaned data, perform statistical analysis, generate visualizations, and save figures.</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">成功</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">Loaded cleaned data shape: (1070, 31) Columns: [&#x27;序号&#x27;, &#x27;孕妇代码&#x27;, &#x27;年龄&#x27;, &#x27;身高&#x27;, &#x27;体重&#x27;, &#x27;末次月经&#x27;, &#x27;IVF妊娠&#x27;, &#x27;检测日期&#x27;, &#x27;检测抽血次数&#x27;, &#x27;检测孕周&#x27;, &#x27;孕妇BMI&#x27;, &#x27;原始读段数&#x27;, &#x27;在参考基因组上比对的比例&#x27;, &#x27;重复读段的比例&#x27;, &#x27;唯一比对的读段数&#x27;, &#x27;GC含量&#x27;, &#x27;13号染色体的Z值&#x27;, &#x27;18号染色体的Z值&#x27;, &#x27;21号染色</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">Local Python execution | status=success | decision=Stage 2: Load cleaned data, perform statistical analysis, generate visualizations, and save figures. | observation=Loaded cleaned data shape: (1070, 31) Columns: [&#x27;序号&#x27;, &#x27;孕妇代码&#x27;, &#x27;年龄&#x27;, &#x27;身高&#x27;, &#x27;体重&#x27;, &#x27;末次月经&#x27;, &#x27;IVF妊娠&#x27;, &#x27;检测日期&#x27;, &#x27;检测抽血次数&#x27;, &#x27;检测孕周&#x27;, &#x27;孕妇BMI&#x27;, &#x27;原始读段数&#x27;, &#x27;在参考基因组上比对的比例&#x27;, &#x27;重复读段的比例&#x27;, &#x27;唯一比对的读段数&#x27;, &#x27;GC含量&#x27;, &#x27;13号染色体的Z值&#x27;, &#x27;18号染色体的Z值&#x27;, &#x27;21号染色</td>\n",
+              "            </tr>\n",
+              "            \n",
+              "            <tr>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">3</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">最终报告</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">Analysis complete. Generate final report with APA-style statistics, effect sizes, and telemetry.</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">成功</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">无</td>\n",
+              "              <td style=\"border:1px solid #d1d5db; padding:8px; vertical-align:top;\">Generated final Markdown report: Analysis complete. Generate final report with APA-style statistics, effect sizes, and telemetry.</td>\n",
+              "            </tr>\n",
+              "            \n",
+              "      </tbody>\n",
+              "    </table>\n",
+              "    "
+            ],
+            "text/plain": [
+              "<IPython.core.display.HTML object>"
+            ]
+          },
+          "execution_count": 4,
+          "metadata": {},
+          "output_type": "execute_result"
+        }
+      ],
+      "source": [
+        "render_trace_table(tabular_result)\n"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## 示例 2:PDF 文献分析\n",
+        "\n",
+        "这里使用一篇小型样例论文。当前版本会:\n",
+        "\n",
+        "- 抽取文献背景\n",
+        "- 识别候选表\n",
+        "- 自动选择一张主表做定量分析\n",
+        "- 将其他候选表作为上下文证据参与报告解释\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": 5,
+      "metadata": {},
+      "outputs": [
+        {
+          "name": "stdout",
+          "output_type": "stream",
+          "text": [
+            "[1/7] Loading runtime configuration...\n",
+            "      Model: deepseek-chat | Tavily credential: detected\n",
+            "      Latency mode: auto\n",
+            "      Vision review: configured\n",
+            "[2/7] Created production run directory...\n",
+            "      Run root: c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130655\n",
+            "[3/7] Running input document ingestion...\n",
+            "      Input kind: pdf\n",
+            "      Document ingestion completed | status = completed\n",
+            "      Summary: PDF 文档解析完成:共提取 2 张候选表,已选择 table_01 作为主表写入 cleaned_data.csv,其余候选表会与文献背景一起作为综合解释上下文。\n",
+            "[4/7] Building compact dataset metadata context...\n",
+            "      Data shape: 7 rows x 5 columns\n",
+            "[5/7] Tool registry ready: PythonInterpreterTool\n",
+            "      Fast path: True | effective max steps: 3\n",
+            "[6/7] Advanced Data Analyst started reasoning (max steps = 3)\n",
+            "      Analysis round: 1\n",
+            "      Step 1/3: thinking...\n",
+            "      Calling PythonInterpreterTool | Stage 1: Load raw PDF table, inspect, clean column names, handle missing values, save cleaned dataset.\n",
+            "      Completed PythonInterpreterTool | status = success\n",
+            "        Observation: Raw data shape: (7, 5) Raw data columns: ['Model Configuration', 'Precision', 'Recall', 'mAP50', 'mAP50-95'] Raw data dtypes: Model Configuration object Precision float64 Recall float64 mAP50 float64 mAP50-95 float64 dty\n",
+            "      Step 2/3: thinking...\n",
+            "      Calling PythonInterpreterTool | Stage 2: Load cleaned data, compute descriptive statistics, rankings, bootstrap confidence intervals, cautious correlation analysis, generate 2-4 light visualizations.\n",
+            "      Completed PythonInterpreterTool | status = success\n",
+            "        Observation: === Loaded cleaned data === Model Configuration Precision Recall mAP50 mAP50_95 0 YOLOv5 0.95920 0.86365 0.93806 0.67804 1 YOLOv6 0.84015 0.80368 0.81593 0.59957 2 YOLOv7 0.73430 0.89090 0.89700 0.67450 3 YOLOv8 0.95006 \n",
+            "      Step 3/3: thinking...\n",
+            "      Final report generated successfully.\n",
+            "[7/7] Saving Markdown report and run trace...\n",
+            "      Final report: c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130655/final_report.md\n",
+            "      Agent trace: c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130655/logs/agent_trace.json\n",
+            "[8/8] Production artifact validation passed.\n"
+          ]
+        },
+        {
+          "data": {
+            "text/markdown": [
+              "\n",
+              "## PDF 案例运行摘要\n",
+              "\n",
+              "- 输入类型: `pdf`\n",
+              "- 数据路径: `C:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130655/data/extracted_tables/table_01.csv`\n",
+              "- 数据规模: `7 x 5`\n",
+              "- 识别领域: `computer_vision_object_detection`\n",
+              "- 使用工具: `PythonInterpreterTool`\n",
+              "- 分析方法: `descriptive_statistics, ranking, bootstrap_confidence_intervals, spearman_correlation, visualization`\n",
+              "- 文档解析状态: `completed`\n",
+              "- 候选表数量: `2`\n",
+              "- 选中主表: `table_01`\n",
+              "- PDF 多表综合: `True`\n",
+              "- 报告路径: `c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130655/final_report.md`\n",
+              "- Trace 路径: `c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130655/logs/agent_trace.json`\n",
+              "- 审稿状态: `skipped`\n",
+              "- 总耗时: `122.89s`\n"
+            ],
+            "text/plain": [
+              "<IPython.core.display.Markdown object>"
+            ]
+          },
+          "metadata": {},
+          "output_type": "display_data"
+        }
+      ],
+      "source": [
+        "pdf_result = run_analysis(\n",
+        "    data_path=PROJECT_ROOT / \"data\" / \"sample_paper.pdf\",\n",
+        "    output_dir=OUTPUT_ROOT,\n",
+        "    quality_mode=\"draft\",\n",
+        "    latency_mode=\"auto\",\n",
+        "    document_ingestion_mode=\"auto\",\n",
+        "    verbose=True,\n",
+        ")\n",
+        "render_run_summary(pdf_result, \"PDF 案例运行摘要\")\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": 6,
+      "metadata": {},
+      "outputs": [
+        {
+          "data": {
+            "text/markdown": [
+              "## 完整报告正文\n",
+              "\n",
+              "# 小样本目标检测模型性能对比分析报告\n",
+              "\n",
+              "## 数据概览\n",
+              "本数据集来源于一篇关于目标检测模型改进的学术论文(PDF 提取表)。数据包含 7 种 YOLO 系列模型配置(YOLOv5、YOLOv6、YOLOv7、YOLOv8、YOLOv9、YOLOv11、YOLOv8‑BFDS)在四个关键性能指标上的表现:\n",
+              "- **Precision**(精确率)\n",
+              "- **Recall**(召回率)\n",
+              "- **mAP50**(平均精度,IoU 阈值为 0.5)\n",
+              "- **mAP50‑95**(平均精度,IoU 阈值从 0.5 到 0.95 的平均值)\n",
+              "\n",
+              "数据规模为 7 行 × 5 列,属于典型的小样本结果表(N=7)。原始数据无缺失值,已进行列名标准化(将 `mAP50-95` 改为 `mAP50_95`)并保存为清洗后 CSV。\n",
+              "\n",
+              "## 方法说明\n",
+              "鉴于样本量极小(N=7),本次分析严格遵循小样本分析原则:\n",
+              "1. **描述性统计**:计算各指标的均值、标准差、四分位数。\n",
+              "2. **排序与排名**:按各指标从高到低排列模型。\n",
+              "3. **Bootstrap 置信区间**:采用非参数 Bootstrap(5000 次重抽样,百分位法)估计各指标总体均值的 95% 置信区间,避免对分布形态的假设。\n",
+              "4. **相关性分析**:使用 Spearman 秩相关系数(非参数)评估指标间的单调关联,同时提供 Pearson 相关系数作为参考。\n",
+              "5. **可视化**:生成 4 幅轻量图表,直观展示模型间性能差异与指标间关系。\n",
+              "\n",
+              "**统计学治理说明**:\n",
+              "- 未进行假设检验(如 t 检验、ANOVA),因为每个模型仅有一个观测值,不具备重复测量或实验组内重复,不符合群体比较的前提。\n",
+              "- 所有结论均基于描述性统计与 Bootstrap 区间,避免过度推断。\n",
+              "- 相关性分析仅提示关联,不暗示因果关系。\n",
+              "\n",
+              "## 核心假设检验结论\n",
+              "**本分析未执行传统假设检验**,原因如下:\n",
+              "- 每个模型配置仅有一个观测值,无法估计组内变异,因此不能进行组间显著性检验。\n",
+              "- 小样本(N=7)下,任何参数检验的统计功效极低,且正态假设难以验证。\n",
+              "- 遵循《PDF_Small_Table_Mode》指导,仅进行描述性、排序、Bootstrap 区间及相关性分析。\n",
+              "\n",
+              "替代的量化结论如下:\n",
+              "\n",
+              "### 1. Bootstrap 95% 置信区间(各指标总体均值)\n",
+              "| 指标 | 样本均值 | 95% CI (Bootstrap) |\n",
+              "|------|----------|---------------------|\n",
+              "| Precision | 0.8983 | [0.8462, 0.9501] |\n",
+              "| Recall | 0.8647 | [0.8063, 0.9232] |\n",
+              "| mAP50 | 0.9018 | [0.8519, 0.9516] |\n",
+              "| mAP50_95 | 0.6733 | [0.5996, 0.7471] |\n",
+              "\n",
+              "**解读**:所有指标的置信区间均较宽,反映小样本下估计的不确定性。mAP50_95 的区间下限最低(约 0.60),提示该指标在不同模型间波动较大。\n",
+              "\n",
+              "### 2. 模型排名(按各指标降序)\n",
+              "- **Precision**:YOLOv8‑BFDS (0.970) > YOLOv5 (0.959) > YOLOv8 (0.950) > YOLOv9 (0.946) > YOLOv11 (0.888) > YOLOv6 (0.840) > YOLOv7 (0.734)\n",
+              "- **Recall**:YOLOv8‑BFDS (0.990) > YOLOv11 (0.899) > YOLOv7 (0.891) > YOLOv5 (0.864) > YOLOv9 (0.808) > YOLOv6 (0.804) > YOLOv8 (0.799)\n",
+              "- **mAP50**:YOLOv8‑BFDS (0.991) > YOLOv11 (0.947) > YOLOv5 (0.938) > YOLOv7 (0.897) > YOLOv8 (0.868) > YOLOv9 (0.855) > YOLOv6 (0.816)\n",
+              "- **mAP50_95**:YOLOv8‑BFDS (0.836) > YOLOv11 (0.699) > YOLOv5 (0.678) > YOLOv7 (0.675) > YOLOv8 (0.622) > YOLOv9 (0.604) > YOLOv6 (0.600)\n",
+              "\n",
+              "**综合表现最佳**:YOLOv8‑BFDS 在四项指标上均排名第一,且领先幅度明显。\n",
+              "\n",
+              "### 3. Spearman 秩相关系数矩阵\n",
+              "| | Precision | Recall | mAP50 | mAP50_95 |\n",
+              "|-------------|-----------|--------|-------|----------|\n",
+              "| Precision  | 1.000 | 0.179 | 0.536 | 0.714 |\n",
+              "| Recall     | 0.179 | 1.000 | 0.536 | 0.536 |\n",
+              "| mAP50      | 0.536 | 0.536 | 1.000 | 0.893 |\n",
+              "| mAP50_95   | 0.714 | 0.536 | 0.893 | 1.000 |\n",
+              "\n",
+              "**解读**:\n",
+              "- mAP50 与 mAP50_95 呈现强正相关(ρ=0.893),说明两者变化趋势高度一致。\n",
+              "- Precision 与 mAP50_95 呈中度正相关(ρ=0.714)。\n",
+              "- Recall 与 Precision 几乎无单调关联(ρ=0.179),提示在这组模型中,高精确率不一定伴随高召回率,反之亦然。\n",
+              "\n",
+              "## 结果解释\n",
+              "1. **YOLOv8‑BFDS 全面领先**:该模型在四项指标上均位列第一,尤其 Recall (0.990) 与 mAP50 (0.991) 接近完美,与文献背景中“增强的 YOLOv8 模型集成 DCNv2、E‑SEModule、Concat_BiFPN 等优化模块”的描述相符,说明其改进有效提升了检测精度与召回。\n",
+              "2. **传统模型表现分化**:YOLOv5 在 Precision 和 mAP50 上表现优异(第二、三位),但 Recall 仅居中;YOLOv7 在 Recall 上较高(第三),但 Precision 最低(0.734)。这种分化反映了不同模型架构在精度‑召回权衡上的差异。\n",
+              "3. **mAP50_95 整体较低**:所有模型的 mAP50_95 均低于 0.84,且 Bootstrap 区间下限仅约 0.60,说明在更严格的 IoU 阈值范围内,模型性能仍有较大提升空间。\n",
+              "4. **相关性提示**:mAP50 与 mAP50_95 强相关,可视为一致性验证;Precision 与 Recall 几乎无关,符合目标检测中常存在的“精度‑召回 trade‑off”现象。\n",
+              "\n",
+              "## 讨论\n",
+              "### 与文献背景的关联\n",
+              "- 背景文献提到“YOLOv8‑BFDS 集成 DCNv2、E‑SEModule、Concat_BiFPN 等模块以提升对变形、遮挡、小目标及低对比度物体的检测能力”。本数据中 YOLOv8‑BFDS 在 Recall(0.990)和 mAP50(0.991)上显著高于其他模型,间接支持了这些模块的有效性。\n",
+              "- 文献亦提及“CB‑YOLOv5s 通过双向通道解决目标相互遮挡及背景相似导致的低检测精度问题”。本表中未包含 CB‑YOLOv5s,但 YOLOv5 本身在 Precision 和 mAP50 上表现良好,说明基础 YOLOv5 已有较强检测能力,改进版本可能在此基础上进一步提升。\n",
+              "\n",
+              "### 方法学局限\n",
+              "1. **样本量极小**:仅 7 个模型,每个模型仅单次观测,无法进行统计检验,所有结论均为描述性。\n",
+              "2. **缺乏重复测量**:未提供同一模型在多组数据上的性能变异,因此无法评估模型稳定性。\n",
+              "3. **数据来源单一**:仅来自一篇论文的单个结果表,可能存在选择性报告偏差。\n",
+              "4. **未控制实验条件**:不同模型的训练数据、超参数、硬件环境可能不同,影响直接可比性。\n",
+              "\n",
+              "### 与候选表的交叉验证\n",
+              "PDF 中另一候选表(table_02)包含 YOLOv8 与 YOLOv8+BiFPN 的对比,其中 YOLOv8+BiFPN 的 Precision、Recall、mAP50 分别为 0.973、0.983、0.988,均高于本表中 YOLOv8 的对应值(0.950、0.799、0.868)。这进一步支持了 BiFPN 结构对性能的提升作用,与本表中 YOLOv8‑BFDS(含 Concat_BiFPN)表现最优的趋势一致。\n",
+              "\n",
+              "## 清洗后数据路径\n",
+              "`c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130655/data/cleaned_data.csv`\n",
+              "\n",
+              "## 图表引用\n",
+              "1. **各模型精确率与召回率对比**  \n",
+              "![精确率与召回率对比](c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130655/figures/review_round_1/bar_precision_recall.png)\n",
+              "2. **精确率 vs 召回率散点图**  \n",
+              "![精确率 vs 召回率散点图](c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130655/figures/review_round_1/scatter_precision_recall.png)\n",
+              "3. **Spearman 秩相关热图**  \n",
+              "![Spearman 秩相关热图](c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130655/figures/review_round_1/heatmap_spearman_corr.png)\n",
+              "4. **多指标平行坐标图**  \n",
+              "![多指标平行坐标图](c:/Users/pc/OneDrive/Desktop/agent/Co-creation-projects/healer-666-Academic-Data-Agent/outputs/run_20260317_130655/figures/review_round_1/parallel_coordinates.png)"
+            ],
+            "text/plain": [
+              "<IPython.core.display.Markdown object>"
+            ]
+          },
+          "execution_count": 6,
+          "metadata": {},
+          "output_type": "execute_result"
+        }
+      ],
+      "source": [
+        "render_full_report(pdf_result)\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": 7,
+      "metadata": {},
+      "outputs": [
+        {
+          "data": {
+            "text/html": [
+              "<h2>错误与诊断详情</h2><p>本次运行无工具级异常。</p>"
+            ],
+            "text/plain": [
+              "<IPython.core.display.HTML object>"
+            ]
+          },
+          "execution_count": 7,
+          "metadata": {},
+          "output_type": "execute_result"
+        }
+      ],
+      "source": [
+        "render_diagnostics(pdf_result)\n"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## 总结与展望\n",
+        "\n",
+        "这个社区版演示重点展示三件事:\n",
+        "\n",
+        "- 结构化表格的自动分析能力\n",
+        "- PDF 文献中的候选表提取与主表分析能力\n",
+        "- 运行产物、报告与 trace 的可追踪性\n",
+        "\n",
+        "完整版项目还提供了 Gradio 工作台、历史记录浏览、视觉审稿和更完整的工程能力。\n"
+      ]
+    }
+  ],
+  "metadata": {
+    "kernelspec": {
+      "display_name": "agent_env",
+      "language": "python",
+      "name": "python3"
+    },
+    "language_info": {
+      "codemirror_mode": {
+        "name": "ipython",
+        "version": 3
+      },
+      "file_extension": ".py",
+      "mimetype": "text/x-python",
+      "name": "python",
+      "nbconvert_exporter": "python",
+      "pygments_lexer": "ipython3",
+      "version": "3.10.20"
+    }
+  },
+  "nbformat": 4,
+  "nbformat_minor": 5
+}

+ 19 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/requirements.txt

@@ -0,0 +1,19 @@
+hello-agents[all]>=0.2.7
+openai>=1.0.0,<2.0.0
+python-dotenv>=1.0.0,<2.0.0
+tiktoken>=0.12.0,<1.0.0
+pandas>=2.1.4,<2.3.0
+numpy>=1.24.4,<2.0.0
+scipy>=1.11.4,<1.13.0
+matplotlib>=3.8.2,<3.9.0
+seaborn>=0.13.2,<0.14.0
+statsmodels>=0.14.1,<0.15.0
+tabulate>=0.9.0,<1.0.0
+openpyxl>=3.1.0,<4.0.0
+xlrd>=2.0.1,<3.0.0
+pdfplumber>=0.11.0,<1.0.0
+pypdf>=4.0.0,<5.0.0
+reportlab>=4.0.0,<5.0.0
+Pillow>=10.4.0,<11.0.0
+jupyter>=1.0.0,<2.0.0
+notebook>=7.0.0,<8.0.0

+ 24 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/__init__.py

@@ -0,0 +1,24 @@
+"""DataAnalysisAgent package."""
+
+from .agent_runner import AnalysisRunResult, ScientificReActRunner, run_analysis
+from .config import RuntimeConfig, apply_token_counter_patch, load_runtime_config
+from .data_context import DataContextSummary, build_data_context
+from .presentation import render_diagnostics, render_full_report, render_trace_table
+from .tools.python_interpreter import PythonInterpreterTool
+from .tools.tavily_search import TavilySearchTool
+
+__all__ = [
+    "AnalysisRunResult",
+    "DataContextSummary",
+    "PythonInterpreterTool",
+    "RuntimeConfig",
+    "ScientificReActRunner",
+    "TavilySearchTool",
+    "apply_token_counter_patch",
+    "build_data_context",
+    "load_runtime_config",
+    "render_diagnostics",
+    "render_full_report",
+    "render_trace_table",
+    "run_analysis",
+]

+ 1809 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/agent_runner.py

@@ -0,0 +1,1809 @@
+"""Custom agent runner and scientific ReAct controller."""
+
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import re
+import time
+from dataclasses import asdict, dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Callable, Optional
+
+from hello_agents import ToolRegistry
+
+from .config import RuntimeConfig, load_runtime_config
+from .data_context import DataContextSummary, build_data_context
+from .document_ingestion import IngestionResult, ingest_input_document
+from .llm import build_llm
+from .prompts import (
+    DEFAULT_QUERY,
+    build_observation_prompt,
+    build_reviewer_prompt,
+    build_response_format_feedback,
+    build_system_prompt,
+)
+from .reporting import (
+    ReportTelemetry,
+    extract_report_and_telemetry,
+    save_markdown_report,
+)
+from .tools.python_interpreter import PythonInterpreterTool
+from .tools.tavily_search import TavilySearchTool
+from .vision_review import VisualReviewResult, run_visual_review
+
+
+EventHandler = Callable[[str, dict[str, Any]], None]
+
+
+@dataclass(frozen=True)
+class AgentStepTrace:
+    step_index: int
+    raw_response: str
+    action: str
+    decision: str = ""
+    tool_name: Optional[str] = None
+    tool_status: str = "unknown"
+    observation: Optional[str] = None
+    observation_preview: str = ""
+    summary: str = ""
+    parse_error: Optional[str] = None
+    llm_duration_ms: int = 0
+    tool_duration_ms: int = 0
+
+
+@dataclass(frozen=True)
+class ParsedAgentReply:
+    action: str
+    decision: str
+    tool_name: str = ""
+    tool_input: str = ""
+    final_answer: str = ""
+
+
+@dataclass(frozen=True)
+class ParsedReviewerReply:
+    decision: str
+    critique: str
+    raw_response: str = ""
+
+
+@dataclass(frozen=True)
+class ArtifactValidationResult:
+    workflow_complete: bool
+    missing_artifacts: tuple[str, ...]
+    warnings: tuple[str, ...]
+    cleaned_data_exists: bool
+    report_exists: bool
+    trace_exists: bool
+
+
+@dataclass(frozen=True)
+class ReviewRecord:
+    round_index: int
+    decision: str
+    critique: str
+    raw_response: str
+    review_log_path: Path
+    candidate_report_path: Path
+
+
+@dataclass(frozen=True)
+class VisualReviewRecord:
+    round_index: int
+    status: str
+    decision: str
+    summary: str
+    figures_reviewed: tuple[str, ...]
+    skipped_figures: tuple[str, ...]
+    duration_ms: int
+    raw_response: str
+    warning: str
+    log_path: Path
+
+
+@dataclass(frozen=True)
+class AnalystRoundRecord:
+    round_index: int
+    report_path: Path
+    step_traces: tuple[AgentStepTrace, ...]
+
+
+@dataclass(frozen=True)
+class AnalysisRunResult:
+    data_context: DataContextSummary
+    raw_result: str
+    report_markdown: str
+    report_path: Path
+    output_dir: Path
+    run_dir: Path
+    data_dir: Path
+    figures_dir: Path
+    logs_dir: Path
+    trace_path: Path
+    cleaned_data_path: Path
+    agent_type: str
+    step_traces: tuple[AgentStepTrace, ...]
+    telemetry: ReportTelemetry
+    methods_used: tuple[str, ...]
+    detected_domain: str
+    tools_used: tuple[str, ...]
+    search_status: str
+    search_notes: str
+    workflow_complete: bool
+    workflow_warnings: tuple[str, ...]
+    missing_artifacts: tuple[str, ...]
+    quality_mode: str
+    review_enabled: bool
+    review_status: str
+    review_rounds_used: int
+    review_critique: str
+    review_log_paths: tuple[Path, ...]
+    input_kind: str = "tabular"
+    document_ingestion_status: str = "not_needed"
+    document_ingestion_summary: str = ""
+    document_ingestion_duration_ms: int = 0
+    document_ingestion_log_path: Path | None = None
+    candidate_table_count: int = 0
+    selected_table_id: str = ""
+    selected_table_shape: tuple[int, int] | None = None
+    pdf_multi_table_mode: bool = False
+    latency_mode: str = "auto"
+    vision_review_mode: str = "auto"
+    vision_review_enabled: bool = False
+    vision_review_status: str = "skipped"
+    vision_review_summary: str = ""
+    vision_review_duration_ms: int = 0
+    vision_review_log_paths: tuple[Path, ...] = ()
+    total_duration_ms: int = 0
+    llm_duration_ms: int = 0
+    tool_duration_ms: int = 0
+    review_duration_ms: int = 0
+    timing_breakdown: dict[str, int] = field(default_factory=dict)
+
+
+def _emit_event(event_handler: Optional[EventHandler], event_type: str, **payload: Any) -> None:
+    if event_handler is not None:
+        event_handler(event_type, payload)
+
+
+def build_plaintext_event_handler() -> EventHandler:
+    """Build a lightweight stdout event handler for notebooks and scripts."""
+
+    def handle_event(event_type: str, payload: dict[str, Any]) -> None:
+        if event_type == "config_loading":
+            print("[1/7] Loading runtime configuration...")
+        elif event_type == "config_loaded":
+            if payload.get("tavily_configured"):
+                print(f"      Model: {payload.get('model_id', 'unknown')} | Tavily credential: detected")
+            else:
+                print(f"      Model: {payload.get('model_id', 'unknown')} | Tavily search: skipped unless configured")
+            print(f"      Latency mode: {payload.get('latency_mode', 'auto')}")
+            print(f"      Vision review: {'configured' if payload.get('vision_configured') else 'not configured'}")
+        elif event_type == "run_directory_created":
+            print("[2/7] Created production run directory...")
+            print(f"      Run root: {payload.get('run_dir', '')}")
+        elif event_type == "document_ingestion_started":
+            print("[3/7] Running input document ingestion...")
+            print(f"      Input kind: {payload.get('input_kind', 'unknown')}")
+        elif event_type == "document_ingestion_completed":
+            print(f"      Document ingestion completed | status = {payload.get('status', 'unknown')}")
+            print(f"      Summary: {payload.get('summary', '')}")
+        elif event_type == "document_ingestion_skipped":
+            print("      Document ingestion skipped: input is already tabular.")
+        elif event_type == "data_context_loading":
+            print("[4/7] Building compact dataset metadata context...")
+        elif event_type == "data_context_ready":
+            shape = payload.get("shape", ("?", "?"))
+            print(f"      Data shape: {shape[0]} rows x {shape[1]} columns")
+        elif event_type == "tool_registry_ready":
+            print(f"[5/7] Tool registry ready: {', '.join(payload.get('tools', []))}")
+            print(
+                f"      Fast path: {payload.get('fast_path_enabled', False)} | "
+                f"effective max steps: {payload.get('effective_max_steps', '?')}"
+            )
+        elif event_type == "analysis_started":
+            print(f"[6/7] {payload.get('agent_name', 'Agent')} started reasoning (max steps = {payload.get('max_steps', '?')})")
+            if payload.get("analysis_round"):
+                print(f"      Analysis round: {payload.get('analysis_round')}")
+        elif event_type == "step_started":
+            print(f"      Step {payload.get('step_index', '?')}/{payload.get('max_steps', '?')}: thinking...")
+        elif event_type == "tool_call_started":
+            tool_name = payload.get("tool_name", "UnknownTool")
+            decision = payload.get("decision", "")
+            print(f"      Calling {tool_name} | {decision}")
+        elif event_type == "tool_call_completed":
+            print(f"      Completed {payload.get('tool_name', 'UnknownTool')} | status = {payload.get('tool_status', 'unknown')}")
+            preview = payload.get("observation_preview")
+            if preview:
+                print(f"        Observation: {preview}")
+        elif event_type == "step_parse_error":
+            print(f"      Protocol parse warning: {payload.get('message', '')}")
+        elif event_type == "report_persisting":
+            print("[7/7] Saving Markdown report and run trace...")
+        elif event_type == "report_saved":
+            print(f"      Final report: {payload.get('report_path', '')}")
+            print(f"      Agent trace: {payload.get('trace_path', '')}")
+        elif event_type == "artifact_validation_completed":
+            if payload.get("workflow_complete"):
+                print("[8/8] Production artifact validation passed.")
+            else:
+                print("[8/8] Production artifact validation failed.")
+                print(f"      Missing: {', '.join(payload.get('missing_artifacts', []))}")
+        elif event_type == "analysis_finished":
+            print("      Final report generated successfully.")
+        elif event_type == "analysis_max_steps":
+            print("      Agent hit the max-step limit and returned a fallback report.")
+        elif event_type == "vision_review_started":
+            print(f"      Vision reviewer round {payload.get('review_round', '?')} started.")
+        elif event_type == "vision_review_completed":
+            print(
+                f"      Vision reviewer completed | status = {payload.get('status', 'unknown')} | "
+                f"decision = {payload.get('decision', 'unknown')}"
+            )
+        elif event_type == "vision_review_skipped":
+            print(f"      Vision reviewer skipped: {payload.get('reason', '')}")
+        elif event_type == "review_started":
+            print(f"      Reviewer round {payload.get('review_round', '?')} started.")
+        elif event_type == "review_rejected":
+            print(f"      [REJECT] 审稿人意见:{payload.get('critique', '')}")
+        elif event_type == "review_accepted":
+            print("      [OK] 审稿通过:报告达到当前质量档位要求。")
+        elif event_type == "review_max_reached":
+            print("      Reviewer max rounds reached. Final report was not formally accepted.")
+
+    return handle_event
+
+
+def build_tool_registry(*, enable_search: bool = True) -> ToolRegistry:
+    """Create the tool registry for the analysis agent."""
+
+    tool_registry = ToolRegistry()
+    for deprecated_tool_name in ("DataCleaningTool", "DataStatisticsTool", "python_interpreter_tool"):
+        tool_registry._tools.pop(deprecated_tool_name, None)
+        tool_registry._functions.pop(deprecated_tool_name, None)
+
+    with contextlib.redirect_stdout(io.StringIO()):
+        tool_registry.register_tool(PythonInterpreterTool())
+        if enable_search:
+            tool_registry.register_tool(TavilySearchTool())
+    return tool_registry
+
+
+def _elapsed_ms(start_time: float) -> int:
+    return int(round((time.perf_counter() - start_time) * 1000))
+
+
+def _accumulate_duration(timing_breakdown: dict[str, int], key: str, duration_ms: int) -> None:
+    timing_breakdown[key] = timing_breakdown.get(key, 0) + max(0, int(duration_ms))
+
+
+def _truncate_text(text: str, limit: int) -> str:
+    normalized = str(text or "").strip()
+    if len(normalized) <= limit:
+        return normalized
+    return normalized[:limit].rstrip() + " ... [truncated]"
+
+
+def _build_observation_summary(
+    *,
+    tool_name: str,
+    observation: str,
+    tool_status: str,
+    observation_preview: str,
+) -> str:
+    try:
+        payload = json.loads(observation)
+    except Exception:
+        return (
+            f"Status: {tool_status}\n"
+            f"Preview: {_truncate_text(observation_preview or observation, 300)}"
+        )
+
+    text = str(payload.get("text", "")).strip()
+    data = payload.get("data", {})
+    parts = [
+        f"Status: {tool_status}",
+        f"Preview: {_truncate_text(observation_preview or text, 300)}",
+    ]
+
+    if tool_name == "PythonInterpreterTool" and isinstance(data, dict):
+        stdout_text = _truncate_text(str(data.get("stdout", "")).strip(), 1200)
+        stderr_text = _truncate_text(str(data.get("stderr", "")).strip(), 800)
+        warning_messages = data.get("warnings", [])
+        if stdout_text:
+            parts.append(f"Stdout:\n{stdout_text}")
+        if stderr_text:
+            parts.append(f"Stderr:\n{stderr_text}")
+        if isinstance(warning_messages, list) and warning_messages:
+            warnings_block = "\n".join(f"- {item}" for item in warning_messages[:5])
+            if len(warning_messages) > 5:
+                warnings_block += f"\n- ... {len(warning_messages) - 5} more warning(s) omitted."
+            parts.append(f"Warnings:\n{warnings_block}")
+        return "\n\n".join(parts)
+
+    if tool_name == "TavilySearchTool" and isinstance(data, dict):
+        query = str(data.get("query", "")).strip()
+        results = data.get("results", [])
+        if query:
+            parts.append(f"Query: {query}")
+        if isinstance(results, list) and results:
+            result_lines = []
+            for index, item in enumerate(results[:3], start=1):
+                if not isinstance(item, dict):
+                    continue
+                title = _truncate_text(str(item.get("title", "Untitled")).strip(), 80)
+                url = _truncate_text(str(item.get("url", "")).strip(), 120)
+                snippet_source = item.get("content", item.get("snippet", ""))
+                snippet = _truncate_text(str(snippet_source).strip(), 200)
+                line = f"{index}. {title}"
+                if url:
+                    line += f" | {url}"
+                if snippet:
+                    line += f" | {snippet}"
+                result_lines.append(line)
+            if result_lines:
+                parts.append("Top search results:\n" + "\n".join(result_lines))
+            if len(results) > 3:
+                parts.append(f"... {len(results) - 3} more result(s) omitted.")
+        return "\n\n".join(parts)
+
+    if text:
+        parts.append(f"Observation text:\n{_truncate_text(text, 1200)}")
+    return "\n\n".join(parts)
+
+
+def _resolve_latency_mode(latency_mode: str) -> str:
+    normalized_mode = latency_mode.strip().lower()
+    if normalized_mode not in {"auto", "quality", "fast"}:
+        raise ValueError(f"Unsupported latency_mode: {latency_mode}")
+    return normalized_mode
+
+
+def _resolve_vision_review_mode(vision_review_mode: str) -> str:
+    normalized_mode = vision_review_mode.strip().lower()
+    if normalized_mode not in {"off", "auto", "on"}:
+        raise ValueError(f"Unsupported vision_review_mode: {vision_review_mode}")
+    return normalized_mode
+
+
+def _is_small_simple_dataset(data_context: DataContextSummary) -> bool:
+    try:
+        file_size_bytes = data_context.absolute_path.stat().st_size
+    except OSError:
+        file_size_bytes = 0
+    rows, cols = data_context.shape
+    return file_size_bytes <= 512 * 1024 and rows <= 2000 and cols <= 50
+
+
+def _should_use_fast_path(latency_mode: str, *, small_simple_dataset: bool) -> bool:
+    return latency_mode == "fast" or (latency_mode == "auto" and small_simple_dataset)
+
+
+def _resolve_effective_max_steps(
+    *,
+    requested_max_steps: int,
+    quality_mode: str,
+    latency_mode: str,
+    small_simple_dataset: bool,
+) -> int:
+    if not _should_use_fast_path(latency_mode, small_simple_dataset=small_simple_dataset):
+        return requested_max_steps
+    caps = {
+        "draft": 3,
+        "standard": 4,
+        "publication": 5,
+    }
+    return min(requested_max_steps, caps[quality_mode])
+
+
+_SEARCH_SIGNAL_KEYWORDS = (
+    "clinical",
+    "biomarker",
+    "chromosome",
+    "nipt",
+    "cpi",
+    "ppv",
+    "odds ratio",
+    "临床",
+    "阈值",
+    "正常范围",
+    "染色体",
+    "宏观",
+    "统计口径",
+)
+
+
+def _should_enable_search(
+    *,
+    runtime_config: RuntimeConfig,
+    data_context: DataContextSummary,
+    query: str,
+    quality_mode: str,
+    latency_mode: str,
+) -> bool:
+    if not runtime_config.tavily_api_key:
+        return False
+    if latency_mode == "quality":
+        return True
+
+    searchable_text = " ".join([query, *data_context.columns])
+    lowered = searchable_text.lower()
+    if any(keyword in lowered for keyword in _SEARCH_SIGNAL_KEYWORDS):
+        return True
+    if re.search(r"\b[A-Z]{2,}[0-9A-Z/_-]*\b", searchable_text):
+        return True
+    return False
+
+
+def _extract_first_json_object(text: str) -> str:
+    stripped = text.strip()
+    if not stripped:
+        raise ValueError("Model returned an empty response.")
+
+    if stripped.startswith("```"):
+        fence_lines = stripped.splitlines()
+        if len(fence_lines) >= 3 and fence_lines[0].startswith("```") and fence_lines[-1].startswith("```"):
+            stripped = "\n".join(fence_lines[1:-1]).strip()
+
+    start = stripped.find("{")
+    if start == -1:
+        raise ValueError("No JSON object found in model response.")
+
+    depth = 0
+    in_string = False
+    escape = False
+
+    for index in range(start, len(stripped)):
+        char = stripped[index]
+        if escape:
+            escape = False
+            continue
+        if char == "\\":
+            escape = True
+            continue
+        if char == '"':
+            in_string = not in_string
+            continue
+        if in_string:
+            continue
+        if char == "{":
+            depth += 1
+        elif char == "}":
+            depth -= 1
+            if depth == 0:
+                return stripped[start : index + 1]
+
+    raise ValueError("Unterminated JSON object in model response.")
+
+
+def _parse_agent_reply(raw_response: str) -> ParsedAgentReply:
+    json_payload = _extract_first_json_object(raw_response)
+
+    try:
+        payload = json.loads(json_payload)
+    except json.JSONDecodeError as exc:
+        raise ValueError(f"Invalid JSON response: {exc}") from exc
+
+    if not isinstance(payload, dict):
+        raise ValueError("Model response JSON must be an object.")
+
+    action = str(payload.get("action", "")).strip().lower()
+    if action not in {"call_tool", "finish"}:
+        raise ValueError("Field 'action' must be either 'call_tool' or 'finish'.")
+
+    decision = str(payload.get("decision", "")).strip()
+
+    if action == "call_tool":
+        tool_name = str(payload.get("tool_name", "")).strip()
+        tool_input = str(payload.get("tool_input", "")).strip()
+        if not tool_name:
+            raise ValueError("Field 'tool_name' is required when action is 'call_tool'.")
+        if not tool_input:
+            raise ValueError("Field 'tool_input' is required when action is 'call_tool'.")
+        return ParsedAgentReply(
+            action=action,
+            decision=decision,
+            tool_name=tool_name,
+            tool_input=tool_input,
+            final_answer="",
+        )
+
+    final_answer = str(payload.get("final_answer", "")).strip()
+    if not final_answer:
+        raise ValueError("Field 'final_answer' is required when action is 'finish'.")
+
+    return ParsedAgentReply(
+        action=action,
+        decision=decision,
+        final_answer=final_answer,
+    )
+
+
+def _parse_reviewer_reply(raw_response: str) -> ParsedReviewerReply:
+    json_payload = _extract_first_json_object(raw_response)
+
+    try:
+        payload = json.loads(json_payload)
+    except json.JSONDecodeError as exc:
+        raise ValueError(f"Invalid reviewer JSON response: {exc}") from exc
+
+    if not isinstance(payload, dict):
+        raise ValueError("Reviewer response JSON must be an object.")
+
+    decision = str(payload.get("decision", "")).strip()
+    if decision not in {"Accept", "Reject"}:
+        raise ValueError("Reviewer field 'decision' must be either 'Accept' or 'Reject'.")
+
+    critique = str(payload.get("critique", "")).strip()
+    if not critique:
+        raise ValueError("Reviewer field 'critique' must be a non-empty string.")
+
+    return ParsedReviewerReply(decision=decision, critique=critique, raw_response=raw_response)
+
+
+def _safe_parse_reviewer_reply(raw_response: str) -> ParsedReviewerReply:
+    try:
+        return _parse_reviewer_reply(raw_response)
+    except ValueError as exc:
+        critique = (
+            "Reviewer response could not be parsed. Treat this as a rejection and revise the report. "
+            f"Parsing issue: {exc}"
+        )
+        return ParsedReviewerReply(decision="Reject", critique=critique, raw_response=raw_response)
+
+
+def _parse_tool_observation(observation: str) -> tuple[str, str]:
+    try:
+        payload = json.loads(observation)
+    except Exception:
+        preview = " ".join(observation.split())
+        return "unknown", preview[:220]
+
+    status = str(payload.get("status", "unknown")).strip() or "unknown"
+    preview = " ".join(str(payload.get("text", "")).split())
+    return status, preview[:220]
+
+
+def _build_step_summary(tool_name: str, decision: str, tool_status: str, observation_preview: str) -> str:
+    if tool_name == "TavilySearchTool":
+        action_text = "Online domain knowledge retrieval"
+    elif tool_name == "PythonInterpreterTool":
+        action_text = "Local Python execution"
+    else:
+        action_text = f"Tool call: {tool_name}"
+
+    summary = f"{action_text} | status={tool_status}"
+    if decision:
+        summary = f"{summary} | decision={decision}"
+    if observation_preview:
+        summary = f"{summary} | observation={observation_preview}"
+    return summary
+
+
+def _determine_search_status(step_traces: tuple[AgentStepTrace, ...], telemetry: ReportTelemetry) -> tuple[str, str]:
+    tavily_steps = [trace for trace in step_traces if trace.tool_name == "TavilySearchTool"]
+    if telemetry.valid and telemetry.search_used:
+        return "used", telemetry.search_notes
+    if not tavily_steps:
+        if telemetry.valid and telemetry.search_notes != "unknown":
+            return "not_used", telemetry.search_notes
+        return "not_used", "No online knowledge retrieval was triggered."
+
+    combined_preview = " ".join(trace.observation_preview for trace in tavily_steps).lower()
+    if "no tavily search credential" in combined_preview:
+        return "skipped", "Tavily credential is not configured, so online search was skipped."
+    if "temporarily unavailable" in combined_preview or "dependency is unavailable" in combined_preview:
+        return "unavailable", "Online retrieval was unavailable; the agent fell back to local analysis."
+    if any(trace.tool_status == "success" for trace in tavily_steps):
+        return "used", telemetry.search_notes if telemetry.search_notes != "unknown" else "Online search results were incorporated."
+    return "attempted", telemetry.search_notes if telemetry.search_notes != "unknown" else "Online search was attempted but did not yield stable results."
+
+
+def _collect_tools_used(step_traces: tuple[AgentStepTrace, ...], telemetry: ReportTelemetry) -> tuple[str, ...]:
+    if telemetry.tools_used:
+        return telemetry.tools_used
+    tool_names = []
+    for trace in step_traces:
+        if trace.tool_name and trace.tool_name not in tool_names:
+            tool_names.append(trace.tool_name)
+    return tuple(tool_names)
+
+
+def _create_run_directory(output_dir: str | Path) -> tuple[Path, Path, Path, Path]:
+    parent_dir = Path(output_dir)
+    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+    run_dir = parent_dir / f"run_{timestamp}"
+    data_dir = run_dir / "data"
+    figures_dir = run_dir / "figures"
+    logs_dir = run_dir / "logs"
+    for directory in (data_dir, figures_dir, logs_dir):
+        directory.mkdir(parents=True, exist_ok=True)
+    return run_dir, data_dir, figures_dir, logs_dir
+
+
+def _build_run_context_text(run_dir: Path, cleaned_data_path: Path, figures_dir: Path, logs_dir: Path) -> str:
+    return (
+        f"\n本次任务的专属输出根目录为:{run_dir.as_posix()}\n"
+        f"清洗后的数据必须保存到:{cleaned_data_path.as_posix()}\n"
+        f"所有图表必须保存到:{figures_dir.as_posix()}\n"
+        f"运行轨迹与日志目录为:{logs_dir.as_posix()}\n"
+        "请务必严格遵守“先清洗落盘,再重读分析”的两阶段流水线。\n"
+    )
+
+
+def _resolve_quality_mode(quality_mode: str) -> str:
+    normalized_mode = quality_mode.strip().lower()
+    if normalized_mode not in {"draft", "standard", "publication"}:
+        raise ValueError(f"Unsupported quality_mode: {quality_mode}")
+    return normalized_mode
+
+
+def _should_attempt_vision_review(*, quality_mode: str, review_enabled: bool, vision_review_mode: str) -> bool:
+    if not review_enabled or vision_review_mode == "off":
+        return False
+    if vision_review_mode == "on":
+        return quality_mode in {"standard", "publication"}
+    return quality_mode == "publication"
+
+
+def _default_max_reviews_for_mode(quality_mode: str) -> int:
+    mapping = {
+        "draft": 0,
+        "standard": 1,
+        "publication": 2,
+    }
+    return mapping[quality_mode]
+
+
+def _build_review_figures_dir(figures_root: Path, review_round: int) -> Path:
+    review_figures_dir = figures_root / f"review_round_{review_round}"
+    review_figures_dir.mkdir(parents=True, exist_ok=True)
+    return review_figures_dir
+
+
+def _serialize_step_traces(step_traces: tuple[AgentStepTrace, ...]) -> list[dict[str, Any]]:
+    return [asdict(trace) for trace in step_traces]
+
+
+def _reindex_step_traces(step_traces: list[AgentStepTrace], start_index: int) -> tuple[AgentStepTrace, ...]:
+    return tuple(
+        AgentStepTrace(
+            step_index=start_index + offset,
+            raw_response=trace.raw_response,
+            action=trace.action,
+            decision=trace.decision,
+            tool_name=trace.tool_name,
+            tool_status=trace.tool_status,
+            observation=trace.observation,
+            observation_preview=trace.observation_preview,
+            summary=trace.summary,
+            parse_error=trace.parse_error,
+            llm_duration_ms=trace.llm_duration_ms,
+            tool_duration_ms=trace.tool_duration_ms,
+        )
+        for offset, trace in enumerate(step_traces)
+    )
+
+
+def _serialize_analysis_rounds(rounds: tuple[AnalystRoundRecord, ...]) -> list[dict[str, Any]]:
+    return [
+        {
+            "round_index": round_record.round_index,
+            "report_path": round_record.report_path.as_posix(),
+            "step_traces": _serialize_step_traces(round_record.step_traces),
+        }
+        for round_record in rounds
+    ]
+
+
+def _serialize_review_history(review_history: tuple[ReviewRecord, ...]) -> list[dict[str, Any]]:
+    return [
+        {
+            "round_index": review.round_index,
+            "decision": review.decision,
+            "critique": review.critique,
+            "raw_response": review.raw_response,
+            "review_log_path": review.review_log_path.as_posix(),
+            "candidate_report_path": review.candidate_report_path.as_posix(),
+        }
+        for review in review_history
+    ]
+
+
+def _serialize_visual_review_history(visual_review_history: tuple[VisualReviewRecord, ...]) -> list[dict[str, Any]]:
+    return [
+        {
+            "round_index": review.round_index,
+            "status": review.status,
+            "decision": review.decision,
+            "summary": review.summary,
+            "figures_reviewed": list(review.figures_reviewed),
+            "skipped_figures": list(review.skipped_figures),
+            "duration_ms": review.duration_ms,
+            "raw_response": review.raw_response,
+            "warning": review.warning,
+            "log_path": review.log_path.as_posix(),
+        }
+        for review in visual_review_history
+    ]
+
+
+def _build_visual_review_summary(review: VisualReviewResult) -> str:
+    parts = [
+        f"- status: {review.status}",
+        f"- decision: {review.decision}",
+        f"- summary: {review.summary}",
+    ]
+    if review.figures_reviewed:
+        parts.append("- figures_reviewed:")
+        parts.extend(f"  - {item}" for item in review.figures_reviewed)
+    if review.skipped_figures:
+        parts.append("- skipped_figures:")
+        parts.extend(f"  - {item}" for item in review.skipped_figures)
+    if review.findings:
+        parts.append("- findings:")
+        for finding in review.findings:
+            parts.append(
+                f"  - {finding.figure} | severity={finding.severity} | issue={finding.issue} | fix={finding.suggested_fix}"
+            )
+    return "\n".join(parts)
+
+
+def _build_reviewer_task(
+    *,
+    data_context: DataContextSummary,
+    report_markdown: str,
+    report_path: Path,
+    step_traces: tuple[AgentStepTrace, ...],
+    artifact_validation: ArtifactValidationResult,
+    telemetry: ReportTelemetry,
+    review_round: int,
+    visual_review_summary: str = "",
+) -> str:
+    trace_lines = []
+    for trace in step_traces:
+        trace_lines.append(
+            f"- Step {trace.step_index} | tool={trace.tool_name or 'finalize'} | "
+            f"status={trace.tool_status} | summary={trace.summary or trace.decision or 'n/a'}"
+        )
+    trace_summary = "\n".join(trace_lines) if trace_lines else "- No execution trace available."
+    missing = ", ".join(artifact_validation.missing_artifacts) if artifact_validation.missing_artifacts else "none"
+    warnings = "; ".join(artifact_validation.warnings) if artifact_validation.warnings else "none"
+    round_pattern = re.compile(rf"review_round_{review_round}(?:/|\\)")
+    round_figures = [
+        figure_path
+        for figure_path in telemetry.figures_generated
+        if round_pattern.search(str(figure_path))
+    ]
+    if not round_figures:
+        round_figures = list(telemetry.figures_generated)
+    figure_evidence_lines = []
+    for figure_path in round_figures:
+        figure_file = Path(figure_path)
+        figure_evidence_lines.append(
+            f"- {figure_file.name} | path={figure_file.as_posix()} | exists={figure_file.exists()}"
+        )
+    figures_block = "\n".join(figure_evidence_lines) if figure_evidence_lines else "- none"
+    figures_dir = report_path.parent / "figures" / f"review_round_{review_round}"
+    if not figures_dir.exists():
+        run_dir = report_path.parent
+        figures_dir = run_dir / "figures" / f"review_round_{review_round}"
+
+    return (
+        f"Review round: {review_round}\n"
+        f"Candidate report path: {report_path.as_posix()}\n\n"
+        "Dataset metadata summary:\n"
+        f"{data_context.context_text}\n"
+        "Execution trace summary:\n"
+        f"{trace_summary}\n\n"
+        "Generated artifacts evidence:\n"
+        f"- telemetry_figures_generated_count: {len(telemetry.figures_generated)}\n"
+        f"- review_round_figures_generated_count: {len(round_figures)}\n"
+        f"- review_round_figures_dir: {figures_dir.as_posix()}\n"
+        f"- review_round_figures_dir_exists: {figures_dir.exists()}\n"
+        f"- candidate_report_path: {report_path.as_posix()}\n"
+        f"- artifact_workflow_complete: {artifact_validation.workflow_complete}\n"
+        f"- artifact_missing_artifacts: {missing}\n"
+        f"- artifact_warnings: {warnings}\n"
+        "Generated figure list:\n"
+        f"{figures_block}\n\n"
+        + (
+            "Visual figure audit summary:\n"
+            f"{visual_review_summary}\n\n"
+            if visual_review_summary
+            else ""
+        )
+        + (
+        "Artifact validation summary:\n"
+        f"- workflow_complete: {artifact_validation.workflow_complete}\n"
+        f"- missing_artifacts: {missing}\n"
+        f"- warnings: {warnings}\n\n"
+        "Candidate final_report.md content:\n"
+        f"{report_markdown}"
+        )
+    )
+
+
+def _save_review_log(
+    *,
+    review_log_path: Path,
+    review_round: int,
+    reviewer_reply: ParsedReviewerReply,
+    candidate_report_path: Path,
+) -> Path:
+    payload = {
+        "round_index": review_round,
+        "decision": reviewer_reply.decision,
+        "critique": reviewer_reply.critique,
+        "raw_response": reviewer_reply.raw_response,
+        "candidate_report_path": candidate_report_path.as_posix(),
+    }
+    review_log_path.parent.mkdir(parents=True, exist_ok=True)
+    review_log_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+    return review_log_path
+
+
+def _save_visual_review_log(
+    *,
+    review_log_path: Path,
+    review_round: int,
+    reviewer_reply: VisualReviewResult,
+) -> Path:
+    payload = {
+        "round_index": review_round,
+        "status": reviewer_reply.status,
+        "decision": reviewer_reply.decision,
+        "summary": reviewer_reply.summary,
+        "figures_reviewed": list(reviewer_reply.figures_reviewed),
+        "skipped_figures": list(reviewer_reply.skipped_figures),
+        "duration_ms": reviewer_reply.duration_ms,
+        "warning": reviewer_reply.warning,
+        "raw_response": reviewer_reply.raw_response,
+        "image_metadata": list(reviewer_reply.image_metadata),
+        "findings": [
+            {
+                "figure": finding.figure,
+                "severity": finding.severity,
+                "issue": finding.issue,
+                "suggested_fix": finding.suggested_fix,
+            }
+            for finding in reviewer_reply.findings
+        ],
+    }
+    review_log_path.parent.mkdir(parents=True, exist_ok=True)
+    review_log_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+    return review_log_path
+
+
+def _save_agent_trace(
+    *,
+    trace_path: Path,
+    runtime_config: RuntimeConfig,
+    data_context: DataContextSummary,
+    run_dir: Path,
+    max_steps: int,
+    effective_max_steps: int,
+    step_traces: tuple[AgentStepTrace, ...],
+    telemetry: ReportTelemetry,
+    search_status: str,
+    search_notes: str,
+    tools_used: tuple[str, ...],
+    artifact_validation: ArtifactValidationResult,
+    analysis_rounds: tuple[AnalystRoundRecord, ...],
+    review_history: tuple[ReviewRecord, ...],
+    visual_review_history: tuple[VisualReviewRecord, ...],
+    document_ingestion: IngestionResult,
+    review_status: str,
+    quality_mode: str,
+    latency_mode: str,
+    vision_review_mode: str,
+    review_enabled: bool,
+    search_enabled: bool,
+    fast_path_enabled: bool,
+    small_simple_dataset: bool,
+    vision_configured: bool,
+    timing_breakdown: dict[str, int],
+) -> Path:
+    payload = {
+        "run_metadata": {
+            "timestamp": datetime.now().isoformat(timespec="seconds"),
+            "model_id": runtime_config.model_id,
+            "max_steps": max_steps,
+            "effective_max_steps": effective_max_steps,
+            "data_path": data_context.absolute_path.as_posix(),
+            "input_kind": document_ingestion.input_kind,
+            "run_dir": run_dir.as_posix(),
+            "quality_mode": quality_mode,
+            "latency_mode": latency_mode,
+            "vision_review_mode": vision_review_mode,
+            "review_enabled": review_enabled,
+            "search_enabled": search_enabled,
+            "fast_path_enabled": fast_path_enabled,
+            "small_simple_dataset": small_simple_dataset,
+            "vision_configured": vision_configured,
+        },
+        "step_traces": _serialize_step_traces(step_traces),
+        "analysis_rounds": _serialize_analysis_rounds(analysis_rounds),
+        "review_history": _serialize_review_history(review_history),
+        "vision_review_history": _serialize_visual_review_history(visual_review_history),
+        "document_ingestion": {
+            "input_kind": document_ingestion.input_kind,
+            "status": document_ingestion.status,
+            "summary": document_ingestion.summary,
+            "normalized_data_path": document_ingestion.normalized_data_path.as_posix(),
+            "duration_ms": document_ingestion.duration_ms,
+            "log_path": document_ingestion.log_path.as_posix() if document_ingestion.log_path else None,
+            "parsed_document_path": (
+                document_ingestion.parsed_document_path.as_posix()
+                if document_ingestion.parsed_document_path
+                else None
+            ),
+            "selected_table_id": document_ingestion.selected_table_id,
+            "candidate_table_count": document_ingestion.candidate_table_count,
+            "selected_table_shape": list(document_ingestion.selected_table_shape)
+            if document_ingestion.selected_table_shape
+            else None,
+            "selected_table_headers": list(document_ingestion.selected_table_headers),
+            "selected_table_numeric_columns": list(document_ingestion.selected_table_numeric_columns),
+            "candidate_table_summaries": list(document_ingestion.candidate_table_summaries),
+            "pdf_multi_table_mode": document_ingestion.pdf_multi_table_mode,
+            "warnings": list(document_ingestion.warnings),
+        },
+        "telemetry": {
+            "methods": list(telemetry.methods),
+            "domain": telemetry.domain,
+            "tools_used": list(tools_used),
+            "search_used": telemetry.search_used,
+            "search_notes": search_notes,
+            "cleaned_data_saved": telemetry.cleaned_data_saved,
+            "cleaned_data_path": telemetry.cleaned_data_path,
+            "figures_generated": list(telemetry.figures_generated),
+            "telemetry_valid": telemetry.valid,
+            "telemetry_warning": telemetry.warning,
+        },
+        "artifact_validation": {
+            "workflow_complete": artifact_validation.workflow_complete,
+            "missing_artifacts": list(artifact_validation.missing_artifacts),
+            "warnings": list(artifact_validation.warnings),
+            "cleaned_data_exists": artifact_validation.cleaned_data_exists,
+            "report_exists": artifact_validation.report_exists,
+            "trace_exists": artifact_validation.trace_exists,
+        },
+        "search_status": search_status,
+        "review_status": review_status,
+        "timing_breakdown": dict(timing_breakdown),
+    }
+    trace_path.parent.mkdir(parents=True, exist_ok=True)
+    trace_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+    return trace_path
+
+
+def _validate_artifacts(
+    *,
+    cleaned_data_path: Path,
+    report_path: Path,
+    trace_path: Path,
+    telemetry: ReportTelemetry,
+) -> ArtifactValidationResult:
+    missing_artifacts: list[str] = []
+    warnings: list[str] = []
+
+    cleaned_data_exists = cleaned_data_path.exists()
+    report_exists = report_path.exists()
+    trace_exists = trace_path.exists()
+
+    if not cleaned_data_exists:
+        missing_artifacts.append(cleaned_data_path.as_posix())
+    if not report_exists:
+        missing_artifacts.append(report_path.as_posix())
+    if not trace_exists:
+        missing_artifacts.append(trace_path.as_posix())
+
+    if telemetry.cleaned_data_saved and not cleaned_data_exists:
+        warnings.append("Telemetry claimed cleaned_data_saved=true, but cleaned_data.csv was not found on disk.")
+    if telemetry.cleaned_data_path and telemetry.cleaned_data_path != cleaned_data_path.as_posix():
+        warnings.append("Telemetry cleaned_data_path does not match the required production path.")
+
+    workflow_complete = not missing_artifacts
+    if not workflow_complete:
+        warnings.append("This run did not complete the production-grade artifact contract.")
+
+    return ArtifactValidationResult(
+        workflow_complete=workflow_complete,
+        missing_artifacts=tuple(missing_artifacts),
+        warnings=tuple(warnings),
+        cleaned_data_exists=cleaned_data_exists,
+        report_exists=report_exists,
+        trace_exists=trace_exists,
+    )
+
+
+class ScientificReActRunner:
+    """Custom JSON-driven ReAct controller for scientific analysis tasks."""
+
+    def __init__(
+        self,
+        *,
+        name: str,
+        llm: Any,
+        system_prompt: str,
+        tool_registry: Any,
+        max_steps: int = 6,
+        fast_path_enabled: bool = False,
+        event_handler: Optional[EventHandler] = None,
+    ) -> None:
+        self.name = name
+        self.llm = llm
+        self.system_prompt = system_prompt
+        self.tool_registry = tool_registry
+        self.max_steps = max_steps
+        self.fast_path_enabled = fast_path_enabled
+        self.event_handler = event_handler
+
+    def build_initial_messages(self, user_task: str) -> list[dict[str, str]]:
+        return [
+            {"role": "system", "content": self.system_prompt},
+            {"role": "user", "content": user_task},
+        ]
+
+    def run(self, user_task: str) -> tuple[str, list[AgentStepTrace]]:
+        final_answer, traces, _ = self.run_with_messages(self.build_initial_messages(user_task))
+        return final_answer, traces
+
+    def run_with_messages(
+        self,
+        messages: list[dict[str, str]],
+        *,
+        analysis_round: int = 1,
+    ) -> tuple[str, list[AgentStepTrace], list[dict[str, str]]]:
+        messages = list(messages)
+        traces: list[AgentStepTrace] = []
+        available_tools = set(self.tool_registry.list_tools())
+
+        _emit_event(
+            self.event_handler,
+            "analysis_started",
+            agent_name=self.name,
+            max_steps=self.max_steps,
+            analysis_round=analysis_round,
+        )
+
+        for step_index in range(1, self.max_steps + 1):
+            _emit_event(self.event_handler, "step_started", step_index=step_index, max_steps=self.max_steps)
+            llm_started_at = time.perf_counter()
+            raw_response = str(self.llm.invoke(messages)).strip()
+            llm_duration_ms = _elapsed_ms(llm_started_at)
+
+            try:
+                reply = _parse_agent_reply(raw_response)
+            except ValueError as exc:
+                parse_error = str(exc)
+                trace = AgentStepTrace(
+                    step_index=step_index,
+                    raw_response=raw_response,
+                    action="parse_error",
+                    tool_status="error",
+                    summary=f"Model response failed JSON validation: {parse_error}",
+                    parse_error=parse_error,
+                    llm_duration_ms=llm_duration_ms,
+                )
+                traces.append(trace)
+                _emit_event(
+                    self.event_handler,
+                    "step_parse_error",
+                    step_index=step_index,
+                    message=parse_error,
+                )
+                messages.append({"role": "assistant", "content": raw_response})
+                messages.append({"role": "user", "content": build_response_format_feedback(parse_error)})
+                continue
+
+            if reply.action == "call_tool":
+                _emit_event(
+                    self.event_handler,
+                    "tool_call_started",
+                    step_index=step_index,
+                    tool_name=reply.tool_name,
+                    decision=reply.decision,
+                )
+
+                if reply.tool_name not in available_tools:
+                    observation = json.dumps(
+                        {
+                            "status": "error",
+                            "text": f"Tool '{reply.tool_name}' is not registered.",
+                            "available_tools": sorted(available_tools),
+                        },
+                        ensure_ascii=False,
+                        indent=2,
+                    )
+                    tool_duration_ms = 0
+                else:
+                    tool_started_at = time.perf_counter()
+                    observation = self.tool_registry.execute_tool(reply.tool_name, reply.tool_input)
+                    tool_duration_ms = _elapsed_ms(tool_started_at)
+
+                tool_status, observation_preview = _parse_tool_observation(observation)
+                observation_summary = _build_observation_summary(
+                    tool_name=reply.tool_name,
+                    observation=observation,
+                    tool_status=tool_status,
+                    observation_preview=observation_preview,
+                )
+                trace = AgentStepTrace(
+                    step_index=step_index,
+                    raw_response=raw_response,
+                    action=reply.action,
+                    decision=reply.decision,
+                    tool_name=reply.tool_name,
+                    tool_status=tool_status,
+                    observation=observation,
+                    observation_preview=observation_preview,
+                    summary=_build_step_summary(reply.tool_name, reply.decision, tool_status, observation_preview),
+                    llm_duration_ms=llm_duration_ms,
+                    tool_duration_ms=tool_duration_ms,
+                )
+                traces.append(trace)
+
+                _emit_event(
+                    self.event_handler,
+                    "tool_call_completed",
+                    step_index=step_index,
+                    tool_name=reply.tool_name,
+                    decision=reply.decision,
+                    tool_status=tool_status,
+                    observation_preview=observation_preview,
+                    summary=trace.summary,
+                    llm_duration_ms=llm_duration_ms,
+                    tool_duration_ms=tool_duration_ms,
+                )
+
+                messages.append({"role": "assistant", "content": raw_response})
+                messages.append(
+                    {
+                        "role": "user",
+                        "content": build_observation_prompt(
+                            tool_name=reply.tool_name,
+                            observation_summary=observation_summary,
+                            remaining_steps=self.max_steps - step_index,
+                            fast_path_enabled=self.fast_path_enabled,
+                        ),
+                    }
+                )
+                continue
+
+            trace = AgentStepTrace(
+                step_index=step_index,
+                raw_response=raw_response,
+                action=reply.action,
+                decision=reply.decision,
+                tool_status="success",
+                summary=f"Generated final Markdown report: {reply.decision or 'analysis complete'}",
+                llm_duration_ms=llm_duration_ms,
+            )
+            traces.append(trace)
+            _emit_event(
+                self.event_handler,
+                "analysis_finished",
+                step_index=step_index,
+                decision=reply.decision,
+            )
+            messages.append({"role": "assistant", "content": raw_response})
+            return reply.final_answer, traces, messages
+
+        fallback_report = (
+            "# Data Analysis Report\n\n"
+            "The agent reached the maximum number of reasoning steps before producing a final report.\n\n"
+            "## Next Action\n"
+            "Please review the step traces to identify whether the issue came from response formatting, "
+            "tool execution errors, or insufficient statistical instructions.\n\n"
+            "<telemetry>{\"methods\": [], \"domain\": \"unknown\", \"tools_used\": [], "
+            "\"search_used\": false, \"search_notes\": \"Agent reached max steps before finalizing.\", "
+            "\"cleaned_data_saved\": false, \"cleaned_data_path\": \"\", \"figures_generated\": []}</telemetry>"
+        )
+        _emit_event(self.event_handler, "analysis_max_steps", max_steps=self.max_steps)
+        return fallback_report, traces, messages
+
+
+def run_analysis(
+    data_path: str | Path,
+    *,
+    query: str = DEFAULT_QUERY,
+    output_dir: str | Path = "outputs",
+    report_path: Optional[str | Path] = None,
+    env_file: Optional[str | Path] = None,
+    agent_name: str = "Advanced Data Analyst",
+    max_steps: int = 6,
+    max_reviews: Optional[int] = None,
+    quality_mode: str = "standard",
+    latency_mode: str = "auto",
+    document_ingestion_mode: str = "auto",
+    max_pdf_pages: int = 20,
+    max_candidate_tables: int = 5,
+    selected_table_id: str | None = None,
+    vision_review_mode: str = "auto",
+    vision_max_images: int = 3,
+    vision_max_image_side: int = 1024,
+    event_handler: Optional[EventHandler] = None,
+    verbose: bool = False,
+) -> AnalysisRunResult:
+    """Run the full data analysis workflow."""
+
+    if event_handler is None and verbose:
+        event_handler = build_plaintext_event_handler()
+
+    run_started_at = time.perf_counter()
+    timing_breakdown: dict[str, int] = {}
+
+    _emit_event(event_handler, "config_loading")
+    config_started_at = time.perf_counter()
+    runtime_config: RuntimeConfig = load_runtime_config(env_file=env_file)
+    _accumulate_duration(timing_breakdown, "config_load_duration_ms", _elapsed_ms(config_started_at))
+
+    resolved_quality_mode = _resolve_quality_mode(quality_mode)
+    resolved_latency_mode = _resolve_latency_mode(latency_mode)
+    resolved_vision_review_mode = _resolve_vision_review_mode(vision_review_mode)
+    review_enabled = resolved_quality_mode != "draft"
+    effective_max_reviews = (
+        _default_max_reviews_for_mode(resolved_quality_mode) if max_reviews is None else max(0, max_reviews)
+    )
+    if not review_enabled:
+        effective_max_reviews = 0
+
+    _emit_event(
+        event_handler,
+        "config_loaded",
+        tavily_configured=bool(runtime_config.tavily_api_key),
+        vision_configured=runtime_config.vision_configured,
+        model_id=runtime_config.model_id,
+        latency_mode=resolved_latency_mode,
+        search_enabled=bool(runtime_config.tavily_api_key),
+    )
+
+    run_dir, data_dir, figures_dir, logs_dir = _create_run_directory(output_dir)
+    cleaned_data_path = data_dir / "cleaned_data.csv"
+    final_report_path = run_dir / "final_report.md"
+    trace_path = logs_dir / "agent_trace.json"
+    _emit_event(
+        event_handler,
+        "run_directory_created",
+        run_dir=run_dir.as_posix(),
+        data_dir=data_dir.as_posix(),
+        figures_dir=figures_dir.as_posix(),
+        logs_dir=logs_dir.as_posix(),
+    )
+
+    source_path = Path(data_path).resolve()
+    input_kind = "pdf" if source_path.suffix.lower() == ".pdf" else "tabular"
+    _emit_event(
+        event_handler,
+        "document_ingestion_started",
+        input_kind=input_kind,
+        data_path=source_path.as_posix(),
+    )
+    ingestion_started_at = time.perf_counter()
+    document_ingestion = ingest_input_document(
+        source_path,
+        run_dir=run_dir,
+        data_dir=data_dir,
+        logs_dir=logs_dir,
+        mode=document_ingestion_mode,
+        max_pdf_pages=max_pdf_pages,
+        max_candidate_tables=max_candidate_tables,
+        selected_table_id=selected_table_id,
+    )
+    _accumulate_duration(
+        timing_breakdown,
+        "document_ingestion_duration_ms",
+        max(document_ingestion.duration_ms, _elapsed_ms(ingestion_started_at)),
+    )
+    if document_ingestion.status == "not_needed":
+        _emit_event(event_handler, "document_ingestion_skipped")
+    else:
+        _emit_event(
+            event_handler,
+            "document_ingestion_completed",
+            status=document_ingestion.status,
+            summary=document_ingestion.summary,
+            input_kind=document_ingestion.input_kind,
+        )
+    if document_ingestion.status == "failed":
+        raise ValueError(document_ingestion.summary)
+
+    _emit_event(event_handler, "data_context_loading", data_path=document_ingestion.normalized_data_path.as_posix())
+    data_context_started_at = time.perf_counter()
+    data_context = build_data_context(
+        document_ingestion.normalized_data_path,
+        input_kind=document_ingestion.input_kind,
+        parsed_document_path=document_ingestion.parsed_document_path,
+    )
+    _accumulate_duration(timing_breakdown, "data_context_duration_ms", _elapsed_ms(data_context_started_at))
+    small_simple_dataset = _is_small_simple_dataset(data_context)
+    fast_path_enabled = _should_use_fast_path(resolved_latency_mode, small_simple_dataset=small_simple_dataset)
+    effective_max_steps = _resolve_effective_max_steps(
+        requested_max_steps=max_steps,
+        quality_mode=resolved_quality_mode,
+        latency_mode=resolved_latency_mode,
+        small_simple_dataset=small_simple_dataset,
+    )
+    search_enabled = _should_enable_search(
+        runtime_config=runtime_config,
+        data_context=data_context,
+        query=query,
+        quality_mode=resolved_quality_mode,
+        latency_mode=resolved_latency_mode,
+    )
+    _emit_event(
+        event_handler,
+        "data_context_ready",
+        data_path=data_context.absolute_path.as_posix(),
+        shape=data_context.shape,
+        columns=data_context.columns,
+        small_simple_dataset=small_simple_dataset,
+    )
+
+    tool_registry = build_tool_registry(enable_search=search_enabled)
+    _emit_event(
+        event_handler,
+        "tool_registry_ready",
+        tools=tool_registry.list_tools(),
+        search_enabled=search_enabled,
+        fast_path_enabled=fast_path_enabled,
+        effective_max_steps=effective_max_steps,
+    )
+
+    llm = build_llm(runtime_config)
+    all_step_traces: list[AgentStepTrace] = []
+    analysis_rounds: list[AnalystRoundRecord] = []
+    review_history: list[ReviewRecord] = []
+    visual_review_history: list[VisualReviewRecord] = []
+
+    raw_result = ""
+    report_markdown = ""
+    telemetry = ReportTelemetry()
+    search_status = "not_used"
+    search_notes = "No online knowledge retrieval was triggered."
+    tools_used: tuple[str, ...] = ()
+    artifact_validation = ArtifactValidationResult(
+        workflow_complete=False,
+        missing_artifacts=(),
+        warnings=(),
+        cleaned_data_exists=False,
+        report_exists=False,
+        trace_exists=False,
+    )
+    review_status = "skipped" if not review_enabled else "rejected"
+    review_critique = ""
+    review_rounds_used = 0
+    vision_review_status = "skipped"
+    vision_review_summary = ""
+    visual_attempt_enabled = False
+    saved_report_path = final_report_path
+    saved_trace_path = trace_path
+    analyst_messages: list[dict[str, str]] | None = None
+    current_runner: Optional[ScientificReActRunner] = None
+
+    total_rounds = 1 if not review_enabled else 1 + effective_max_reviews
+
+    for review_round in range(1, total_rounds + 1):
+        review_figures_dir = _build_review_figures_dir(figures_dir, review_round)
+        system_prompt = build_system_prompt(
+            run_dir=run_dir.as_posix(),
+            cleaned_data_path=cleaned_data_path.as_posix(),
+            figures_dir=review_figures_dir.as_posix(),
+            logs_dir=logs_dir.as_posix(),
+            background_literature_context=data_context.background_literature_context,
+            max_steps=effective_max_steps,
+            tool_descriptions=tool_registry.get_tools_description(),
+            search_enabled=search_enabled,
+            latency_mode=resolved_latency_mode,
+            fast_path_enabled=fast_path_enabled,
+            pdf_small_table_mode=data_context.pdf_small_table_mode,
+        )
+        current_runner = ScientificReActRunner(
+            name=agent_name,
+            llm=llm,
+            system_prompt=system_prompt,
+            tool_registry=tool_registry,
+            max_steps=effective_max_steps,
+            fast_path_enabled=fast_path_enabled,
+            event_handler=event_handler,
+        )
+        run_context_text = _build_run_context_text(run_dir, cleaned_data_path, review_figures_dir, logs_dir)
+        if analyst_messages is None:
+            analyst_messages = current_runner.build_initial_messages(
+                f"{query}\n{data_context.context_text}\n{run_context_text}"
+            )
+        else:
+            analyst_messages[0] = {"role": "system", "content": system_prompt}
+
+        raw_result, round_traces, analyst_messages = current_runner.run_with_messages(
+            analyst_messages,
+            analysis_round=review_round,
+        )
+        _accumulate_duration(
+            timing_breakdown,
+            "llm_duration_ms",
+            sum(trace.llm_duration_ms for trace in round_traces),
+        )
+        _accumulate_duration(
+            timing_breakdown,
+            "tool_duration_ms",
+            sum(trace.tool_duration_ms for trace in round_traces),
+        )
+        _accumulate_duration(
+            timing_breakdown,
+            "tavily_duration_ms",
+            sum(trace.tool_duration_ms for trace in round_traces if trace.tool_name == "TavilySearchTool"),
+        )
+        reindexed_traces = _reindex_step_traces(round_traces, start_index=len(all_step_traces) + 1)
+        all_step_traces.extend(reindexed_traces)
+
+        extraction = extract_report_and_telemetry(raw_result)
+        report_markdown = extraction.report_markdown
+        telemetry = extraction.telemetry
+
+        round_report_path = run_dir / f"review_round_{review_round}_report.md"
+        analysis_rounds.append(
+            AnalystRoundRecord(
+                round_index=review_round,
+                report_path=round_report_path,
+                step_traces=reindexed_traces,
+            )
+        )
+
+        _emit_event(
+            event_handler,
+            "report_persisting",
+            report_path=final_report_path.as_posix(),
+            trace_path=trace_path.as_posix(),
+            review_round=review_round,
+        )
+        report_persist_started_at = time.perf_counter()
+        saved_report_path = save_markdown_report(report_markdown, final_report_path)
+        save_markdown_report(report_markdown, round_report_path)
+        _accumulate_duration(timing_breakdown, "report_persist_duration_ms", _elapsed_ms(report_persist_started_at))
+
+        step_traces_tuple = tuple(all_step_traces)
+        tools_used = _collect_tools_used(step_traces_tuple, telemetry)
+        search_status, search_notes = _determine_search_status(step_traces_tuple, telemetry)
+
+        initial_validation = ArtifactValidationResult(
+            workflow_complete=False,
+            missing_artifacts=(),
+            warnings=(),
+            cleaned_data_exists=cleaned_data_path.exists(),
+            report_exists=saved_report_path.exists(),
+            trace_exists=False,
+        )
+        trace_persist_started_at = time.perf_counter()
+        saved_trace_path = _save_agent_trace(
+            trace_path=trace_path,
+            runtime_config=runtime_config,
+            data_context=data_context,
+            run_dir=run_dir,
+            max_steps=max_steps,
+            effective_max_steps=effective_max_steps,
+            step_traces=step_traces_tuple,
+            telemetry=telemetry,
+            search_status=search_status,
+            search_notes=search_notes,
+            tools_used=tools_used,
+            artifact_validation=initial_validation,
+            analysis_rounds=tuple(analysis_rounds),
+            review_history=tuple(review_history),
+            visual_review_history=tuple(visual_review_history),
+            document_ingestion=document_ingestion,
+            review_status=review_status,
+            quality_mode=resolved_quality_mode,
+            latency_mode=resolved_latency_mode,
+            vision_review_mode=resolved_vision_review_mode,
+            review_enabled=review_enabled,
+            search_enabled=search_enabled,
+            fast_path_enabled=fast_path_enabled,
+            small_simple_dataset=small_simple_dataset,
+            vision_configured=runtime_config.vision_configured,
+            timing_breakdown=dict(timing_breakdown),
+        )
+        _accumulate_duration(timing_breakdown, "trace_persist_duration_ms", _elapsed_ms(trace_persist_started_at))
+
+        artifact_validation = _validate_artifacts(
+            cleaned_data_path=cleaned_data_path,
+            report_path=saved_report_path,
+            trace_path=saved_trace_path,
+            telemetry=telemetry,
+        )
+
+        if not review_enabled:
+            review_status = "skipped"
+            review_rounds_used = 0
+            review_critique = "Review skipped in draft mode."
+            break
+
+        visual_attempt_enabled = _should_attempt_vision_review(
+            quality_mode=resolved_quality_mode,
+            review_enabled=review_enabled,
+            vision_review_mode=resolved_vision_review_mode,
+        )
+        visual_review_log_path = logs_dir / f"review_round_{review_round}_visual_review.json"
+        if visual_attempt_enabled:
+            _emit_event(
+                event_handler,
+                "vision_review_started",
+                review_round=review_round,
+                report_path=saved_report_path.as_posix(),
+            )
+            visual_review_result = run_visual_review(
+                runtime_config=runtime_config,
+                report_markdown=report_markdown,
+                telemetry=telemetry,
+                run_dir=run_dir,
+                review_round=review_round,
+                max_images=max(1, int(vision_max_images)),
+                max_image_side=max(256, min(int(vision_max_image_side), 2048)),
+            )
+        else:
+            visual_review_result = VisualReviewResult(
+                status="skipped",
+                decision="Skipped",
+                summary="当前质量档位与视觉审稿模式组合未启用视觉审稿。",
+            )
+
+        if visual_review_result.duration_ms:
+            _accumulate_duration(timing_breakdown, "vision_review_duration_ms", visual_review_result.duration_ms)
+        vision_review_status = visual_review_result.status
+        vision_review_summary = visual_review_result.summary
+        saved_visual_review_log_path = _save_visual_review_log(
+            review_log_path=visual_review_log_path,
+            review_round=review_round,
+            reviewer_reply=visual_review_result,
+        )
+        visual_review_history.append(
+            VisualReviewRecord(
+                round_index=review_round,
+                status=visual_review_result.status,
+                decision=visual_review_result.decision,
+                summary=visual_review_result.summary,
+                figures_reviewed=visual_review_result.figures_reviewed,
+                skipped_figures=visual_review_result.skipped_figures,
+                duration_ms=visual_review_result.duration_ms,
+                raw_response=visual_review_result.raw_response,
+                warning=visual_review_result.warning,
+                log_path=saved_visual_review_log_path,
+            )
+        )
+        if visual_review_result.status == "completed":
+            _emit_event(
+                event_handler,
+                "vision_review_completed",
+                review_round=review_round,
+                status=visual_review_result.status,
+                decision=visual_review_result.decision,
+                summary=visual_review_result.summary,
+            )
+        else:
+            _emit_event(
+                event_handler,
+                "vision_review_skipped",
+                review_round=review_round,
+                reason=visual_review_result.summary,
+                status=visual_review_result.status,
+            )
+
+        reviewer_messages = [
+            {
+                "role": "system",
+                "content": build_reviewer_prompt(
+                    resolved_quality_mode,
+                    focus_major_issues=(
+                        fast_path_enabled
+                        and resolved_quality_mode == "standard"
+                        and artifact_validation.workflow_complete
+                        and not any(trace.tool_status == "error" or trace.parse_error for trace in reindexed_traces)
+                    ),
+                ),
+            },
+            {
+                "role": "user",
+                "content": _build_reviewer_task(
+                    data_context=data_context,
+                    report_markdown=report_markdown,
+                    report_path=saved_report_path,
+                    step_traces=reindexed_traces,
+                    artifact_validation=artifact_validation,
+                    telemetry=telemetry,
+                    review_round=review_round,
+                    visual_review_summary=_build_visual_review_summary(visual_review_result),
+                ),
+            },
+        ]
+        _emit_event(
+            event_handler,
+            "review_started",
+            review_round=review_round,
+            report_path=saved_report_path.as_posix(),
+        )
+        review_started_at = time.perf_counter()
+        reviewer_raw_response = str(llm.invoke(reviewer_messages)).strip()
+        review_duration_ms = _elapsed_ms(review_started_at)
+        _accumulate_duration(timing_breakdown, "review_duration_ms", review_duration_ms)
+        _accumulate_duration(timing_breakdown, "llm_duration_ms", review_duration_ms)
+        reviewer_reply = _safe_parse_reviewer_reply(reviewer_raw_response)
+        review_log_path = logs_dir / f"review_round_{review_round}_review.json"
+        saved_review_log_path = _save_review_log(
+            review_log_path=review_log_path,
+            review_round=review_round,
+            reviewer_reply=reviewer_reply,
+            candidate_report_path=saved_report_path,
+        )
+        review_history.append(
+            ReviewRecord(
+                round_index=review_round,
+                decision=reviewer_reply.decision,
+                critique=reviewer_reply.critique,
+                raw_response=reviewer_reply.raw_response,
+                review_log_path=saved_review_log_path,
+                candidate_report_path=saved_report_path,
+            )
+        )
+        review_rounds_used = review_round
+        review_critique = reviewer_reply.critique
+
+        if reviewer_reply.decision == "Accept":
+            review_status = "accepted"
+            _emit_event(
+                event_handler,
+                "review_accepted",
+                review_round=review_round,
+                critique=reviewer_reply.critique,
+            )
+            break
+
+        _emit_event(
+            event_handler,
+            "review_rejected",
+            review_round=review_round,
+            critique=reviewer_reply.critique,
+        )
+
+        review_status = "rejected"
+        if review_round >= total_rounds:
+            review_status = "max_reviews_reached"
+            _emit_event(
+                event_handler,
+                "review_max_reached",
+                review_round=review_round,
+                critique=reviewer_reply.critique,
+            )
+            break
+
+        analyst_messages.append(
+            {
+                "role": "user",
+                "content": (
+                    f"[审稿人拒稿意见]:{reviewer_reply.critique}\n"
+                    "你必须逐条回应并修复以下全部问题,重新分析并重写报告。"
+                    f"下一轮所有新图表必须保存到:{(figures_dir / f'review_round_{review_round + 1}').as_posix()}。"
+                    "不要重复原报告中的问题,也不要忽略任何已经指出的主要缺陷。"
+                ),
+            }
+        )
+
+    if report_path is not None:
+        save_markdown_report(report_markdown, Path(report_path))
+
+    step_traces_tuple = tuple(all_step_traces)
+    tools_used = _collect_tools_used(step_traces_tuple, telemetry)
+    search_status, search_notes = _determine_search_status(step_traces_tuple, telemetry)
+
+    timing_snapshot = dict(timing_breakdown)
+    timing_snapshot["total_duration_ms"] = _elapsed_ms(run_started_at)
+
+    final_trace_persist_started_at = time.perf_counter()
+    _save_agent_trace(
+        trace_path=trace_path,
+        runtime_config=runtime_config,
+        data_context=data_context,
+        run_dir=run_dir,
+        max_steps=max_steps,
+        effective_max_steps=effective_max_steps,
+        step_traces=step_traces_tuple,
+        telemetry=telemetry,
+        search_status=search_status,
+        search_notes=search_notes,
+        tools_used=tools_used,
+        artifact_validation=artifact_validation,
+        analysis_rounds=tuple(analysis_rounds),
+        review_history=tuple(review_history),
+        visual_review_history=tuple(visual_review_history),
+        document_ingestion=document_ingestion,
+        review_status=review_status,
+        quality_mode=resolved_quality_mode,
+        latency_mode=resolved_latency_mode,
+        vision_review_mode=resolved_vision_review_mode,
+        review_enabled=review_enabled,
+        search_enabled=search_enabled,
+        fast_path_enabled=fast_path_enabled,
+        small_simple_dataset=small_simple_dataset,
+        vision_configured=runtime_config.vision_configured,
+        timing_breakdown=timing_snapshot,
+    )
+    _accumulate_duration(
+        timing_breakdown,
+        "trace_persist_duration_ms",
+        _elapsed_ms(final_trace_persist_started_at),
+    )
+    final_timing_breakdown = dict(timing_breakdown)
+    final_timing_breakdown["total_duration_ms"] = _elapsed_ms(run_started_at)
+
+    _emit_event(
+        event_handler,
+        "report_saved",
+        report_path=saved_report_path.as_posix(),
+        trace_path=saved_trace_path.as_posix(),
+        tools_used=tools_used,
+        search_status=search_status,
+        telemetry_valid=telemetry.valid,
+    )
+    _emit_event(
+        event_handler,
+        "artifact_validation_completed",
+        workflow_complete=artifact_validation.workflow_complete,
+        missing_artifacts=artifact_validation.missing_artifacts,
+        warnings=artifact_validation.warnings,
+    )
+
+    return AnalysisRunResult(
+        data_context=data_context,
+        raw_result=raw_result,
+        report_markdown=report_markdown,
+        report_path=saved_report_path,
+        output_dir=run_dir,
+        run_dir=run_dir,
+        data_dir=data_dir,
+        figures_dir=figures_dir,
+        logs_dir=logs_dir,
+        trace_path=saved_trace_path,
+        cleaned_data_path=cleaned_data_path,
+        agent_type=current_runner.__class__.__name__ if current_runner is not None else ScientificReActRunner.__name__,
+        step_traces=step_traces_tuple,
+        telemetry=telemetry,
+        methods_used=telemetry.methods,
+        detected_domain=telemetry.domain,
+        tools_used=tools_used,
+        search_status=search_status,
+        search_notes=search_notes,
+        workflow_complete=artifact_validation.workflow_complete,
+        workflow_warnings=artifact_validation.warnings,
+        missing_artifacts=artifact_validation.missing_artifacts,
+        quality_mode=resolved_quality_mode,
+        review_enabled=review_enabled,
+        review_status=review_status,
+        review_rounds_used=review_rounds_used,
+        review_critique=review_critique,
+        review_log_paths=tuple(review.review_log_path for review in review_history),
+        input_kind=document_ingestion.input_kind,
+        document_ingestion_status=document_ingestion.status,
+        document_ingestion_summary=document_ingestion.summary,
+        document_ingestion_duration_ms=final_timing_breakdown.get("document_ingestion_duration_ms", 0),
+        document_ingestion_log_path=document_ingestion.log_path,
+        candidate_table_count=document_ingestion.candidate_table_count,
+        selected_table_id=document_ingestion.selected_table_id,
+        selected_table_shape=document_ingestion.selected_table_shape,
+        pdf_multi_table_mode=document_ingestion.pdf_multi_table_mode,
+        latency_mode=resolved_latency_mode,
+        vision_review_mode=resolved_vision_review_mode,
+        vision_review_enabled=visual_attempt_enabled if review_enabled else False,
+        vision_review_status=vision_review_status,
+        vision_review_summary=vision_review_summary,
+        vision_review_duration_ms=final_timing_breakdown.get("vision_review_duration_ms", 0),
+        vision_review_log_paths=tuple(review.log_path for review in visual_review_history),
+        total_duration_ms=final_timing_breakdown.get("total_duration_ms", 0),
+        llm_duration_ms=final_timing_breakdown.get("llm_duration_ms", 0),
+        tool_duration_ms=final_timing_breakdown.get("tool_duration_ms", 0),
+        review_duration_ms=final_timing_breakdown.get("review_duration_ms", 0),
+        timing_breakdown=final_timing_breakdown,
+    )

+ 105 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/config.py

@@ -0,0 +1,105 @@
+"""Runtime configuration and tokenizer compatibility helpers."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Optional
+
+import tiktoken
+from dotenv import load_dotenv
+
+
+_TOKEN_PATCH_APPLIED = False
+_SAFE_TIKTOKEN_MODEL_PREFIXES = (
+    "gpt-3.5",
+    "gpt-4",
+    "text-embedding-3",
+    "text-embedding-ada",
+)
+
+
+@dataclass(frozen=True)
+class RuntimeConfig:
+    model_id: str
+    api_key: str
+    base_url: str
+    timeout: int = 120
+    tavily_api_key: Optional[str] = None
+    vision_model_id: Optional[str] = None
+    vision_api_key: Optional[str] = None
+    vision_base_url: Optional[str] = None
+    vision_timeout: int = 120
+
+    @property
+    def vision_configured(self) -> bool:
+        return bool(self.vision_model_id and self.vision_api_key and self.vision_base_url)
+
+
+def _patched_get_encoding(self):
+    model_name = str(getattr(self, "model", "") or "").strip().lower()
+
+    try:
+        if model_name and any(model_name.startswith(prefix) for prefix in _SAFE_TIKTOKEN_MODEL_PREFIXES):
+            return tiktoken.encoding_for_model(model_name)
+        return tiktoken.get_encoding("cl100k_base")
+    except Exception:
+        try:
+            return tiktoken.get_encoding("cl100k_base")
+        except Exception:
+            return None
+
+
+def apply_token_counter_patch():
+    """Apply a generic OpenAI-compatible tokenizer fallback patch once."""
+
+    global _TOKEN_PATCH_APPLIED
+    if _TOKEN_PATCH_APPLIED:
+        return _patched_get_encoding
+
+    try:
+        import hello_agents.context.token_counter
+    except ModuleNotFoundError:
+        _TOKEN_PATCH_APPLIED = True
+        return _patched_get_encoding
+
+    hello_agents.context.token_counter.TokenCounter._get_encoding = _patched_get_encoding
+    _TOKEN_PATCH_APPLIED = True
+    return _patched_get_encoding
+
+
+def load_runtime_config(env_file: Optional[str | Path] = None) -> RuntimeConfig:
+    """Load and validate runtime configuration from the environment."""
+
+    if env_file is not None:
+        load_dotenv(dotenv_path=env_file, override=False)
+    else:
+        load_dotenv(override=False)
+
+    required_env_vars = ("LLM_MODEL_ID", "LLM_BASE_URL", "LLM_API_KEY")
+    missing_env_vars = [name for name in required_env_vars if not os.getenv(name)]
+    if missing_env_vars:
+        raise ValueError(
+            "Missing required environment variables: "
+            + ", ".join(missing_env_vars)
+            + ". Create a .env file from .env.example or export them before running the project."
+        )
+
+    timeout = int(os.getenv("LLM_TIMEOUT", "120"))
+    vision_timeout = int(os.getenv("VISION_LLM_TIMEOUT", str(timeout)))
+    config = RuntimeConfig(
+        model_id=os.environ["LLM_MODEL_ID"],
+        api_key=os.environ["LLM_API_KEY"],
+        base_url=os.environ["LLM_BASE_URL"],
+        timeout=timeout,
+        tavily_api_key=os.getenv("TAVILY_API_KEY"),
+        vision_model_id=os.getenv("VISION_LLM_MODEL_ID"),
+        vision_api_key=os.getenv("VISION_LLM_API_KEY"),
+        vision_base_url=os.getenv("VISION_LLM_BASE_URL"),
+        vision_timeout=vision_timeout,
+    )
+
+    os.environ.setdefault("LLM_TIMEOUT", str(config.timeout))
+    apply_token_counter_patch()
+    return config

+ 258 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/data_context.py

@@ -0,0 +1,258 @@
+"""Dataset metadata extraction."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+
+
+@dataclass(frozen=True)
+class DataContextSummary:
+    data_path: Path
+    absolute_path: Path
+    columns: list[str]
+    dtypes: str
+    shape: tuple[int, int]
+    head_markdown: str
+    sample_size_warning: str
+    small_sample_warning: bool
+    context_text: str
+    input_kind: str = "tabular"
+    background_literature_context: str = ""
+    parsed_document_path: Path | None = None
+    pdf_small_table_mode: bool = False
+    candidate_table_count: int = 0
+    selected_table_id: str = ""
+    pdf_multi_table_mode: bool = False
+    candidate_table_summaries_text: str = ""
+
+
+def _read_dataframe(data_path: Path) -> pd.DataFrame:
+    suffix = data_path.suffix.lower()
+    if suffix == ".csv":
+        return pd.read_csv(data_path)
+    if suffix in {".xls", ".xlsx"}:
+        return pd.read_excel(data_path)
+    raise ValueError(f"Unsupported data file format: {data_path.suffix}")
+
+
+def _normalize_background_text(text: str, *, limit: int = 2000) -> str:
+    normalized = " ".join(str(text or "").split()).strip()
+    return normalized[:limit]
+
+
+def _load_parsed_document_context(parsed_document_path: Path | None) -> tuple[str, Path | None, dict[str, object]]:
+    if parsed_document_path is None or not parsed_document_path.exists():
+        return "", None, {}
+
+    try:
+        payload = json.loads(parsed_document_path.read_text(encoding="utf-8"))
+    except Exception:
+        return "", parsed_document_path, {}
+
+    if not isinstance(payload, dict):
+        return "", parsed_document_path, {}
+
+    background = payload.get("background_literature_context", "")
+    if not background:
+        background = payload.get("abstract", "")
+    if not background:
+        background = payload.get("text_excerpt", "")
+    return _normalize_background_text(str(background or "")), parsed_document_path, payload
+
+
+def _extract_selected_table_metadata(
+    parsed_payload: dict[str, object],
+) -> tuple[int, str, tuple[int, int] | None, tuple[str, ...], tuple[str, ...], bool]:
+    candidate_tables = parsed_payload.get("candidate_tables", [])
+    selected_table_id = str(parsed_payload.get("selected_table_id", "") or "")
+    pdf_multi_table_mode = bool(parsed_payload.get("pdf_multi_table_mode", False))
+    if not isinstance(candidate_tables, list):
+        return 0, selected_table_id, None, (), (), pdf_multi_table_mode
+
+    selected_shape: tuple[int, int] | None = None
+    selected_headers: tuple[str, ...] = ()
+    selected_numeric_columns: tuple[str, ...] = ()
+    for candidate in candidate_tables:
+        if not isinstance(candidate, dict) or str(candidate.get("table_id", "") or "") != selected_table_id:
+            continue
+        shape = candidate.get("shape", [])
+        if isinstance(shape, list) and len(shape) == 2:
+            try:
+                selected_shape = (int(shape[0]), int(shape[1]))
+            except (TypeError, ValueError):
+                selected_shape = None
+        headers = candidate.get("headers", [])
+        numeric_columns = candidate.get("numeric_columns", [])
+        if isinstance(headers, list):
+            selected_headers = tuple(str(item) for item in headers)
+        if isinstance(numeric_columns, list):
+            selected_numeric_columns = tuple(str(item) for item in numeric_columns)
+        break
+    return (
+        len(candidate_tables),
+        selected_table_id,
+        selected_shape,
+        selected_headers,
+        selected_numeric_columns,
+        pdf_multi_table_mode,
+    )
+
+
+def _format_candidate_table_summaries(parsed_payload: dict[str, object], *, limit: int = 5) -> str:
+    candidate_tables = parsed_payload.get("candidate_table_summaries", parsed_payload.get("candidate_tables", []))
+    if not isinstance(candidate_tables, list) or not candidate_tables:
+        return ""
+
+    lines: list[str] = []
+    for candidate in candidate_tables[:limit]:
+        if not isinstance(candidate, dict):
+            continue
+        table_id = str(candidate.get("table_id", "") or "unknown")
+        page_number = candidate.get("page_number", "?")
+        shape = candidate.get("shape", [])
+        headers = candidate.get("headers", [])
+        numeric_columns = candidate.get("numeric_columns", [])
+        content_hint = str(candidate.get("content_hint", "") or "").strip()
+        selected = bool(candidate.get("selected_as_primary", False))
+        shape_text = (
+            f"{shape[0]} x {shape[1]}"
+            if isinstance(shape, list) and len(shape) == 2
+            else "unknown"
+        )
+        header_text = ", ".join(str(item) for item in headers[:6]) if isinstance(headers, list) else ""
+        numeric_text = ", ".join(str(item) for item in numeric_columns[:6]) if isinstance(numeric_columns, list) else ""
+        line = (
+            f"- {table_id} | page={page_number} | shape={shape_text} | "
+            f"headers={header_text or 'none'} | numeric_columns={numeric_text or 'none'} | "
+            f"selected_as_primary={selected}"
+        )
+        if content_hint:
+            line += f" | content_hint={content_hint}"
+        lines.append(line)
+    return "\n".join(lines)
+
+
+def _is_pdf_small_table(
+    *,
+    input_kind: str,
+    selected_shape: tuple[int, int] | None,
+    columns: list[str],
+    selected_numeric_columns: tuple[str, ...],
+) -> bool:
+    if input_kind != "pdf" or selected_shape is None:
+        return False
+    rows, cols = selected_shape
+    has_numeric = bool(selected_numeric_columns)
+    has_text_label = len(columns) > len(selected_numeric_columns)
+    return rows <= 30 and cols <= 10 and has_numeric and has_text_label
+
+
+def build_data_context(
+    data_path: str | Path,
+    *,
+    input_kind: str = "tabular",
+    parsed_document_path: str | Path | None = None,
+) -> DataContextSummary:
+    """Build a compact metadata-first prompt context for a local dataset."""
+
+    path = Path(data_path)
+    try:
+        normalized_path = path.resolve().relative_to(Path.cwd().resolve())
+    except ValueError:
+        normalized_path = path
+
+    df = _read_dataframe(path)
+    absolute_path = path.resolve()
+    columns = df.columns.tolist()
+    dtypes = df.dtypes.to_string()
+    shape = df.shape
+    head_markdown = df.head().to_markdown(index=False)
+
+    sample_size_warning = ""
+    small_sample_warning = shape[0] < 30
+    if small_sample_warning:
+        sample_size_warning = (
+            "WARNING / 红色警告:当前样本量极小 (N<30),强烈建议优先考虑非参数检验"
+            "(如 Mann-Whitney U 检验),并对正态分布假设保持高度谨慎。"
+        )
+
+    literature_context, resolved_parsed_document, parsed_payload = _load_parsed_document_context(
+        Path(parsed_document_path) if parsed_document_path is not None else None
+    )
+    (
+        candidate_table_count,
+        selected_table_id,
+        selected_table_shape,
+        _selected_table_headers,
+        selected_table_numeric_columns,
+        pdf_multi_table_mode,
+    ) = _extract_selected_table_metadata(parsed_payload)
+    candidate_table_summaries_text = _format_candidate_table_summaries(parsed_payload)
+    pdf_small_table_mode = _is_pdf_small_table(
+        input_kind=input_kind,
+        selected_shape=selected_table_shape,
+        columns=columns,
+        selected_numeric_columns=selected_table_numeric_columns,
+    )
+
+    context_lines = [
+        f"数据文件相对路径: {normalized_path.as_posix()}",
+        f"数据文件绝对路径: {absolute_path.as_posix()}",
+        f"输入类型: {input_kind}",
+        f"数据列名: {columns}",
+        f"数据类型:\n{dtypes}",
+        f"数据规模: {shape}",
+    ]
+    if sample_size_warning:
+        context_lines.append(sample_size_warning)
+    if literature_context:
+        context_lines.append(
+            "<Background_Literature_Context>\n"
+            f"{literature_context}\n"
+            "</Background_Literature_Context>"
+        )
+    if candidate_table_summaries_text:
+        context_lines.append(
+            "<PDF_Candidate_Tables_Context>\n"
+            f"candidate_table_count={candidate_table_count}\n"
+            f"selected_table_id={selected_table_id or 'unknown'}\n"
+            f"pdf_multi_table_mode={pdf_multi_table_mode}\n"
+            f"{candidate_table_summaries_text}\n"
+            "</PDF_Candidate_Tables_Context>"
+        )
+    if pdf_small_table_mode:
+        context_lines.append(
+            "<PDF_Small_Table_Mode>\n"
+            "This is a PDF-derived small results table, often representing model comparison or compact experimental outcomes.\n"
+            "Use a lightweight template: descriptive statistics, ranking, bootstrap confidence intervals, cautious correlation analysis, optional top-vs-bottom descriptive comparisons, and 2-4 light figures.\n"
+            "The selected primary table is the only table for formal quantitative analysis. Other candidate tables are contextual evidence only and must not trigger extra significance testing by default.\n"
+            "Do not run one-sample tests, do not treat distinct models as repeated observations from one population, and do not run group significance tests without repeated measurements or explicit experimental groups.\n"
+            "</PDF_Small_Table_Mode>"
+        )
+    context_lines.append(f"前 5 行样本:\n{head_markdown}")
+    context_text = "\n".join(context_lines).strip() + "\n"
+
+    return DataContextSummary(
+        data_path=normalized_path,
+        absolute_path=absolute_path,
+        columns=columns,
+        dtypes=dtypes,
+        shape=shape,
+        head_markdown=head_markdown,
+        sample_size_warning=sample_size_warning,
+        small_sample_warning=small_sample_warning,
+        context_text=context_text,
+        input_kind=input_kind,
+        background_literature_context=literature_context,
+        parsed_document_path=resolved_parsed_document,
+        pdf_small_table_mode=pdf_small_table_mode,
+        candidate_table_count=candidate_table_count,
+        selected_table_id=selected_table_id,
+        pdf_multi_table_mode=pdf_multi_table_mode,
+        candidate_table_summaries_text=candidate_table_summaries_text,
+    )

+ 497 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/document_ingestion.py

@@ -0,0 +1,497 @@
+"""Input ingestion helpers for tabular files and PDF documents."""
+
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import tempfile
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+
+
+SUPPORTED_TABULAR_SUFFIXES = frozenset({".csv", ".xls", ".xlsx"})
+SUPPORTED_DOCUMENT_SUFFIXES = frozenset({".pdf"})
+
+
+@dataclass(frozen=True)
+class ExtractedTableRecord:
+    table_id: str
+    page_number: int
+    csv_path: Path
+    rows: int
+    cols: int
+    area: int
+    headers: tuple[str, ...]
+    numeric_columns: tuple[str, ...]
+    content_hint: str = ""
+    selected_as_primary: bool = False
+
+
+@dataclass(frozen=True)
+class IngestionResult:
+    input_kind: str
+    status: str
+    summary: str
+    normalized_data_path: Path
+    duration_ms: int
+    log_path: Path | None = None
+    parsed_document_path: Path | None = None
+    extracted_table_paths: tuple[Path, ...] = ()
+    warnings: tuple[str, ...] = ()
+    selected_table_id: str = ""
+    background_literature_context: str = ""
+    candidate_table_count: int = 0
+    selected_table_shape: tuple[int, int] | None = None
+    selected_table_headers: tuple[str, ...] = ()
+    selected_table_numeric_columns: tuple[str, ...] = ()
+    candidate_table_summaries: tuple[dict[str, Any], ...] = ()
+    pdf_multi_table_mode: bool = False
+
+
+@dataclass(frozen=True)
+class PdfPreviewResult:
+    source_pdf: Path
+    background_literature_context: str
+    candidate_tables: tuple[ExtractedTableRecord, ...]
+    default_table_id: str = ""
+    warnings: tuple[str, ...] = ()
+
+
+def _elapsed_ms(start_time: float) -> int:
+    return int(round((time.perf_counter() - start_time) * 1000))
+
+
+def _normalize_header(header: Any, index: int) -> str:
+    text = " ".join(str(header or "").split()).strip()
+    if not text:
+        return f"column_{index + 1}"
+    return text
+
+
+def _normalize_cell(value: Any) -> str:
+    return " ".join(str(value or "").split()).strip()
+
+
+def _extract_background_context(full_text: str, *, limit: int = 2000) -> str:
+    normalized = re.sub(r"\s+", " ", str(full_text or "")).strip()
+    if not normalized:
+        return ""
+
+    abstract_match = re.search(
+        r"(?:^|\b)(abstract|摘要)\b[::\s-]*(.+?)(?:\b(?:keywords|introduction|背景|方法|materials?|results?)\b|$)",
+        normalized,
+        flags=re.IGNORECASE,
+    )
+    if abstract_match:
+        return abstract_match.group(2).strip()[:limit]
+    return normalized[:limit]
+
+
+def _coerce_numeric_columns(df: pd.DataFrame) -> tuple[str, ...]:
+    numeric_columns: list[str] = []
+    for column in df.columns:
+        series = (
+            df[column]
+            .astype(str)
+            .str.replace(",", "", regex=False)
+            .str.replace("%", "", regex=False)
+            .str.strip()
+        )
+        converted = pd.to_numeric(series, errors="coerce")
+        if converted.notna().any():
+            numeric_columns.append(str(column))
+    return tuple(numeric_columns)
+
+
+def _build_content_hint(df: pd.DataFrame, *, max_columns: int = 4, max_rows: int = 2) -> str:
+    preview_rows: list[str] = []
+    limited_columns = list(df.columns[:max_columns])
+    for _, row in df.head(max_rows).iterrows():
+        values = []
+        for column in limited_columns:
+            value = " ".join(str(row[column] or "").split()).strip()
+            if value:
+                values.append(value)
+        if values:
+            preview_rows.append(" | ".join(values))
+    return " || ".join(preview_rows)
+
+
+def _table_to_dataframe(raw_table: list[list[Any]] | tuple[tuple[Any, ...], ...]) -> pd.DataFrame | None:
+    rows = [list(row) for row in raw_table if any(str(cell or "").strip() for cell in row)]
+    if len(rows) < 2:
+        return None
+    headers = [_normalize_header(value, index) for index, value in enumerate(rows[0])]
+    data_rows = [
+        [_normalize_cell(row[index] if index < len(row) else "") for index in range(len(headers))]
+        for row in rows[1:]
+    ]
+    df = pd.DataFrame(data_rows, columns=headers)
+    stripped = df.astype(str).apply(lambda column: column.str.strip())
+    if df.empty or (stripped == "").all().all():
+        return None
+    return df
+
+
+def _extract_pdf_payload(
+    pdf_path: Path,
+    *,
+    max_pdf_pages: int,
+    max_candidate_tables: int,
+    extracted_tables_dir: Path,
+    persist_csv: bool = True,
+) -> tuple[str, list[ExtractedTableRecord]]:
+    try:
+        import pdfplumber
+    except ModuleNotFoundError as exc:  # pragma: no cover - depends on local environment
+        raise RuntimeError(
+            "pdfplumber is not installed. Install project dependencies before using PDF ingestion."
+        ) from exc
+
+    extracted_tables_dir.mkdir(parents=True, exist_ok=True)
+    page_texts: list[str] = []
+    records: list[ExtractedTableRecord] = []
+    table_counter = 1
+
+    with pdfplumber.open(pdf_path) as pdf:
+        for page_index, page in enumerate(pdf.pages[: max(1, max_pdf_pages)], start=1):
+            page_text = page.extract_text() or ""
+            if page_text.strip():
+                page_texts.append(page_text.strip())
+
+            for raw_table in page.extract_tables() or []:
+                if len(records) >= max(1, max_candidate_tables):
+                    break
+                df = _table_to_dataframe(raw_table)
+                if df is None:
+                    continue
+
+                csv_path = extracted_tables_dir / f"table_{table_counter:02d}.csv"
+                if persist_csv:
+                    df.to_csv(csv_path, index=False, encoding="utf-8-sig")
+                numeric_columns = _coerce_numeric_columns(df)
+                records.append(
+                    ExtractedTableRecord(
+                        table_id=f"table_{table_counter:02d}",
+                        page_number=page_index,
+                        csv_path=csv_path.resolve() if persist_csv else csv_path,
+                        rows=int(df.shape[0]),
+                        cols=int(df.shape[1]),
+                        area=int(df.shape[0] * df.shape[1]),
+                        headers=tuple(str(column) for column in df.columns),
+                        numeric_columns=numeric_columns,
+                        content_hint=_build_content_hint(df),
+                    )
+                )
+                table_counter += 1
+
+            if len(records) >= max(1, max_candidate_tables):
+                break
+
+    return "\n\n".join(page_texts).strip(), records
+
+
+def _select_primary_table(records: list[ExtractedTableRecord]) -> ExtractedTableRecord | None:
+    eligible = [record for record in records if record.numeric_columns]
+    if not eligible:
+        return None
+    return max(eligible, key=lambda record: (record.area, record.rows, record.cols, record.table_id))
+
+
+def _serialize_candidate_tables(records: list[ExtractedTableRecord]) -> list[dict[str, Any]]:
+    return [
+        {
+            "table_id": record.table_id,
+            "page_number": record.page_number,
+            "csv_path": record.csv_path.as_posix(),
+            "shape": [record.rows, record.cols],
+            "area": record.area,
+            "headers": list(record.headers[:12]),
+            "numeric_columns": list(record.numeric_columns),
+            "content_hint": record.content_hint,
+            "selected_as_primary": record.selected_as_primary,
+        }
+        for record in records
+    ]
+
+
+def _serialize_parsed_document(
+    *,
+    source_pdf: Path,
+    background_literature_context: str,
+    full_text_excerpt: str,
+    selected_table_id: str,
+    records: list[ExtractedTableRecord],
+) -> dict[str, Any]:
+    candidate_tables = _serialize_candidate_tables(records)
+    return {
+        "input_kind": "pdf",
+        "pdf_multi_table_mode": True,
+        "source_pdf": source_pdf.resolve().as_posix(),
+        "background_literature_context": background_literature_context,
+        "text_excerpt": full_text_excerpt,
+        "selected_table_id": selected_table_id,
+        "candidate_tables": candidate_tables,
+        "candidate_table_summaries": candidate_tables,
+    }
+
+
+def preview_pdf_tables(
+    data_path: str | Path,
+    *,
+    max_pdf_pages: int = 20,
+    max_candidate_tables: int = 5,
+) -> PdfPreviewResult:
+    source_path = Path(data_path).resolve()
+    if source_path.suffix.lower() not in SUPPORTED_DOCUMENT_SUFFIXES:
+        raise ValueError(f"Unsupported input file format: {source_path.suffix}")
+
+    scratch_root = source_path.parent / ".pdf_preview_tmp"
+    scratch_root.mkdir(parents=True, exist_ok=True)
+    scratch_dir = Path(tempfile.mkdtemp(prefix="pdf_preview_", dir=scratch_root))
+    try:
+        full_text, records = _extract_pdf_payload(
+            source_path,
+            max_pdf_pages=max_pdf_pages,
+            max_candidate_tables=max_candidate_tables,
+            extracted_tables_dir=scratch_dir,
+            persist_csv=False,
+        )
+    finally:
+        shutil.rmtree(scratch_dir, ignore_errors=True)
+
+    default_record = _select_primary_table(records)
+    warnings: list[str] = []
+    if records:
+        warnings.append("系统将以主表做定量分析,并结合其他候选表与文献背景生成综合报告。")
+    else:
+        warnings.append("当前 PDF 未提取到可用结构化表格。")
+
+    return PdfPreviewResult(
+        source_pdf=source_path,
+        background_literature_context=_extract_background_context(full_text),
+        candidate_tables=tuple(records),
+        default_table_id=default_record.table_id if default_record is not None else "",
+        warnings=tuple(warnings),
+    )
+
+
+def ingest_input_document(
+    data_path: str | Path,
+    *,
+    run_dir: str | Path,
+    data_dir: str | Path,
+    logs_dir: str | Path,
+    mode: str = "auto",
+    max_pdf_pages: int = 20,
+    max_candidate_tables: int = 5,
+    selected_table_id: str | None = None,
+) -> IngestionResult:
+    started_at = time.perf_counter()
+    source_path = Path(data_path).resolve()
+    data_dir = Path(data_dir)
+    logs_dir = Path(logs_dir)
+    normalized_mode = str(mode or "auto").strip().lower()
+    if normalized_mode not in {"auto", "text_only", "vision_fallback"}:
+        raise ValueError(f"Unsupported document_ingestion_mode: {mode}")
+
+    log_path = logs_dir / "document_ingestion.json"
+    if source_path.suffix.lower() in SUPPORTED_TABULAR_SUFFIXES:
+        result = IngestionResult(
+            input_kind="tabular",
+            status="not_needed",
+            summary="输入文件已经是结构化表格,跳过文档解析阶段。",
+            normalized_data_path=source_path,
+            duration_ms=_elapsed_ms(started_at),
+            log_path=log_path,
+            candidate_table_count=0,
+            pdf_multi_table_mode=False,
+        )
+        payload = {
+            "input_kind": result.input_kind,
+            "status": result.status,
+            "summary": result.summary,
+            "normalized_data_path": result.normalized_data_path.as_posix(),
+            "duration_ms": result.duration_ms,
+            "candidate_table_count": 0,
+            "pdf_multi_table_mode": False,
+            "mode": normalized_mode,
+        }
+        log_path.parent.mkdir(parents=True, exist_ok=True)
+        log_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+        return result
+
+    if source_path.suffix.lower() not in SUPPORTED_DOCUMENT_SUFFIXES:
+        raise ValueError(f"Unsupported input file format: {source_path.suffix}")
+
+    if normalized_mode == "vision_fallback":
+        raise ValueError("V1 暂不支持 vision_fallback,请先使用文本型 PDF 或手动裁剪目标表格。")
+
+    extracted_tables_dir = data_dir / "extracted_tables"
+    cleaned_data_path = (data_dir / "cleaned_data.csv").resolve()
+    parsed_document_path = (data_dir / "parsed_document.json").resolve()
+
+    full_text, records = _extract_pdf_payload(
+        source_path,
+        max_pdf_pages=max_pdf_pages,
+        max_candidate_tables=max_candidate_tables,
+        extracted_tables_dir=extracted_tables_dir,
+    )
+    background_literature_context = _extract_background_context(full_text)
+    requested_table_id = str(selected_table_id or "").strip()
+    requested_record = None
+    if requested_table_id:
+        requested_record = next((record for record in records if record.table_id == requested_table_id), None)
+        if requested_record is None:
+            raise ValueError(
+                f"Selected table_id '{requested_table_id}' was not found in the extracted candidate tables."
+            )
+        if not requested_record.numeric_columns:
+            raise ValueError(
+                f"Selected table_id '{requested_table_id}' does not contain any numeric columns and cannot be analyzed."
+            )
+    primary_record = requested_record or _select_primary_table(records)
+    warnings: list[str] = []
+
+    if primary_record is None:
+        summary = (
+            "PDF 解析失败:未提取到满足主表路由规则的结构化表格。"
+            "V1 暂不支持复杂多表路由或扫描件恢复,请手动裁剪 PDF 或改上传目标表格。"
+        )
+        parsed_payload = _serialize_parsed_document(
+            source_pdf=source_path,
+            background_literature_context=background_literature_context,
+            full_text_excerpt=full_text[:2000],
+            selected_table_id="",
+            records=records,
+        )
+        parsed_document_path.parent.mkdir(parents=True, exist_ok=True)
+        parsed_document_path.write_text(json.dumps(parsed_payload, ensure_ascii=False, indent=2), encoding="utf-8")
+        result = IngestionResult(
+            input_kind="pdf",
+            status="failed",
+            summary=summary,
+            normalized_data_path=cleaned_data_path,
+            duration_ms=_elapsed_ms(started_at),
+            log_path=log_path,
+            parsed_document_path=parsed_document_path,
+            extracted_table_paths=tuple(record.csv_path for record in records),
+            warnings=tuple(warnings),
+            background_literature_context=background_literature_context,
+            candidate_table_count=len(records),
+            candidate_table_summaries=tuple(parsed_payload["candidate_table_summaries"]),
+            pdf_multi_table_mode=True,
+        )
+        payload = {
+            "input_kind": result.input_kind,
+            "status": result.status,
+            "summary": result.summary,
+            "normalized_data_path": result.normalized_data_path.as_posix(),
+            "duration_ms": result.duration_ms,
+            "parsed_document_path": parsed_document_path.as_posix(),
+            "candidate_tables": parsed_payload["candidate_tables"],
+            "candidate_table_summaries": parsed_payload["candidate_table_summaries"],
+            "candidate_table_count": len(records),
+            "selected_table_id": "",
+            "selected_table_shape": None,
+            "selected_table_headers": [],
+            "selected_table_numeric_columns": [],
+            "pdf_multi_table_mode": True,
+            "warnings": list(result.warnings),
+            "mode": normalized_mode,
+        }
+        log_path.parent.mkdir(parents=True, exist_ok=True)
+        log_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+        return result
+
+    primary_df = pd.read_csv(primary_record.csv_path)
+    cleaned_data_path.parent.mkdir(parents=True, exist_ok=True)
+    primary_df.to_csv(cleaned_data_path, index=False, encoding="utf-8-sig")
+
+    selected_records = [
+        ExtractedTableRecord(
+            table_id=record.table_id,
+            page_number=record.page_number,
+            csv_path=record.csv_path,
+            rows=record.rows,
+            cols=record.cols,
+            area=record.area,
+            headers=record.headers,
+            numeric_columns=record.numeric_columns,
+            content_hint=record.content_hint,
+            selected_as_primary=(record.table_id == primary_record.table_id),
+        )
+        for record in records
+    ]
+    parsed_payload = _serialize_parsed_document(
+        source_pdf=source_path,
+        background_literature_context=background_literature_context,
+        full_text_excerpt=full_text[:2000],
+        selected_table_id=primary_record.table_id,
+        records=selected_records,
+    )
+    parsed_document_path.parent.mkdir(parents=True, exist_ok=True)
+    parsed_document_path.write_text(json.dumps(parsed_payload, ensure_ascii=False, indent=2), encoding="utf-8")
+
+    summary = (
+        f"PDF 文档解析完成:共提取 {len(records)} 张候选表,"
+        f"已选择 {primary_record.table_id} 作为主表写入 cleaned_data.csv,"
+        "其余候选表会与文献背景一起作为综合解释上下文。"
+    )
+    warnings.append("V1 暂不支持多表联合定量分析;若主表选错,请手动裁剪 PDF 或在前端改选主表。")
+    result = IngestionResult(
+        input_kind="pdf",
+        status="completed",
+        summary=summary,
+        normalized_data_path=primary_record.csv_path,
+        duration_ms=_elapsed_ms(started_at),
+        log_path=log_path,
+        parsed_document_path=parsed_document_path,
+        extracted_table_paths=tuple(record.csv_path for record in selected_records),
+        warnings=tuple(warnings),
+        selected_table_id=primary_record.table_id,
+        background_literature_context=background_literature_context,
+        candidate_table_count=len(selected_records),
+        selected_table_shape=(primary_record.rows, primary_record.cols),
+        selected_table_headers=primary_record.headers,
+        selected_table_numeric_columns=primary_record.numeric_columns,
+        candidate_table_summaries=tuple(parsed_payload["candidate_table_summaries"]),
+        pdf_multi_table_mode=True,
+    )
+    payload = {
+        "input_kind": result.input_kind,
+        "status": result.status,
+        "summary": result.summary,
+        "normalized_data_path": result.normalized_data_path.as_posix(),
+        "duration_ms": result.duration_ms,
+        "parsed_document_path": parsed_document_path.as_posix(),
+        "candidate_tables": parsed_payload["candidate_tables"],
+        "candidate_table_summaries": parsed_payload["candidate_table_summaries"],
+        "candidate_table_count": len(selected_records),
+        "selected_table_id": primary_record.table_id,
+        "selected_table_shape": list(result.selected_table_shape) if result.selected_table_shape else None,
+        "selected_table_headers": list(result.selected_table_headers),
+        "selected_table_numeric_columns": list(result.selected_table_numeric_columns),
+        "pdf_multi_table_mode": True,
+        "warnings": list(result.warnings),
+        "mode": normalized_mode,
+    }
+    log_path.parent.mkdir(parents=True, exist_ok=True)
+    log_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+    return result
+
+
+__all__ = [
+    "ExtractedTableRecord",
+    "IngestionResult",
+    "PdfPreviewResult",
+    "SUPPORTED_DOCUMENT_SUFFIXES",
+    "SUPPORTED_TABULAR_SUFFIXES",
+    "ingest_input_document",
+    "preview_pdf_tables",
+]

+ 18 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/llm.py

@@ -0,0 +1,18 @@
+"""LLM construction helpers."""
+
+from __future__ import annotations
+
+from hello_agents import HelloAgentsLLM
+
+from .config import RuntimeConfig
+
+
+def build_llm(config: RuntimeConfig) -> HelloAgentsLLM:
+    """Construct the hello-agents LLM client from validated config."""
+
+    return HelloAgentsLLM(
+        model=config.model_id,
+        api_key=config.api_key,
+        base_url=config.base_url,
+        timeout=config.timeout,
+    )

+ 255 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/plotting.py

@@ -0,0 +1,255 @@
+"""Plot styling helpers for publication-quality static charts."""
+
+from __future__ import annotations
+
+import logging
+import re
+import textwrap
+import unicodedata
+import warnings
+from pathlib import Path
+from typing import Iterable, Sequence
+
+
+def configure_plotting_backend():
+    """Configure a non-interactive matplotlib backend and return plotting modules."""
+
+    import matplotlib
+
+    current_backend = matplotlib.get_backend().lower()
+    if "agg" not in current_backend:
+        matplotlib.use("Agg", force=True)
+
+    logging.getLogger("matplotlib.category").setLevel(logging.WARNING)
+
+    import matplotlib.pyplot as plt
+    import seaborn as sns
+
+    return plt, sns
+
+
+def get_plot_font_family() -> str:
+    """Return the best available CJK-capable font family for the local machine."""
+
+    configure_plotting_backend()
+    from matplotlib import font_manager
+
+    preferred_families = [
+        "Microsoft YaHei",
+        "Noto Sans SC",
+        "SimHei",
+        "SimSun",
+        "Arial Unicode MS",
+        "DejaVu Sans",
+    ]
+    available = {font.name for font in font_manager.fontManager.ttflist}
+    for family in preferred_families:
+        if family in available:
+            return family
+    return "DejaVu Sans"
+
+
+def apply_publication_style():
+    """Apply a consistent scientific plotting style with Chinese-safe fonts."""
+
+    plt, sns = configure_plotting_backend()
+    font_family = get_plot_font_family()
+
+    sns.set_theme(context="talk", style="whitegrid", palette="deep")
+    plt.rcParams.update(
+        {
+            "figure.figsize": (10.5, 6.2),
+            "figure.dpi": 140,
+            # Keep layout control conservative here; save_figure() owns final save-time fallback.
+            "figure.constrained_layout.use": False,
+            "savefig.dpi": 300,
+            "savefig.bbox": "tight",
+            "savefig.facecolor": "white",
+            "axes.facecolor": "#FAFAFA",
+            "axes.edgecolor": "#2F2F2F",
+            "axes.labelcolor": "#1F1F1F",
+            "axes.titleweight": "bold",
+            "axes.titlesize": 16,
+            "axes.labelsize": 12,
+            "axes.linewidth": 1.0,
+            "axes.spines.top": False,
+            "axes.spines.right": False,
+            "grid.alpha": 0.18,
+            "grid.linestyle": "--",
+            "grid.linewidth": 0.8,
+            "legend.frameon": False,
+            "legend.fontsize": 10,
+            "legend.title_fontsize": 11,
+            "lines.linewidth": 2.2,
+            "lines.markersize": 6,
+            "xtick.color": "#333333",
+            "ytick.color": "#333333",
+            "xtick.labelsize": 10,
+            "ytick.labelsize": 10,
+            "font.family": "sans-serif",
+            "font.sans-serif": [font_family, "Microsoft YaHei", "Noto Sans SC", "SimHei", "DejaVu Sans"],
+            "axes.unicode_minus": False,
+        }
+    )
+    return plt, sns
+
+
+def ensure_ascii_text(value: object, fallback: str = "label") -> str:
+    """Convert labels to ASCII-only text when a fully ASCII figure is desired."""
+
+    text = str(value).strip()
+    normalized = unicodedata.normalize("NFKD", text)
+    ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
+    compact_text = " ".join(ascii_text.split()).strip()
+    return compact_text or fallback
+
+
+def ensure_ascii_sequence(values: Iterable[object], prefix: str = "label") -> list[str]:
+    """Convert a sequence of labels to ASCII-only strings."""
+
+    converted: list[str] = []
+    for index, value in enumerate(values, start=1):
+        converted.append(ensure_ascii_text(value, fallback=f"{prefix}_{index}"))
+    return converted
+
+
+def prepare_month_index(values: Sequence[object]):
+    """Convert Chinese or ISO-like month labels to a stable datetime index when possible."""
+
+    import pandas as pd
+
+    normalized_values = []
+    for value in values:
+        text = str(value).strip()
+        normalized_text = text.replace("年", "-").replace("月", "").replace("/", "-")
+        match = re.fullmatch(r"(\d{4})-(\d{1,2})", normalized_text)
+        if match:
+            year = int(match.group(1))
+            month = int(match.group(2))
+            normalized_values.append(f"{year:04d}-{month:02d}-01")
+        else:
+            normalized_values.append(text)
+
+    parsed = pd.to_datetime(normalized_values, errors="coerce", format="%Y-%m-%d")
+    if getattr(parsed, "notna", None) is not None and parsed.notna().all():
+        return parsed
+    return list(values)
+
+
+def wrap_text(value: object, width: int = 16) -> str:
+    """Wrap long text labels for cleaner legends and axis ticks."""
+
+    text = str(value)
+    if len(text) <= width:
+        return text
+    return "\n".join(textwrap.wrap(text, width=width, break_long_words=False, break_on_hyphens=False))
+
+
+def beautify_axes(
+    ax,
+    *,
+    title: str | None = None,
+    xlabel: str | None = None,
+    ylabel: str | None = None,
+    rotate_xticks: int = 25,
+    wrap_xticks: bool = False,
+    wrap_width: int = 14,
+    legend: bool = True,
+):
+    """Apply consistent axis-level polish to reduce overlap and improve readability."""
+
+    if title:
+        ax.set_title(title, pad=14)
+    if xlabel:
+        ax.set_xlabel(xlabel, labelpad=10)
+    if ylabel:
+        ax.set_ylabel(ylabel, labelpad=10)
+
+    if wrap_xticks:
+        tick_labels = [wrap_text(label.get_text(), width=wrap_width) for label in ax.get_xticklabels()]
+        ax.set_xticklabels(tick_labels)
+
+    for label in ax.get_xticklabels():
+        label.set_rotation(rotate_xticks)
+        label.set_horizontalalignment("right" if rotate_xticks else "center")
+
+    ax.tick_params(axis="x", pad=6)
+    ax.tick_params(axis="y", pad=6)
+    ax.margins(x=0.02)
+
+    if legend and ax.get_legend() is not None:
+        ax.legend(loc="best", frameon=False)
+
+    return ax
+
+
+def _resolve_save_figure_args(*args):
+    """Support the new single-argument API and a minimal backward-compatible path."""
+
+    plt, _ = configure_plotting_backend()
+    if len(args) == 1:
+        return plt.gcf(), args[0]
+    if len(args) == 2 and hasattr(args[0], "savefig"):
+        return args[0], args[1]
+    raise TypeError("save_figure() expects save_figure(output_path) as the standard API.")
+
+
+def _is_layout_conflict(exc: Exception) -> bool:
+    message = str(exc).lower()
+    keywords = (
+        "layout engine",
+        "tight_layout",
+        "constrained_layout",
+        "colorbar layout",
+    )
+    return any(keyword in message for keyword in keywords)
+
+
+def _attempt_figure_save(fig, destination: Path) -> None:
+    fig.savefig(destination, dpi=300, bbox_inches="tight", facecolor="white")
+
+
+def save_figure(*args) -> Path:
+    """Save the current figure defensively.
+
+    Standard API:
+        save_figure(output_path)
+
+    A minimal backward-compatible path for save_figure(fig, output_path) is kept
+    internally, but prompt/tooling should only expose the single-argument form.
+    """
+
+    fig, output_path = _resolve_save_figure_args(*args)
+    destination = Path(output_path)
+    destination.parent.mkdir(parents=True, exist_ok=True)
+
+    with warnings.catch_warnings():
+        warnings.filterwarnings("ignore", message=".*figure layout has changed to tight.*")
+        try:
+            _attempt_figure_save(fig, destination)
+        except Exception as exc:
+            if not _is_layout_conflict(exc):
+                raise
+
+            # Defensive fallback: disable layout engines and retry without throwing
+            # the common matplotlib heatmap/colorbar conflict back to the agent.
+            try:
+                if hasattr(fig, "set_layout_engine"):
+                    fig.set_layout_engine(None)
+            except Exception:
+                pass
+
+            try:
+                if hasattr(fig, "set_constrained_layout"):
+                    fig.set_constrained_layout(False)
+            except Exception:
+                pass
+
+            try:
+                fig.subplots_adjust(left=0.08, right=0.98, top=0.92, bottom=0.12)
+            except Exception:
+                pass
+
+            _attempt_figure_save(fig, destination)
+
+    return destination

+ 134 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/presentation.py

@@ -0,0 +1,134 @@
+"""Notebook-oriented presentation helpers for analysis results."""
+
+from __future__ import annotations
+
+import html
+from typing import Iterable
+
+from .agent_runner import AgentStepTrace, AnalysisRunResult
+
+
+def _tool_label(tool_name: str | None) -> str:
+    if tool_name == "PythonInterpreterTool":
+        return "本地 Python 分析"
+    if tool_name == "TavilySearchTool":
+        return "联网背景检索"
+    if tool_name:
+        return tool_name
+    return "报告收敛"
+
+
+def _status_label(status: str) -> str:
+    mapping = {
+        "success": "成功",
+        "partial": "部分完成",
+        "error": "失败",
+        "unknown": "未知",
+    }
+    return mapping.get(status, status)
+
+
+def _escape(value: object) -> str:
+    return html.escape(str(value))
+
+
+def _trace_short_observation(trace: AgentStepTrace) -> str:
+    if trace.observation_preview:
+        return trace.observation_preview
+    if trace.observation:
+        return " ".join(trace.observation.split())[:220]
+    return ""
+
+
+def _iter_failed_traces(step_traces: Iterable[AgentStepTrace]) -> list[AgentStepTrace]:
+    failed = []
+    for trace in step_traces:
+        observation = trace.observation or ""
+        if trace.tool_status == "error" or "Traceback" in observation:
+            failed.append(trace)
+    return failed
+
+
+def render_trace_table(result: AnalysisRunResult):
+    """Render the agent reasoning trace as notebook-friendly HTML."""
+
+    from IPython.display import HTML
+
+    rows = []
+    for trace in result.step_traces:
+        if trace.action == "call_tool":
+            stage = f"{_tool_label(trace.tool_name)} ({trace.tool_name})"
+        else:
+            stage = "最终报告"
+        rows.append(
+            """
+            <tr>
+              <td style="border:1px solid #d1d5db; padding:8px; vertical-align:top;">{step}</td>
+              <td style="border:1px solid #d1d5db; padding:8px; vertical-align:top;">{stage}</td>
+              <td style="border:1px solid #d1d5db; padding:8px; vertical-align:top;">{decision}</td>
+              <td style="border:1px solid #d1d5db; padding:8px; vertical-align:top;">{status}</td>
+              <td style="border:1px solid #d1d5db; padding:8px; vertical-align:top;">{observation}</td>
+              <td style="border:1px solid #d1d5db; padding:8px; vertical-align:top;">{notes}</td>
+            </tr>
+            """.format(
+                step=_escape(trace.step_index),
+                stage=_escape(stage),
+                decision=_escape(trace.decision or trace.action),
+                status=_escape(_status_label(trace.tool_status)),
+                observation=_escape(_trace_short_observation(trace) or "无"),
+                notes=_escape(trace.summary or trace.parse_error or "无"),
+            )
+        )
+
+    html_content = """
+    <h2>Agent 推理轨迹表</h2>
+    <table style="width:100%; border-collapse:collapse; font-size:14px;">
+      <thead>
+        <tr style="background:#f3f4f6;">
+          <th style="border:1px solid #d1d5db; padding:8px;">Step</th>
+          <th style="border:1px solid #d1d5db; padding:8px;">Stage / Tool</th>
+          <th style="border:1px solid #d1d5db; padding:8px;">Decision</th>
+          <th style="border:1px solid #d1d5db; padding:8px;">Status</th>
+          <th style="border:1px solid #d1d5db; padding:8px;">Short Observation</th>
+          <th style="border:1px solid #d1d5db; padding:8px;">Notes</th>
+        </tr>
+      </thead>
+      <tbody>
+        {rows}
+      </tbody>
+    </table>
+    """.format(rows="".join(rows))
+    return HTML(html_content)
+
+
+def render_full_report(result: AnalysisRunResult):
+    """Render the full Markdown report without relying on plain print()."""
+
+    from IPython.display import Markdown
+
+    return Markdown("## 完整报告正文\n\n" + result.report_markdown)
+
+
+def render_diagnostics(result: AnalysisRunResult):
+    """Render expandable diagnostics with full observations and tracebacks."""
+
+    from IPython.display import HTML
+
+    failed_traces = _iter_failed_traces(result.step_traces)
+    if not failed_traces:
+        return HTML("<h2>错误与诊断详情</h2><p>本次运行无工具级异常。</p>")
+
+    details_blocks = []
+    for trace in failed_traces:
+        title = f"Step {trace.step_index} Traceback"
+        body = _escape(trace.observation or trace.parse_error or "No diagnostic text available.")
+        details_blocks.append(
+            f"""
+            <details style="margin-bottom:12px;">
+              <summary style="cursor:pointer; font-weight:600;">{_escape(title)}</summary>
+              <pre style="white-space:pre-wrap; background:#111827; color:#f9fafb; padding:12px; border-radius:8px; margin-top:8px;">{body}</pre>
+            </details>
+            """
+        )
+
+    return HTML("<h2>错误与诊断详情</h2>" + "".join(details_blocks))

+ 350 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/prompts.py

@@ -0,0 +1,350 @@
+"""Prompt definitions for the custom scientific ReAct runner."""
+
+from __future__ import annotations
+
+
+DEFAULT_QUERY = "请分析以下数据集:"
+
+
+def build_system_prompt(
+    *,
+    run_dir: str,
+    cleaned_data_path: str,
+    figures_dir: str,
+    logs_dir: str,
+    background_literature_context: str = "",
+    max_steps: int = 6,
+    tool_descriptions: str = "",
+    search_enabled: bool = True,
+    latency_mode: str = "quality",
+    fast_path_enabled: bool = False,
+    pdf_small_table_mode: bool = False,
+) -> str:
+    """Return the system prompt for the custom JSON-driven analysis runner."""
+
+    tools_block = tool_descriptions or "- PythonInterpreterTool: Execute Python code and print analysis results."
+
+    search_policy_block = (
+        "Online domain search is available in this run. Use TavilySearchTool only when the available tools list includes it and the domain context genuinely requires external background knowledge."
+        if search_enabled
+        else "Online domain search is disabled for this run. Do not call TavilySearchTool unless it explicitly appears in the available tools list."
+    )
+    fast_path_block = (
+        "Fast-path is enabled. If Stage 1 has already saved cleaned_data.csv and the latest Stage 2 observation includes the needed statistics, interpretations, and saved-figure confirmations without tool errors, finish instead of exploring extra branches."
+        if fast_path_enabled
+        else "Fast-path is disabled. Optimize for completeness over latency."
+    )
+    literature_context_block = (
+        "\nBackground literature context from PDF ingestion:\n"
+        "<Background_Literature_Context>\n"
+        f"{background_literature_context}\n"
+        "</Background_Literature_Context>\n"
+        if background_literature_context
+        else ""
+    )
+    pdf_small_table_block = (
+        "\nPDF small-table mode is enabled for this run.\n"
+        "<PDF_Small_Table_Mode>\n"
+        "This dataset is a small PDF-derived results table, often representing model comparison or compact experimental outcomes.\n"
+        "Preferred template: data overview, descriptive statistics, ranking, bootstrap confidence intervals, cautious correlation analysis, 2-4 light visualizations, discussion grounded in the literature background, and explicit limitations.\n"
+        "The selected primary table is the only table for formal quantitative analysis in this run. Other PDF candidate tables may be used only as contextual evidence for interpretation, cross-checking, and limitations.\n"
+        "Do not introduce one-sample tests, do not treat distinct models as repeated observations from one population, and do not run group significance tests without repeated measurements or clearly defined experimental groups.\n"
+        "</PDF_Small_Table_Mode>\n"
+        if pdf_small_table_mode
+        else ""
+    )
+
+    return f"""You are a top-tier quantitative data scientist working in a rigorous research setting.
+
+You are a cross-domain scientific analysis agent. You can analyze structured datasets from macroeconomics, finance, biology, medicine, public policy, operations, education, and other tabular research domains as long as the data can be read locally and the schema can be inferred from metadata plus sampled rows.
+
+Your job is to analyze the dataset described in the user-provided data_context. The data_context only contains file paths, schema information, shape, sampled rows, and this run's artifact directory information. It does not contain the full dataset. You must use the available tools to load the local file, inspect the real data, infer the most likely domain, clean it, run statistical analysis, and save charts locally.
+{literature_context_block}
+{pdf_small_table_block}
+
+Available tools:
+{tools_block}
+
+Core Workflow Mandatory Policy / 核心工作流强制规范:
+You must follow the two-stage pipeline below. You are not allowed to skip Stage 1 and directly analyze the raw file.
+
+Stage 1 - Data Cleaning and Preprocessing:
+- First read the raw dataset from the local source path provided in data_context.
+- Handle missing values, obvious outliers when appropriate, malformed headers, garbled column names, and dtype normalization.
+- Save the cleaned dataset to exactly this path: `{cleaned_data_path}`.
+- You must use print() to confirm that the cleaned dataset was saved successfully.
+- You must not proceed to Stage 2 until the save confirmation appears in the tool observation.
+
+Stage 2 - Statistical Analysis and Visualization:
+- After Stage 1 succeeds, write new Python code that re-loads the cleaned dataset from `{cleaned_data_path}`.
+- All statistical analysis, hypothesis testing, and plotting must use the cleaned dataset as the primary input.
+- Save all generated figures under `{figures_dir}` only.
+- Do not save figures outside the run directory.
+
+Academic Guardrails / 统计学汇报规范:
+- These guardrails are mandatory. You are operating under APA-style academic reporting expectations, not hacker-style p-value dumping.
+- If you run any hypothesis test, you must report the test statistic, the p-value, an effect size, and a 95% CI together. Never report an isolated p-value.
+- For t-tests, prefer reporting Cohen's d together with a 95% CI. For ANOVA, prefer reporting eta squared (η²) together with a 95% CI or an explicitly justified interval estimate when available.
+- If you compare more than 2 groups and perform pairwise comparisons, you must apply Bonferroni correction or Tukey HSD in code and state the correction method explicitly in the report.
+- If data_context contains a small-sample warning, treat it as a serious methodological constraint. Prefer non-parametric tests such as Mann-Whitney U or Kruskal-Wallis unless you have a strong printed justification for parametric assumptions.
+- If <PDF_Small_Table_Mode> appears in the context, you must use the lightweight template for small PDF results tables. Unless the data clearly contains repeated observations or explicit experimental groups with valid sample replication, do not invent hypothesis tests.
+- If <PDF_Candidate_Tables_Context> appears in the context, treat the selected primary table as the only table for formal quantitative analysis. Use the remaining candidate tables only as contextual evidence for interpretation, cross-checking, and discussion.
+- In the final report, you must explicitly warn when a small sample size limits distributional assumptions, inferential stability, or generalizability.
+- In Result Interpretation, Discussion, and Conclusion sections, strictly separate correlation from causation.
+- Without experimental design, random assignment, or causal identification evidence, do not use causal wording such as “导致”, “引发”, “造成”, or “证明 X 影响 Y”.
+- Use non-causal wording such as “相关”, “关联”, “差异”, “提示”, or “可能有关”.
+- If your observations do not contain effect sizes, confidence intervals, or the required multiple-comparison correction details, the analysis is not ready to finish.
+
+Hard prohibitions:
+- Do not analyze the raw dataset before saving cleaned_data.csv.
+- Do not keep using the raw file as the main analytical input during Stage 2.
+- Do not save charts outside `{figures_dir}`.
+- Do not reference old outputs/ paths in the final report unless they are inside this run directory.
+
+Execution rules:
+1. Use PythonInterpreterTool whenever you need to read data, clean data, compute statistics, run hypothesis tests, fit models, or generate plots.
+2. You may use pandas, numpy, scipy.stats, statsmodels, matplotlib, and seaborn when available in the environment.
+3. The tool namespace already provides plt, sns, apply_publication_style(), beautify_axes(), prepare_month_index(), get_plot_font_family(), ensure_ascii_text(), ensure_ascii_sequence(), and save_figure(). Use them instead of building chart styling from scratch.
+4. All charts must support Chinese text correctly. Before plotting, call apply_publication_style() and rely on the detected Chinese-capable font from get_plot_font_family().
+5. To avoid overlap and garbled figures, convert month-like labels with prepare_month_index() when appropriate, rotate crowded x-axis labels, wrap long labels when needed, and call beautify_axes(...) before saving.
+6. Domain knowledge retrieval: if the data_context contains unfamiliar technical terms, abbreviations, biomedical markers, financial metrics, or complex indicator names, call TavilySearchTool only when it is actually listed in the available tools. When used, incorporate retrieved domain knowledge into Result Interpretation and Discussion instead of merely repeating raw numbers.
+7. First infer the likely domain from the column names, sample rows, and search results. Then choose methods that fit that domain and data shape. Do not assume the dataset is always economic.
+8. Use a polished publication-style aesthetic: clean white background, subtle grid, readable typography, balanced spacing, strong visual hierarchy, and high-resolution output.
+9. If there are too many categories, prefer horizontal bar charts, top-k subsets, or larger figure sizes instead of forcing all labels into one crowded view.
+10. Extremely important: your Python code must use print() for every result, statistic, p-value, interpretation, or file path you want to observe.
+11. If a tool returns an error traceback, carefully read it, fix the code, and try again.
+12. Never invent numbers or conclusions. Every statistical claim must be grounded in tool observations.
+13. You have at most {max_steps} controller steps, so make each tool call complete and information-dense.
+14. {search_policy_block}
+15. Latency mode for this run: {latency_mode}. {fast_path_block}
+
+Official plotting protocol / 官方绘图协议:
+- The only standard save API is save_figure(output_path).
+- Do not call save_figure(fig, path) in new code.
+- Do not call plt.tight_layout() manually.
+- Do not redefine save_fig(), save_plot(), or other private save helpers unless absolutely necessary.
+- Do not manually adjust constrained_layout, bbox_inches, dpi, or facecolor. The backend already handles publication-ready saving.
+- Focus only on plotting the data and labeling the axes. If additional axis polish is needed, use beautify_axes(...).
+
+Official plotting template:
+```python
+fig, ax = plt.subplots()
+# draw your chart here
+beautify_axes(ax, title="...", xlabel="...", ylabel="...")
+save_figure(".../figures/chart.png")
+print("Saved figure to .../figures/chart.png")
+```
+
+Heatmap rule:
+- sns.heatmap(...) is allowed.
+- After drawing a heatmap, save it only with save_figure(path).
+- Do not call plt.tight_layout() before or after a heatmap.
+
+Run directory contract:
+- Run root directory: `{run_dir}`
+- Cleaned data path: `{cleaned_data_path}`
+- Figures directory: `{figures_dir}`
+- Logs directory: `{logs_dir}`
+
+Response contract:
+- Every single response must be exactly one JSON object.
+- Do not wrap the JSON in Markdown unless the model absolutely insists; plain JSON is preferred.
+- Do not add commentary before or after the JSON object.
+
+Use this schema:
+{{
+  "decision": "One short sentence describing the next concrete step.",
+  "action": "call_tool" or "finish",
+  "tool_name": "PythonInterpreterTool or TavilySearchTool",
+  "tool_input": "Complete Python code or a natural-language search query as a string. Required only when action is call_tool.",
+  "final_answer": "Complete Markdown report followed by a trailing <telemetry>{{...}}</telemetry> block. Required only when action is finish."
+}}
+
+Validation rules:
+- If action is "call_tool", provide a non-empty tool_name and tool_input, and leave final_answer as an empty string.
+- If background knowledge is needed, set tool_name to "TavilySearchTool" and provide a concise natural-language search query in tool_input.
+- Only call a tool if it is explicitly listed in the Available tools block above.
+- If action is "finish", provide the complete final Markdown report in final_answer, and leave tool_name and tool_input as empty strings.
+- The final answer must end with exactly one telemetry block in this form:
+<telemetry>
+{{"methods": [...], "domain": "...", "tools_used": [...], "search_used": true_or_false, "search_notes": "...", "cleaned_data_saved": true_or_false, "cleaned_data_path": "...", "figures_generated": ["..."]}}
+</telemetry>
+- The telemetry block must appear only once, at the very end, after the Markdown report body.
+- The telemetry block must reflect actual tool usage and real analysis steps. Do not fabricate methods, domain, search usage, or artifact paths.
+- The final Markdown report must include:
+  - 数据概览
+  - 方法说明
+  - 统计学治理说明
+  - 核心假设检验结论
+  - 结果解释
+  - 讨论
+  - 清洗后数据路径
+  - 图表引用 such as ![图表]({figures_dir}/chart.png)
+  - If any hypothesis test was run, the report must include the test statistic, p-value, effect size, and 95% CI together.
+  - If more than two groups were compared pairwise, the report must state the multiple-comparison correction method explicitly.
+"""
+
+
+def build_reviewer_prompt(review_mode: str, *, focus_major_issues: bool = False) -> str:
+    """Return the system prompt for the reviewer agent."""
+
+    normalized_mode = review_mode.strip().lower()
+    if normalized_mode not in {"standard", "publication"}:
+        raise ValueError(f"Unsupported reviewer mode: {review_mode}")
+
+    if normalized_mode == "publication":
+        reviewer_role = "You are an exceptionally strict reviewer from a top-tier journal ecosystem such as Nature, Science, or Cell."
+        checklist = """Review checklist:
+- Verify that figure references are present, coherent, and point to this run's actual figure paths.
+- If Generated artifacts evidence confirms that figures were saved in this run and artifact validation is green, do not reject solely because the compressed execution trace omits plotting details.
+- Verify that any hypothesis test is reported with the test statistic, p-value, effect size, and 95% CI together.
+- Verify that multi-group pairwise comparisons explicitly mention Bonferroni correction or Tukey HSD when required.
+- Verify that the report does not confuse correlation with causation.
+- Verify that there are no obvious logical leaps, implausible claims, over-interpretation relative to the sample size, or conclusions that contradict the execution trace.
+- Verify that the report does not cite files, figures, or cleaned-data paths outside the current run directory contract.
+- Verify that the chosen methods match the data structure, including dependency, repeated measures, or time-series risks when present.
+"""
+        decision_policy = """Decision policy:
+- Return "Accept" only if the report is publication-grade, internally coherent, statistically defensible, and adequately grounded in the supplied evidence.
+- Return "Reject" if any major statistical, logical, citation, artifact, or interpretation issue remains.
+- You must not invent new results, new p-values, or new evidence that does not appear in the candidate report or the supplied review context.
+"""
+    else:
+        reviewer_role = "You are a rigorous reviewer for a high-quality technical or academic report."
+        checklist = """Review checklist:
+- Verify that figure references are present, coherent, and point to this run's actual figure paths.
+- If Generated artifacts evidence confirms that figures were saved in this run and artifact validation is green, do not reject solely because the compressed execution trace omits plotting details.
+- Verify that major hypothesis tests are not reported as isolated p-values.
+- Verify that there are no obvious logical errors, broken artifact references, or contradictions with the execution trace.
+- Verify that the report does not confuse correlation with causation in a plainly misleading way.
+- Verify that the report does not cite files, figures, or cleaned-data paths outside the current run directory contract.
+"""
+        decision_policy = """Decision policy:
+- Return "Accept" only if the report is coherent, well-supported, and free of major technical, logical, or artifact issues.
+- Return "Reject" if any major issue remains that would materially reduce trust in the report.
+- You must not invent new results, new p-values, or new evidence that does not appear in the candidate report or the supplied review context.
+"""
+    focus_block = (
+        "\nFast review focus:\n- Prioritize major blocking issues over minor polish items.\n"
+        if focus_major_issues
+        else ""
+    )
+
+    return f"""{reviewer_role}
+
+You are not the analyst. You are an independent statistical and logical reviewer.
+
+Your task is to review the candidate final_report.md, together with the provided dataset metadata, execution-trace summary, and artifact-validation summary.
+
+{checklist}
+One-pass review principle:
+- You must list all major visible rejection reasons in this round.
+- Do not intentionally hold back major problems for a later round if they are already visible now.
+- Your critique must be structured as an actionable numbered list so that the analyst can respond point by point.
+{focus_block}
+
+{decision_policy}
+Output contract:
+- Return exactly one JSON object and nothing else.
+- The JSON object must follow this schema:
+{{
+  "decision": "Accept" or "Reject",
+  "critique": "Use Simplified Chinese. If Reject, provide a numbered actionable revision list in Chinese. If Accept, provide a short approval note in Chinese."
+}}
+
+Validation rules:
+- decision must be exactly "Accept" or "Reject".
+- critique must be a non-empty Chinese string written in Simplified Chinese.
+- Do not wrap the JSON in Markdown.
+"""
+
+
+def build_response_format_feedback(parse_error: str) -> str:
+    """Return a corrective prompt when the model violates the JSON contract."""
+
+    return f"""Your previous response could not be parsed by the controller.
+
+Parsing error:
+{parse_error}
+
+Re-emit your answer as exactly one JSON object that matches the required schema.
+Do not add any explanation outside the JSON.
+If you need to continue working, use action "call_tool".
+If and only if the analysis is complete, use action "finish".
+Remember that a final answer must end with a valid <telemetry>{{...}}</telemetry> block.
+"""
+
+
+def build_observation_prompt(
+    *,
+    tool_name: str,
+    observation_summary: str = "",
+    observation: str = "",
+    remaining_steps: int,
+    fast_path_enabled: bool = False,
+) -> str:
+    """Return the observation prompt fed back to the controller loop."""
+
+    observation_text = observation_summary or observation
+    fast_path_hint = (
+        "- Fast-path hint: if cleaned_data.csv has already been saved and the latest Python observation includes the required statistics plus figure save confirmations, finish now instead of exploring extra branches.\n"
+        if fast_path_enabled
+        else ""
+    )
+
+    return f"""Observation summary from {tool_name}:
+{observation_text}
+
+Read the observation carefully.
+- If the tool returned an error or incomplete result, fix your Python code or revise the search query and call the tool again.
+- If Stage 1 has not yet saved cleaned_data.csv successfully, do not move to Stage 2.
+- If you already ran hypothesis tests but the observation does not show effect sizes, 95% CIs, or the required multiple-comparison correction details, do not finish yet.
+- If the statistical analysis is complete and defensible, return action "finish" with the full Markdown report plus the required trailing telemetry block.
+{fast_path_hint}- The observation above is intentionally compressed. Do not assume omitted text means omitted evidence; use the visible summary and your own prior steps to decide the next action.
+- Remaining controller steps: {remaining_steps}
+"""
+
+
+def build_visual_reviewer_prompt() -> str:
+    """Return the system prompt for the visual reviewer agent."""
+
+    return """You are an expert visual reviewer for scientific figures used in research reports.
+
+You will receive a small set of compressed chart images. These are compressed review copies of the original figures, created only to reduce latency and token cost. You must still judge whether the figures are readable, well-labeled, visually coherent, and consistent with the stated figure descriptions.
+
+Review scope:
+- Check whether titles, axis labels, legends, units, and color bars are present and understandable.
+- Check whether labels overlap, are cut off, are too dense, or appear garbled.
+- Check whether the color contrast is poor or the visual encoding is likely to mislead.
+- Check whether the chart looks empty, overcrowded, or visually low-confidence.
+- Check whether the visible content obviously conflicts with the provided figure description or alt text.
+
+Do not:
+- Recompute statistics.
+- Infer values that are not visually legible.
+- Review PDFs, OCR output, or SVG vector markup.
+- Invent issues that are not visible in the supplied images.
+
+Output contract:
+- Return exactly one JSON object and nothing else.
+- The JSON object must follow this schema:
+{
+  "decision": "Pass" or "Flag",
+  "summary": "Use Simplified Chinese. Summarize the overall visual quality in 1-3 sentences.",
+  "findings": [
+    {
+      "figure": "Figure filename or label",
+      "severity": "low" | "medium" | "high",
+      "issue": "Use Simplified Chinese.",
+      "suggested_fix": "Use Simplified Chinese."
+    }
+  ]
+}
+
+Validation rules:
+- decision must be exactly "Pass" or "Flag".
+- summary must be a non-empty Simplified Chinese string.
+- findings may be an empty list when decision is "Pass".
+- When decision is "Flag", findings must include all major visible issues in this round.
+- Do not wrap the JSON in Markdown.
+"""

+ 194 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/reporting.py

@@ -0,0 +1,194 @@
+"""Report extraction, telemetry parsing, and persistence helpers."""
+
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+from urllib.parse import quote
+
+
+_TELEMETRY_PATTERN = re.compile(r"\s*<telemetry>\s*(\{[\s\S]*?\})\s*</telemetry>\s*$", re.IGNORECASE)
+_MARKDOWN_IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
+_URL_SCHEMES = ("http://", "https://", "data:", "file://")
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+
+
+@dataclass(frozen=True)
+class ReportTelemetry:
+    methods: tuple[str, ...] = ()
+    domain: str = "unknown"
+    tools_used: tuple[str, ...] = ()
+    search_used: bool = False
+    search_notes: str = "unknown"
+    cleaned_data_saved: bool = False
+    cleaned_data_path: str = ""
+    figures_generated: tuple[str, ...] = ()
+    valid: bool = False
+    warning: str | None = None
+    raw_payload: dict[str, Any] | None = None
+
+
+@dataclass(frozen=True)
+class ReportExtractionResult:
+    report_markdown: str
+    telemetry: ReportTelemetry
+
+
+def _normalize_string_list(value: Any) -> tuple[str, ...]:
+    if not isinstance(value, list):
+        return ()
+    normalized = [str(item).strip() for item in value if str(item).strip()]
+    return tuple(normalized)
+
+
+def extract_report_and_telemetry(result_text: str) -> ReportExtractionResult:
+    """Extract the report body and structured telemetry from the model output."""
+
+    if not result_text.strip():
+        return ReportExtractionResult(
+            report_markdown="# Data Analysis Report\n\nNo valid output was produced.",
+            telemetry=ReportTelemetry(warning="missing_output"),
+        )
+
+    raw_text = result_text.strip()
+    telemetry = ReportTelemetry(warning="missing")
+    telemetry_match = _TELEMETRY_PATTERN.search(raw_text)
+    report_body = raw_text
+
+    if telemetry_match:
+        report_body = raw_text[: telemetry_match.start()].strip()
+        telemetry_json = telemetry_match.group(1).strip()
+        try:
+            payload = json.loads(telemetry_json)
+            if not isinstance(payload, dict):
+                raise ValueError("Telemetry JSON must decode to an object.")
+            telemetry = ReportTelemetry(
+                methods=_normalize_string_list(payload.get("methods")),
+                domain=str(payload.get("domain", "unknown")).strip() or "unknown",
+                tools_used=_normalize_string_list(payload.get("tools_used")),
+                search_used=bool(payload.get("search_used", False)),
+                search_notes=str(payload.get("search_notes", "unknown")).strip() or "unknown",
+                cleaned_data_saved=bool(payload.get("cleaned_data_saved", False)),
+                cleaned_data_path=str(payload.get("cleaned_data_path", "")).strip(),
+                figures_generated=_normalize_string_list(payload.get("figures_generated")),
+                valid=True,
+                warning=None,
+                raw_payload=payload,
+            )
+        except Exception as exc:
+            telemetry = ReportTelemetry(warning=f"malformed:{exc}")
+
+    report_match = re.search(r"(# .+[\s\S]*)", report_body)
+    if report_match:
+        cleaned_report = report_match.group(1).strip()
+    else:
+        cleaned_report = report_body.strip()
+
+    if not cleaned_report:
+        cleaned_report = "# Data Analysis Report\n\nNo valid Markdown report body was produced."
+
+    return ReportExtractionResult(report_markdown=cleaned_report, telemetry=telemetry)
+
+
+def extract_markdown_report(result_text: str) -> str:
+    """Extract only the human-facing Markdown report from the agent output."""
+
+    return extract_report_and_telemetry(result_text).report_markdown
+
+
+def _resolve_markdown_asset_path(
+    raw_target: str,
+    *,
+    project_root: str | Path | None = None,
+    base_dir: str | Path | None = None,
+) -> str:
+    target = raw_target.strip()
+    if not target:
+        return raw_target
+
+    if target.startswith(_URL_SCHEMES) or target.startswith("/"):
+        return target
+
+    if target.startswith("<") and target.endswith(">"):
+        target = target[1:-1].strip()
+
+    candidate_path = Path(target)
+    if candidate_path.is_absolute():
+        return candidate_path.resolve().as_posix()
+
+    roots: list[Path] = []
+    if base_dir is not None:
+        roots.append(Path(base_dir))
+    if project_root is not None:
+        roots.append(Path(project_root))
+    else:
+        roots.append(PROJECT_ROOT)
+    roots.append(Path.cwd())
+
+    for root in roots:
+        try:
+            resolved = (root / candidate_path).resolve()
+        except OSError:
+            continue
+        if resolved.exists():
+            return resolved.as_posix()
+
+    fallback_root = Path(project_root) if project_root is not None else PROJECT_ROOT
+    return (fallback_root / candidate_path).resolve().as_posix()
+
+
+def normalize_markdown_image_paths(
+    report_markdown: str,
+    *,
+    project_root: str | Path | None = None,
+    base_dir: str | Path | None = None,
+) -> str:
+    """Convert Markdown image references to absolute filesystem paths."""
+
+    def replace(match: re.Match[str]) -> str:
+        alt_text = match.group(1)
+        raw_target = match.group(2).strip()
+        normalized_target = _resolve_markdown_asset_path(
+            raw_target,
+            project_root=project_root,
+            base_dir=base_dir,
+        )
+        return f"![{alt_text}]({normalized_target})"
+
+    return _MARKDOWN_IMAGE_PATTERN.sub(replace, report_markdown)
+
+
+def convert_markdown_images_to_gradio_urls(
+    report_markdown: str,
+    *,
+    project_root: str | Path | None = None,
+    base_dir: str | Path | None = None,
+) -> str:
+    """Convert Markdown image references to Gradio-served file URLs."""
+
+    def replace(match: re.Match[str]) -> str:
+        alt_text = match.group(1)
+        raw_target = match.group(2).strip()
+        absolute_target = _resolve_markdown_asset_path(
+            raw_target,
+            project_root=project_root,
+            base_dir=base_dir,
+        )
+        # Gradio 4.x serves local files through the /file=... route.
+        gradio_target = f"/file={quote(absolute_target, safe='/:')}"
+        return f"![{alt_text}]({gradio_target})"
+
+    return _MARKDOWN_IMAGE_PATTERN.sub(replace, report_markdown)
+
+
+def save_markdown_report(report_markdown: str, report_path: str | Path) -> Path:
+    """Persist a Markdown report to disk."""
+
+    path = Path(report_path)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(report_markdown, encoding="utf-8")
+    return path

+ 61 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/tool_protocol.py

@@ -0,0 +1,61 @@
+"""Local structured tool response protocol."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any, Dict, Optional
+
+
+class ToolStatus(Enum):
+    SUCCESS = "success"
+    PARTIAL = "partial"
+    ERROR = "error"
+
+
+class ToolErrorCode:
+    INVALID_PARAM = "INVALID_PARAM"
+    EXECUTION_ERROR = "EXECUTION_ERROR"
+
+
+@dataclass
+class ToolResponse:
+    status: ToolStatus
+    text: str
+    data: Dict[str, Any] = field(default_factory=dict)
+    error_info: Optional[Dict[str, str]] = None
+    context: Optional[Dict[str, Any]] = None
+
+    def to_dict(self) -> Dict[str, Any]:
+        payload = {
+            "status": self.status.value,
+            "text": self.text,
+            "data": self.data,
+        }
+        if self.error_info:
+            payload["error"] = self.error_info
+        if self.context:
+            payload["context"] = self.context
+        return payload
+
+    def to_json(self) -> str:
+        return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
+
+    @classmethod
+    def success(cls, text: str, data: Optional[Dict[str, Any]] = None, context: Optional[Dict[str, Any]] = None):
+        return cls(status=ToolStatus.SUCCESS, text=text, data=data or {}, context=context)
+
+    @classmethod
+    def partial(cls, text: str, data: Optional[Dict[str, Any]] = None, context: Optional[Dict[str, Any]] = None):
+        return cls(status=ToolStatus.PARTIAL, text=text, data=data or {}, context=context)
+
+    @classmethod
+    def error(cls, code: str, message: str, context: Optional[Dict[str, Any]] = None):
+        return cls(
+            status=ToolStatus.ERROR,
+            text=message,
+            data={},
+            error_info={"code": code, "message": message},
+            context=context,
+        )

+ 6 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/tools/__init__.py

@@ -0,0 +1,6 @@
+"""Tool exports."""
+
+from .python_interpreter import PythonInterpreterTool
+from .tavily_search import TavilySearchTool
+
+__all__ = ["PythonInterpreterTool", "TavilySearchTool"]

+ 169 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/tools/python_interpreter.py

@@ -0,0 +1,169 @@
+"""Python execution sandbox tool."""
+
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import os
+import traceback
+import warnings
+from pathlib import Path
+from typing import Any, Dict, List
+
+import numpy as np
+import pandas as pd
+from hello_agents.tools import Tool, ToolParameter
+
+from ..plotting import (
+    apply_publication_style,
+    beautify_axes,
+    configure_plotting_backend,
+    ensure_ascii_sequence,
+    ensure_ascii_text,
+    get_plot_font_family,
+    prepare_month_index,
+    save_figure,
+)
+from ..tool_protocol import ToolErrorCode, ToolResponse
+
+
+class PythonInterpreterTool(Tool):
+    """Isolated Python execution sandbox for local data analysis."""
+
+    def __init__(self):
+        super().__init__(
+            name="PythonInterpreterTool",
+            description=(
+                "This is a Python interpreter. You can use installed scientific libraries such as pandas, numpy, scipy, statsmodels, matplotlib, and related tooling when they are available in the local environment. "
+                "Extremely important: you must use print() to display every result, statistic, file path, or conclusion you want to observe, otherwise you will receive no Observation. "
+                "Each execution runs in a fresh isolated namespace, so your code must include every import and variable definition it depends on. "
+                "The namespace already includes plotting helpers: plt, sns, apply_publication_style(), beautify_axes(), prepare_month_index(), get_plot_font_family(), ensure_ascii_text(), ensure_ascii_sequence(), and save_figure(). "
+                "Use save_figure(output_path) as the only standard figure-saving API. Focus on plotting the data, then call save_figure(path) directly. "
+                "Do not pass fig manually, do not call plt.tight_layout() manually, and do not redefine your own save_fig/save_plot helper unless absolutely necessary. "
+                "If data_context warns that N < 30, prioritize non-parametric tests when appropriate and remain highly cautious about normality assumptions."
+            ),
+        )
+        plt, sns = apply_publication_style()
+        self._base_namespace = {
+            "__builtins__": __builtins__,
+            "__name__": "__main__",
+            "pd": pd,
+            "np": np,
+            "json": json,
+            "os": os,
+            "Path": Path,
+            "io": io,
+            "warnings": warnings,
+            "plt": plt,
+            "sns": sns,
+            "apply_publication_style": apply_publication_style,
+            "beautify_axes": beautify_axes,
+            "configure_plotting_backend": configure_plotting_backend,
+            "ensure_ascii_text": ensure_ascii_text,
+            "ensure_ascii_sequence": ensure_ascii_sequence,
+            "get_plot_font_family": get_plot_font_family,
+            "prepare_month_index": prepare_month_index,
+            "save_figure": save_figure,
+        }
+
+    def _build_namespace(self) -> Dict[str, Any]:
+        return dict(self._base_namespace)
+
+    def execute(self, parameters: Dict[str, Any]) -> ToolResponse:
+        code = parameters.get("code", parameters.get("input", ""))
+        if not isinstance(code, str) or not code.strip():
+            return ToolResponse.error(
+                code=ToolErrorCode.INVALID_PARAM,
+                message="PythonInterpreterTool expected a non-empty 'code' string.",
+            )
+
+        namespace = self._build_namespace()
+        redirected_output = io.StringIO()
+        redirected_error = io.StringIO()
+
+        try:
+            compiled_code = compile(code, "<python_interpreter_tool>", "exec")
+            with warnings.catch_warnings(record=True) as captured_warnings:
+                warnings.simplefilter("always")
+                with contextlib.redirect_stdout(redirected_output), contextlib.redirect_stderr(redirected_error):
+                    exec(compiled_code, namespace, namespace)
+        except Exception:
+            error_traceback = traceback.format_exc()
+            return ToolResponse.error(
+                code=ToolErrorCode.EXECUTION_ERROR,
+                message=(
+                    "Python execution failed. Full traceback:\n"
+                    f"{error_traceback}"
+                ),
+                context={
+                    "code": code,
+                    "traceback": error_traceback,
+                },
+            )
+
+        stdout_text = redirected_output.getvalue()
+        stderr_text = redirected_error.getvalue()
+        warning_messages = []
+        for item in captured_warnings:
+            warning_message = warnings.formatwarning(
+                message=item.message,
+                category=item.category,
+                filename=item.filename,
+                lineno=item.lineno,
+                line=item.line,
+            ).strip()
+            if warning_message and warning_message not in warning_messages:
+                warning_messages.append(warning_message)
+
+        combined_output_parts = []
+        if stdout_text.strip():
+            combined_output_parts.append(stdout_text.strip())
+        if stderr_text.strip():
+            combined_output_parts.append(f"[stderr]\n{stderr_text.strip()}")
+        if warning_messages:
+            combined_output_parts.append("[warnings]\n" + "\n".join(warning_messages))
+
+        combined_output = "\n\n".join(combined_output_parts).strip()
+        data = {
+            "stdout": stdout_text,
+            "stderr": stderr_text,
+            "warnings": warning_messages,
+        }
+
+        if stdout_text.strip():
+            return ToolResponse.success(text=combined_output, data=data, context={"code": code})
+
+        if stderr_text.strip() or warning_messages:
+            return ToolResponse.partial(
+                text=(
+                    f"{combined_output}\n\n"
+                    "Code executed without stdout. Please use print() for every result you want returned in the Observation."
+                ).strip(),
+                data=data,
+                context={"code": code},
+            )
+
+        return ToolResponse.partial(
+            text=(
+                "Code executed successfully, but no stdout was captured. "
+                "Please use print() for every result you want returned in the Observation."
+            ),
+            data=data,
+            context={"code": code},
+        )
+
+    def run(self, parameters: Dict[str, Any]) -> str:
+        return self.execute(parameters).to_json()
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(
+                name="code",
+                type="string",
+                description=(
+                    "Python code to execute. Include all required imports and use print() for every value, statistic, or conclusion you want returned in the Observation."
+                ),
+                required=True,
+            )
+        ]

+ 94 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/tools/tavily_search.py

@@ -0,0 +1,94 @@
+"""Web search tool backed by Tavily for domain knowledge lookup."""
+
+from __future__ import annotations
+
+import os
+from typing import Any, Dict, List
+
+from hello_agents.tools import Tool, ToolParameter
+
+from ..tool_protocol import ToolErrorCode, ToolResponse
+
+
+class TavilySearchTool(Tool):
+    """Research-oriented Tavily search wrapper with graceful degradation."""
+
+    def __init__(self):
+        super().__init__(
+            name="TavilySearchTool",
+            description=(
+                "Search the web for scientific background knowledge, domain terminology, acronyms, normal ranges, and contextual references. "
+                "Use this tool before analysis whenever dataset columns or indicators contain unfamiliar professional terms or abbreviations. "
+                "Provide a concise natural-language search query."
+            ),
+        )
+
+    def execute(self, parameters: Dict[str, Any]) -> ToolResponse:
+        query = parameters.get("query", parameters.get("input", ""))
+        if not isinstance(query, str) or not query.strip():
+            return ToolResponse.error(
+                code=ToolErrorCode.INVALID_PARAM,
+                message="TavilySearchTool expected a non-empty 'query' string.",
+            )
+
+        api_key = os.getenv("TAVILY_API_KEY")
+        if not api_key:
+            return ToolResponse.partial(
+                text="No Tavily search credential is configured. Skip online search and proceed with local analysis only.",
+                data={"query": query, "results": []},
+                context={"query": query},
+            )
+
+        try:
+            from tavily import TavilyClient
+        except Exception:
+            return ToolResponse.partial(
+                text="The tavily-python dependency is unavailable in the current environment. Skip online search and proceed with local analysis only.",
+                data={"query": query, "results": []},
+                context={"query": query},
+            )
+
+        try:
+            client = TavilyClient(api_key=api_key)
+            response = client.search(query=query, search_depth="advanced")
+        except Exception as exc:
+            return ToolResponse.partial(
+                text=f"Tavily search is temporarily unavailable ({exc}). Skip online search and proceed with local analysis only.",
+                data={"query": query, "results": []},
+                context={"query": query},
+            )
+
+        results = response.get("results", []) if isinstance(response, dict) else []
+        lines = [f"Search query: {query}"]
+        for index, item in enumerate(results[:5], start=1):
+            title = str(item.get("title", "Untitled")).strip()
+            url = str(item.get("url", "")).strip()
+            content = str(item.get("content", "")).strip()
+            snippet = " ".join(content.split())[:500]
+            lines.append(f"{index}. {title}")
+            if url:
+                lines.append(f"   URL: {url}")
+            if snippet:
+                lines.append(f"   Snippet: {snippet}")
+
+        if len(lines) == 1:
+            lines.append("No relevant results were returned.")
+
+        return ToolResponse.success(
+            text="\n".join(lines),
+            data={"query": query, "results": results, "raw_response": response},
+            context={"query": query},
+        )
+
+    def run(self, parameters: Dict[str, Any]) -> str:
+        return self.execute(parameters).to_json()
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(
+                name="query",
+                type="string",
+                description="Natural-language search query describing the domain term, abbreviation, biomarker, financial metric, or indicator that needs background knowledge.",
+                required=True,
+            )
+        ]

+ 399 - 0
Co-creation-projects/healer-666-Academic-Data-Agent/src/data_analysis_agent/vision_review.py

@@ -0,0 +1,399 @@
+"""Visual review helpers for scientific figure auditing."""
+
+from __future__ import annotations
+
+import base64
+import io
+import json
+import re
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from .config import RuntimeConfig
+from .prompts import build_visual_reviewer_prompt
+from .reporting import ReportTelemetry
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+SUPPORTED_VISION_SUFFIXES = frozenset({".png", ".jpg", ".jpeg"})
+_MARKDOWN_IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
+
+
+@dataclass(frozen=True)
+class VisualReviewFinding:
+    figure: str
+    severity: str
+    issue: str
+    suggested_fix: str
+
+
+@dataclass(frozen=True)
+class PreparedVisionImage:
+    path: Path
+    alt_text: str
+    original_size: tuple[int, int]
+    resized_size: tuple[int, int]
+    output_bytes: int
+    encoded_image: str
+    media_type: str = "image/jpeg"
+
+
+@dataclass(frozen=True)
+class VisualReviewResult:
+    status: str
+    decision: str
+    summary: str
+    findings: tuple[VisualReviewFinding, ...] = ()
+    figures_reviewed: tuple[str, ...] = ()
+    skipped_figures: tuple[str, ...] = ()
+    duration_ms: int = 0
+    raw_response: str = ""
+    image_metadata: tuple[dict[str, Any], ...] = ()
+    warning: str = ""
+
+
+def _elapsed_ms(start_time: float) -> int:
+    return int(round((time.perf_counter() - start_time) * 1000))
+
+
+def _extract_first_json_object(text: str) -> str:
+    stripped = str(text or "").strip()
+    if not stripped:
+        raise ValueError("Model returned an empty response.")
+
+    if stripped.startswith("```"):
+        fence_lines = stripped.splitlines()
+        if len(fence_lines) >= 3 and fence_lines[0].startswith("```") and fence_lines[-1].startswith("```"):
+            stripped = "\n".join(fence_lines[1:-1]).strip()
+
+    start = stripped.find("{")
+    if start == -1:
+        raise ValueError("No JSON object found in model response.")
+
+    depth = 0
+    in_string = False
+    escape = False
+    for index in range(start, len(stripped)):
+        char = stripped[index]
+        if escape:
+            escape = False
+            continue
+        if char == "\\":
+            escape = True
+            continue
+        if char == '"':
+            in_string = not in_string
+            continue
+        if in_string:
+            continue
+        if char == "{":
+            depth += 1
+        elif char == "}":
+            depth -= 1
+            if depth == 0:
+                return stripped[start : index + 1]
+    raise ValueError("Unterminated JSON object in visual review response.")
+
+
+def _parse_visual_response(raw_response: str) -> tuple[str, str, tuple[VisualReviewFinding, ...]]:
+    payload = json.loads(_extract_first_json_object(raw_response))
+    if not isinstance(payload, dict):
+        raise ValueError("Visual reviewer response JSON must be an object.")
+
+    decision = str(payload.get("decision", "")).strip()
+    if decision not in {"Pass", "Flag"}:
+        raise ValueError("Visual reviewer decision must be Pass or Flag.")
+
+    summary = str(payload.get("summary", "")).strip()
+    if not summary:
+        raise ValueError("Visual reviewer summary must be a non-empty string.")
+
+    findings_payload = payload.get("findings", [])
+    if not isinstance(findings_payload, list):
+        raise ValueError("Visual reviewer findings must be a list.")
+
+    findings: list[VisualReviewFinding] = []
+    for item in findings_payload:
+        if not isinstance(item, dict):
+            continue
+        findings.append(
+            VisualReviewFinding(
+                figure=str(item.get("figure", "")).strip() or "unknown",
+                severity=str(item.get("severity", "")).strip() or "medium",
+                issue=str(item.get("issue", "")).strip() or "未提供具体问题。",
+                suggested_fix=str(item.get("suggested_fix", "")).strip() or "请检查该图表的可读性与标注。",
+            )
+        )
+    return decision, summary, tuple(findings)
+
+
+def _resolve_candidate_path(raw_target: str | Path, *, run_dir: Path) -> Path:
+    candidate = Path(str(raw_target).strip())
+    if candidate.is_absolute():
+        return candidate.resolve()
+
+    project_candidate = (PROJECT_ROOT / candidate).resolve()
+    if project_candidate.exists():
+        return project_candidate
+
+    run_candidate = (run_dir / candidate).resolve()
+    if run_candidate.exists():
+        return run_candidate
+
+    return project_candidate
+
+
+def _matches_review_round(path: Path, review_round: int) -> bool:
+    return f"review_round_{review_round}" in path.as_posix()
+
+
+def _iter_report_image_refs(report_markdown: str) -> list[tuple[str, str]]:
+    return [(match.group(1).strip(), match.group(2).strip()) for match in _MARKDOWN_IMAGE_PATTERN.finditer(report_markdown)]
+
+
+def select_visual_review_candidates(
+    *,
+    report_markdown: str,
+    telemetry: ReportTelemetry,
+    run_dir: Path,
+    review_round: int,
+    max_images: int,
+) -> tuple[list[tuple[Path, str]], tuple[str, ...]]:
+    selected: list[tuple[Path, str]] = []
+    skipped: list[str] = []
+    seen: set[str] = set()
+
+    def maybe_add(raw_target: str | Path, alt_text: str) -> None:
+        resolved = _resolve_candidate_path(raw_target, run_dir=run_dir)
+        resolved_key = resolved.as_posix()
+        if resolved_key in seen:
+            return
+        seen.add(resolved_key)
+
+        if not resolved.exists():
+            skipped.append(f"{resolved.name or resolved_key} (missing_file)")
+            return
+        if not _matches_review_round(resolved, review_round):
+            skipped.append(f"{resolved.name} (different_review_round)")
+            return
+        if resolved.suffix.lower() not in SUPPORTED_VISION_SUFFIXES:
+            skipped.append(f"{resolved.name} (unsupported_suffix:{resolved.suffix.lower() or 'none'})")
+            return
+        if len(selected) >= max_images:
+            skipped.append(f"{resolved.name} (omitted_due_to_limit)")
+            return
+        selected.append((resolved, alt_text.strip() or resolved.stem))
+
+    for alt_text, target in _iter_report_image_refs(report_markdown):
+        maybe_add(target, alt_text)
+
+    for figure_path in telemetry.figures_generated:
+        maybe_add(figure_path, Path(str(figure_path)).stem)
+
+    return selected, tuple(skipped)
+
+
+def prepare_image_for_vision(
+    image_path: Path,
+    *,
+    alt_text: str,
+    max_image_side: int = 1024,
+    jpeg_quality: int = 80,
+) -> PreparedVisionImage:
+    from PIL import Image
+
+    side_limit = max(256, min(int(max_image_side), 2048))
+    quality = max(40, min(int(jpeg_quality), 90))
+
+    with Image.open(image_path) as opened:
+        image = opened.convert("RGB")
+        original_size = (image.width, image.height)
+        longest_side = max(image.size)
+        if longest_side > side_limit:
+            try:
+                resample = Image.Resampling.LANCZOS
+            except AttributeError:  # pragma: no cover - compatibility fallback
+                resample = Image.LANCZOS
+            image.thumbnail((side_limit, side_limit), resample)
+        resized_size = (image.width, image.height)
+
+        buffer = io.BytesIO()
+        image.save(buffer, format="JPEG", quality=quality, optimize=True)
+        encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
+
+    return PreparedVisionImage(
+        path=image_path.resolve(),
+        alt_text=alt_text,
+        original_size=original_size,
+        resized_size=resized_size,
+        output_bytes=len(base64.b64decode(encoded)),
+        encoded_image=encoded,
+    )
+
+
+def _extract_message_text(message_content: Any) -> str:
+    if isinstance(message_content, str):
+        return message_content.strip()
+    if isinstance(message_content, list):
+        text_parts: list[str] = []
+        for item in message_content:
+            if isinstance(item, dict) and item.get("type") == "text":
+                text_parts.append(str(item.get("text", "")).strip())
+        return "\n".join(part for part in text_parts if part).strip()
+    return str(message_content).strip()
+
+
+def run_visual_review(
+    *,
+    runtime_config: RuntimeConfig,
+    report_markdown: str,
+    telemetry: ReportTelemetry,
+    run_dir: Path,
+    review_round: int,
+    max_images: int = 3,
+    max_image_side: int = 1024,
+) -> VisualReviewResult:
+    started_at = time.perf_counter()
+
+    if not runtime_config.vision_configured:
+        return VisualReviewResult(
+            status="unavailable",
+            decision="Unavailable",
+            summary="视觉审稿未启用:未检测到完整的视觉模型配置。",
+            duration_ms=_elapsed_ms(started_at),
+            warning="missing_vision_configuration",
+        )
+
+    selected, skipped = select_visual_review_candidates(
+        report_markdown=report_markdown,
+        telemetry=telemetry,
+        run_dir=run_dir,
+        review_round=review_round,
+        max_images=max_images,
+    )
+    if not selected:
+        return VisualReviewResult(
+            status="skipped",
+            decision="Skipped",
+            summary="视觉审稿已跳过:当前轮没有可审查的栅格图表。",
+            skipped_figures=skipped,
+            duration_ms=_elapsed_ms(started_at),
+            warning="no_supported_figures",
+        )
+
+    prepared_images: list[PreparedVisionImage] = []
+    skipped_figures = list(skipped)
+    for image_path, alt_text in selected:
+        try:
+            prepared_images.append(
+                prepare_image_for_vision(
+                    image_path,
+                    alt_text=alt_text,
+                    max_image_side=max_image_side,
+                )
+            )
+        except Exception as exc:
+            skipped_figures.append(f"{image_path.name} (prepare_failed:{exc})")
+
+    if not prepared_images:
+        return VisualReviewResult(
+            status="failed",
+            decision="Failed",
+            summary="视觉审稿失败:候选图表均无法完成图像预处理。",
+            skipped_figures=tuple(skipped_figures),
+            duration_ms=_elapsed_ms(started_at),
+            warning="image_preparation_failed",
+        )
+
+    content: list[dict[str, Any]] = [
+        {
+            "type": "text",
+            "text": (
+                f"Review round: {review_round}\n"
+                "You are reviewing compressed scientific figure copies for readability, layout quality, labeling, "
+                "color usage, and consistency with the report's figure references.\n"
+                "Candidate figure references:\n"
+                + "\n".join(
+                    f"- {image.path.name} | alt={image.alt_text} | original={image.original_size[0]}x{image.original_size[1]} | "
+                    f"compressed={image.resized_size[0]}x{image.resized_size[1]}"
+                    for image in prepared_images
+                )
+            ),
+        }
+    ]
+    for index, image in enumerate(prepared_images, start=1):
+        content.append(
+            {
+                "type": "text",
+                "text": f"Figure {index}: {image.path.name}\nReported alt text: {image.alt_text}",
+            }
+        )
+        content.append(
+            {
+                "type": "image_url",
+                "image_url": {"url": f"data:{image.media_type};base64,{image.encoded_image}"},
+            }
+        )
+
+    try:
+        from openai import OpenAI
+
+        client = OpenAI(
+            api_key=runtime_config.vision_api_key,
+            base_url=runtime_config.vision_base_url,
+            timeout=runtime_config.vision_timeout,
+        )
+        response = client.chat.completions.create(
+            model=str(runtime_config.vision_model_id),
+            temperature=0,
+            messages=[
+                {"role": "system", "content": build_visual_reviewer_prompt()},
+                {"role": "user", "content": content},
+            ],
+        )
+        raw_response = _extract_message_text(response.choices[0].message.content if response.choices else "")
+        decision, summary, findings = _parse_visual_response(raw_response)
+        duration_ms = _elapsed_ms(started_at)
+        return VisualReviewResult(
+            status="completed",
+            decision=decision,
+            summary=summary,
+            findings=findings,
+            figures_reviewed=tuple(image.path.as_posix() for image in prepared_images),
+            skipped_figures=tuple(skipped_figures),
+            duration_ms=duration_ms,
+            raw_response=raw_response,
+            image_metadata=tuple(
+                {
+                    "path": image.path.as_posix(),
+                    "alt_text": image.alt_text,
+                    "original_size": list(image.original_size),
+                    "resized_size": list(image.resized_size),
+                    "output_bytes": image.output_bytes,
+                    "media_type": image.media_type,
+                }
+                for image in prepared_images
+            ),
+        )
+    except Exception as exc:
+        return VisualReviewResult(
+            status="failed",
+            decision="Failed",
+            summary=f"视觉审稿失败:{exc}",
+            figures_reviewed=tuple(image.path.as_posix() for image in prepared_images),
+            skipped_figures=tuple(skipped_figures),
+            duration_ms=_elapsed_ms(started_at),
+            warning=str(exc),
+        )
+
+
+__all__ = [
+    "PreparedVisionImage",
+    "SUPPORTED_VISION_SUFFIXES",
+    "VisualReviewFinding",
+    "VisualReviewResult",
+    "prepare_image_for_vision",
+    "run_visual_review",
+    "select_visual_review_candidates",
+]