From cb36a80c91e8c2cae736fa3904781d0090e6b072 Mon Sep 17 00:00:00 2001
From: meng-f21
Date: Thu, 9 Apr 2026 09:41:07 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9F=BA=E4=BA=8E=20?=
=?UTF-8?q?agno=20=E6=A1=86=E6=9E=B6=E7=9A=84=E9=87=8D=E6=9E=84=E5=AE=9E?=
=?UTF-8?q?=E9=AA=8C=E7=89=88=E6=9C=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
在 experimental/agno_version/ 目录下新增 BettaFish 项目的一个
实验性重构版本,基于 agno (https://github.com/agno-agi/agno) 框架。
## 完整保留的核心特性
- 段落级 Forum 反馈循环(BettaFish 最核心特性)
- ForumHost 4 段式发言结构(事件梳理/观点整合/趋势预测/问题引导)
- 三 Agent 并发执行(用 asyncio.gather 等价实现 Streamlit 子进程并发)
- 原项目所有 SYSTEM_PROMPT(含 JSON Schema)
## 新增能力
- 海外数据源扩展:Hacker News / GitHub / YouTube / Reddit(12 个新工具)
- 海外工具动态裁剪:未配置 key 的平台自动从 prompt 中移除
- ReportAgent 5 阶段综合报告生成(大纲 → 并发章节 → 跨源 → 摘要 → HTML)
- 6 种专业可视化组件(KPI 卡片、Chart.js、Callout、信息源矩阵、时间线、Quote)
## 架构差异
- 进程模型:Flask + Streamlit 子进程 → 单进程 asyncio
- Agent 间通信:logs/*.log 文件监控 → 内存 ForumState + asyncio.Lock
- LLM 编排:自定义 Node 系统 → agno Agent
- 报告生成:ReportEngine (1700 行) → ReportAgent (5 阶段)
## 完全不影响原项目
本 PR 只新增 experimental/agno_version/ 目录,不修改任何现有文件。
## 当前状态
- ✅ 三个核心 Agent 完整迁移
- ✅ ForumHost + ForumState 段落级反馈
- ✅ ReportAgent 多阶段综合报告
- ✅ Chart.js + 6 种可视化组件
- ⚠️ 用 SQLite mock 数据库代替 MindSpider 爬虫
- ⚠️ 仅命令行入口,未实现 Web UI
详见 experimental/agno_version/CONTRIBUTION_NOTE.md
源仓库(含完整 git 历史):https://github.com/NextE-Moffatt/agno-mirofish
---
experimental/agno_version/.env.example | 56 ++
experimental/agno_version/.gitignore | 20 +
.../agno_version/CONTRIBUTION_NOTE.md | 140 +++
experimental/agno_version/README.md | 348 +++++++
.../agno_version/agno_agents/__init__.py | 32 +
.../agno_version/agno_agents/insight_agent.py | 881 ++++++++++++++++
.../agno_version/agno_agents/media_agent.py | 544 ++++++++++
.../agno_version/agno_agents/models.py | 75 ++
.../agno_version/agno_agents/query_agent.py | 540 ++++++++++
.../agno_version/agno_agents/report_agent.py | 952 ++++++++++++++++++
.../agno_version/agno_agents/report_blocks.py | 411 ++++++++
.../agno_version/agno_agents/report_styles.py | 836 +++++++++++++++
.../agno_version/agno_team/__init__.py | 17 +
.../agno_version/agno_team/_agno_setup.py | 86 ++
.../agno_version/agno_team/agent_runner.py | 420 ++++++++
.../agno_version/agno_team/forum_agent.py | 55 +
.../agno_version/agno_team/forum_host.py | 153 +++
.../agno_version/agno_team/forum_state.py | 135 +++
.../agno_version/agno_team/opinion_team.py | 108 ++
.../agno_version/agno_tools/__init__.py | 106 ++
.../agno_version/agno_tools/crawler_tools.py | 34 +
.../agno_version/agno_tools/db_query_tools.py | 449 +++++++++
.../agno_version/agno_tools/github_tools.py | 133 +++
.../agno_tools/hackernews_tools.py | 95 ++
.../agno_version/agno_tools/keyword_tools.py | 18 +
.../agno_tools/media_search_tools.py | 249 +++++
.../agno_tools/news_search_tools.py | 201 ++++
.../agno_version/agno_tools/reddit_tools.py | 219 ++++
.../agno_tools/sentiment_tools.py | 137 +++
.../agno_version/agno_tools/shared_utils.py | 19 +
.../agno_version/agno_tools/youtube_tools.py | 188 ++++
experimental/agno_version/frontend-api.md | 64 ++
experimental/agno_version/interfaces.md | 85 ++
experimental/agno_version/main.py | 74 ++
experimental/agno_version/requirements.txt | 73 ++
.../agno_version/run_full_pipeline.py | 119 +++
experimental/agno_version/run_single_agent.py | 342 +++++++
.../agno_version/scripts/init_mock_db.py | 385 +++++++
experimental/agno_version/test_integration.py | 95 ++
...67\345\261\202\350\277\201\347\247\273.md" | 224 +++++
...5\277\203Agent\350\277\201\347\247\273.md" | 272 +++++
...45\345\217\243\350\277\201\347\247\273.md" | 375 +++++++
42 files changed, 9765 insertions(+)
create mode 100644 experimental/agno_version/.env.example
create mode 100644 experimental/agno_version/.gitignore
create mode 100644 experimental/agno_version/CONTRIBUTION_NOTE.md
create mode 100644 experimental/agno_version/README.md
create mode 100644 experimental/agno_version/agno_agents/__init__.py
create mode 100644 experimental/agno_version/agno_agents/insight_agent.py
create mode 100644 experimental/agno_version/agno_agents/media_agent.py
create mode 100644 experimental/agno_version/agno_agents/models.py
create mode 100644 experimental/agno_version/agno_agents/query_agent.py
create mode 100644 experimental/agno_version/agno_agents/report_agent.py
create mode 100644 experimental/agno_version/agno_agents/report_blocks.py
create mode 100644 experimental/agno_version/agno_agents/report_styles.py
create mode 100644 experimental/agno_version/agno_team/__init__.py
create mode 100644 experimental/agno_version/agno_team/_agno_setup.py
create mode 100644 experimental/agno_version/agno_team/agent_runner.py
create mode 100644 experimental/agno_version/agno_team/forum_agent.py
create mode 100644 experimental/agno_version/agno_team/forum_host.py
create mode 100644 experimental/agno_version/agno_team/forum_state.py
create mode 100644 experimental/agno_version/agno_team/opinion_team.py
create mode 100644 experimental/agno_version/agno_tools/__init__.py
create mode 100644 experimental/agno_version/agno_tools/crawler_tools.py
create mode 100644 experimental/agno_version/agno_tools/db_query_tools.py
create mode 100644 experimental/agno_version/agno_tools/github_tools.py
create mode 100644 experimental/agno_version/agno_tools/hackernews_tools.py
create mode 100644 experimental/agno_version/agno_tools/keyword_tools.py
create mode 100644 experimental/agno_version/agno_tools/media_search_tools.py
create mode 100644 experimental/agno_version/agno_tools/news_search_tools.py
create mode 100644 experimental/agno_version/agno_tools/reddit_tools.py
create mode 100644 experimental/agno_version/agno_tools/sentiment_tools.py
create mode 100644 experimental/agno_version/agno_tools/shared_utils.py
create mode 100644 experimental/agno_version/agno_tools/youtube_tools.py
create mode 100644 experimental/agno_version/frontend-api.md
create mode 100644 experimental/agno_version/interfaces.md
create mode 100644 experimental/agno_version/main.py
create mode 100644 experimental/agno_version/requirements.txt
create mode 100644 experimental/agno_version/run_full_pipeline.py
create mode 100644 experimental/agno_version/run_single_agent.py
create mode 100644 experimental/agno_version/scripts/init_mock_db.py
create mode 100644 experimental/agno_version/test_integration.py
create mode 100644 "experimental/agno_version/\344\273\273\345\212\241\344\271\246_A_\346\225\260\346\215\256\345\261\202\344\270\216\345\267\245\345\205\267\345\261\202\350\277\201\347\247\273.md"
create mode 100644 "experimental/agno_version/\344\273\273\345\212\241\344\271\246_B_\346\240\270\345\277\203Agent\350\277\201\347\247\273.md"
create mode 100644 "experimental/agno_version/\344\273\273\345\212\241\344\271\246_C_\347\274\226\346\216\222\345\261\202\344\270\216\345\272\224\347\224\250\345\205\245\345\217\243\350\277\201\347\247\273.md"
diff --git a/experimental/agno_version/.env.example b/experimental/agno_version/.env.example
new file mode 100644
index 000000000..a1839c942
--- /dev/null
+++ b/experimental/agno_version/.env.example
@@ -0,0 +1,56 @@
+# .env.example
+# 复制为 .env 并填写真实值后方可运行
+# 三人开发均需配置此文件
+
+# =================== 服务配置 ===================
+HOST=0.0.0.0
+PORT=5000
+
+# =================== 数据库配置 ===================
+DB_DIALECT=postgresql
+DB_HOST=your_db_host
+DB_PORT=5432
+DB_USER=your_db_user
+DB_PASSWORD=your_db_password
+DB_NAME=your_db_name
+DB_CHARSET=utf8mb4
+
+# =================== LLM 配置 ===================
+
+# InsightAgent(推荐 kimi-k2,申请:https://platform.moonshot.cn/)
+INSIGHT_ENGINE_API_KEY=
+INSIGHT_ENGINE_BASE_URL=https://api.moonshot.cn/v1
+INSIGHT_ENGINE_MODEL_NAME=kimi-k2-0711-preview
+
+# MediaAgent(推荐 gemini-2.5-pro,中转:https://aihubmix.com)
+MEDIA_ENGINE_API_KEY=
+MEDIA_ENGINE_BASE_URL=https://aihubmix.com/v1
+MEDIA_ENGINE_MODEL_NAME=gemini-2.5-pro
+
+# QueryAgent(推荐 deepseek-chat,申请:https://platform.deepseek.com/)
+QUERY_ENGINE_API_KEY=
+QUERY_ENGINE_BASE_URL=https://api.deepseek.com
+QUERY_ENGINE_MODEL_NAME=deepseek-chat
+
+# ReportAgent(推荐 gemini-2.5-pro)
+REPORT_ENGINE_API_KEY=
+REPORT_ENGINE_BASE_URL=https://aihubmix.com/v1
+REPORT_ENGINE_MODEL_NAME=gemini-2.5-pro
+
+# 论坛主持人(推荐 qwen-plus,申请:https://www.aliyun.com/product/bailian)
+FORUM_HOST_API_KEY=
+FORUM_HOST_BASE_URL=
+FORUM_HOST_MODEL_NAME=qwen-plus
+
+# 关键词优化器(推荐 qwen-plus)
+KEYWORD_OPTIMIZER_API_KEY=
+KEYWORD_OPTIMIZER_BASE_URL=
+KEYWORD_OPTIMIZER_MODEL_NAME=qwen-plus
+
+# =================== 搜索工具 ===================
+SEARCH_TOOL_TYPE=AnspireAPI
+ANSPIRE_API_KEY=
+ANSPIRE_BASE_URL=https://plugin.anspire.cn/api/ntsearch/search
+BOCHA_WEB_SEARCH_API_KEY=
+BOCHA_BASE_URL=https://api.bocha.cn/v1/ai-search
+TAVILY_API_KEY=
diff --git a/experimental/agno_version/.gitignore b/experimental/agno_version/.gitignore
new file mode 100644
index 000000000..104b8a95e
--- /dev/null
+++ b/experimental/agno_version/.gitignore
@@ -0,0 +1,20 @@
+.env
+__pycache__/
+*.pyc
+*.pyo
+.DS_Store
+logs/
+final_reports/
+*.html
+*.pdf
+*.log
+.venv/
+venv/
+BettaFish/
+final_reports/
+
+# 生成的报告产物
+reports/
+
+# Mock 数据库(运行 scripts/init_mock_db.py 重新生成)
+data/*.db
diff --git a/experimental/agno_version/CONTRIBUTION_NOTE.md b/experimental/agno_version/CONTRIBUTION_NOTE.md
new file mode 100644
index 000000000..f2f886918
--- /dev/null
+++ b/experimental/agno_version/CONTRIBUTION_NOTE.md
@@ -0,0 +1,140 @@
+# 基于 agno 框架的重构实验
+
+本目录是 BettaFish 项目的一个实验性重构版本,基于 [agno](https://github.com/agno-agi/agno) 多智能体框架。
+
+---
+
+## 🔗 与原项目的关系
+
+- **完全独立**:本目录代码不影响原 BettaFish 任何文件(只新增,不修改)
+- **架构差异**:
+
+| 维度 | 原 BettaFish | 本 agno 版本 |
+|---|---|---|
+| 进程模型 | Flask + 3 个 Streamlit 子进程 | 单进程 asyncio |
+| Agent 间通信 | `logs/*.log` 文件 + LogMonitor 轮询 | 内存共享 `ForumState` + `asyncio.Lock` |
+| LLM 编排 | 自定义 Node 系统(`nodes/`, `state/`, `llms/`) | agno `Agent` |
+| 工具定义 | Python 类方法 | `@agno.tools.tool` 装饰器 |
+| 报告生成 | ReportEngine(1700 行,IR Schema + Chart.js) | 5 阶段 ReportAgent + 6 章节并发 |
+| 可视化 | IR JSON → Chart.js | 自定义 HTML 标签(``, `` 等)→ Chart.js |
+
+---
+
+## ✅ 保留的核心特性
+
+- **段落级 Forum 反馈循环**:每个 agent 产出段落总结后写入 `ForumState`,达到阈值(默认 5 条)自动触发 `ForumHost` 主持人发言,**反向影响下一段写作方向**。这是 BettaFish 最核心的特性。
+- **ForumHost 的 4 段式发言结构**:事件梳理 / 观点整合 / 趋势预测 / 问题引导,与原项目 prompt 完全一致。
+- **三 Agent 并发执行**:`asyncio.gather` 实现,与原项目的 Streamlit 子进程并发等价。
+- **原项目所有 prompt**:`SYSTEM_PROMPT_REPORT_STRUCTURE` / `FIRST_SEARCH` / `FIRST_SUMMARY` / `REFLECTION` / `REFLECTION_SUMMARY` / `REPORT_FORMATTING` 完整保留,包含 JSON Schema。
+
+---
+
+## ✨ 新增能力
+
+### 1. 海外数据源扩展(4 个新平台)
+
+InsightAgent 在原有 6 个中文社交媒体工具基础上,新增:
+
+| 平台 | 工具数 | 认证 |
+|---|---|---|
+| Hacker News | 3 | 无需 |
+| GitHub | 3 | 可选 PAT |
+| YouTube | 3 | Data API Key |
+| Reddit | 3 | OAuth |
+
+**动态裁剪**:未配置 key 的平台自动从 prompt 和 tool list 中移除,不会被 LLM 调用。
+
+### 2. 专业可视化组件(6 种)
+
+ReportAgent 生成的 HTML 报告支持:
+
+- `` — 数据卡片网格(带 tone 和 delta)
+- `` — Chart.js 图表(bar/line/pie/doughnut/radar)
+- `` — 语义化提示框(info/insight/warning/danger/success)
+- `` — 信息源覆盖矩阵(★星级可视化)
+- `` — 事件时间线(含 crisis/release/update 分类)
+- `` — 用户原声卡片
+
+一份典型报告会包含 30+ 个可视化组件。
+
+### 3. ReportAgent 多阶段流程
+
+- **Stage 1**: 大纲规划(综合三 agent 报告 + Host 发言,LLM 生成 5-7 章结构)
+- **Stage 2**: 6 章节**并发**写作(`asyncio.gather`)
+- **Stage 3**: 跨源对比验证(三方共识 / 分歧 / 可信度评级)
+- **Stage 4**: 执行摘要(一句话结论 + 关键发现 + 风险预警)
+- **Stage 5**: HTML 渲染(含 Chart.js CDN + 响应式 CSS)
+
+---
+
+## 📦 当前状态
+
+| 模块 | 状态 |
+|---|---|
+| InsightAgent / MediaAgent / QueryAgent | ✅ 完整迁移 |
+| ForumHost + ForumState 段落级反馈 | ✅ 完整保留 |
+| ReportAgent 5 阶段综合报告 | ✅ 实现 |
+| Chart.js + 6 种可视化组件 | ✅ 实现 |
+| 海外数据源(HN/GitHub/YouTube/Reddit)| ✅ 实现 |
+| MindSpider 爬虫集成 | ❌ 用 SQLite mock 数据库(`scripts/init_mock_db.py`)|
+| Web UI(Flask + Streamlit)| ❌ 仅命令行 |
+| PDF 导出 | ❌ 仅 HTML |
+
+---
+
+## 🚀 快速验证
+
+```bash
+cd experimental/agno_version
+
+# 1. 安装依赖
+pip install -r requirements.txt
+
+# 2. 配置 .env(参考 README 的 .env 示例)
+cp .env.example .env
+vim .env # 填入各 Agent 的 API key
+
+# 3. 初始化 mock 数据库
+python scripts/init_mock_db.py
+
+# 4. 运行完整流程
+python run_full_pipeline.py "Claude Code 在中文程序员社区的舆情分析"
+```
+
+输出见 `reports/full_pipeline/{主题}_{时间戳}/final_report.html`。
+
+完整文档见本目录的 `README.md`。
+
+---
+
+## 💡 设计取舍说明
+
+### 为什么用 asyncio 而不是 agno Team?
+
+agno 的 `Team` 是 **回合制**(agent 轮流说话)或 **路由式**(协调者选一个 agent),**不支持「三 agent 真并发 + 共享公告板 + 外部观察者反馈」**这种模式。
+
+BettaFish 原版用 Streamlit 子进程+文件监控实现了这个模式。agno 版用 `asyncio.gather + ForumState + ForumHost 回调` 实现了等价功能。
+
+### 为什么工具调用没用 agno 自主 dispatch?
+
+agno 原生支持 `agent.run()` 自主选择并调用工具,但这样会**失去段落级反馈循环的插入点**:我们需要在「工具调用」和「段落总结」之间插入 HOST 引导读取。
+
+所以保留了手写的 6 步流程(搜索决策 → 工具调用 → 读 HOST → 总结 → 反思 → 深化),但每一步的 LLM 调用都走 agno Agent。
+
+---
+
+## 🤝 期望
+
+希望这个实验版本能给原项目作者提供一个新的架构参考:
+
+1. 如果觉得 agno 版本有合并价值,可以讨论后续合作方向
+2. 如果想从这里摘取某些部分(比如可视化组件或海外数据源工具),欢迎
+3. 即便只是作为实验保留给社区参考也很好
+
+---
+
+## 🔗 源仓库
+
+本目录代码的完整 git 历史见:https://github.com/NextE-Moffatt/agno-mirofish
+
+(由于是大规模重构,本 PR 采用 rsync 复制的方式合并,没有保留 commit 历史。完整历史请查看源仓库。)
diff --git a/experimental/agno_version/README.md b/experimental/agno_version/README.md
new file mode 100644
index 000000000..46fb28091
--- /dev/null
+++ b/experimental/agno_version/README.md
@@ -0,0 +1,348 @@
+# agno-mirofish
+
+> 基于 [agno](https://github.com/agno-agi/agno) 框架重构的多 Agent 舆情分析系统,迁移自 [BettaFish (微舆)](https://github.com/666ghj/BettaFish)。
+
+三个专业 Agent 并发工作,通过共享论坛 + 主持人引导实现段落级反馈,最终由 ReportAgent 综合产出含 Chart.js 图表、KPI 卡片、信息源矩阵等可视化组件的专业 HTML 报告。
+
+---
+
+## ✨ 核心特性
+
+- **三 Agent 并发协作**:InsightAgent(社交媒体舆情)、MediaAgent(多模态网页)、QueryAgent(新闻深度调查)异步并行
+- **段落级 Forum 反馈**:每个 agent 产出一段总结后写入共享 ForumState,达到阈值自动触发 ForumHost 主持人发言,反向引导下一段写作(保留 BettaFish 的核心反馈循环)
+- **18 个工具开箱即用**:本地 SQLite 数据库(mock)、Tavily 新闻、Bocha 多模态、Hacker News、GitHub、YouTube、Reddit
+- **海外平台动态裁剪**:根据 `.env` 中实际配置的 key 自动裁剪可用工具,未配置的不会进入 prompt 也不会被调用
+- **专业可视化报告**:6 种自定义可视化组件(KPI 卡片、Chart.js 图表、信息源矩阵、事件时间线、Callout、Quote 卡片)+ 响应式 HTML
+- **真正的 agno 集成**:核心 LLM 调用走 agno Agent,自动处理重试/日志/异步
+
+---
+
+## 🏗 架构
+
+```
+ 用户输入: "Claude Code 在中文程序员社区的舆情分析"
+ │
+ ▼
+ ┌──────────────────────── opinion_team.py (asyncio.gather) ────────────────────────┐
+ │ │
+ ▼ ▼ ▼ │
+┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
+│ InsightAgent │ │ MediaAgent │ │ QueryAgent │ │
+│ │ │ │ │ │ │
+│ • 6 国内DB工具│ │ • 5 Bocha工具│ │ • 6 Tavily工具│ │
+│ • 12 海外工具 │ │ │ │ │ │
+│ • 多步流程 │ │ • 多步流程 │ │ • 多步流程 │ │
+│ • 段落总结 │ │ • 段落总结 │ │ • 段落总结 │ │
+└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
+ │ 段落写入 │ 段落写入 │ 段落写入 │
+ └────────────────┬───────┴────────────────────────────────┘ │
+ ▼ │
+ ┌──────────────────┐ │
+ │ ForumState │ ◄── 累计 N 条触发 │
+ │ (asyncio Lock) │ │
+ └────────┬─────────┘ │
+ │ │
+ ▼ │
+ ┌──────────────────┐ │
+ │ ForumHost │ ── 主持人发言 ──┐ │
+ │ (agno Agent) │ │ │
+ └──────────────────┘ │ │
+ │ │
+ ◄───────────────┘ │
+ 下一段写作时 agent 读取 HOST 引导,调整写作方向 │
+ │
+└────────────────────────────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+ 三份 agent 报告 + forum_log + host_speeches
+ │
+ ▼
+ ┌─────────────────────────┐
+ │ ReportAgent │
+ │ (4 个 agno Agent) │
+ │ │
+ │ Stage 1: 大纲规划 │
+ │ Stage 2: 6章节并发写作 │
+ │ Stage 3: 跨源验证 │
+ │ Stage 4: 执行摘要 │
+ │ Stage 5: HTML 渲染 │
+ └─────────────┬───────────┘
+ │
+ ▼
+ final_report.md + final_report.html (含 36+ 可视化组件)
+```
+
+---
+
+## 📦 安装
+
+### 1. 环境要求
+
+- Python 3.10+
+- conda 推荐(项目用 `agno_mirofish` 环境名)
+- macOS / Linux(Windows 未测试)
+
+### 2. 克隆并安装依赖
+
+```bash
+git clone https://github.com/NextE-Moffatt/agno-mirofish.git
+cd agno-mirofish
+
+conda create -n agno_mirofish python=3.10
+conda activate agno_mirofish
+
+pip install -r requirements.txt
+```
+
+### 3. 配置 `.env`
+
+复制 `.env.example` 为 `.env` 并填写:
+
+```bash
+cp .env.example .env
+```
+
+**最低可用配置**(只跑 QueryAgent):
+
+```env
+QUERY_ENGINE_API_KEY=sk-xxx
+QUERY_ENGINE_BASE_URL=https://api.deepseek.com
+QUERY_ENGINE_MODEL_NAME=deepseek-chat
+TAVILY_API_KEY=tvly-xxx
+```
+
+**完整配置**(三个 agent + ReportAgent + ForumHost):
+
+```env
+# 三个核心 Agent 的 LLM
+INSIGHT_ENGINE_API_KEY=sk-xxx
+INSIGHT_ENGINE_BASE_URL=https://api.deepseek.com
+INSIGHT_ENGINE_MODEL_NAME=deepseek-chat
+
+MEDIA_ENGINE_API_KEY=sk-xxx
+MEDIA_ENGINE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+MEDIA_ENGINE_MODEL_NAME=qwen3-vl-plus
+
+QUERY_ENGINE_API_KEY=sk-xxx
+QUERY_ENGINE_BASE_URL=https://api.deepseek.com
+QUERY_ENGINE_MODEL_NAME=deepseek-chat
+
+# ForumHost 主持人(推荐 qwen-plus)
+FORUM_HOST_API_KEY=sk-xxx
+FORUM_HOST_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+FORUM_HOST_MODEL_NAME=qwen-plus
+
+# ReportAgent 综合报告生成(推荐 gemini-2.5-pro 或 qwen-plus)
+REPORT_ENGINE_API_KEY=sk-xxx
+REPORT_ENGINE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+REPORT_ENGINE_MODEL_NAME=qwen-plus
+
+# 搜索 API
+TAVILY_API_KEY=tvly-xxx # QueryAgent 必需
+BOCHA_WEB_SEARCH_API_KEY=sk-xxx # MediaAgent 必需
+
+# 海外数据源(可选,未配置时自动跳过)
+GITHUB_TOKEN=ghp_xxx # 可选,无 token 限速 60/h
+YOUTUBE_API_KEY=AIzaSy-xxx # 可选
+REDDIT_CLIENT_ID=xxx # 可选
+REDDIT_CLIENT_SECRET=xxx # 可选
+
+# InsightAgent 本地数据库(用 mock 时填这两行)
+DB_DIALECT=sqlite
+DB_NAME=/绝对路径/agno-mirofish/data/mock_yuqing.db
+```
+
+### 4. 初始化 Mock 数据库(首次运行 InsightAgent 必做)
+
+```bash
+python scripts/init_mock_db.py
+```
+
+这会创建 `data/mock_yuqing.db`,包含 14 张表(7 内容表 + 7 评论表)和约 100 条围绕"Claude Code"主题的假数据。
+
+如果你想跑真实数据,需要自己部署 MediaCrawler PostgreSQL 数据库(参考 [BettaFish 文档](https://github.com/666ghj/BettaFish))。
+
+---
+
+## 🚀 使用
+
+### 完整流程:三 Agent 并发 + ForumHost + ReportAgent
+
+```bash
+python run_full_pipeline.py "你的分析主题"
+```
+
+**示例**:
+
+```bash
+python run_full_pipeline.py "Claude Code 在中文程序员社区的舆情分析"
+python run_full_pipeline.py "2026 美国大选舆情走势"
+python run_full_pipeline.py "苹果 Vision Pro 中国市场反响"
+```
+
+**可选参数**:
+
+```bash
+# 调整 Host 触发阈值(默认 5 条 agent 段落触发一次)
+python run_full_pipeline.py "..." --threshold 3
+
+# 跳过 ReportAgent 综合报告生成(节省时间)
+python run_full_pipeline.py "..." --no-report
+
+# 自定义输出目录
+python run_full_pipeline.py "..." --output reports/my_reports
+```
+
+**输出**(保存在 `reports/full_pipeline/{主题}_{时间戳}/`):
+
+| 文件 | 说明 |
+|---|---|
+| `insight_report.md` | InsightAgent 独立报告 |
+| `media_report.md` | MediaAgent 独立报告 |
+| `query_report.md` | QueryAgent 独立报告 |
+| `forum_log.txt` | 完整论坛对话日志 |
+| `host_speeches.md` | 主持人 N 次引导发言 |
+| **`final_report.md`** | **综合报告 Markdown** |
+| **`final_report.html`** | **专业 HTML 报告(含 Chart.js 图表)** |
+| `summary.json` | 结构化元数据 |
+
+### 单 Agent 独立运行
+
+```bash
+python run_single_agent.py insight "某品牌产品危机舆情"
+python run_single_agent.py media "AI 编程工具市场对比"
+python run_single_agent.py query "某国际事件深度调查"
+```
+
+每个 agent 独立产出 markdown 报告,不走 Forum 协作。适合调试单 agent 或验证某个数据源。
+
+---
+
+## 🛠 项目结构
+
+```
+agno-mirofish/
+├── agno_tools/ # 工具层(@tool 装饰)
+│ ├── db_query_tools.py # InsightAgent 6 个本地 DB 工具
+│ ├── news_search_tools.py # QueryAgent 6 个 Tavily 工具
+│ ├── media_search_tools.py # MediaAgent 5 个 Bocha 工具
+│ ├── hackernews_tools.py # 3 个 HN 工具(无需 key)
+│ ├── github_tools.py # 3 个 GitHub 工具
+│ ├── youtube_tools.py # 3 个 YouTube 工具
+│ ├── reddit_tools.py # 3 个 Reddit 工具
+│ └── sentiment_tools.py # 多语言情感分析(transformers)
+│
+├── agno_agents/ # Agent 层
+│ ├── models.py # 共享 pydantic 模型(AnalysisResult 等)
+│ ├── insight_agent.py # InsightAgent + 完整 prompt
+│ ├── media_agent.py # MediaAgent + 完整 prompt
+│ ├── query_agent.py # QueryAgent + 完整 prompt
+│ ├── report_agent.py # ReportAgent (5 阶段 + 4 个 agno Agent)
+│ ├── report_blocks.py # 自定义可视化标签解析器
+│ └── report_styles.py # 专业 CSS + Chart.js CDN
+│
+├── agno_team/ # 编排层
+│ ├── _agno_setup.py # ⭐ 必须最先导入:清代理 + patch agno httpx
+│ ├── forum_state.py # 共享论坛状态(asyncio Lock)
+│ ├── forum_host.py # 主持人 (agno Agent)
+│ ├── agent_runner.py # 单 agent 多步异步流程
+│ └── opinion_team.py # 三 agent asyncio.gather 调度
+│
+├── scripts/
+│ └── init_mock_db.py # 初始化 SQLite mock 数据库
+│
+├── run_single_agent.py # 单 agent 命令行入口
+├── run_full_pipeline.py # 完整流程命令行入口
+├── config.py # pydantic-settings 配置(从 .env 读取)
+├── requirements.txt
+└── README.md
+```
+
+---
+
+## 🎨 报告可视化组件
+
+ReportAgent 生成的 HTML 报告支持以下专业组件,全部由 LLM 在 prompt 引导下输出,渲染器自动转换:
+
+| 组件 | 标签 | 用途 |
+|---|---|---|
+| **KPI 数据卡片** | `` | 4-6 个核心指标,带 tone(up/down/neutral)和 delta 变化值 |
+| **Chart.js 图表** | `` | bar / line / pie / doughnut / radar,自动注入主题色 |
+| **Callout 提示框** | `` | info / insight / warning / danger / success 五种语义色 |
+| **信息源矩阵** | `` | 三 Agent 覆盖度可视化(★★★ 主力 / ★★ 部分 / ★ 弱 / — 无)|
+| **事件时间线** | `` | 圆点 + 日期 + 事件,支持 release/crisis/update 不同颜色 |
+| **用户原声卡片** | `` | 带装饰引号,显示作者/平台/点赞数 |
+
+一份典型报告会包含 30+ 个可视化组件。
+
+---
+
+## 🔧 常见问题
+
+### Q: 报错 `connection refused` / `proxy error`?
+
+A: 你的系统设置了 SOCKS/HTTP 代理。`agno_team/_agno_setup.py` 已经做了全局 patch,理论上自动处理。如果仍然报错,尝试:
+
+```bash
+unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY
+python run_full_pipeline.py "..."
+```
+
+### Q: ForumHost 报错 `Model Not Exist`?
+
+A: 你的 `FORUM_HOST_*` 三个字段配置不一致(key/base_url/model 分属不同提供商)。修正 `.env`,确保三个字段同属一家厂商。
+
+### Q: InsightAgent 报错 `database is locked` 或 `no such table`?
+
+A: 没有运行 mock DB 初始化脚本。执行:
+
+```bash
+python scripts/init_mock_db.py
+```
+
+### Q: Bocha API 返回 403?
+
+A: Bocha 账户余额不足。可以选择充值或临时改用 Tavily(修改 `MediaAgent` 的工具配置)。
+
+### Q: 某个海外平台(YouTube/Reddit)的工具调用失败?
+
+A: 检查 `.env` 中对应的 key 是否配置。**未配置的工具会自动从 InsightAgent 的工具列表中移除,不会出现在 LLM 的 prompt 里**,所以一般不会触发失败。如果还是失败,可能是 API 限速。
+
+---
+
+## 📋 开发笔记
+
+### agno 框架使用情况
+
+| 模块 | 是否使用 agno |
+|---|---|
+| ForumHost | ✅ agno Agent |
+| ReportAgent (5 阶段) | ✅ 4 个专用 agno Agent |
+| 三 agent 多步流程的 LLM 调用 | ✅ 通过缓存的 agno Agent |
+| 工具函数 | `@tool` 装饰器(保留以备 agent 自主调用模式)|
+| 三 agent 协作 | ❌ asyncio.gather(agno Team 会丢失段落级反馈)|
+| 工具 dispatch | ❌ 手写(保留以维持精细控制)|
+
+### 关键设计决策
+
+1. **段落级反馈循环**:保留 BettaFish 最核心的特性 —— 每个 agent 产出段落总结后立即被 ForumHost 看到,反向影响下一段。这不能用 agno Team 实现。
+
+2. **代理 patch 全局化**:`agno_team/_agno_setup.py` 必须在所有 agno import 之前导入,否则 agno 会缓存系统代理配置导致无法连接 LLM API。
+
+3. **三档 API 回退**:所有 Agent 的 API key 配置都支持回退(如 ReportAgent: `REPORT_*` → `FORUM_HOST_*` → `QUERY_ENGINE_*`),但**必须三个字段一组回退**,避免出现 DeepSeek 的 key 配 aihubmix base_url 这种荒谬情况。
+
+4. **海外工具动态裁剪**:`_detect_available_overseas()` 在创建 InsightAgent 时检查每个海外平台 key 是否配置,未配置的工具完全不进入 tools 列表也不进入 prompt。
+
+---
+
+## 📜 致谢
+
+- 原项目:[BettaFish (微舆)](https://github.com/666ghj/BettaFish) — 多 Agent 舆情分析系统的完整工程实现
+- 框架:[agno](https://github.com/agno-agi/agno) — Python multi-agent framework
+- 数据源:Tavily / Bocha / Hacker News / GitHub / YouTube / Reddit / MediaCrawler
+
+---
+
+## 📄 License
+
+MIT
diff --git a/experimental/agno_version/agno_agents/__init__.py b/experimental/agno_version/agno_agents/__init__.py
new file mode 100644
index 000000000..80eac9d00
--- /dev/null
+++ b/experimental/agno_version/agno_agents/__init__.py
@@ -0,0 +1,32 @@
+# agno_agents/__init__.py
+# 统一导出三个核心 Agent 的工厂函数、运行接口和共享模型
+
+from .models import (
+ SearchDecision,
+ ParagraphOutline,
+ ReportStructure,
+ ParagraphResult,
+ AnalysisResult,
+)
+from .insight_agent import create_insight_agent, run_insight_analysis
+from .media_agent import create_media_agent, run_media_analysis
+from .query_agent import create_query_agent, run_query
+from .report_agent import (
+ ReportAgent,
+ create_report_agent,
+ run_report_generation,
+ run_report_generation_async,
+)
+
+__all__ = [
+ # 共享模型(ForumEngine / ReportEngine 消费)
+ "SearchDecision", "ParagraphOutline", "ReportStructure",
+ "ParagraphResult", "AnalysisResult",
+ # 三个核心 Agent
+ "create_insight_agent", "run_insight_analysis",
+ "create_media_agent", "run_media_analysis",
+ "create_query_agent", "run_query",
+ # ReportAgent
+ "ReportAgent", "create_report_agent",
+ "run_report_generation", "run_report_generation_async",
+]
diff --git a/experimental/agno_version/agno_agents/insight_agent.py b/experimental/agno_version/agno_agents/insight_agent.py
new file mode 100644
index 000000000..f98a8dfb2
--- /dev/null
+++ b/experimental/agno_version/agno_agents/insight_agent.py
@@ -0,0 +1,881 @@
+# agno_agents/insight_agent.py
+# 迁移自 InsightEngine/agent.py (DeepSearchAgent)
+# 定位:本地社交媒体数据库舆情分析(微博/B站/知乎/贴吧等)
+
+import json
+import httpx
+from openai import OpenAI as _OpenAI
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+
+from .models import (
+ SearchDecision,
+ ReportStructure,
+ ParagraphResult,
+ AnalysisResult,
+ parse_analysis_result,
+)
+
+from agno_tools import (
+ # 中文社交媒体(本地数据库)
+ search_hot_content, search_topic_globally, search_topic_by_date,
+ get_comments_for_topic, search_topic_on_platform, analyze_sentiment,
+ # 海外数据源(实时 API)
+ search_hackernews, search_hackernews_recent, search_hackernews_comments,
+ search_github_repos, search_github_issues, search_github_code,
+ search_youtube_videos, get_youtube_comments, search_youtube_with_comments,
+ search_reddit, get_subreddit_hot, get_reddit_post_comments,
+)
+
+# ===== JSON Schema 定义(保留原始 Schema,供 prompt 引用)=====
+
+output_schema_report_structure = {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"}
+ }
+ }
+}
+
+input_schema_first_search = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"}
+ }
+}
+
+output_schema_first_search = {
+ "type": "object",
+ "properties": {
+ "search_query": {"type": "string"},
+ "search_tool": {"type": "string"},
+ "reasoning": {"type": "string"},
+ "start_date": {"type": "string", "description": "开始日期,格式YYYY-MM-DD,search_topic_by_date和search_topic_on_platform工具可能需要"},
+ "end_date": {"type": "string", "description": "结束日期,格式YYYY-MM-DD,search_topic_by_date和search_topic_on_platform工具可能需要"},
+ "platform": {"type": "string", "description": "平台名称,search_topic_on_platform工具必需,可选值:bilibili, weibo, douyin, kuaishou, xhs, zhihu, tieba"},
+ "time_period": {"type": "string", "description": "时间周期,search_hot_content工具可选,可选值:24h, week, year"},
+ "enable_sentiment": {"type": "boolean", "description": "是否启用自动情感分析,默认为true,适用于除analyze_sentiment外的所有搜索工具"},
+ "texts": {"type": "array", "items": {"type": "string"}, "description": "文本列表,仅用于analyze_sentiment工具"}
+ },
+ "required": ["search_query", "search_tool", "reasoning"]
+}
+
+input_schema_first_summary = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"},
+ "search_query": {"type": "string"},
+ "search_results": {
+ "type": "array",
+ "items": {"type": "string"}
+ }
+ }
+}
+
+output_schema_first_summary = {
+ "type": "object",
+ "properties": {
+ "paragraph_latest_state": {"type": "string"}
+ }
+}
+
+input_schema_reflection = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"},
+ "paragraph_latest_state": {"type": "string"}
+ }
+}
+
+output_schema_reflection = {
+ "type": "object",
+ "properties": {
+ "search_query": {"type": "string"},
+ "search_tool": {"type": "string"},
+ "reasoning": {"type": "string"},
+ "start_date": {"type": "string", "description": "开始日期,格式YYYY-MM-DD,search_topic_by_date和search_topic_on_platform工具可能需要"},
+ "end_date": {"type": "string", "description": "结束日期,格式YYYY-MM-DD,search_topic_by_date和search_topic_on_platform工具可能需要"},
+ "platform": {"type": "string", "description": "平台名称,search_topic_on_platform工具必需,可选值:bilibili, weibo, douyin, kuaishou, xhs, zhihu, tieba"},
+ "time_period": {"type": "string", "description": "时间周期,search_hot_content工具可选,可选值:24h, week, year"},
+ "enable_sentiment": {"type": "boolean", "description": "是否启用自动情感分析,默认为true,适用于除analyze_sentiment外的所有搜索工具"},
+ "texts": {"type": "array", "items": {"type": "string"}, "description": "文本列表,仅用于analyze_sentiment工具"}
+ },
+ "required": ["search_query", "search_tool", "reasoning"]
+}
+
+input_schema_reflection_summary = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"},
+ "search_query": {"type": "string"},
+ "search_results": {
+ "type": "array",
+ "items": {"type": "string"}
+ },
+ "paragraph_latest_state": {"type": "string"}
+ }
+}
+
+output_schema_reflection_summary = {
+ "type": "object",
+ "properties": {
+ "updated_paragraph_latest_state": {"type": "string"}
+ }
+}
+
+input_schema_report_formatting = {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "paragraph_latest_state": {"type": "string"}
+ }
+ }
+}
+
+# ===== 系统提示词定义(完整保留原始 prompt)=====
+
+SYSTEM_PROMPT_REPORT_STRUCTURE = f"""
+你是一位专业的舆情分析师和报告架构师。给定一个查询,你需要规划一个全面、深入的舆情分析报告结构。
+
+**报告规划要求:**
+1. **段落数量**:设计5个核心段落,每个段落都要有足够的深度和广度
+2. **内容丰富度**:每个段落应该包含多个子话题和分析维度,确保能挖掘出大量真实数据
+3. **逻辑结构**:从宏观到微观、从现象到本质、从数据到洞察的递进式分析
+4. **多维分析**:确保涵盖情感倾向、平台差异、时间演变、群体观点、深度原因等多个维度
+
+**段落设计原则:**
+- **背景与事件概述**:全面梳理事件起因、发展脉络、关键节点
+- **舆情热度与传播分析**:数据统计、平台分布、传播路径、影响范围
+- **公众情感与观点分析**:情感倾向、观点分布、争议焦点、价值观冲突
+- **不同群体与平台差异**:年龄层、地域、职业、平台用户群体的观点差异
+- **深层原因与社会影响**:根本原因、社会心理、文化背景、长远影响
+
+**内容深度要求:**
+每个段落的content字段应该详细描述该段落需要包含的具体内容:
+- 至少3-5个子分析点
+- 需要引用的数据类型(评论数、转发数、情感分布等)
+- 需要体现的不同观点和声音
+- 具体的分析角度和维度
+
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_report_structure, indent=2, ensure_ascii=False)}
+
+
+标题和内容属性将用于后续的深度数据挖掘和分析。
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_FIRST_SEARCH = f"""
+你是一位专业的舆情分析师。你将获得报告中的一个段落,其标题和预期内容将按照以下JSON模式定义提供:
+
+
+{json.dumps(input_schema_first_search, indent=2, ensure_ascii=False)}
+
+
+你可以使用以下6种专业的本地舆情数据库查询工具来挖掘真实的民意和公众观点:
+
+1. **search_hot_content** - 查找热点内容工具
+ - 适用于:挖掘当前最受关注的舆情事件和话题
+ - 特点:基于真实的点赞、评论、分享数据发现热门话题,自动进行情感分析
+ - 参数:time_period ('24h', 'week', 'year'),limit(数量限制),enable_sentiment(是否启用情感分析,默认True)
+
+2. **search_topic_globally** - 全局话题搜索工具
+ - 适用于:全面了解公众对特定话题的讨论和观点
+ - 特点:覆盖B站、微博、抖音、快手、小红书、知乎、贴吧等主流平台的真实用户声音,自动进行情感分析
+ - 参数:limit_per_table(每个表的结果数量限制),enable_sentiment(是否启用情感分析,默认True)
+
+3. **search_topic_by_date** - 按日期搜索话题工具
+ - 适用于:追踪舆情事件的时间线发展和公众情绪变化
+ - 特点:精确的时间范围控制,适合分析舆情演变过程,自动进行情感分析
+ - 特殊要求:需要提供start_date和end_date参数,格式为'YYYY-MM-DD'
+ - 参数:limit_per_table(每个表的结果数量限制),enable_sentiment(是否启用情感分析,默认True)
+
+4. **get_comments_for_topic** - 获取话题评论工具
+ - 适用于:深度挖掘网民的真实态度、情感和观点
+ - 特点:直接获取用户评论,了解民意走向和情感倾向,自动进行情感分析
+ - 参数:limit(评论总数量限制),enable_sentiment(是否启用情感分析,默认True)
+
+5. **search_topic_on_platform** - 平台定向搜索工具
+ - 适用于:分析特定社交平台用户群体的观点特征
+ - 特点:针对不同平台用户群体的观点差异进行精准分析,自动进行情感分析
+ - 特殊要求:需要提供platform参数,可选start_date和end_date
+ - 参数:platform(必须),start_date, end_date(可选),limit(数量限制),enable_sentiment(是否启用情感分析,默认True)
+
+6. **analyze_sentiment** - 多语言情感分析工具
+ - 适用于:对文本内容进行专门的情感倾向分析
+ - 特点:支持中文、英文、西班牙文、阿拉伯文、日文、韩文等22种语言的情感分析,输出5级情感等级(非常负面、负面、中性、正面、非常正面)
+ - 参数:texts(文本或文本列表),query也可用作单个文本输入
+ - 用途:当搜索结果的情感倾向不明确或需要专门的情感分析时使用
+
+**你的核心使命:挖掘真实的民意和人情味**
+
+你的任务是:
+1. **深度理解段落需求**:根据段落主题,思考需要了解哪些具体的公众观点和情感
+2. **精准选择查询工具**:选择最能获取真实民意数据的工具
+3. **设计接地气的搜索词**:**这是最关键的环节!**
+ - **避免官方术语**:不要用"舆情传播"、"公众反应"、"情绪倾向"等书面语
+ - **使用网民真实表达**:模拟普通网友会怎么谈论这个话题
+ - **贴近生活语言**:用简单、直接、口语化的词汇
+ - **包含情感词汇**:网民常用的褒贬词、情绪词
+ - **考虑话题热词**:相关的网络流行语、缩写、昵称
+4. **情感分析策略选择**:
+ - **自动情感分析**:默认启用(enable_sentiment: true),适用于搜索工具,能自动分析搜索结果的情感倾向
+ - **专门情感分析**:当需要对特定文本进行详细情感分析时,使用analyze_sentiment工具
+ - **关闭情感分析**:在某些特殊情况下(如纯事实性内容),可设置enable_sentiment: false
+5. **参数优化配置**:
+ - search_topic_by_date: 必须提供start_date和end_date参数(格式:YYYY-MM-DD)
+ - search_topic_on_platform: 必须提供platform参数(bilibili, weibo, douyin, kuaishou, xhs, zhihu, tieba之一)
+ - analyze_sentiment: 使用texts参数提供文本列表,或使用search_query作为单个文本
+ - 系统自动配置数据量参数,无需手动设置limit或limit_per_table参数
+6. **阐述选择理由**:说明为什么这样的查询和情感分析策略能够获得最真实的民意反馈
+
+**搜索词设计核心原则**:
+- **想象网友怎么说**:如果你是个普通网友,你会怎么讨论这个话题?
+- **避免学术词汇**:杜绝"舆情"、"传播"、"倾向"等专业术语
+- **使用具体词汇**:用具体的事件、人名、地名、现象描述
+- **包含情感表达**:如"支持"、"反对"、"担心"、"愤怒"、"点赞"等
+- **考虑网络文化**:网民的表达习惯、缩写、俚语、表情符号文字描述
+
+**举例说明**:
+- ❌ 错误:"武汉大学舆情 公众反应"
+- ✅ 正确:"武大" 或 "武汉大学怎么了" 或 "武大学生"
+- ❌ 错误:"校园事件 学生反应"
+- ✅ 正确:"学校出事" 或 "同学们都在说" 或 "校友群炸了"
+
+**不同平台语言特色参考**:
+- **微博**:热搜词汇、话题标签,如 "武大又上热搜"、"心疼武大学子"
+- **知乎**:问答式表达,如 "如何看待武汉大学"、"武大是什么体验"
+- **B站**:弹幕文化,如 "武大yyds"、"武大人路过"、"我武最强"
+- **贴吧**:直接称呼,如 "武大吧"、"武大的兄弟们"
+- **抖音/快手**:短视频描述,如 "武大日常"、"武大vlog"
+- **小红书**:分享式,如 "武大真的很美"、"武大攻略"
+
+**情感表达词汇库**:
+- 正面:"太棒了"、"牛逼"、"绝了"、"爱了"、"yyds"、"666"
+- 负面:"无语"、"离谱"、"绝了"、"服了"、"麻了"、"破防"
+- 中性:"围观"、"吃瓜"、"路过"、"有一说一"、"实名"
+
+请按照以下JSON模式定义格式化输出(文字请使用中文):
+
+
+{json.dumps(output_schema_first_search, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_FIRST_SUMMARY = f"""
+你是一位专业的舆情分析师和深度内容创作专家。你将获得丰富的真实社交媒体数据,需要将其转化为深度、全面的舆情分析段落:
+
+
+{json.dumps(input_schema_first_summary, indent=2, ensure_ascii=False)}
+
+
+**你的核心任务:创建信息密集、数据丰富的舆情分析段落**
+
+**撰写标准(每段不少于800-1200字):**
+
+1. **开篇框架**:
+ - 用2-3句话概括本段要分析的核心问题
+ - 提出关键观察点和分析维度
+
+2. **数据详实呈现**:
+ - **大量引用原始数据**:具体的用户评论(至少5-8条代表性评论)
+ - **精确数据统计**:点赞数、评论数、转发数、参与用户数等具体数字
+ - **情感分析数据**:详细的情感分布比例(正面X%、负面Y%、中性Z%)
+ - **平台数据对比**:不同平台的数据表现和用户反应差异
+
+3. **多层次深度分析**:
+ - **现象描述层**:具体描述观察到的舆情现象和表现
+ - **数据分析层**:用数字说话,分析趋势和模式
+ - **观点挖掘层**:提炼不同群体的核心观点和价值取向
+ - **深层洞察层**:分析背后的社会心理和文化因素
+
+4. **结构化内容组织**:
+ ```
+ ## 核心发现概述
+ [2-3个关键发现点]
+
+ ## 详细数据分析
+ [具体数据和统计]
+
+ ## 代表性声音
+ [引用具体用户评论和观点]
+
+ ## 深层次解读
+ [分析背后的原因和意义]
+
+ ## 趋势和特征
+ [总结规律和特点]
+ ```
+
+5. **具体引用要求**:
+ - **直接引用**:使用引号标注的用户原始评论
+ - **数据引用**:标注具体来源平台和数量
+ - **多样性展示**:涵盖不同观点、不同情感倾向的声音
+ - **典型案例**:选择最有代表性的评论和讨论
+
+6. **语言表达要求**:
+ - 专业而不失生动,准确而富有感染力
+ - 避免空洞的套话,每句话都要有信息含量
+ - 用具体的例子和数据支撑每个观点
+ - 体现舆情的复杂性和多面性
+
+7. **深度分析维度**:
+ - **情感演变**:描述情感变化的具体过程和转折点
+ - **群体分化**:不同年龄、职业、地域群体的观点差异
+ - **话语分析**:分析用词特点、表达方式、文化符号
+ - **传播机制**:分析观点如何传播、扩散、发酵
+
+**内容密度要求**:
+- 每100字至少包含1-2个具体数据点或用户引用
+- 每个分析点都要有数据或实例支撑
+- 避免空洞的理论分析,重点关注实证发现
+- 确保信息密度高,让读者获得充分的信息价值
+
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_first_summary, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_REFLECTION = f"""
+你是一位资深的舆情分析师。你负责深化舆情报告的内容,让其更贴近真实的民意和社会情感。你将获得段落标题、计划内容摘要,以及你已经创建的段落最新状态:
+
+
+{json.dumps(input_schema_reflection, indent=2, ensure_ascii=False)}
+
+
+你可以使用以下6种专业的本地舆情数据库查询工具来深度挖掘民意:
+
+1. **search_hot_content** - 查找热点内容工具(自动情感分析)
+2. **search_topic_globally** - 全局话题搜索工具(自动情感分析)
+3. **search_topic_by_date** - 按日期搜索话题工具(自动情感分析)
+4. **get_comments_for_topic** - 获取话题评论工具(自动情感分析)
+5. **search_topic_on_platform** - 平台定向搜索工具(自动情感分析)
+6. **analyze_sentiment** - 多语言情感分析工具(专门的情感分析)
+
+**反思的核心目标:让报告更有人情味和真实感**
+
+你的任务是:
+1. **深度反思内容质量**:
+ - 当前段落是否过于官方化、套路化?
+ - 是否缺乏真实的民众声音和情感表达?
+ - 是否遗漏了重要的公众观点和争议焦点?
+ - 是否需要补充具体的网民评论和真实案例?
+
+2. **识别信息缺口**:
+ - 缺少哪个平台的用户观点?(如B站年轻人、微博话题讨论、知乎深度分析等)
+ - 缺少哪个时间段的舆情变化?
+ - 缺少哪些具体的民意表达和情感倾向?
+
+3. **精准补充查询**:
+ - 选择最能填补信息缺口的查询工具
+ - **设计接地气的搜索关键词**:
+ * 避免继续使用官方化、书面化的词汇
+ * 思考网民会用什么词来表达这个观点
+ * 使用具体的、有情感色彩的词汇
+ * 考虑不同平台的语言特色(如B站弹幕文化、微博热搜词汇等)
+ - 重点关注评论区和用户原创内容
+
+4. **参数配置要求**:
+ - search_topic_by_date: 必须提供start_date和end_date参数(格式:YYYY-MM-DD)
+ - search_topic_on_platform: 必须提供platform参数(bilibili, weibo, douyin, kuaishou, xhs, zhihu, tieba之一)
+ - 系统自动配置数据量参数,无需手动设置limit或limit_per_table参数
+
+5. **阐述补充理由**:明确说明为什么需要这些额外的民意数据
+
+**反思重点**:
+- 报告是否反映了真实的社会情绪?
+- 是否包含了不同群体的观点和声音?
+- 是否有具体的用户评论和真实案例支撑?
+- 是否体现了舆情的复杂性和多面性?
+- 语言表达是否贴近民众,避免过度官方化?
+
+**搜索词优化示例(重要!)**:
+- 如果需要了解争议话题:
+ * ❌ 不要用:"争议事件"、"公众争议"
+ * ✅ 应该用:"出事了"、"怎么回事"、"翻车"、"炸了"
+- 如果需要了解情感态度:
+ * ❌ 不要用:"情感倾向"、"态度分析"
+ * ✅ 应该用:"支持"、"反对"、"心疼"、"气死"、"666"、"绝了"
+
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_reflection, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_REFLECTION_SUMMARY = f"""
+你是一位资深的舆情分析师和内容深化专家。
+你正在对已有的舆情报告段落进行深度优化和内容扩充,让其更加全面、深入、有说服力。
+数据将按照以下JSON模式定义提供:
+
+
+{json.dumps(input_schema_reflection_summary, indent=2, ensure_ascii=False)}
+
+
+**你的核心任务:大幅丰富和深化段落内容**
+
+**内容扩充策略(目标:每段1000-1500字):**
+
+1. **保留精华,大量补充**:
+ - 保留原段落的核心观点和重要发现
+ - 大量增加新的数据点、用户声音和分析层次
+ - 用新搜索到的数据验证、补充或修正之前的观点
+
+2. **数据密集化处理**:
+ - **新增具体数据**:更多的数量统计、比例分析、趋势数据
+ - **更多用户引用**:新增5-10条有代表性的用户评论和观点
+ - **情感分析升级**:
+ * 对比分析:新旧情感数据的变化趋势
+ * 细分分析:不同平台、群体的情感分布差异
+ * 时间演变:情感随时间的变化轨迹
+ * 置信度分析:高置信度情感分析结果的深度解读
+
+3. **结构化内容组织**:
+ ```
+ ### 核心发现(更新版)
+ [整合原有发现和新发现]
+
+ ### 详细数据画像
+ [原有数据 + 新增数据的综合分析]
+
+ ### 多元声音汇聚
+ [原有评论 + 新增评论的多角度展示]
+
+ ### 深层洞察升级
+ [基于更多数据的深度分析]
+
+ ### 趋势和模式识别
+ [综合所有数据得出的新规律]
+
+ ### 对比分析
+ [不同数据源、时间点、平台的对比]
+ ```
+
+4. **多维度深化分析**:
+ - **横向比较**:不同平台、群体、时间段的数据对比
+ - **纵向追踪**:事件发展过程中的变化轨迹
+ - **关联分析**:与相关事件、话题的关联性分析
+ - **影响评估**:对社会、文化、心理层面的影响分析
+
+5. **具体扩充要求**:
+ - **原创内容保持率**:保留原段落70%的核心内容
+ - **新增内容比例**:新增内容不少于原内容的100%
+ - **数据引用密度**:每200字至少包含3-5个具体数据点
+ - **用户声音密度**:每段至少包含8-12条用户评论引用
+
+6. **质量提升标准**:
+ - **信息密度**:大幅提升信息含量,减少空话套话
+ - **论证充分**:每个观点都有充分的数据和实例支撑
+ - **层次丰富**:从表面现象到深层原因的多层次分析
+ - **视角多元**:体现不同群体、平台、时期的观点差异
+
+7. **语言表达优化**:
+ - 更加精准、生动的语言表达
+ - 用数据说话,让每句话都有价值
+ - 平衡专业性和可读性
+ - 突出重点,形成有力的论证链条
+
+**内容丰富度检查清单**:
+- [ ] 是否包含足够多的具体数据和统计信息?
+- [ ] 是否引用了足够多样化的用户声音?
+- [ ] 是否进行了多层次的深度分析?
+- [ ] 是否体现了不同维度的对比和趋势?
+- [ ] 是否具有较强的说服力和可读性?
+- [ ] 是否达到了预期的字数和信息密度要求?
+
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_reflection_summary, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_REPORT_FORMATTING = f"""
+你是一位资深的舆情分析专家和报告编撰大师。你专精于将复杂的民意数据转化为深度洞察的专业舆情报告。
+你将获得以下JSON格式的数据:
+
+
+{json.dumps(input_schema_report_formatting, indent=2, ensure_ascii=False)}
+
+
+**你的核心使命:创建一份深度挖掘民意、洞察社会情绪的专业舆情分析报告,不少于一万字**
+
+**舆情分析报告的独特架构:**
+
+```markdown
+# 【舆情洞察】[主题]深度民意分析报告
+
+## 执行摘要
+### 核心舆情发现
+- 主要情感倾向和分布
+- 关键争议焦点
+- 重要舆情数据指标
+
+### 民意热点概览
+- 最受关注的讨论点
+- 不同平台的关注重点
+- 情感演变趋势
+
+## 一、[段落1标题]
+### 1.1 民意数据画像
+| 平台 | 参与用户数 | 内容数量 | 正面情感% | 负面情感% | 中性情感% |
+|------|------------|----------|-----------|-----------|-----------|
+| 微博 | XX万 | XX条 | XX% | XX% | XX% |
+| 知乎 | XX万 | XX条 | XX% | XX% | XX% |
+
+### 1.2 代表性民声
+**支持声音 (XX%)**:
+> "具体用户评论1" —— @用户A (点赞数:XXXX)
+> "具体用户评论2" —— @用户B (转发数:XXXX)
+
+**反对声音 (XX%)**:
+> "具体用户评论3" —— @用户C (评论数:XXXX)
+> "具体用户评论4" —— @用户D (热度:XXXX)
+
+### 1.3 深度舆情解读
+[详细的民意分析和社会心理解读]
+
+### 1.4 情感演变轨迹
+[时间线上的情感变化分析]
+
+## 二、[段落2标题]
+[重复相同的结构...]
+
+## 舆情态势综合分析
+### 整体民意倾向
+[基于所有数据的综合民意判断]
+
+### 不同群体观点对比
+| 群体类型 | 主要观点 | 情感倾向 | 影响力 | 活跃度 |
+|----------|----------|----------|--------|--------|
+| 学生群体 | XX | XX | XX | XX |
+| 职场人士 | XX | XX | XX | XX |
+
+### 平台差异化分析
+[不同平台用户群体的观点特征]
+
+### 舆情发展预判
+[基于当前数据的趋势预测]
+
+## 深层洞察与建议
+### 社会心理分析
+[民意背后的深层社会心理]
+
+### 舆情管理建议
+[针对性的舆情应对建议]
+
+## 数据附录
+### 关键舆情指标汇总
+### 重要用户评论合集
+### 情感分析详细数据
+```
+
+**舆情报告特色格式化要求:**
+
+1. **情感可视化**:
+ - 用emoji表情符号增强情感表达:😊 😡 😢 🤔
+ - 用颜色概念描述情感分布:"红色警戒区"、"绿色安全区"
+ - 用温度比喻描述舆情热度:"沸腾"、"升温"、"降温"
+
+2. **民意声音突出**:
+ - 大量使用引用块展示用户原声
+ - 用表格对比不同观点和数据
+ - 突出高赞、高转发的代表性评论
+
+3. **数据故事化**:
+ - 将枯燥数字转化为生动描述
+ - 用对比和趋势展现数据变化
+ - 结合具体案例说明数据意义
+
+4. **社会洞察深度**:
+ - 从个人情感到社会心理的递进分析
+ - 从表面现象到深层原因的挖掘
+ - 从当前状态到未来趋势的预判
+
+5. **专业舆情术语**:
+ - 使用专业的舆情分析词汇
+ - 体现对网络文化和社交媒体的深度理解
+ - 展现对民意形成机制的专业认知
+
+**质量控制标准:**
+- **民意覆盖度**:确保涵盖各主要平台和群体的声音
+- **情感精准度**:准确描述和量化各种情感倾向
+- **洞察深度**:从现象分析到本质洞察的多层次思考
+- **预判价值**:提供有价值的趋势预测和建议
+
+**最终输出**:一份充满人情味、数据丰富、洞察深刻的专业舆情分析报告,不少于一万字,让读者能够深度理解民意脉搏和社会情绪。
+"""
+
+# ===== 合并后的 Agent 指令(将各阶段 prompt 编排为完整工作流)=====
+
+# ===== 海外平台描述模块(按可用性动态拼接)=====
+
+_OVERSEAS_DESC_HN = """
+**Hacker News(YC 科技社区,无需 key)**
+- `search_hackernews(query, max_results)`: 搜索热门帖子(程序员/创业者讨论)
+- `search_hackernews_recent(query, max_results)`: 搜索最新帖子
+- `search_hackernews_comments(query, max_results)`: 搜索评论(深度讨论)
+"""
+
+_OVERSEAS_DESC_GITHUB = """
+**GitHub(开源社区)**
+- `search_github_repos(query, max_results)`: 搜索仓库(按 star 排序,看技术工具的开源生态)
+- `search_github_issues(query, max_results)`: 搜索 issues / discussions(开发者反馈、bug 报告)
+- `search_github_code(query, max_results)`: 搜索代码片段(具体使用示例)
+"""
+
+_OVERSEAS_DESC_YOUTUBE = """
+**YouTube(视频平台 + 评论)**
+- `search_youtube_videos(query, max_results)`: 搜索视频(含播放量/点赞/评论数)
+- `get_youtube_comments(video_id, max_results)`: 拿热门评论
+- `search_youtube_with_comments(query, max_videos, comments_per_video)`: 一步到位
+"""
+
+_OVERSEAS_DESC_REDDIT = """
+**Reddit(综合论坛)**
+- `search_reddit(query, subreddit?, max_results, sort)`: 全站或限定 subreddit 搜索
+- `get_subreddit_hot(subreddit, max_results, time_filter)`: 拿某 subreddit 的热门帖
+- `get_reddit_post_comments(post_id, subreddit, max_results)`: 拿帖子的热门评论
+"""
+
+_OVERSEAS_STRATEGY_BASE = """
+**国际舆情搜索策略(重要!)**:
+
+1. **判断主题地域归属**:
+ - 国际话题(科技产品、AI 模型、开源项目)→ 优先用海外工具
+ - 国内话题(中文明星、国内政策)→ 优先用本地数据库工具
+ - 跨境话题(中美关系、国际品牌)→ 两边都用,对比中外视角差异
+
+2. **海外搜索关键词必须用英文**:
+ - ❌ "Claude Code 中文社区"
+ - ✅ "Claude Code anthropic AI coding"
+"""
+
+_OVERSEAS_PLATFORM_TIPS = {
+ "hackernews": ' - Hacker News:技术理性 → "claude code performance benchmark"',
+ "github": ' - GitHub:精确技术词 → "claude-code language:python"',
+ "youtube": ' - YouTube:教程导向 → "claude code tutorial review"',
+ "reddit": ' - Reddit:口语化 → "is claude code worth it" / "claude code vs cursor"',
+}
+
+_REDDIT_SUBREDDIT_TIP = """
+4. **subreddit 推荐**(搜 Reddit 时按主题选):
+ - 科技/AI: r/programming, r/MachineLearning, r/LocalLLaMA, r/ChatGPTCoding, r/singularity
+ - 通用科技: r/technology, r/Futurology
+ - 商业: r/business, r/startups
+"""
+
+
+def _build_overseas_section(available_platforms: set) -> str:
+ """根据可用的海外平台动态拼接 prompt 段落"""
+ if not available_platforms:
+ return ""
+
+ parts = ["## 国际舆情扩展(重要)\n"]
+ parts.append("除了上述国内社交媒体工具,你**还可以使用以下海外数据源工具**,用于获取国际社区对话题的真实声音:")
+
+ if "hackernews" in available_platforms:
+ parts.append(_OVERSEAS_DESC_HN)
+ if "github" in available_platforms:
+ parts.append(_OVERSEAS_DESC_GITHUB)
+ if "youtube" in available_platforms:
+ parts.append(_OVERSEAS_DESC_YOUTUBE)
+ if "reddit" in available_platforms:
+ parts.append(_OVERSEAS_DESC_REDDIT)
+
+ parts.append(_OVERSEAS_STRATEGY_BASE)
+
+ # 平台 tips:只包含可用平台的
+ parts.append("\n3. **针对不同平台用不同语言风格**:")
+ for plat in ["hackernews", "github", "youtube", "reddit"]:
+ if plat in available_platforms:
+ parts.append(_OVERSEAS_PLATFORM_TIPS[plat])
+
+ if "reddit" in available_platforms:
+ parts.append(_REDDIT_SUBREDDIT_TIP)
+
+ parts.append("""
+5. **国际+国内对比分析(关键 insight 来源)**:
+ - 同一话题在国际社区上的态度 vs 国内平台的态度
+ - 投票结构反映的群体差异
+ - 评论情绪强度对比
+""")
+
+ return "\n".join(parts)
+
+
+def _build_insight_prompt(available_platforms: set) -> str:
+ """根据可用平台动态构建完整的 InsightAgent system prompt"""
+ overseas_section = _build_overseas_section(available_platforms)
+
+ return f"""
+你是一位专业的舆情分析师,专注于挖掘社交媒体上真实的民意和公众情感。
+
+你需要严格按照以下阶段工作,每个阶段产出符合指定 JSON Schema 的结构化输出。
+
+## 阶段一:规划报告结构
+
+{SYSTEM_PROMPT_REPORT_STRUCTURE}
+
+## 阶段二:逐段搜索与总结
+
+对阶段一输出的每个段落,依次执行以下子步骤:
+
+### 2a. 首次搜索决策
+
+{SYSTEM_PROMPT_FIRST_SEARCH}
+
+### 2b. 首次总结
+
+{SYSTEM_PROMPT_FIRST_SUMMARY}
+
+### 2c. 反思(至少执行1次)
+
+{SYSTEM_PROMPT_REFLECTION}
+
+### 2d. 反思总结
+
+{SYSTEM_PROMPT_REFLECTION_SUMMARY}
+
+## 阶段三:最终报告格式化
+
+{SYSTEM_PROMPT_REPORT_FORMATTING}
+
+{overseas_section}
+
+## 最终输出要求
+
+完成所有阶段后,你必须输出一个 JSON 对象,包含以下字段:
+- "query": 原始分析主题
+- "paragraphs": 各段落结果数组,每个元素包含 "title" 和 "paragraph_latest_state"
+- "final_report": 阶段三生成的完整 Markdown 报告
+"""
+
+
+# 兼容旧引用(agent_runner.py 等可能直接 import 这个常量)
+# 默认使用「全部可用」的 prompt,实际运行时由 create_insight_agent 重新生成
+INSIGHT_SYSTEM_PROMPT = _build_insight_prompt({"hackernews", "github", "youtube", "reddit"})
+
+
+def _detect_available_overseas(config) -> set:
+ """检查每个海外平台所需的 key 是否配置,返回可用平台集合"""
+ available = set()
+
+ # HN 永远可用(无需 key)
+ available.add("hackernews")
+
+ # GitHub 永远可用(无 token 也行,只是限速)
+ available.add("github")
+
+ # YouTube:需要 YOUTUBE_API_KEY
+ if getattr(config, "YOUTUBE_API_KEY", None):
+ available.add("youtube")
+
+ # Reddit:需要 client_id + client_secret
+ if getattr(config, "REDDIT_CLIENT_ID", None) and getattr(config, "REDDIT_CLIENT_SECRET", None):
+ available.add("reddit")
+
+ return available
+
+
+def _build_overseas_tool_list(available: set) -> list:
+ """根据可用集合返回对应的工具列表"""
+ tools = []
+ if "hackernews" in available:
+ tools += [search_hackernews, search_hackernews_recent, search_hackernews_comments]
+ if "github" in available:
+ tools += [search_github_repos, search_github_issues, search_github_code]
+ if "youtube" in available:
+ tools += [search_youtube_videos, get_youtube_comments, search_youtube_with_comments]
+ if "reddit" in available:
+ tools += [search_reddit, get_subreddit_hot, get_reddit_post_comments]
+ return tools
+
+
+def create_insight_agent(config=None):
+ """
+ 创建 InsightAgent 实例。
+ 替代原 DeepSearchAgent.__init__()
+
+ 工具集会根据 .env 中实际配置的 key 动态裁剪:
+ - 国内 6 工具:始终启用
+ - HN / GitHub:始终启用(无需 key)
+ - YouTube / Reddit:仅当对应 key 已配置时启用
+ """
+ if config is None:
+ from config import settings
+ config = settings
+
+ available_overseas = _detect_available_overseas(config)
+ overseas_tools = _build_overseas_tool_list(available_overseas)
+ instructions = _build_insight_prompt(available_overseas)
+
+ print(f"[InsightAgent] 海外平台已启用: {sorted(available_overseas)}")
+
+ return Agent(
+ name="InsightAgent",
+ model=OpenAIChat(
+ id=config.INSIGHT_ENGINE_MODEL_NAME,
+ api_key=config.INSIGHT_ENGINE_API_KEY,
+ base_url=config.INSIGHT_ENGINE_BASE_URL,
+ role_map={"system": "system", "user": "user", "assistant": "assistant", "tool": "tool", "model": "assistant"},
+ client=_OpenAI(
+ api_key=config.INSIGHT_ENGINE_API_KEY,
+ base_url=config.INSIGHT_ENGINE_BASE_URL,
+ http_client=httpx.Client(proxy=None, timeout=300),
+ ),
+ ),
+ tools=[
+ # 国内社交媒体(始终启用)
+ search_hot_content, search_topic_globally, search_topic_by_date,
+ get_comments_for_topic, search_topic_on_platform, analyze_sentiment,
+ ] + overseas_tools,
+ instructions=instructions,
+ system_message_role="system",
+ markdown=True,
+ debug_mode=True,
+ )
+
+
+def run_insight_analysis(query: str, config=None) -> AnalysisResult:
+ """
+ 执行深度舆情分析。
+ 替代原 DeepSearchAgent.run(query)
+
+ Args:
+ query: 分析主题,如 "某品牌产品质量危机舆情分析"
+ Returns:
+ AnalysisResult 结构化对象,包含:
+ - paragraphs: 各段落的标题和内容(供 ForumEngine 消费)
+ - final_report: 完整 Markdown 报告(供 ReportEngine 消费)
+ """
+ agent = create_insight_agent(config)
+ response = agent.run(query)
+ return parse_analysis_result(response.content, query)
diff --git a/experimental/agno_version/agno_agents/media_agent.py b/experimental/agno_version/agno_agents/media_agent.py
new file mode 100644
index 000000000..cb1cc315e
--- /dev/null
+++ b/experimental/agno_version/agno_agents/media_agent.py
@@ -0,0 +1,544 @@
+# agno_agents/media_agent.py
+# 迁移自 MediaEngine/agent.py
+# 定位:多媒体/网页内容分析(综合搜索、图片、结构化数据)
+
+import json
+import httpx
+from openai import OpenAI as _OpenAI
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+
+from .models import (
+ SearchDecision,
+ ReportStructure,
+ ParagraphResult,
+ AnalysisResult,
+ parse_analysis_result,
+)
+
+from agno_tools import (
+ comprehensive_search, web_search_only,
+ search_for_structured_data,
+ search_last_24_hours, search_last_week,
+)
+
+# ===== JSON Schema 定义(保留原始 Schema,供 prompt 引用)=====
+
+output_schema_report_structure = {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"}
+ }
+ }
+}
+
+input_schema_first_search = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"}
+ }
+}
+
+output_schema_first_search = {
+ "type": "object",
+ "properties": {
+ "search_query": {"type": "string"},
+ "search_tool": {"type": "string"},
+ "reasoning": {"type": "string"}
+ },
+ "required": ["search_query", "search_tool", "reasoning"]
+}
+
+input_schema_first_summary = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"},
+ "search_query": {"type": "string"},
+ "search_results": {
+ "type": "array",
+ "items": {"type": "string"}
+ }
+ }
+}
+
+output_schema_first_summary = {
+ "type": "object",
+ "properties": {
+ "paragraph_latest_state": {"type": "string"}
+ }
+}
+
+input_schema_reflection = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"},
+ "paragraph_latest_state": {"type": "string"}
+ }
+}
+
+output_schema_reflection = {
+ "type": "object",
+ "properties": {
+ "search_query": {"type": "string"},
+ "search_tool": {"type": "string"},
+ "reasoning": {"type": "string"}
+ },
+ "required": ["search_query", "search_tool", "reasoning"]
+}
+
+input_schema_reflection_summary = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"},
+ "search_query": {"type": "string"},
+ "search_results": {
+ "type": "array",
+ "items": {"type": "string"}
+ },
+ "paragraph_latest_state": {"type": "string"}
+ }
+}
+
+output_schema_reflection_summary = {
+ "type": "object",
+ "properties": {
+ "updated_paragraph_latest_state": {"type": "string"}
+ }
+}
+
+input_schema_report_formatting = {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "paragraph_latest_state": {"type": "string"}
+ }
+ }
+}
+
+# ===== 系统提示词定义(完整保留原始 prompt)=====
+
+SYSTEM_PROMPT_REPORT_STRUCTURE = f"""
+你是一位深度研究助手。给定一个查询,你需要规划一个报告的结构和其中包含的段落。最多5个段落。
+确保段落的排序合理有序。
+一旦大纲创建完成,你将获得工具来分别为每个部分搜索网络并进行反思。
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_report_structure, indent=2, ensure_ascii=False)}
+
+
+标题和内容属性将用于更深入的研究。
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_FIRST_SEARCH = f"""
+你是一位深度研究助手。你将获得报告中的一个段落,其标题和预期内容将按照以下JSON模式定义提供:
+
+
+{json.dumps(input_schema_first_search, indent=2, ensure_ascii=False)}
+
+
+你可以使用以下5种专业的多模态搜索工具:
+
+1. **comprehensive_search** - 全面综合搜索工具
+ - 适用于:一般性的研究需求,需要完整信息时
+ - 特点:返回网页、图片、AI总结、追问建议和可能的结构化数据,是最常用的基础工具
+
+2. **web_search_only** - 纯网页搜索工具
+ - 适用于:只需要网页链接和摘要,不需要AI分析时
+ - 特点:速度更快,成本更低,只返回网页结果
+
+3. **search_for_structured_data** - 结构化数据查询工具
+ - 适用于:查询天气、股票、汇率、百科定义等结构化信息时
+ - 特点:专门用于触发"模态卡"的查询,返回结构化数据
+
+4. **search_last_24_hours** - 24小时内信息搜索工具
+ - 适用于:需要了解最新动态、突发事件时
+ - 特点:只搜索过去24小时内发布的内容
+
+5. **search_last_week** - 本周信息搜索工具
+ - 适用于:需要了解近期发展趋势时
+ - 特点:搜索过去一周内的主要报道
+
+你的任务是:
+1. 根据段落主题选择最合适的搜索工具
+2. 制定最佳的搜索查询
+3. 解释你的选择理由
+
+注意:所有工具都不需要额外参数,选择工具主要基于搜索意图和需要的信息类型。
+请按照以下JSON模式定义格式化输出(文字请使用中文):
+
+
+{json.dumps(output_schema_first_search, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_FIRST_SUMMARY = f"""
+你是一位专业的多媒体内容分析师和深度报告撰写专家。你将获得搜索查询、多模态搜索结果以及你正在研究的报告段落,数据将按照以下JSON模式定义提供:
+
+
+{json.dumps(input_schema_first_summary, indent=2, ensure_ascii=False)}
+
+
+**你的核心任务:创建信息丰富、多维度的综合分析段落(每段不少于800-1200字)**
+
+**撰写标准和多模态内容整合要求:**
+
+1. **开篇概述**:
+ - 用2-3句话明确本段的分析焦点和核心问题
+ - 突出多模态信息的整合价值
+
+2. **多源信息整合层次**:
+ - **网页内容分析**:详细分析网页搜索结果中的文字信息、数据、观点
+ - **图片信息解读**:深入分析相关图片所传达的信息、情感、视觉元素
+ - **AI总结整合**:利用AI总结信息,提炼关键观点和趋势
+ - **结构化数据应用**:充分利用天气、股票、百科等结构化信息(如适用)
+
+3. **内容结构化组织**:
+ ```
+ ## 综合信息概览
+ [多种信息源的核心发现]
+
+ ## 文本内容深度分析
+ [网页、文章内容的详细分析]
+
+ ## 视觉信息解读
+ [图片、多媒体内容的分析]
+
+ ## 数据综合分析
+ [各类数据的整合分析]
+
+ ## 多维度洞察
+ [基于多种信息源的深度洞察]
+ ```
+
+4. **具体内容要求**:
+ - **文本引用**:大量引用搜索结果中的具体文字内容
+ - **图片描述**:详细描述相关图片的内容、风格、传达的信息
+ - **数据提取**:准确提取和分析各种数据信息
+ - **趋势识别**:基于多源信息识别发展趋势和模式
+
+5. **信息密度标准**:
+ - 每100字至少包含2-3个来自不同信息源的具体信息点
+ - 充分利用搜索结果的多样性和丰富性
+ - 避免信息冗余,确保每个信息点都有价值
+ - 实现文字、图像、数据的有机结合
+
+6. **分析深度要求**:
+ - **关联分析**:分析不同信息源之间的关联性和一致性
+ - **对比分析**:比较不同来源信息的差异和互补性
+ - **趋势分析**:基于多源信息判断发展趋势
+ - **影响评估**:评估事件或话题的影响范围和程度
+
+7. **多模态特色体现**:
+ - **视觉化描述**:用文字生动描述图片内容和视觉冲击
+ - **数据可视**:将数字信息转化为易理解的描述
+ - **立体化分析**:从多个感官和维度理解分析对象
+ - **综合判断**:基于文字、图像、数据的综合判断
+
+8. **语言表达要求**:
+ - 准确、客观、具有分析深度
+ - 既要专业又要生动有趣
+ - 充分体现多模态信息的丰富性
+ - 逻辑清晰,条理分明
+
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_first_summary, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_REFLECTION = f"""
+你是一位深度研究助手。你负责为研究报告构建全面的段落。你将获得段落标题、计划内容摘要,以及你已经创建的段落最新状态,所有这些都将按照以下JSON模式定义提供:
+
+
+{json.dumps(input_schema_reflection, indent=2, ensure_ascii=False)}
+
+
+你可以使用以下5种专业的多模态搜索工具:
+
+1. **comprehensive_search** - 全面综合搜索工具
+2. **web_search_only** - 纯网页搜索工具
+3. **search_for_structured_data** - 结构化数据查询工具
+4. **search_last_24_hours** - 24小时内信息搜索工具
+5. **search_last_week** - 本周信息搜索工具
+
+你的任务是:
+1. 反思段落文本的当前状态,思考是否遗漏了主题的某些关键方面
+2. 选择最合适的搜索工具来补充缺失信息
+3. 制定精确的搜索查询
+4. 解释你的选择和推理
+
+注意:所有工具都不需要额外参数,选择工具主要基于搜索意图和需要的信息类型。
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_reflection, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_REFLECTION_SUMMARY = f"""
+你是一位深度研究助手。
+你将获得搜索查询、搜索结果、段落标题以及你正在研究的报告段落的预期内容。
+你正在迭代完善这个段落,并且段落的最新状态也会提供给你。
+数据将按照以下JSON模式定义提供:
+
+
+{json.dumps(input_schema_reflection_summary, indent=2, ensure_ascii=False)}
+
+
+你的任务是根据搜索结果和预期内容丰富段落的当前最新状态。
+不要删除最新状态中的关键信息,尽量丰富它,只添加缺失的信息。
+适当地组织段落结构以便纳入报告中。
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_reflection_summary, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_REPORT_FORMATTING = f"""
+你是一位资深的多媒体内容分析专家和融合报告编辑。你专精于将文字、图像、数据等多维信息整合为全景式的综合分析报告。
+你将获得以下JSON格式的数据:
+
+
+{json.dumps(input_schema_report_formatting, indent=2, ensure_ascii=False)}
+
+
+**你的核心使命:创建一份立体化、多维度的全景式多媒体分析报告,不少于一万字**
+
+**多媒体分析报告的创新架构:**
+
+```markdown
+# 【全景解析】[主题]多维度融合分析报告
+
+## 全景概览
+### 多维信息摘要
+- 文字信息核心发现
+- 视觉内容关键洞察
+- 数据趋势重要指标
+- 跨媒体关联分析
+
+### 信息源分布图
+- 网页文字内容:XX%
+- 图片视觉信息:XX%
+- 结构化数据:XX%
+- AI分析洞察:XX%
+
+## 一、[段落1标题]
+### 1.1 多模态信息画像
+| 信息类型 | 数量 | 主要内容 | 情感倾向 | 传播效果 | 影响力指数 |
+|----------|------|----------|----------|----------|------------|
+| 文字内容 | XX条 | XX主题 | XX | XX | XX/10 |
+| 图片内容 | XX张 | XX类型 | XX | XX | XX/10 |
+| 数据信息 | XX项 | XX指标 | 中性 | XX | XX/10 |
+
+### 1.2 视觉内容深度解析
+**图片类型分布**:
+- 新闻图片 (XX张):展现事件现场,情感倾向偏向客观中性
+ - 代表性图片:"图片描述内容..." (传播热度:★★★★☆)
+ - 视觉冲击力:强,主要展现XX场景
+
+- 用户创作 (XX张):体现个人观点,情感表达多样化
+ - 代表性图片:"图片描述内容..." (互动数据:XX点赞)
+ - 创意特点:XX风格,传达XX情感
+
+### 1.3 文字与视觉的融合分析
+[文字信息与图片内容的关联性分析]
+
+### 1.4 数据与内容的交叉验证
+[结构化数据与多媒体内容的相互印证]
+
+## 二、[段落2标题]
+[重复相同的多媒体分析结构...]
+
+## 跨媒体综合分析
+### 信息一致性评估
+| 维度 | 文字内容 | 图片内容 | 数据信息 | 一致性得分 |
+|------|----------|----------|----------|------------|
+| 主题焦点 | XX | XX | XX | XX/10 |
+| 情感倾向 | XX | XX | 中性 | XX/10 |
+| 传播效果 | XX | XX | XX | XX/10 |
+
+### 多维度影响力对比
+**文字传播特征**:
+- 信息密度:高,包含大量细节和观点
+- 理性程度:较高,逻辑性强
+- 传播深度:深,适合深度讨论
+
+**视觉传播特征**:
+- 情感冲击:强,直观的视觉效果
+- 传播速度:快,易于快速理解
+- 记忆效果:好,视觉印象深刻
+
+**数据信息特征**:
+- 准确性:极高,客观可靠
+- 权威性:强,基于事实
+- 参考价值:高,支撑分析判断
+
+### 融合效应分析
+[多种媒体形式结合产生的综合效应]
+
+## 多维洞察与预测
+### 跨媒体趋势识别
+[基于多种信息源的趋势预判]
+
+### 传播效应评估
+[不同媒体形式的传播效果对比]
+
+### 综合影响力评估
+[多媒体内容的整体社会影响]
+
+## 多媒体数据附录
+### 图片内容汇总表
+### 关键数据指标集
+### 跨媒体关联分析图
+### AI分析结果汇总
+```
+
+**多媒体报告特色格式化要求:**
+
+1. **多维信息整合**:
+ - 创建跨媒体对比表格
+ - 用综合评分体系量化分析
+ - 展现不同信息源的互补性
+
+2. **立体化叙述**:
+ - 从多个感官维度描述内容
+ - 用电影分镜的概念描述视觉内容
+ - 结合文字、图像、数据讲述完整故事
+
+3. **创新分析视角**:
+ - 信息传播效果的跨媒体对比
+ - 视觉与文字的情感一致性分析
+ - 多媒体组合的协同效应评估
+
+4. **专业多媒体术语**:
+ - 使用视觉传播、多媒体融合等专业词汇
+ - 体现对不同媒体形式特点的深度理解
+ - 展现多维度信息整合的专业能力
+
+**质量控制标准:**
+- **信息覆盖度**:充分利用文字、图像、数据等各类信息
+- **分析立体度**:从多个维度和角度进行综合分析
+- **融合深度**:实现不同信息类型的深度融合
+- **创新价值**:提供传统单一媒体分析无法实现的洞察
+
+**最终输出**:一份融合多种媒体形式、具有立体化视角、创新分析方法的全景式多媒体分析报告,不少于一万字,为读者提供前所未有的全方位信息体验。
+"""
+
+# ===== 合并后的 Agent 指令(将各阶段 prompt 编排为完整工作流)=====
+
+MEDIA_SYSTEM_PROMPT = f"""
+你是一位专业的多媒体内容分析师,专注于整合网页文字、图片、结构化数据等多维信息进行深度研究。
+
+你需要严格按照以下阶段工作,每个阶段产出符合指定 JSON Schema 的结构化输出。
+
+## 阶段一:规划报告结构
+
+{SYSTEM_PROMPT_REPORT_STRUCTURE}
+
+## 阶段二:逐段搜索与总结
+
+对阶段一输出的每个段落,依次执行以下子步骤:
+
+### 2a. 首次搜索决策
+
+{SYSTEM_PROMPT_FIRST_SEARCH}
+
+### 2b. 首次总结
+
+{SYSTEM_PROMPT_FIRST_SUMMARY}
+
+### 2c. 反思(至少执行1次)
+
+{SYSTEM_PROMPT_REFLECTION}
+
+### 2d. 反思总结
+
+{SYSTEM_PROMPT_REFLECTION_SUMMARY}
+
+## 阶段三:最终报告格式化
+
+{SYSTEM_PROMPT_REPORT_FORMATTING}
+
+## 最终输出要求
+
+完成所有阶段后,你必须输出一个 JSON 对象,包含以下字段:
+- "query": 原始分析主题
+- "paragraphs": 各段落结果数组,每个元素包含 "title" 和 "paragraph_latest_state"
+- "final_report": 阶段三生成的完整 Markdown 报告
+"""
+
+
+def create_media_agent(config=None):
+ """
+ 创建 MediaAgent 实例。
+ 替代原 MediaEngine agent.__init__()
+ """
+ if config is None:
+ from config import settings
+ config = settings
+
+ return Agent(
+ name="MediaAgent",
+ model=OpenAIChat(
+ id=config.MEDIA_ENGINE_MODEL_NAME,
+ api_key=config.MEDIA_ENGINE_API_KEY,
+ base_url=config.MEDIA_ENGINE_BASE_URL,
+ role_map={"system": "system", "user": "user", "assistant": "assistant", "tool": "tool", "model": "assistant"},
+ client=_OpenAI(
+ api_key=config.MEDIA_ENGINE_API_KEY,
+ base_url=config.MEDIA_ENGINE_BASE_URL,
+ http_client=httpx.Client(proxy=None, timeout=300),
+ ),
+ ),
+ tools=[
+ comprehensive_search, web_search_only, search_for_structured_data,
+ search_last_24_hours, search_last_week,
+ ],
+ instructions=MEDIA_SYSTEM_PROMPT,
+ system_message_role="system",
+ markdown=True,
+ debug_mode=True,
+ )
+
+
+def run_media_analysis(query: str, config=None) -> AnalysisResult:
+ """
+ 执行媒体内容分析。
+ 替代原 MediaEngine agent.run(query)
+
+ Args:
+ query: 分析主题
+ Returns:
+ AnalysisResult 结构化对象,包含:
+ - paragraphs: 各段落的标题和内容(供 ForumEngine 消费)
+ - final_report: 完整 Markdown 报告(供 ReportEngine 消费)
+ """
+ agent = create_media_agent(config)
+ response = agent.run(query)
+ return parse_analysis_result(response.content, query)
diff --git a/experimental/agno_version/agno_agents/models.py b/experimental/agno_version/agno_agents/models.py
new file mode 100644
index 000000000..ce86d4eb3
--- /dev/null
+++ b/experimental/agno_version/agno_agents/models.py
@@ -0,0 +1,75 @@
+# agno_agents/models.py
+# 三个 Agent 共享的结构化输出模型
+# ForumEngine / ReportEngine 依赖这些模型解析 Agent 的中间和最终输出
+
+import json as _json
+import re as _re
+
+from pydantic import BaseModel, Field
+from typing import List, Optional
+
+
+class SearchDecision(BaseModel):
+ """搜索决策(对应原 FirstSearchNode / ReflectionNode 输出)"""
+ search_query: str = Field(description="搜索关键词")
+ search_tool: str = Field(description="选用的搜索工具名称")
+ reasoning: str = Field(description="选择该工具和关键词的理由")
+ start_date: Optional[str] = Field(None, description="开始日期,格式YYYY-MM-DD")
+ end_date: Optional[str] = Field(None, description="结束日期,格式YYYY-MM-DD")
+ platform: Optional[str] = Field(None, description="平台名称,如 bilibili, weibo, douyin 等")
+ time_period: Optional[str] = Field(None, description="时间周期,如 24h, week, year")
+ enable_sentiment: Optional[bool] = Field(True, description="是否启用情感分析")
+ texts: Optional[List[str]] = Field(None, description="文本列表,仅用于 analyze_sentiment")
+
+
+class ParagraphOutline(BaseModel):
+ """报告段落大纲(对应原 ReportStructureNode 输出的每个元素)"""
+ title: str = Field(description="段落标题")
+ content: str = Field(description="段落预期内容描述")
+
+
+class ReportStructure(BaseModel):
+ """报告结构规划(对应原 ReportStructureNode 完整输出)"""
+ paragraphs: List[ParagraphOutline] = Field(description="报告段落列表,最多5个")
+
+
+class ParagraphResult(BaseModel):
+ """单个段落的分析结果(对应原 FirstSummaryNode / ReflectionSummaryNode 输出)"""
+ title: str = Field(description="段落标题")
+ paragraph_latest_state: str = Field(description="段落最新内容(800-1500字)")
+
+
+class AnalysisResult(BaseModel):
+ """Agent 完整分析结果(ForumEngine / ReportEngine 消费此结构)"""
+ query: str = Field(description="原始分析主题")
+ paragraphs: List[ParagraphResult] = Field(description="各段落分析结果")
+ final_report: str = Field(description="最终格式化的完整 Markdown 报告")
+
+
+def parse_analysis_result(content: str, query: str) -> AnalysisResult:
+ """
+ 从 Agent 的纯文本输出中解析 AnalysisResult。
+ 兼容两种情况:
+ 1. LLM 返回了符合 AnalysisResult 的 JSON → 直接解析
+ 2. LLM 返回了纯 Markdown 报告 → 包装为 AnalysisResult
+ """
+ if isinstance(content, AnalysisResult):
+ return content
+
+ text = str(content).strip()
+
+ # 尝试从文本中提取 JSON
+ json_match = _re.search(r'\{[\s\S]*"final_report"[\s\S]*\}', text)
+ if json_match:
+ try:
+ data = _json.loads(json_match.group())
+ return AnalysisResult(**data)
+ except Exception:
+ pass
+
+ # 纯 Markdown 回退:将整篇报告作为 final_report
+ return AnalysisResult(
+ query=query,
+ paragraphs=[],
+ final_report=text,
+ )
diff --git a/experimental/agno_version/agno_agents/query_agent.py b/experimental/agno_version/agno_agents/query_agent.py
new file mode 100644
index 000000000..3a3248ddd
--- /dev/null
+++ b/experimental/agno_version/agno_agents/query_agent.py
@@ -0,0 +1,540 @@
+# agno_agents/query_agent.py
+# 迁移自 QueryEngine/agent.py
+# 定位:新闻深度分析(多源核实、事实还原、客观报道)
+
+import json
+import httpx
+from openai import OpenAI as _OpenAI
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+
+from .models import (
+ SearchDecision,
+ ReportStructure,
+ ParagraphResult,
+ AnalysisResult,
+ parse_analysis_result,
+)
+
+from agno_tools import (
+ basic_search_news, deep_search_news,
+ search_news_last_24_hours, search_news_last_week,
+ search_images_for_news, search_news_by_date,
+)
+
+# ===== JSON Schema 定义(保留原始 Schema,供 prompt 引用)=====
+
+output_schema_report_structure = {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"}
+ }
+ }
+}
+
+input_schema_first_search = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"}
+ }
+}
+
+output_schema_first_search = {
+ "type": "object",
+ "properties": {
+ "search_query": {"type": "string"},
+ "search_tool": {"type": "string"},
+ "reasoning": {"type": "string"},
+ "start_date": {"type": "string", "description": "开始日期,格式YYYY-MM-DD,search_news_by_date工具需要"},
+ "end_date": {"type": "string", "description": "结束日期,格式YYYY-MM-DD,search_news_by_date工具需要"}
+ },
+ "required": ["search_query", "search_tool", "reasoning"]
+}
+
+input_schema_first_summary = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"},
+ "search_query": {"type": "string"},
+ "search_results": {
+ "type": "array",
+ "items": {"type": "string"}
+ }
+ }
+}
+
+output_schema_first_summary = {
+ "type": "object",
+ "properties": {
+ "paragraph_latest_state": {"type": "string"}
+ }
+}
+
+input_schema_reflection = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"},
+ "paragraph_latest_state": {"type": "string"}
+ }
+}
+
+output_schema_reflection = {
+ "type": "object",
+ "properties": {
+ "search_query": {"type": "string"},
+ "search_tool": {"type": "string"},
+ "reasoning": {"type": "string"},
+ "start_date": {"type": "string", "description": "开始日期,格式YYYY-MM-DD,search_news_by_date工具需要"},
+ "end_date": {"type": "string", "description": "结束日期,格式YYYY-MM-DD,search_news_by_date工具需要"}
+ },
+ "required": ["search_query", "search_tool", "reasoning"]
+}
+
+input_schema_reflection_summary = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "content": {"type": "string"},
+ "search_query": {"type": "string"},
+ "search_results": {
+ "type": "array",
+ "items": {"type": "string"}
+ },
+ "paragraph_latest_state": {"type": "string"}
+ }
+}
+
+output_schema_reflection_summary = {
+ "type": "object",
+ "properties": {
+ "updated_paragraph_latest_state": {"type": "string"}
+ }
+}
+
+input_schema_report_formatting = {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "paragraph_latest_state": {"type": "string"}
+ }
+ }
+}
+
+# ===== 系统提示词定义(完整保留原始 QueryEngine prompt)=====
+
+SYSTEM_PROMPT_REPORT_STRUCTURE = f"""
+你是一位深度研究助手。给定一个查询,你需要规划一个报告的结构和其中包含的段落。最多五个段落。
+确保段落的排序合理有序。
+一旦大纲创建完成,你将获得工具来分别为每个部分搜索网络并进行反思。
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_report_structure, indent=2, ensure_ascii=False)}
+
+
+标题和内容属性将用于更深入的研究。
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_FIRST_SEARCH = f"""
+你是一位深度研究助手。你将获得报告中的一个段落,其标题和预期内容将按照以下JSON模式定义提供:
+
+
+{json.dumps(input_schema_first_search, indent=2, ensure_ascii=False)}
+
+
+你可以使用以下6种专业的新闻搜索工具:
+
+1. **basic_search_news** - 基础新闻搜索工具
+ - 适用于:一般性的新闻搜索,不确定需要何种特定搜索时
+ - 特点:快速、标准的通用搜索,是最常用的基础工具
+
+2. **deep_search_news** - 深度新闻分析工具
+ - 适用于:需要全面深入了解某个主题时
+ - 特点:提供最详细的分析结果,包含高级AI摘要
+
+3. **search_news_last_24_hours** - 24小时最新新闻工具
+ - 适用于:需要了解最新动态、突发事件时
+ - 特点:只搜索过去24小时的新闻
+
+4. **search_news_last_week** - 本周新闻工具
+ - 适用于:需要了解近期发展趋势时
+ - 特点:搜索过去一周的新闻报道
+
+5. **search_images_for_news** - 图片搜索工具
+ - 适用于:需要可视化信息、图片资料时
+ - 特点:提供相关图片和图片描述
+
+6. **search_news_by_date** - 按日期范围搜索工具
+ - 适用于:需要研究特定历史时期时
+ - 特点:可以指定开始和结束日期进行搜索
+ - 特殊要求:需要提供start_date和end_date参数,格式为'YYYY-MM-DD'
+ - 注意:只有这个工具需要额外的时间参数
+
+你的任务是:
+1. 根据段落主题选择最合适的搜索工具
+2. 制定最佳的搜索查询
+3. 如果选择search_news_by_date工具,必须同时提供start_date和end_date参数(格式:YYYY-MM-DD)
+4. 解释你的选择理由
+5. 仔细核查新闻中的可疑点,破除谣言和误导,尽力还原事件原貌
+
+注意:除了search_news_by_date工具外,其他工具都不需要额外参数。
+请按照以下JSON模式定义格式化输出(文字请使用中文):
+
+
+{json.dumps(output_schema_first_search, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_FIRST_SUMMARY = f"""
+你是一位专业的新闻分析师和深度内容创作专家。你将获得搜索查询、搜索结果以及你正在研究的报告段落,数据将按照以下JSON模式定义提供:
+
+
+{json.dumps(input_schema_first_summary, indent=2, ensure_ascii=False)}
+
+
+**你的核心任务:创建信息密集、结构完整的新闻分析段落(每段不少于800-1200字)**
+
+**撰写标准和要求:**
+
+1. **开篇框架**:
+ - 用2-3句话概括本段要分析的核心问题
+ - 明确分析的角度和重点方向
+
+2. **丰富的信息层次**:
+ - **事实陈述层**:详细引用新闻报道的具体内容、数据、事件细节
+ - **多源验证层**:对比不同新闻源的报道角度和信息差异
+ - **数据分析层**:提取并分析相关的数量、时间、地点等关键数据
+ - **深度解读层**:分析事件背后的原因、影响和意义
+
+3. **结构化内容组织**:
+ ```
+ ## 核心事件概述
+ [详细的事件描述和关键信息]
+
+ ## 多方报道分析
+ [不同媒体的报道角度和信息汇总]
+
+ ## 关键数据提取
+ [重要的数字、时间、地点等数据]
+
+ ## 深度背景分析
+ [事件的背景、原因、影响分析]
+
+ ## 发展趋势判断
+ [基于现有信息的趋势分析]
+ ```
+
+4. **具体引用要求**:
+ - **直接引用**:大量使用引号标注的新闻原文
+ - **数据引用**:精确引用报道中的数字、统计数据
+ - **多源对比**:展示不同新闻源的表述差异
+ - **时间线整理**:按时间顺序整理事件发展脉络
+
+5. **信息密度要求**:
+ - 每100字至少包含2-3个具体信息点(数据、引用、事实)
+ - 每个分析点都要有新闻源支撑
+ - 避免空洞的理论分析,重点关注实证信息
+ - 确保信息的准确性和完整性
+
+6. **分析深度要求**:
+ - **横向分析**:同类事件的比较分析
+ - **纵向分析**:事件发展的时间线分析
+ - **影响评估**:分析事件的短期和长期影响
+ - **多角度视角**:从不同利益相关方的角度分析
+
+7. **语言表达标准**:
+ - 客观、准确、具有新闻专业性
+ - 条理清晰,逻辑严密
+ - 信息量大,避免冗余和套话
+ - 既要专业又要易懂
+
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_first_summary, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_REFLECTION = f"""
+你是一位深度研究助手。你负责为研究报告构建全面的段落。你将获得段落标题、计划内容摘要,以及你已经创建的段落最新状态,所有这些都将按照以下JSON模式定义提供:
+
+
+{json.dumps(input_schema_reflection, indent=2, ensure_ascii=False)}
+
+
+你可以使用以下6种专业的新闻搜索工具:
+
+1. **basic_search_news** - 基础新闻搜索工具
+2. **deep_search_news** - 深度新闻分析工具
+3. **search_news_last_24_hours** - 24小时最新新闻工具
+4. **search_news_last_week** - 本周新闻工具
+5. **search_images_for_news** - 图片搜索工具
+6. **search_news_by_date** - 按日期范围搜索工具(需要时间参数)
+
+你的任务是:
+1. 反思段落文本的当前状态,思考是否遗漏了主题的某些关键方面
+2. 选择最合适的搜索工具来补充缺失信息
+3. 制定精确的搜索查询
+4. 如果选择search_news_by_date工具,必须同时提供start_date和end_date参数(格式:YYYY-MM-DD)
+5. 解释你的选择和推理
+6. 仔细核查新闻中的可疑点,破除谣言和误导,尽力还原事件原貌
+
+注意:除了search_news_by_date工具外,其他工具都不需要额外参数。
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_reflection, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_REFLECTION_SUMMARY = f"""
+你是一位深度研究助手。
+你将获得搜索查询、搜索结果、段落标题以及你正在研究的报告段落的预期内容。
+你正在迭代完善这个段落,并且段落的最新状态也会提供给你。
+数据将按照以下JSON模式定义提供:
+
+
+{json.dumps(input_schema_reflection_summary, indent=2, ensure_ascii=False)}
+
+
+你的任务是根据搜索结果和预期内容丰富段落的当前最新状态。
+不要删除最新状态中的关键信息,尽量丰富它,只添加缺失的信息。
+适当地组织段落结构以便纳入报告中。
+请按照以下JSON模式定义格式化输出:
+
+
+{json.dumps(output_schema_reflection_summary, indent=2, ensure_ascii=False)}
+
+
+确保输出是一个符合上述输出JSON模式定义的JSON对象。
+只返回JSON对象,不要有解释或额外文本。
+"""
+
+SYSTEM_PROMPT_REPORT_FORMATTING = f"""
+你是一位资深的新闻分析专家和调查报告编辑。你专精于将复杂的新闻信息整合为客观、严谨的专业分析报告。
+你将获得以下JSON格式的数据:
+
+
+{json.dumps(input_schema_report_formatting, indent=2, ensure_ascii=False)}
+
+
+**你的核心使命:创建一份事实准确、逻辑严密的专业新闻分析报告,不少于一万字**
+
+**新闻分析报告的专业架构:**
+
+```markdown
+# 【深度调查】[主题]全面新闻分析报告
+
+## 核心要点摘要
+### 关键事实发现
+- 核心事件梳理
+- 重要数据指标
+- 主要结论要点
+
+### 信息来源概览
+- 主流媒体报道统计
+- 官方信息发布
+- 权威数据来源
+
+## 一、[段落1标题]
+### 1.1 事件脉络梳理
+| 时间 | 事件 | 信息来源 | 可信度 | 影响程度 |
+|------|------|----------|--------|----------|
+| XX月XX日 | XX事件 | XX媒体 | 高 | 重大 |
+| XX月XX日 | XX进展 | XX官方 | 极高 | 中等 |
+
+### 1.2 多方报道对比
+**主流媒体观点**:
+- 《XX日报》:"具体报道内容..." (发布时间:XX)
+- 《XX新闻》:"具体报道内容..." (发布时间:XX)
+
+**官方声明**:
+- XX部门:"官方表态内容..." (发布时间:XX)
+- XX机构:"权威数据/说明..." (发布时间:XX)
+
+### 1.3 关键数据分析
+[重要数据的专业解读和趋势分析]
+
+### 1.4 事实核查与验证
+[信息真实性验证和可信度评估]
+
+## 二、[段落2标题]
+[重复相同的结构...]
+
+## 综合事实分析
+### 事件全貌还原
+[基于多源信息的完整事件重构]
+
+### 信息可信度评估
+| 信息类型 | 来源数量 | 可信度 | 一致性 | 时效性 |
+|----------|----------|--------|--------|--------|
+| 官方数据 | XX个 | 极高 | 高 | 及时 |
+| 媒体报道 | XX篇 | 高 | 中等 | 较快 |
+
+### 发展趋势研判
+[基于事实的客观趋势分析]
+
+### 影响评估
+[多维度的影响范围和程度评估]
+
+## 专业结论
+### 核心事实总结
+[客观、准确的事实梳理]
+
+### 专业观察
+[基于新闻专业素养的深度观察]
+
+## 信息附录
+### 重要数据汇总
+### 关键报道时间线
+### 权威来源清单
+```
+
+**新闻报告特色格式化要求:**
+
+1. **事实优先原则**:
+ - 严格区分事实和观点
+ - 用专业的新闻语言表述
+ - 确保信息的准确性和客观性
+ - 仔细核查新闻中的可疑点,破除谣言和误导,尽力还原事件原貌
+
+2. **多源验证体系**:
+ - 详细标注每个信息的来源
+ - 对比不同媒体的报道差异
+ - 突出官方信息和权威数据
+
+3. **时间线清晰**:
+ - 按时间顺序梳理事件发展
+ - 标注关键时间节点
+ - 分析事件演进逻辑
+
+4. **数据专业化**:
+ - 用专业图表展示数据趋势
+ - 进行跨时间、跨区域的数据对比
+ - 提供数据背景和解读
+
+5. **新闻专业术语**:
+ - 使用标准的新闻报道术语
+ - 体现新闻调查的专业方法
+ - 展现对媒体生态的深度理解
+
+**质量控制标准:**
+- **事实准确性**:确保所有事实信息准确无误
+- **来源可靠性**:优先引用权威和官方信息源
+- **逻辑严密性**:保持分析推理的严密性
+- **客观中立性**:避免主观偏见,保持专业中立
+
+**最终输出**:一份基于事实、逻辑严密、专业权威的新闻分析报告,不少于一万字,为读者提供全面、准确的信息梳理和专业判断。
+"""
+
+# ===== 合并后的 Agent 指令(将各阶段 prompt 编排为完整工作流)=====
+
+QUERY_SYSTEM_PROMPT = f"""
+你是一位资深新闻分析师,专注于通过多源核实还原事件真相,破除谣言,提供客观严谨的深度报道分析。
+
+你需要严格按照以下阶段工作,每个阶段产出符合指定 JSON Schema 的结构化输出。
+
+## 阶段一:规划报告结构
+
+{SYSTEM_PROMPT_REPORT_STRUCTURE}
+
+## 阶段二:逐段搜索与总结
+
+对阶段一输出的每个段落,依次执行以下子步骤:
+
+### 2a. 首次搜索决策
+
+{SYSTEM_PROMPT_FIRST_SEARCH}
+
+### 2b. 首次总结
+
+{SYSTEM_PROMPT_FIRST_SUMMARY}
+
+### 2c. 反思(至少执行1次)
+
+{SYSTEM_PROMPT_REFLECTION}
+
+### 2d. 反思总结
+
+{SYSTEM_PROMPT_REFLECTION_SUMMARY}
+
+## 阶段三:最终报告格式化
+
+{SYSTEM_PROMPT_REPORT_FORMATTING}
+
+## 最终输出要求
+
+完成所有阶段后,你必须输出一个 JSON 对象,包含以下字段:
+- "query": 原始分析主题
+- "paragraphs": 各段落结果数组,每个元素包含 "title" 和 "paragraph_latest_state"
+- "final_report": 阶段三生成的完整 Markdown 报告
+"""
+
+
+def create_query_agent(config=None):
+ """
+ 创建 QueryAgent 实例。
+ 替代原 QueryEngine agent.__init__()
+ """
+ if config is None:
+ from config import settings
+ config = settings
+
+ return Agent(
+ name="QueryAgent",
+ model=OpenAIChat(
+ id=config.QUERY_ENGINE_MODEL_NAME,
+ api_key=config.QUERY_ENGINE_API_KEY,
+ base_url=config.QUERY_ENGINE_BASE_URL,
+ role_map={"system": "system", "user": "user", "assistant": "assistant", "tool": "tool", "model": "assistant"},
+ client=_OpenAI(
+ api_key=config.QUERY_ENGINE_API_KEY,
+ base_url=config.QUERY_ENGINE_BASE_URL,
+ http_client=httpx.Client(proxy=None, timeout=300),
+ ),
+ ),
+ tools=[
+ basic_search_news, deep_search_news, search_news_last_24_hours,
+ search_news_last_week, search_images_for_news, search_news_by_date,
+ ],
+ instructions=QUERY_SYSTEM_PROMPT,
+ system_message_role="system",
+ markdown=True,
+ debug_mode=True,
+ )
+
+
+def run_query(query: str, config=None) -> AnalysisResult:
+ """
+ 执行新闻深度分析。
+ 替代原 QueryEngine agent.run(query)
+
+ Args:
+ query: 分析主题或新闻事件
+ Returns:
+ AnalysisResult 结构化对象,包含:
+ - paragraphs: 各段落的标题和内容(供 ForumEngine 使用)
+ - final_report: 完整 Markdown 报告(供 ReportEngine 使用)
+ """
+ agent = create_query_agent(config)
+ response = agent.run(query)
+ return parse_analysis_result(response.content, query)
diff --git a/experimental/agno_version/agno_agents/report_agent.py b/experimental/agno_version/agno_agents/report_agent.py
new file mode 100644
index 000000000..cf875c239
--- /dev/null
+++ b/experimental/agno_version/agno_agents/report_agent.py
@@ -0,0 +1,952 @@
+# agno_agents/report_agent.py
+# 综合报告生成 Agent
+#
+# 输入:3 个 agent 的分析结果(insight/media/query)+ 论坛日志 + Host 引导发言
+# 输出:完整的 Markdown 报告 + 渲染好的 HTML 文件
+#
+# 工作流程(多阶段 + 章节并行):
+# 1. 大纲规划 → 1 次 LLM 调用
+# 2. 章节并行写作 → N 次 LLM 调用(asyncio.gather)
+# 3. 跨源对比验证 → 1 次 LLM 调用
+# 4. 执行摘要生成 → 1 次 LLM 调用
+# 5. Markdown 组装 + HTML 渲染
+
+from __future__ import annotations
+
+# 必须最先导入:清代理 + patch agno httpx client
+from agno_team import _agno_setup # noqa: F401
+
+import asyncio
+import json
+import re
+import html as html_lib
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, Any, List, Optional
+
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+
+from .report_styles import REPORT_CSS, CHART_JS_LIBS
+from .report_blocks import preprocess_custom_blocks
+
+
+# 通用 OpenAI 客户端 role_map:兼容 DeepSeek/Qwen 等不识别 "developer" 角色的 API
+_ROLE_MAP = {
+ "system": "system",
+ "user": "user",
+ "assistant": "assistant",
+ "tool": "tool",
+ "model": "assistant",
+}
+
+
+# ===== Prompts =====
+
+OUTLINE_PROMPT = """你是一位资深的舆情分析报告主编。你将收到三个专业 Agent 对同一主题的分析报告:
+- **InsightAgent**:基于本地社交媒体数据库(微博、B站、知乎、贴吧、抖音、快手、小红书)+ 海外社区(HN、GitHub、Reddit、YouTube)的舆情挖掘
+- **MediaAgent**:基于多模态网页搜索(综合搜索、图片、结构化数据卡)的媒体内容分析
+- **QueryAgent**:基于新闻搜索(Tavily)的新闻深度分析与事实核查
+
+以及一份**论坛主持人**对三 Agent 讨论过程的引导记录。
+
+你的任务:**为最终的综合报告设计一个专业、完整、有深度的章节大纲**。
+
+**要求:**
+1. 章节数量:5-7 章(不含执行摘要、跨源验证、附录)
+2. 每章必须有明确的分析角度,避免主题重复
+3. 章节顺序遵循:宏观背景 → 现象描述 → 数据分析 → 深层解读 → 影响评估 → 趋势预测
+4. 每章标注:title(标题)、focus(核心问题)、source_agents(应主要参考哪些 agent,可多选)、target_words(目标字数 1500-2500)
+
+**输出格式(严格 JSON,不要任何解释)**:
+```json
+{
+ "report_title": "报告主标题(吸引人,专业,不超过30字)",
+ "report_subtitle": "副标题(一句话说明报告核心价值)",
+ "chapters": [
+ {
+ "id": "ch1",
+ "title": "章节标题",
+ "focus": "本章要回答的核心问题",
+ "source_agents": ["insight", "media", "query"],
+ "target_words": 2000
+ }
+ ]
+}
+```
+
+**输入数据预览:**
+{input_preview}
+
+只返回 JSON,不要任何额外文字。"""
+
+
+CHAPTER_PROMPT = """你是一位资深的舆情分析报告主笔。现在请你撰写综合报告中的一章。
+
+**报告主题**:{query}
+**章节标题**:{chapter_title}
+**核心问题**:{chapter_focus}
+**目标字数**:{target_words} 字
+
+**可用素材(来自三个分析 Agent 的原始报告)**:
+
+{source_materials}
+
+**论坛主持人的相关引导**:
+{host_hints}
+
+## 📊 可视化组件(本章必须使用至少 2-3 个)
+
+除了 Markdown 正文,你可以嵌入以下专业组件提升报告的专业度和信息密度。**每一章都必须至少使用 2-3 个可视化组件**。
+
+### 1. KPI 数据卡片(用于突出关键数据)
+格式:
+```
+
+[
+ {{"label": "话题阅读量", "value": "5.2", "unit": "亿", "delta": "+23%", "tone": "up"}},
+ {{"label": "正面情感占比", "value": "62", "unit": "%", "delta": "+8pp", "tone": "up"}},
+ {{"label": "主要平台数", "value": "7", "tone": "neutral"}},
+ {{"label": "负面争议数", "value": "1.2", "unit": "K+", "delta": "+45%", "tone": "down"}}
+]
+
+```
+tone 只能是 "up"(正向/增长)/ "down"(负向/下降)/ "neutral"(中性)。
+
+### 2. 图表卡片(用于数据对比/趋势/分布)
+支持 Chart.js 所有图表类型:bar / line / pie / doughnut / radar。
+
+**示例 1 - 柱状图(平台情感对比)**:
+```
+
+{{
+ "type": "bar",
+ "data": {{
+ "labels": ["微博", "B站", "知乎", "抖音", "小红书"],
+ "datasets": [
+ {{"label": "正面%", "data": [62, 71, 58, 55, 66]}},
+ {{"label": "负面%", "data": [23, 18, 27, 30, 19]}},
+ {{"label": "中性%", "data": [15, 11, 15, 15, 15]}}
+ ]
+ }}
+}}
+
+```
+
+**示例 2 - 饼图(情感分布)**:
+```
+
+{{
+ "type": "doughnut",
+ "data": {{
+ "labels": ["正面", "负面", "中性"],
+ "datasets": [{{"data": [62, 23, 15]}}]
+ }}
+}}
+
+```
+
+**示例 3 - 折线图(趋势)**:
+```
+
+{{
+ "type": "line",
+ "data": {{
+ "labels": ["2/1", "2/2", "2/3", "2/4", "2/5", "2/6", "2/7"],
+ "datasets": [{{"label": "阅读量(万)", "data": [120, 180, 340, 520, 780, 650, 590]}}]
+ }}
+}}
+
+```
+
+**数据要求**:图表里的数据**必须来自素材中真实提到的数字**。如果素材里没有量化数据,就用定性描述生成估算值,但要在图表标题中标注"估算"字样。不要编造完全不存在的数据维度。
+
+### 3. 信息源矩阵(用于跨源对比章节)
+```
+
+{{
+ "headers": ["维度", "InsightAgent", "MediaAgent", "QueryAgent"],
+ "rows": [
+ {{"dimension": "社交媒体热度", "insightagent": "primary", "mediaagent": "secondary", "queryagent": "none"}},
+ {{"dimension": "主流媒体报道", "insightagent": "none", "mediaagent": "primary", "queryagent": "primary"}},
+ {{"dimension": "图片视觉数据", "insightagent": "weak", "mediaagent": "primary", "queryagent": "none"}}
+ ]
+}}
+
+```
+每个 cell 值:`primary`(★★★主力)/ `secondary`(★★部分)/ `weak`(★弱)/ `none`(—无)。
+
+### 4. Callout 提示框(用于强调关键洞察/风险)
+```
+
+**程序员社区对 Claude Code 的评价呈现明显的"两极化"**:技术爱好者高度推崇其命令行体验,但中小团队因定价产生强烈反弹。
+
+```
+type:`info` / `insight` / `warning` / `danger` / `success`。
+
+### 5. 时间线(用于事件演变章节)
+```
+
+[
+ {{"date": "2025-02", "event": "Claude Code 首次发布", "type": "release"}},
+ {{"date": "2025-03", "event": "Pro 订阅定价引发争议", "type": "crisis", "detail": "月费从免费试用切换到20美元"}},
+ {{"date": "2025-04", "event": "推出学生折扣方案", "type": "update"}}
+]
+
+```
+type:`default` / `release`(绿色)/ `crisis`(红色)/ `update`(橙色)。
+
+### 6. 用户原声卡片(突出典型用户评论)
+```
+
+用 Claude Code 重构了一个 5000 行的老项目,效率提升至少 10 倍。Anthropic 牛逼
+
+```
+
+---
+
+## 写作要求
+
+1. **结构化呈现**:使用清晰的 H2/H3 二级三级标题组织内容
+2. **数据密集**:每段至少 1-2 个具体数据点(数字、引用、案例)
+3. **可视化优先**:能用组件的尽量用组件,不要都堆在 Markdown 表格里
+ - 数字对比 → 用 chart-card(bar/line)
+ - 占比分布 → 用 chart-card(pie/doughnut)或 kpi-grid
+ - 关键数据 → 用 kpi-grid
+ - 关键洞察 → 用 callout
+ - 用户原话 → 用 quote-card 而不是 markdown `>`
+ - 事件演变 → 用 timeline
+4. **多源融合**:综合三个 Agent 的发现,形成统一叙事
+5. **引用标注**:引用某个 Agent 的发现时用 *(来源: InsightAgent)* 这种斜体标注
+
+**禁止事项**:
+- ❌ 不要写"本章将分析..."这种废话开头
+- ❌ 不要在章节末尾写"综上所述..."这种总结
+- ❌ 不要重复其他章节的内容
+- ❌ 不要输出 JSON,直接输出 Markdown 正文(JSON 只能在自定义标签内部)
+- ❌ 自定义标签内的 JSON 不能有多余换行或注释,必须是合法 JSON
+- ❌ 自定义标签的内容不能嵌套其他自定义标签
+
+直接输出本章 Markdown 正文(从 ## 章节标题 开始),目标 {target_words} 字,**必须包含至少 2-3 个可视化组件**。"""
+
+
+CROSS_VALIDATION_PROMPT = """你是舆情数据交叉验证专家。基于以下三个 Agent 的分析结果,进行跨源对比验证。
+
+**主题**:{query}
+
+**三 Agent 报告摘要**:
+{agent_summaries}
+
+## 📊 必须使用的可视化组件
+
+### 1. 信息源覆盖矩阵(必须)
+用 `` 组件替代普通 markdown 表格:
+```
+
+{{
+ "headers": ["维度", "InsightAgent", "MediaAgent", "QueryAgent"],
+ "rows": [
+ {{"dimension": "社交媒体热度", "insightagent": "primary", "mediaagent": "secondary", "queryagent": "none"}},
+ {{"dimension": "主流媒体报道", "insightagent": "none", "mediaagent": "primary", "queryagent": "primary"}},
+ {{"dimension": "图片视觉数据", "insightagent": "weak", "mediaagent": "primary", "queryagent": "none"}},
+ {{"dimension": "事实核查", "insightagent": "none", "mediaagent": "secondary", "queryagent": "primary"}},
+ {{"dimension": "海外视角", "insightagent": "secondary", "mediaagent": "secondary", "queryagent": "primary"}}
+ ]
+}}
+
+```
+cell 值:primary(★★★主力)/ secondary(★★部分)/ weak(★弱)/ none(—无)。
+
+### 2. 可信度评级 KPI(必须)
+```
+
+[
+ {{"label": "数据覆盖度", "value": "A", "delta": "完整", "tone": "up"}},
+ {{"label": "时效性", "value": "B", "delta": "近期", "tone": "neutral"}},
+ {{"label": "偏差风险", "value": "B", "delta": "可控", "tone": "neutral"}},
+ {{"label": "整体评级", "value": "A-", "delta": "可信", "tone": "up"}}
+]
+
+```
+
+---
+
+## 输出结构
+
+请输出一份**跨源验证分析**章节(约 1500-2000 字):
+
+## 跨源对比与可信度评估
+
+### 信息源覆盖矩阵
+
+[必须使用 info-matrix 组件,包含 5-7 行维度]
+
+### 三方共识
+
+提炼三个 Agent 都达成一致的核心结论(3-5 条),每条说明:
+- 共识内容
+- 三方各自的支撑证据
+- 可信度评分(高/中/低)
+
+### 三方分歧
+
+识别三个 Agent 之间存在明显差异的判断(2-4 处),每处说明:
+- 分歧的具体内容
+- 各方观点
+- 可能的原因(数据源差异?时间窗口?立场差异?)
+- 哪一方更可信,理由是什么
+
+**对于关键分歧**,用 callout 高亮:
+```
+
+...
+
+```
+
+### 信息可信度评估
+
+[必须使用上面的 kpi-grid 组件]
+
+随后用文字说明为什么是这个评级。
+
+直接输出 Markdown(可嵌入自定义标签),不要解释。"""
+
+
+EXECUTIVE_SUMMARY_PROMPT = """你是高管简报撰写专家。基于以下完整报告内容,撰写一份给高管/决策者的执行摘要。
+
+**报告主题**:{query}
+**报告标题**:{report_title}
+**章节内容(节选)**:
+
+{chapters_preview}
+
+**跨源验证结论**:
+{cross_validation_summary}
+
+## 📊 必须使用的可视化组件
+
+执行摘要必须大量使用可视化组件,让高管一眼看到关键信息:
+
+### 1. KPI 数据卡片(必须,4-6 个核心指标)
+```
+
+[
+ {{"label": "指标名", "value": "数值", "unit": "单位", "delta": "变化", "tone": "up/down/neutral"}}
+]
+
+```
+
+### 2. 整体情感分布饼图(必须)
+```
+
+{{
+ "type": "doughnut",
+ "data": {{
+ "labels": ["正面", "负面", "中性"],
+ "datasets": [{{"data": [X, Y, Z]}}]
+ }}
+}}
+
+```
+
+### 3. 关键洞察 Callout(必须,用于一句话结论)
+```
+
+一句话概括整个分析的核心结论
+
+```
+
+### 4. 风险预警 Callout(必须)
+```
+
+需要警惕的风险点
+
+```
+
+---
+
+## 输出结构
+
+请严格按以下结构输出:
+
+## 执行摘要
+
+[使用 callout type="insight" 组件写一句话结论]
+
+### 关键数据快览
+
+[使用 kpi-grid 组件,包含 4-6 个最重要的数据指标]
+
+### 情感全景
+
+[使用 chart-card doughnut 图表展示整体情感分布]
+
+### 五大关键发现
+
+1. **[发现标题]**:具体内容(50-80 字)
+2. **[发现标题]**:具体内容
+3. ...
+
+### 核心建议
+
+1. [具体行动建议]
+2. ...
+3. ...
+
+### 风险预警
+
+[使用 callout type="warning" 组件列出 2-3 个主要风险]
+
+---
+
+**数据来源**:数字和比例**必须来自章节内容里提到的真实数据**。如果没有精确数字,可以基于定性描述估算,但要保持内部一致性。
+
+直接输出 Markdown(可嵌入自定义标签),不要其他解释。"""
+
+
+# ===== Helper functions =====
+
+def _summarize_for_outline(agent_results: Dict[str, Any], host_speeches: List[str], max_chars: int = 3000) -> str:
+ """为大纲规划阶段生成精简的输入预览"""
+ parts = []
+ for agent_type, result in agent_results.items():
+ if not result:
+ continue
+ agent_name = result.get("agent_name", agent_type.title())
+ paragraphs = result.get("paragraphs", [])
+ parts.append(f"### {agent_name}")
+ if paragraphs:
+ parts.append("段落标题:")
+ for p in paragraphs:
+ parts.append(f"- {p.get('title', '')}")
+ # 取最终报告前 600 字作为预览
+ final = result.get("final_report", "")
+ if final:
+ parts.append(f"报告开头:{final[:600]}...")
+ parts.append("")
+
+ if host_speeches:
+ parts.append("### 论坛主持人引导(关键观点)")
+ for i, s in enumerate(host_speeches[:3], 1):
+ parts.append(f"{i}. {s[:300]}...")
+
+ text = "\n".join(parts)
+ if len(text) > max_chars:
+ text = text[:max_chars] + "\n...(内容已截断)"
+ return text
+
+
+def _build_source_materials(
+ agent_results: Dict[str, Any],
+ source_agents: List[str],
+ chapter_focus: str,
+ max_per_agent: int = 4000,
+) -> str:
+ """为某一章节抽取相关 agent 的素材"""
+ parts = []
+ name_map = {"insight": "InsightAgent (社交媒体舆情)", "media": "MediaAgent (多模态网页)", "query": "QueryAgent (新闻调查)"}
+
+ for at in source_agents:
+ result = agent_results.get(at)
+ if not result:
+ continue
+ parts.append(f"### {name_map.get(at, at)}")
+ # 优先用 paragraphs(结构化),否则用 final_report
+ paragraphs = result.get("paragraphs", [])
+ if paragraphs:
+ for p in paragraphs:
+ title = p.get("title", "")
+ state = p.get("paragraph_latest_state", "")
+ parts.append(f"\n**{title}**\n{state[:max_per_agent // max(len(paragraphs), 1)]}")
+ else:
+ parts.append(result.get("final_report", "")[:max_per_agent])
+ parts.append("")
+ return "\n".join(parts)
+
+
+def _filter_relevant_host_speeches(host_speeches: List[str], chapter_focus: str) -> str:
+ """从所有 host 发言里找跟本章 focus 相关的(简单实现:返回最近 2 条)"""
+ if not host_speeches:
+ return "(本章无相关主持人引导)"
+ return "\n\n".join(f"- {s[:500]}..." if len(s) > 500 else f"- {s}" for s in host_speeches[-2:])
+
+
+# ===== ReportAgent core =====
+
+class ReportAgent:
+ """综合报告生成器(支持异步章节并行)"""
+
+ def __init__(self, config=None):
+ if config is None:
+ from config import settings
+ config = settings
+ self.config = config
+
+ # 必须三个字段一组回退:避免 DeepSeek 的 key 配 aihubmix 的 base_url
+ if config.REPORT_ENGINE_API_KEY:
+ api_key = config.REPORT_ENGINE_API_KEY
+ base_url = config.REPORT_ENGINE_BASE_URL
+ model_name = config.REPORT_ENGINE_MODEL_NAME
+ elif config.FORUM_HOST_API_KEY:
+ print("⚠️ REPORT_ENGINE_API_KEY 未配置,回退到 FORUM_HOST_*(qwen)")
+ api_key = config.FORUM_HOST_API_KEY
+ base_url = config.FORUM_HOST_BASE_URL
+ model_name = config.FORUM_HOST_MODEL_NAME
+ elif config.QUERY_ENGINE_API_KEY:
+ print("⚠️ REPORT_ENGINE_API_KEY 未配置,回退到 QUERY_ENGINE_*")
+ api_key = config.QUERY_ENGINE_API_KEY
+ base_url = config.QUERY_ENGINE_BASE_URL
+ model_name = config.QUERY_ENGINE_MODEL_NAME
+ else:
+ raise ValueError("ReportAgent 需要 REPORT_ENGINE_API_KEY / FORUM_HOST_API_KEY / QUERY_ENGINE_API_KEY 至少一个")
+
+ self.model_name = model_name
+ print(f"[ReportAgent] 使用模型: {model_name}(agno 模式)")
+
+ # 创建一个共享的 OpenAIChat model(每个 agent 用独立实例避免状态污染)
+ def _make_model():
+ return OpenAIChat(
+ id=model_name,
+ api_key=api_key,
+ base_url=base_url,
+ role_map=_ROLE_MAP,
+ )
+
+ # ===== 4 个专用 agno Agent =====
+
+ # Stage 1: 大纲规划(输出 JSON)
+ self.outline_agent = Agent(
+ name="OutlineDesigner",
+ model=_make_model(),
+ instructions="你是一位资深的舆情分析报告主编,必须输出严格的 JSON 格式(不要任何 markdown 代码块包裹)。",
+ system_message_role="system",
+ markdown=False,
+ )
+
+ # Stage 2: 章节写作(输出 Markdown + 自定义可视化标签)
+ self.chapter_agent = Agent(
+ name="ChapterWriter",
+ model=_make_model(),
+ instructions="你是一位资深的舆情分析报告主笔,擅长将多源数据融合为深度洞察,能够使用 chart-card / kpi-grid / callout 等专业可视化组件。",
+ system_message_role="system",
+ markdown=True,
+ )
+
+ # Stage 3: 跨源验证
+ self.cross_validator_agent = Agent(
+ name="CrossValidator",
+ model=_make_model(),
+ instructions="你是舆情数据交叉验证专家,擅长识别多源数据的共识与分歧。",
+ system_message_role="system",
+ markdown=True,
+ )
+
+ # Stage 4: 执行摘要
+ self.exec_summary_agent = Agent(
+ name="ExecutiveSummaryWriter",
+ model=_make_model(),
+ instructions="你是高管简报撰写专家,能用最简洁的语言传达最核心的洞察。",
+ system_message_role="system",
+ markdown=True,
+ )
+
+ async def _agent_run(self, agent: Agent, user_prompt: str) -> str:
+ """统一的 agno agent 异步调用入口,自动剥取 content"""
+ try:
+ response = await agent.arun(user_prompt)
+ return response.content if response else ""
+ except Exception as e:
+ print(f"⚠️ agno agent {agent.name} 调用失败: {e}")
+ return ""
+
+ @staticmethod
+ def _parse_json(text: str) -> Optional[Dict]:
+ text = text.strip()
+ text = re.sub(r"^```(?:json)?\s*", "", text)
+ text = re.sub(r"\s*```$", "", text)
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ match = re.search(r"\{[\s\S]*\}", text)
+ if match:
+ try:
+ return json.loads(match.group())
+ except json.JSONDecodeError:
+ pass
+ return None
+
+ # ===== 阶段一:大纲规划 =====
+ async def generate_outline(
+ self,
+ query: str,
+ agent_results: Dict[str, Any],
+ host_speeches: List[str],
+ ) -> Dict[str, Any]:
+ print("📋 [Stage 1/5] 规划报告大纲(agno OutlineDesigner)...")
+ input_preview = _summarize_for_outline(agent_results, host_speeches)
+ prompt = OUTLINE_PROMPT.replace("{input_preview}", input_preview)
+
+ raw = await self._agent_run(self.outline_agent, prompt)
+
+ outline = self._parse_json(raw)
+ if not outline or "chapters" not in outline:
+ # 兜底:生成默认 5 章结构
+ outline = {
+ "report_title": f"关于「{query}」的综合舆情分析报告",
+ "report_subtitle": "基于多源数据的深度洞察",
+ "chapters": [
+ {"id": "ch1", "title": "事件背景与核心脉络", "focus": "梳理事件起因、时间线、关键节点", "source_agents": ["query", "insight"], "target_words": 2000},
+ {"id": "ch2", "title": "舆情热度与传播分析", "focus": "数据统计、平台分布、传播路径", "source_agents": ["insight", "media"], "target_words": 2000},
+ {"id": "ch3", "title": "公众情感与观点图谱", "focus": "情感倾向、观点分布、争议焦点", "source_agents": ["insight"], "target_words": 2200},
+ {"id": "ch4", "title": "媒体报道与权威信息", "focus": "主流媒体报道、官方信息、事实核查", "source_agents": ["query", "media"], "target_words": 2000},
+ {"id": "ch5", "title": "深层影响与趋势研判", "focus": "社会影响、未来趋势、风险预警", "source_agents": ["insight", "media", "query"], "target_words": 2200},
+ ],
+ }
+ chapter_count = len(outline.get("chapters", []))
+ print(f" ✅ 规划了 {chapter_count} 章: {outline.get('report_title', '')}")
+ return outline
+
+ # ===== 阶段二:章节并行写作 =====
+ async def write_chapter(
+ self,
+ query: str,
+ chapter: Dict[str, Any],
+ agent_results: Dict[str, Any],
+ host_speeches: List[str],
+ ) -> Dict[str, Any]:
+ title = chapter.get("title", "")
+ focus = chapter.get("focus", "")
+ source_agents = chapter.get("source_agents", ["insight", "media", "query"])
+ target_words = chapter.get("target_words", 2000)
+
+ source_materials = _build_source_materials(agent_results, source_agents, focus)
+ host_hints = _filter_relevant_host_speeches(host_speeches, focus)
+
+ prompt = CHAPTER_PROMPT.format(
+ query=query,
+ chapter_title=title,
+ chapter_focus=focus,
+ target_words=target_words,
+ source_materials=source_materials,
+ host_hints=host_hints,
+ )
+
+ content = await self._agent_run(self.chapter_agent, prompt)
+ print(f" ✅ 章节「{title}」完成({len(content)} 字)")
+
+ return {**chapter, "content": content}
+
+ async def write_all_chapters(
+ self,
+ query: str,
+ outline: Dict[str, Any],
+ agent_results: Dict[str, Any],
+ host_speeches: List[str],
+ ) -> List[Dict[str, Any]]:
+ print(f"\n📝 [Stage 2/5] 并行写作 {len(outline.get('chapters', []))} 个章节(agno ChapterWriter)...")
+ tasks = [
+ self.write_chapter(query, ch, agent_results, host_speeches)
+ for ch in outline.get("chapters", [])
+ ]
+ return await asyncio.gather(*tasks)
+
+ # ===== 阶段三:跨源验证 =====
+ async def cross_validate(
+ self,
+ query: str,
+ agent_results: Dict[str, Any],
+ ) -> str:
+ print("\n🔍 [Stage 3/5] 跨源对比验证(agno CrossValidator)...")
+ summaries = []
+ for at, result in agent_results.items():
+ if not result:
+ continue
+ name_map = {"insight": "InsightAgent", "media": "MediaAgent", "query": "QueryAgent"}
+ summary = result.get("final_report", "")[:2500]
+ summaries.append(f"### {name_map.get(at, at)}\n{summary}")
+
+ agent_summaries_text = "\n\n".join(summaries)
+ prompt = CROSS_VALIDATION_PROMPT.format(
+ query=query,
+ agent_summaries=agent_summaries_text,
+ )
+ result = await self._agent_run(self.cross_validator_agent, prompt)
+ print(f" ✅ 跨源验证完成({len(result)} 字)")
+ return result
+
+ # ===== 阶段四:执行摘要 =====
+ async def generate_executive_summary(
+ self,
+ query: str,
+ outline: Dict[str, Any],
+ chapters: List[Dict[str, Any]],
+ cross_validation: str,
+ ) -> str:
+ print("\n📊 [Stage 4/5] 撰写执行摘要(agno ExecutiveSummaryWriter)...")
+ chapters_preview = "\n\n".join(
+ f"### {ch['title']}\n{ch.get('content', '')[:1000]}..." for ch in chapters
+ )
+
+ prompt = EXECUTIVE_SUMMARY_PROMPT.format(
+ query=query,
+ report_title=outline.get("report_title", ""),
+ chapters_preview=chapters_preview[:6000],
+ cross_validation_summary=cross_validation[:2000],
+ )
+ result = await self._agent_run(self.exec_summary_agent, prompt)
+ print(f" ✅ 执行摘要完成({len(result)} 字)")
+ return result
+
+ # ===== 阶段五:组装 Markdown =====
+ def assemble_markdown(
+ self,
+ query: str,
+ outline: Dict[str, Any],
+ executive_summary: str,
+ chapters: List[Dict[str, Any]],
+ cross_validation: str,
+ forum_log: str,
+ host_speeches: List[str],
+ ) -> str:
+ report_title = outline.get("report_title", f"关于「{query}」的综合舆情分析报告")
+ report_subtitle = outline.get("report_subtitle", "")
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+ parts = []
+
+ # 封面信息(用 metadata 块标记)
+ parts.append(f"")
+ parts.append("")
+
+ # 执行摘要
+ parts.append(executive_summary)
+ parts.append("")
+
+ # 目录占位(HTML 渲染时会基于 H1/H2 自动生成)
+ parts.append("")
+ parts.append("")
+
+ # 章节正文:统一前置一个 # 标题,便于 HTML 渲染时自动编号"第X章"
+ for ch in chapters:
+ content = ch.get("content", "").strip()
+ chapter_title = ch.get("title", "").strip()
+
+ # 策略:无论 LLM 是否在内部用了 #/##/### 标题,
+ # 都强制在最前面加一个 # 章节标题,作为 H1。
+ # 同时把 LLM 内部所有的标题级别下推一级(# → ##, ## → ###, ...)
+ # 这样章节标题永远是 H1,内部分节是 H2/H3/H4。
+ if content:
+ # 下推所有 markdown 标题级别一级(最多到 H6)
+ def _shift_heading(m):
+ hashes = m.group(1)
+ text = m.group(2)
+ new_level = min(len(hashes) + 1, 6)
+ return "#" * new_level + " " + text
+
+ content = re.sub(r"^(#{1,5})\s+(.+)$", _shift_heading, content, flags=re.MULTILINE)
+
+ parts.append(f"# {chapter_title}")
+ parts.append("")
+ parts.append(content)
+ parts.append("")
+
+ # 跨源验证
+ parts.append("# 跨源数据对比与可信度评估")
+ parts.append("")
+ # 去掉跨源验证里可能的重复标题
+ cv = re.sub(r"^##?\s*跨源.*?\n", "", cross_validation, count=1)
+ parts.append(cv)
+ parts.append("")
+
+ # 附录:论坛日志
+ parts.append("# 附录:分析过程论坛日志")
+ parts.append("")
+ parts.append(f"以下是 InsightAgent / MediaAgent / QueryAgent 在分析过程中的发言记录,以及 ForumHost 主持人的 {len(host_speeches)} 次引导发言。\n")
+ parts.append("```")
+ parts.append(forum_log[:8000] + ("\n...(已截断)" if len(forum_log) > 8000 else ""))
+ parts.append("```")
+
+ return "\n".join(parts)
+
+ # ===== 阶段六:渲染 HTML =====
+ def render_html(self, markdown_text: str, query: str) -> str:
+ print("\n🎨 [Stage 5/5] 渲染 HTML(含可视化组件)...")
+
+ try:
+ import markdown as md_lib
+ except ImportError:
+ return self._render_html_fallback(markdown_text)
+
+ # 提取封面信息
+ cover_match = re.search(r"", markdown_text)
+ cover_info = {}
+ if cover_match:
+ for line in cover_match.group(1).split("\n"):
+ if ":" in line:
+ k, v = line.split(":", 1)
+ cover_info[k.strip()] = v.strip()
+ markdown_text = markdown_text.replace(cover_match.group(0), "")
+
+ # ⭐ 步骤 1:预处理自定义可视化块(转换为占位符,HTML 先被收集)
+ markdown_text, block_collector = preprocess_custom_blocks(markdown_text)
+ print(f" 🧩 检测到 {len(block_collector.blocks)} 个可视化组件")
+
+ # 步骤 2:markdown → html
+ body_html = md_lib.markdown(
+ markdown_text,
+ extensions=["tables", "fenced_code", "toc", "nl2br", "sane_lists"],
+ )
+
+ # ⭐ 步骤 3:恢复占位符为真实的可视化 HTML
+ body_html = block_collector.restore(body_html)
+
+ # 构建完整 HTML
+ title = cover_info.get("title", "舆情分析报告")
+ subtitle = cover_info.get("subtitle", "")
+ date = cover_info.get("date", datetime.now().strftime("%Y-%m-%d"))
+ query_safe = html_lib.escape(cover_info.get("query", query))
+
+ full_html = f"""
+
+
+
+
+ {html_lib.escape(title)}
+ {CHART_JS_LIBS}
+ {REPORT_CSS}
+
+
+
+
舆情洞察报告
+
{html_lib.escape(title)}
+
{html_lib.escape(subtitle)}
+
+
+
+ {body_html}
+
+
+
+"""
+ return full_html
+
+ def _render_html_fallback(self, markdown_text: str) -> str:
+ """没有 markdown 库时的纯文本回退"""
+ return f"""
+{REPORT_CSS}
+{html_lib.escape(markdown_text)} """
+
+ # ===== 主入口 =====
+ async def generate_report(
+ self,
+ query: str,
+ agent_results: Dict[str, Any],
+ forum_log: str = "",
+ host_speeches: Optional[List[str]] = None,
+ ) -> Dict[str, str]:
+ """
+ 生成完整的综合报告。
+
+ Returns:
+ dict: {
+ "title": 报告标题,
+ "markdown": 完整的 Markdown 报告,
+ "html": 完整的 HTML 报告,
+ "outline": 大纲(dict),
+ "stats": {章节数、字数等}
+ }
+ """
+ if host_speeches is None:
+ host_speeches = []
+
+ print(f"\n{'=' * 60}")
+ print(f" ReportAgent 启动")
+ print(f" 主题: {query}")
+ print(f" Agent 报告数: {len(agent_results)}")
+ print(f" Host 引导次数: {len(host_speeches)}")
+ print(f"{'=' * 60}")
+
+ # 阶段一:大纲
+ outline = await self.generate_outline(query, agent_results, host_speeches)
+
+ # 阶段二:章节并行写作
+ chapters = await self.write_all_chapters(query, outline, agent_results, host_speeches)
+
+ # 阶段三:跨源验证(可与章节并行,但放在后面好理解)
+ cross_validation = await self.cross_validate(query, agent_results)
+
+ # 阶段四:执行摘要
+ executive_summary = await self.generate_executive_summary(
+ query, outline, chapters, cross_validation
+ )
+
+ # 阶段五:组装 Markdown
+ print("\n📦 组装 Markdown...")
+ markdown_text = self.assemble_markdown(
+ query=query,
+ outline=outline,
+ executive_summary=executive_summary,
+ chapters=chapters,
+ cross_validation=cross_validation,
+ forum_log=forum_log,
+ host_speeches=host_speeches,
+ )
+
+ # 阶段六:HTML 渲染
+ html_text = self.render_html(markdown_text, query)
+
+ total_chars = len(markdown_text)
+ print(f"\n{'=' * 60}")
+ print(f" ✅ 报告生成完成")
+ print(f" Markdown 长度: {total_chars} 字符")
+ print(f" 章节数: {len(chapters)}")
+ print(f"{'=' * 60}\n")
+
+ return {
+ "title": outline.get("report_title", ""),
+ "markdown": markdown_text,
+ "html": html_text,
+ "outline": outline,
+ "stats": {
+ "chapter_count": len(chapters),
+ "markdown_chars": total_chars,
+ "html_chars": len(html_text),
+ },
+ }
+
+
+# ===== 命令行风格入口 =====
+
+def create_report_agent(config=None) -> ReportAgent:
+ return ReportAgent(config=config)
+
+
+async def run_report_generation_async(
+ query: str,
+ agent_results: Dict[str, Any],
+ forum_log: str = "",
+ host_speeches: Optional[List[str]] = None,
+ config=None,
+) -> Dict[str, str]:
+ agent = create_report_agent(config)
+ return await agent.generate_report(query, agent_results, forum_log, host_speeches)
+
+
+def run_report_generation(
+ query: str,
+ agent_results: Dict[str, Any],
+ forum_log: str = "",
+ host_speeches: Optional[List[str]] = None,
+ config=None,
+) -> Dict[str, str]:
+ """同步入口"""
+ return asyncio.run(
+ run_report_generation_async(query, agent_results, forum_log, host_speeches, config)
+ )
diff --git a/experimental/agno_version/agno_agents/report_blocks.py b/experimental/agno_version/agno_agents/report_blocks.py
new file mode 100644
index 000000000..abfd4b6c7
--- /dev/null
+++ b/experimental/agno_version/agno_agents/report_blocks.py
@@ -0,0 +1,411 @@
+# agno_agents/report_blocks.py
+# 自定义可视化块解析器与渲染器
+#
+# LLM 在章节中可以输出以下自定义标签,本模块负责:
+# 1. 在 Markdown → HTML 转换前,先把这些标签替换成 HTML 占位符
+# 2. 保护占位符不被 markdown 解析器破坏
+# 3. 最终替换为完整的 HTML 组件
+#
+# 支持的自定义标签:
+# ...JSON...
+# ...JSON array...
+# ...markdown...
+# ...JSON...
+# ...JSON array...
+# ...text...
+
+from __future__ import annotations
+import json
+import re
+import html as html_lib
+from typing import Dict, Tuple, List
+
+
+# ===== 占位符机制 =====
+# 避免 markdown 库把我们的 HTML 当成普通文本破坏
+
+class BlockCollector:
+ """收集所有自定义块,生成占位符,最后一次性替换"""
+ def __init__(self):
+ self.blocks: Dict[str, str] = {}
+ self._counter = 0
+
+ def add(self, html: str) -> str:
+ self._counter += 1
+ # 用非常独特的字符串作为占位符,markdown 不会处理
+ # 前后加换行,保证成为独立段落
+ placeholder = f"\n\nXMIROFISH_BLOCK_{self._counter:04d}_XEND\n\n"
+ self.blocks[f"XMIROFISH_BLOCK_{self._counter:04d}_XEND"] = html
+ return placeholder
+
+ def restore(self, html: str) -> str:
+ # markdown 会把占位符包进
,需要先剥掉
+ for token, block_html in self.blocks.items():
+ # 可能的形式:TOKEN
,直接 TOKEN
+ html = re.sub(rf"\s*{token}\s*
", block_html, html)
+ html = html.replace(token, block_html)
+ return html
+
+
+# ===== 单个块的渲染函数 =====
+
+_chart_counter = [0]
+
+
+def render_chart_card(json_config: str, title: str = "") -> str:
+ """渲染一个 Chart.js 图表卡片"""
+ _chart_counter[0] += 1
+ chart_id = f"mchart_{_chart_counter[0]}"
+ title_html = f'{html_lib.escape(title)}
' if title else ""
+
+ # 清理并校验 JSON
+ try:
+ config = json.loads(json_config.strip())
+ except json.JSONDecodeError:
+ # 回退:显示原始 JSON 和错误提示
+ return f"""
+
+ {title_html}
+
⚠️ 图表配置解析失败,原始数据:
+
{html_lib.escape(json_config[:500])}
+
+"""
+
+ # 注入默认样式
+ if "options" not in config:
+ config["options"] = {}
+ config["options"].setdefault("responsive", True)
+ config["options"].setdefault("maintainAspectRatio", False)
+ config["options"].setdefault("plugins", {}).setdefault("legend", {}).setdefault("position", "bottom")
+
+ # 颜色美化:如果数据集没有颜色,用我们的主题色
+ palette = ["#4A90E2", "#E85D75", "#50C878", "#FFB347", "#9B59B6", "#3498DB", "#E67E22", "#16A085"]
+ data = config.get("data", {})
+ datasets = data.get("datasets", [])
+ chart_type = config.get("type", "bar")
+
+ for i, ds in enumerate(datasets):
+ if chart_type in ("pie", "doughnut", "polarArea"):
+ if "backgroundColor" not in ds and data.get("labels"):
+ ds["backgroundColor"] = palette[: len(data["labels"])]
+ else:
+ if "backgroundColor" not in ds:
+ ds["backgroundColor"] = palette[i % len(palette)]
+ if "borderColor" not in ds and chart_type in ("line", "radar"):
+ ds["borderColor"] = palette[i % len(palette)]
+ ds["fill"] = False
+ ds["tension"] = 0.3
+
+ config_json = json.dumps(config, ensure_ascii=False)
+
+ return f"""
+
+"""
+
+
+def render_kpi_grid(json_items: str) -> str:
+ """渲染 KPI 数据卡片网格"""
+ try:
+ items = json.loads(json_items.strip())
+ except json.JSONDecodeError:
+ return f'⚠️ KPI 数据解析失败
'
+
+ if not isinstance(items, list):
+ return '⚠️ KPI 需要数组格式
'
+
+ cards = []
+ for item in items:
+ label = html_lib.escape(str(item.get("label", "")))
+ value = html_lib.escape(str(item.get("value", "")))
+ unit = html_lib.escape(str(item.get("unit", "")))
+ delta = html_lib.escape(str(item.get("delta", "")))
+ tone = str(item.get("tone", "neutral")).lower()
+ if tone not in ("up", "down", "neutral"):
+ tone = "neutral"
+
+ delta_html = ""
+ if delta:
+ icon = "▲" if tone == "up" else "▼" if tone == "down" else "●"
+ delta_html = f'{icon} {delta}
'
+
+ unit_html = f'{unit} ' if unit else ""
+
+ cards.append(f"""
+
+
{label}
+
{value}{unit_html}
+ {delta_html}
+
+""")
+
+ return f'{"".join(cards)}
'
+
+
+def render_callout(content: str, callout_type: str = "info", title: str = "") -> str:
+ """渲染提示框"""
+ callout_type = callout_type.lower()
+ if callout_type not in ("info", "warning", "danger", "success", "insight"):
+ callout_type = "info"
+
+ icons = {
+ "info": "💡",
+ "warning": "⚠️",
+ "danger": "🚨",
+ "success": "✅",
+ "insight": "🔍",
+ }
+ icon = icons.get(callout_type, "💡")
+
+ # 把内容里的 markdown 先做简单转换(**bold**, *italic*)
+ content = content.strip()
+ content = re.sub(r"\*\*(.+?)\*\*", r"\1 ", content)
+ content = re.sub(r"\*(.+?)\*", r"\1 ", content)
+ content = re.sub(r"\n\n", "
", content)
+ content = f"
{content}
"
+
+ title_html = f'{html_lib.escape(title)}
' if title else ""
+
+ return f"""
+
+
{icon}
+
+ {title_html}
+ {content}
+
+
+"""
+
+
+def render_info_matrix(json_data: str, title: str = "") -> str:
+ """渲染信息源覆盖矩阵"""
+ try:
+ data = json.loads(json_data.strip())
+ except json.JSONDecodeError:
+ return '⚠️ 矩阵数据解析失败
'
+
+ headers = data.get("headers", [])
+ rows = data.get("rows", [])
+ if not headers or not rows:
+ return '⚠️ 矩阵缺少 headers 或 rows
'
+
+ # cell 值的图标映射
+ level_map = {
+ "primary": ('★★★ ', "主力数据源"),
+ "strong": ('★★★ ', "主力数据源"),
+ "secondary": ('★★ ', "部分覆盖"),
+ "partial": ('★★ ', "部分覆盖"),
+ "weak": ('★ ', "弱覆盖"),
+ "none": ('— ', "未覆盖"),
+ }
+
+ title_html = f'{html_lib.escape(title)}
' if title else ""
+
+ head_cells = "".join(f"{html_lib.escape(str(h))} " for h in headers)
+ body_rows = []
+ for row in rows:
+ cells = [f"{html_lib.escape(str(row.get('dimension', '')))} "]
+ for agent in headers[1:]:
+ level = str(row.get(agent.lower(), "none")).lower()
+ cell_html, _ = level_map.get(level, level_map["none"])
+ cells.append(f"{cell_html} ")
+ body_rows.append(f"{''.join(cells)} ")
+
+ return f"""
+
+ {title_html}
+
+ {head_cells}
+ {"".join(body_rows)}
+
+
+ ★★★ 主力
+ ★★ 部分
+ ★ 弱覆盖
+ — 无
+
+
+"""
+
+
+def render_timeline(json_items: str, title: str = "") -> str:
+ """渲染事件时间线"""
+ try:
+ items = json.loads(json_items.strip())
+ except json.JSONDecodeError:
+ return '⚠️ 时间线数据解析失败
'
+
+ if not isinstance(items, list):
+ return '⚠️ 时间线需要数组格式
'
+
+ title_html = f'{html_lib.escape(title)}
' if title else ""
+
+ events = []
+ for item in items:
+ date = html_lib.escape(str(item.get("date", "")))
+ event = html_lib.escape(str(item.get("event", "")))
+ detail = item.get("detail", "")
+ if detail:
+ detail = f'{html_lib.escape(str(detail))}
'
+ event_type = str(item.get("type", "default")).lower()
+
+ events.append(f"""
+
+
+
+
{date}
+
{event}
+ {detail}
+
+
+""")
+
+ return f"""
+
+ {title_html}
+
{"".join(events)}
+
+"""
+
+
+def render_quote_card(content: str, source: str = "", author: str = "", likes: str = "") -> str:
+ """渲染用户原声卡片"""
+ content = html_lib.escape(content.strip())
+ source = html_lib.escape(source)
+ author = html_lib.escape(author)
+
+ meta = []
+ if author:
+ meta.append(f'@{author} ')
+ if source:
+ meta.append(f'{source} ')
+ if likes:
+ meta.append(f'❤ {html_lib.escape(str(likes))} ')
+ meta_html = f'{" · ".join(meta)}
' if meta else ""
+
+ return f"""
+
+
❝
+
{content}
+ {meta_html}
+
+"""
+
+
+# ===== 总解析器 =====
+
+def preprocess_custom_blocks(markdown_text: str) -> Tuple[str, BlockCollector]:
+ """
+ 扫描 markdown 文本,把所有自定义块替换为占位符,同时记录对应的 HTML。
+ 返回 (替换后的 markdown, collector)。
+ """
+ collector = BlockCollector()
+
+ # chart-card
+ def replace_chart(m):
+ attrs = _parse_attrs(m.group(1))
+ title = attrs.get("title", "")
+ html = render_chart_card(m.group(2), title=title)
+ return collector.add(html)
+
+ markdown_text = re.sub(
+ r"]*)>([\s\S]*?) ",
+ replace_chart,
+ markdown_text,
+ )
+
+ # kpi-grid
+ def replace_kpi(m):
+ html = render_kpi_grid(m.group(1))
+ return collector.add(html)
+
+ markdown_text = re.sub(
+ r"]*>([\s\S]*?) ",
+ replace_kpi,
+ markdown_text,
+ )
+
+ # callout
+ def replace_callout(m):
+ attrs = _parse_attrs(m.group(1))
+ callout_type = attrs.get("type", "info")
+ title = attrs.get("title", "")
+ html = render_callout(m.group(2), callout_type=callout_type, title=title)
+ return collector.add(html)
+
+ markdown_text = re.sub(
+ r"]*)>([\s\S]*?) ",
+ replace_callout,
+ markdown_text,
+ )
+
+ # info-matrix
+ def replace_matrix(m):
+ attrs = _parse_attrs(m.group(1))
+ title = attrs.get("title", "")
+ html = render_info_matrix(m.group(2), title=title)
+ return collector.add(html)
+
+ markdown_text = re.sub(
+ r"]*)>([\s\S]*?) ",
+ replace_matrix,
+ markdown_text,
+ )
+
+ # timeline
+ def replace_timeline(m):
+ attrs = _parse_attrs(m.group(1))
+ title = attrs.get("title", "")
+ html = render_timeline(m.group(2), title=title)
+ return collector.add(html)
+
+ markdown_text = re.sub(
+ r"]*)>([\s\S]*?) ",
+ replace_timeline,
+ markdown_text,
+ )
+
+ # quote-card
+ def replace_quote(m):
+ attrs = _parse_attrs(m.group(1))
+ html = render_quote_card(
+ content=m.group(2),
+ source=attrs.get("source", ""),
+ author=attrs.get("author", ""),
+ likes=attrs.get("likes", ""),
+ )
+ return collector.add(html)
+
+ markdown_text = re.sub(
+ r"]*)>([\s\S]*?) ",
+ replace_quote,
+ markdown_text,
+ )
+
+ return markdown_text, collector
+
+
+def _parse_attrs(attr_str: str) -> Dict[str, str]:
+ """解析 HTML 属性,形如 ' type="bar" title="hello"'"""
+ attrs = {}
+ for m in re.finditer(r'(\w+)=[\'"]([^\'"]*)[\'"]', attr_str):
+ attrs[m.group(1)] = m.group(2)
+ return attrs
diff --git a/experimental/agno_version/agno_agents/report_styles.py b/experimental/agno_version/agno_agents/report_styles.py
new file mode 100644
index 000000000..efc1e4d96
--- /dev/null
+++ b/experimental/agno_version/agno_agents/report_styles.py
@@ -0,0 +1,836 @@
+# agno_agents/report_styles.py
+# 专业报告 HTML 渲染样式
+
+# Chart.js 库:用于渲染 组件
+# 通过 CDN 加载,离线时有 fallback 提示
+CHART_JS_LIBS = """
+
+
+"""
+
+REPORT_CSS = """
+
+"""
diff --git a/experimental/agno_version/agno_team/__init__.py b/experimental/agno_version/agno_team/__init__.py
new file mode 100644
index 000000000..b401e2053
--- /dev/null
+++ b/experimental/agno_version/agno_team/__init__.py
@@ -0,0 +1,17 @@
+# agno_team/__init__.py
+# 编排层:三 agent 并发协作 + ForumHost 引导
+
+from .forum_state import ForumState, ForumEntry, format_host_speech_for_prompt
+from .forum_host import ForumHost
+from .agent_runner import run_agent_pipeline
+from .opinion_team import run_opinion_pipeline, run_opinion_analysis
+
+__all__ = [
+ "ForumState",
+ "ForumEntry",
+ "format_host_speech_for_prompt",
+ "ForumHost",
+ "run_agent_pipeline",
+ "run_opinion_pipeline",
+ "run_opinion_analysis",
+]
diff --git a/experimental/agno_version/agno_team/_agno_setup.py b/experimental/agno_version/agno_team/_agno_setup.py
new file mode 100644
index 000000000..a3705b2ac
--- /dev/null
+++ b/experimental/agno_version/agno_team/_agno_setup.py
@@ -0,0 +1,86 @@
+# agno_team/_agno_setup.py
+"""
+agno 环境初始化(必须最先导入!)
+
+作用:
+1. 清除环境变量代理(http_proxy / https_proxy / all_proxy)
+2. Patch agno.models.openai.chat 的全局 httpx client 获取函数,
+ 强制使用 proxy=None 的客户端,避免在用 SOCKS 代理的环境下 agno 调用失败
+
+使用方式:
+ from agno_team import _agno_setup # noqa: F401(必须最先导入)
+ from agno.agent import Agent # 之后才能正常使用 agno
+"""
+
+import os
+import httpx
+
+# ============== 第一步:清除代理环境变量 ==============
+_PROXY_KEYS = [
+ "http_proxy", "https_proxy", "all_proxy",
+ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
+]
+for _k in _PROXY_KEYS:
+ os.environ.pop(_k, None)
+
+
+# ============== 第二步:Patch agno 的全局 httpx client 获取函数 ==============
+
+def _create_no_proxy_sync_client() -> httpx.Client:
+ """创建一个绕过代理的 sync httpx client(保持 agno 默认配置)"""
+ return httpx.Client(
+ limits=httpx.Limits(max_connections=1000, max_keepalive_connections=200),
+ timeout=httpx.Timeout(300.0),
+ http2=False,
+ follow_redirects=True,
+ proxy=None, # 关键
+ )
+
+
+def _create_no_proxy_async_client() -> httpx.AsyncClient:
+ """创建一个绕过代理的 async httpx client"""
+ return httpx.AsyncClient(
+ limits=httpx.Limits(max_connections=1000, max_keepalive_connections=200),
+ timeout=httpx.Timeout(300.0),
+ http2=True,
+ follow_redirects=True,
+ proxy=None,
+ )
+
+
+def _patch_agno_clients():
+ """覆盖 agno 的全局 client 函数,让它们返回无代理的客户端"""
+ try:
+ import agno.models.openai.chat as _agno_chat
+ except ImportError:
+ return False
+
+ # 单例缓存
+ _cached_sync = None
+ _cached_async = None
+
+ def patched_get_sync():
+ nonlocal _cached_sync
+ if _cached_sync is None or _cached_sync.is_closed:
+ _cached_sync = _create_no_proxy_sync_client()
+ return _cached_sync
+
+ def patched_get_async():
+ nonlocal _cached_async
+ if _cached_async is None or _cached_async.is_closed:
+ _cached_async = _create_no_proxy_async_client()
+ return _cached_async
+
+ _agno_chat.get_default_sync_client = patched_get_sync
+ _agno_chat.get_default_async_client = patched_get_async
+
+ # 同时清空模块级单例(如果已经创建过)
+ if hasattr(_agno_chat, "_global_sync_client"):
+ _agno_chat._global_sync_client = None
+ if hasattr(_agno_chat, "_global_async_client"):
+ _agno_chat._global_async_client = None
+
+ return True
+
+
+_PATCHED = _patch_agno_clients()
diff --git a/experimental/agno_version/agno_team/agent_runner.py b/experimental/agno_version/agno_team/agent_runner.py
new file mode 100644
index 000000000..ae81335cd
--- /dev/null
+++ b/experimental/agno_version/agno_team/agent_runner.py
@@ -0,0 +1,420 @@
+# agno_team/agent_runner.py
+# 单个 Agent 的多步流程编排(async 版本)
+# 提取自 run_single_agent.py,加入 ForumState 集成
+#
+# Stage 3 改造:所有 LLM 调用改用 agno Agent(之前是裸 OpenAI SDK)
+# - 每个 (engine_type, stage) 组合用一个独立的 agno Agent 实例
+# - Agent 的 instructions 在每次调用前动态设置,避免预创建 30+ 个 Agent
+# - 工具调用仍然走我们手写的 dispatch(保持段落级流程的精细控制)
+
+from __future__ import annotations
+
+# 必须最先导入:清代理 + patch agno httpx client
+from . import _agno_setup # noqa: F401
+
+import asyncio
+import json
+import re
+from typing import List, Dict, Any, Optional
+
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+
+from .forum_state import ForumState, format_host_speech_for_prompt
+
+
+_ROLE_MAP = {
+ "system": "system",
+ "user": "user",
+ "assistant": "assistant",
+ "tool": "tool",
+ "model": "assistant",
+}
+
+
+# ===== agno Agent 缓存 =====
+# 按 engine_type 缓存一个"通用"的 agno Agent,每次调用时通过 message 传 system/user
+
+_agents_cache: Dict[str, Agent] = {}
+
+
+def _get_agno_agent(config_prefix: str) -> Agent:
+ """
+ 获取或创建一个 engine 对应的 agno Agent。
+
+ 设计取舍:
+ - 不为每个 stage 单独创建 agent(避免 30+ 个 agent 实例)
+ - 让 agent 的 instructions 保持空,每次调用都把 system_prompt 拼到 user message 前面
+ - 这相当于把 agno 当成一个"带代理 patch 的 OpenAI SDK 包装器"使用
+ """
+ if config_prefix in _agents_cache:
+ return _agents_cache[config_prefix]
+
+ from config import settings
+ api_key = getattr(settings, f"{config_prefix}_API_KEY")
+ base_url = getattr(settings, f"{config_prefix}_BASE_URL")
+ model_name = getattr(settings, f"{config_prefix}_MODEL_NAME")
+
+ if not api_key:
+ raise ValueError(f"{config_prefix}_API_KEY 未配置")
+
+ agent = Agent(
+ name=f"{config_prefix}_Worker",
+ model=OpenAIChat(
+ id=model_name,
+ api_key=api_key,
+ base_url=base_url,
+ role_map=_ROLE_MAP,
+ ),
+ # 不设 instructions,每次调用通过 user prompt 携带 system_prompt
+ # 这样可以让一个 agent 实例服务于 6 个不同 stage 的 prompt
+ system_message_role="system",
+ markdown=True,
+ )
+ _agents_cache[config_prefix] = agent
+ return agent
+
+
+async def _call_llm(client_or_agent, model_name_or_unused, system_prompt: str, user_content: str) -> str:
+ """
+ 调用 LLM 的统一入口(agno 模式)。
+
+ 保持与旧版相同的签名,便于其他代码不改动。
+ 第一/二个参数实际上不再使用(保留是为了兼容性),真正的 agent 通过
+ config_prefix → _get_agno_agent 动态获取。
+
+ Stage 3 改造:传入的 client/model 参数被忽略,改用全局 agno Agent 缓存。
+ """
+ # 如果第一个参数已经是 agno Agent(兼容直接传入的情况)
+ if isinstance(client_or_agent, Agent):
+ agent = client_or_agent
+ else:
+ # 兼容老调用方式:第一个参数曾经是 OpenAI client,现在用全局 fallback
+ # (实际上 Stage 3 之后会通过 config_prefix 直接拿)
+ raise ValueError("agent_runner Stage 3 改造后,请通过 _call_llm_via_engine 调用")
+
+ # agno Agent 的 instructions 是在 init 时设置的;这里我们用 prepend 方式
+ # 把 system_prompt 拼到 user message 前面,让 agent 临时拥有这个角色
+ full_message = f"\n{system_prompt}\n \n\n{user_content}"
+ response = await agent.arun(full_message)
+ return response.content if response else ""
+
+
+async def _call_llm_via_engine(config_prefix: str, system_prompt: str, user_content: str) -> str:
+ """
+ Stage 3 推荐入口:根据 engine_type 拿对应的 agno Agent,调用一次。
+ """
+ agent = _get_agno_agent(config_prefix)
+ full_message = f"\n{system_prompt}\n \n\n{user_content}"
+ try:
+ response = await agent.arun(full_message)
+ return response.content if response else ""
+ except Exception as e:
+ print(f"⚠️ agno agent 调用失败 ({config_prefix}): {e}")
+ return ""
+
+
+# 兼容旧的 _get_client / _call_llm_sync API(其他文件可能还在用)
+
+def _get_client(config_prefix: str):
+ """兼容旧 API:返回 (agent, model_name) 元组"""
+ agent = _get_agno_agent(config_prefix)
+ from config import settings
+ return agent, getattr(settings, f"{config_prefix}_MODEL_NAME")
+
+
+def _parse_json(text: str) -> Optional[Any]:
+ """容错解析 LLM 返回的 JSON"""
+ text = text.strip()
+ text = re.sub(r"^```(?:json)?\s*", "", text)
+ text = re.sub(r"\s*```$", "", text)
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ match = re.search(r"\{[\s\S]*?\}", text) or re.search(r"\[[\s\S]*?\]", text)
+ if match:
+ try:
+ return json.loads(match.group())
+ except json.JSONDecodeError:
+ pass
+ return None
+
+
+# ===== 工具调用调度 =====
+
+async def _call_tool_async(agent_type: str, decision: Dict[str, Any], default_topic: str) -> str:
+ """根据 decision 字典调用对应的工具(异步包装同步工具)"""
+ tool_name = decision.get("search_tool", "")
+ search_query = decision.get("search_query", default_topic)
+
+ if agent_type == "query":
+ from agno_tools import call_news_tool
+ tool_kwargs = {"query": search_query}
+ if tool_name == "search_news_by_date":
+ tool_kwargs["start_date"] = decision.get("start_date", "")
+ tool_kwargs["end_date"] = decision.get("end_date", "")
+ if not tool_name:
+ tool_name = "basic_search_news"
+ return await asyncio.to_thread(call_news_tool, tool_name, **tool_kwargs)
+
+ elif agent_type == "media":
+ from agno_tools import call_media_tool
+ tool_kwargs = {"query": search_query}
+ if not tool_name:
+ tool_name = "comprehensive_search"
+ return await asyncio.to_thread(call_media_tool, tool_name, **tool_kwargs)
+
+ elif agent_type == "insight":
+ # 国内本地数据库工具
+ domestic_tools = {
+ "search_hot_content", "search_topic_globally", "search_topic_by_date",
+ "get_comments_for_topic", "search_topic_on_platform", "analyze_sentiment",
+ }
+ # 海外工具
+ overseas_tools = {
+ "search_hackernews", "search_hackernews_recent", "search_hackernews_comments",
+ "search_github_repos", "search_github_issues", "search_github_code",
+ "search_youtube_videos", "get_youtube_comments", "search_youtube_with_comments",
+ "search_reddit", "get_subreddit_hot", "get_reddit_post_comments",
+ }
+
+ if tool_name in overseas_tools:
+ from agno_tools import call_overseas_tool
+ # 海外工具的参数命名是 query 而不是 topic
+ tool_kwargs = {"query": search_query}
+ if tool_name == "search_reddit":
+ if decision.get("subreddit"):
+ tool_kwargs["subreddit"] = decision["subreddit"]
+ tool_kwargs["sort"] = decision.get("sort", "relevance")
+ elif tool_name == "get_subreddit_hot":
+ tool_kwargs = {
+ "subreddit": decision.get("subreddit", "programming"),
+ "time_filter": decision.get("time_filter", "week"),
+ }
+ elif tool_name == "get_youtube_comments":
+ tool_kwargs = {"video_id": decision.get("video_id", "")}
+ elif tool_name == "get_reddit_post_comments":
+ tool_kwargs = {
+ "post_id": decision.get("post_id", ""),
+ "subreddit": decision.get("subreddit", ""),
+ }
+ return await asyncio.to_thread(call_overseas_tool, tool_name, **tool_kwargs)
+
+ # 国内工具(默认路径)
+ from agno_tools import call_insight_tool
+ tool_kwargs = {"topic": search_query}
+ if tool_name == "search_topic_by_date":
+ tool_kwargs["start_date"] = decision.get("start_date", "")
+ tool_kwargs["end_date"] = decision.get("end_date", "")
+ elif tool_name == "search_topic_on_platform":
+ tool_kwargs["platform"] = decision.get("platform", "weibo")
+ if decision.get("start_date"):
+ tool_kwargs["start_date"] = decision.get("start_date")
+ tool_kwargs["end_date"] = decision.get("end_date", "")
+ elif tool_name == "search_hot_content":
+ tool_kwargs = {"time_period": decision.get("time_period", "week")}
+ elif tool_name == "analyze_sentiment":
+ tool_kwargs = {"texts": decision.get("texts") or [search_query]}
+ if not tool_name or tool_name not in domestic_tools:
+ tool_name = "search_topic_globally"
+ return await asyncio.to_thread(call_insight_tool, tool_name, **tool_kwargs)
+
+ return f"未知 agent_type: {agent_type}"
+
+
+# ===== 单 Agent 完整流程(异步版)=====
+
+AGENT_CONFIG = {
+ "insight": ("InsightAgent", "INSIGHT_ENGINE", "INSIGHT"),
+ "media": ("MediaAgent", "MEDIA_ENGINE", "MEDIA"),
+ "query": ("QueryAgent", "QUERY_ENGINE", "QUERY"),
+}
+
+
+async def run_agent_pipeline(
+ agent_type: str,
+ query: str,
+ forum_state: Optional[ForumState] = None,
+) -> Dict[str, Any]:
+ """
+ 异步运行单个 Agent 的完整三阶段流程:
+ 1. 规划报告结构
+ 2. 逐段:搜索决策 → 工具调用 → 撰写初稿 → 反思 → 补充搜索 → 深化
+ 3. 最终格式化报告
+
+ 在每个段落的 SummaryNode 之前会读取 forum_state 的最新 HOST 发言,
+ 塞进 prompt 前缀作为引导;段落产出后写入 forum_state。
+
+ Returns:
+ dict: {
+ "agent_type": str,
+ "agent_name": str,
+ "query": str,
+ "paragraphs": List[{"title": str, "paragraph_latest_state": str}],
+ "final_report": str,
+ }
+ """
+ if agent_type not in AGENT_CONFIG:
+ raise ValueError(f"未知 agent_type: {agent_type}")
+
+ agent_name, config_prefix, forum_role = AGENT_CONFIG[agent_type]
+ # Stage 3 改造:不再创建 OpenAI client,所有 LLM 调用通过 _call_llm_via_engine 走 agno
+ # 仍然预热一次 agent 实例(缓存命中后续都很快)
+ _get_agno_agent(config_prefix)
+
+ # 检测可用的海外工具(仅 insight 需要)
+ overseas_extra = ""
+ if agent_type == "insight":
+ from config import settings
+ from agno_agents.insight_agent import _detect_available_overseas, _build_overseas_section
+ available = _detect_available_overseas(settings)
+ overseas_extra = _build_overseas_section(available)
+
+ # 动态导入对应 agent 的 prompt
+ if agent_type == "insight":
+ from agno_agents.insight_agent import (
+ SYSTEM_PROMPT_REPORT_STRUCTURE,
+ SYSTEM_PROMPT_FIRST_SEARCH,
+ SYSTEM_PROMPT_FIRST_SUMMARY,
+ SYSTEM_PROMPT_REFLECTION,
+ SYSTEM_PROMPT_REFLECTION_SUMMARY,
+ SYSTEM_PROMPT_REPORT_FORMATTING,
+ )
+ elif agent_type == "media":
+ from agno_agents.media_agent import (
+ SYSTEM_PROMPT_REPORT_STRUCTURE,
+ SYSTEM_PROMPT_FIRST_SEARCH,
+ SYSTEM_PROMPT_FIRST_SUMMARY,
+ SYSTEM_PROMPT_REFLECTION,
+ SYSTEM_PROMPT_REFLECTION_SUMMARY,
+ SYSTEM_PROMPT_REPORT_FORMATTING,
+ )
+ else:
+ from agno_agents.query_agent import (
+ SYSTEM_PROMPT_REPORT_STRUCTURE,
+ SYSTEM_PROMPT_FIRST_SEARCH,
+ SYSTEM_PROMPT_FIRST_SUMMARY,
+ SYSTEM_PROMPT_REFLECTION,
+ SYSTEM_PROMPT_REFLECTION_SUMMARY,
+ SYSTEM_PROMPT_REPORT_FORMATTING,
+ )
+
+ print(f"\n🚀 [{agent_name}] 启动,主题: {query}")
+
+ # ===== 阶段一:规划报告结构 =====
+ structure_raw = await _call_llm_via_engine(config_prefix, SYSTEM_PROMPT_REPORT_STRUCTURE, query)
+ paragraphs_outline = _parse_json(structure_raw)
+ if not isinstance(paragraphs_outline, list):
+ paragraphs_outline = []
+
+ print(f"📋 [{agent_name}] 规划了 {len(paragraphs_outline)} 个段落")
+
+ # ===== 阶段二:逐段搜索 + 总结 + 反思 =====
+ paragraph_results: List[Dict[str, str]] = []
+
+ for idx, para in enumerate(paragraphs_outline, 1):
+ if not isinstance(para, dict):
+ continue
+ title = para.get("title", "")
+ content = para.get("content", "")
+ para_input = json.dumps({"title": title, "content": content}, ensure_ascii=False)
+
+ print(f"🔍 [{agent_name}] 段落 {idx}/{len(paragraphs_outline)}: {title}")
+
+ # 2a. 首次搜索决策(insight 类型在 system prompt 末尾追加海外平台说明)
+ search_system = SYSTEM_PROMPT_FIRST_SEARCH + (overseas_extra if overseas_extra else "")
+ search_decision_raw = await _call_llm_via_engine(config_prefix, search_system, para_input)
+ decision = _parse_json(search_decision_raw)
+
+ # 2b. 调用真实工具
+ search_results = f"(无搜索结果:{title})"
+ if isinstance(decision, dict):
+ try:
+ search_results = await _call_tool_async(agent_type, decision, title)
+ except Exception as e:
+ search_results = f"工具调用失败: {e}"
+
+ # 2c. 读取 HOST 引导发言并撰写初稿
+ host_hint = forum_state.get_latest_host_speech() if forum_state else None
+ host_prefix = format_host_speech_for_prompt(host_hint) if host_hint else ""
+
+ summary_input = host_prefix + json.dumps({
+ "title": title,
+ "content": content,
+ "search_query": query,
+ "search_results": [search_results],
+ }, ensure_ascii=False)
+
+ summary_raw = await _call_llm_via_engine(config_prefix, SYSTEM_PROMPT_FIRST_SUMMARY, summary_input)
+ summary_data = _parse_json(summary_raw)
+ paragraph_state = (
+ summary_data.get("paragraph_latest_state", summary_raw)
+ if isinstance(summary_data, dict)
+ else summary_raw
+ )
+
+ # ⭐ 写入 forum_state(可能触发 Host)
+ if forum_state is not None:
+ await forum_state.write(forum_role, paragraph_state)
+
+ # 2d. 反思:基于当前段落生成补充搜索
+ reflection_input = json.dumps({
+ "title": title,
+ "content": content,
+ "paragraph_latest_state": paragraph_state,
+ }, ensure_ascii=False)
+
+ reflection_system = SYSTEM_PROMPT_REFLECTION + (overseas_extra if overseas_extra else "")
+ reflection_raw = await _call_llm_via_engine(config_prefix, reflection_system, reflection_input)
+ ref_decision = _parse_json(reflection_raw)
+
+ ref_search_results = f"(无补充搜索结果:{title})"
+ if isinstance(ref_decision, dict):
+ try:
+ ref_search_results = await _call_tool_async(agent_type, ref_decision, title)
+ except Exception as e:
+ ref_search_results = f"补充搜索失败: {e}"
+
+ # 2e. 反思总结:再次读取 HOST 引导,深化段落
+ host_hint2 = forum_state.get_latest_host_speech() if forum_state else None
+ host_prefix2 = format_host_speech_for_prompt(host_hint2) if host_hint2 else ""
+
+ ref_summary_input = host_prefix2 + json.dumps({
+ "title": title,
+ "content": content,
+ "search_query": query,
+ "search_results": [ref_search_results],
+ "paragraph_latest_state": paragraph_state,
+ }, ensure_ascii=False)
+
+ ref_summary_raw = await _call_llm_via_engine(config_prefix, SYSTEM_PROMPT_REFLECTION_SUMMARY, ref_summary_input)
+ ref_summary_data = _parse_json(ref_summary_raw)
+ final_state = (
+ ref_summary_data.get("updated_paragraph_latest_state", paragraph_state)
+ if isinstance(ref_summary_data, dict)
+ else paragraph_state
+ )
+
+ # ⭐ 深化后的段落再次写入 forum_state
+ if forum_state is not None:
+ await forum_state.write(forum_role, final_state)
+
+ paragraph_results.append({
+ "title": title,
+ "paragraph_latest_state": final_state,
+ })
+
+ # ===== 阶段三:最终报告格式化 =====
+ print(f"📝 [{agent_name}] 生成最终报告...")
+ formatting_input = json.dumps(paragraph_results, ensure_ascii=False)
+ final_report = await _call_llm_via_engine(config_prefix, SYSTEM_PROMPT_REPORT_FORMATTING, formatting_input)
+
+ print(f"✅ [{agent_name}] 完成({len(final_report)} 字)")
+
+ return {
+ "agent_type": agent_type,
+ "agent_name": agent_name,
+ "query": query,
+ "paragraphs": paragraph_results,
+ "final_report": final_report,
+ }
diff --git a/experimental/agno_version/agno_team/forum_agent.py b/experimental/agno_version/agno_team/forum_agent.py
new file mode 100644
index 000000000..d2de4eddb
--- /dev/null
+++ b/experimental/agno_version/agno_team/forum_agent.py
@@ -0,0 +1,55 @@
+# agno_team/forum_agent.py
+# TODO(C组): 迁移自 ForumEngine/monitor.py + ForumEngine/llm_host.py
+# 负责监控各引擎进度并生成论坛主持人发言
+
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+from agno.tools import tool
+from typing import Dict
+
+
+@tool(description="获取当前各分析引擎的最新运行状态和进度摘要")
+def get_engine_progress() -> Dict:
+ """
+ 读取各引擎日志,返回实时进度。
+ 迁移自 ForumEngine/monitor.py LogMonitor 类
+
+ Returns:
+ {
+ "insight": {"status": "running|done|idle", "progress": 0~100, "latest_output": str},
+ "media": {"status": ..., "progress": ..., "latest_output": ...},
+ "query": {"status": ..., "progress": ..., "latest_output": ...},
+ }
+ """
+ # TODO(C组): 复用 ForumEngine/monitor.py 中的 LogMonitor 日志读取逻辑
+ raise NotImplementedError("TODO(C组)")
+
+
+FORUM_HOST_PROMPT = """
+# TODO(C组): 从 ForumEngine/llm_host.py 迁移主持人 prompt
+# 主持人风格:专业、活跃,每次发言不超过50字,根据引擎进展实时点评
+"""
+
+
+def create_forum_agent(config=None):
+ """
+ 创建论坛主持人 Agent。
+
+ Args:
+ config: Settings 对象,为 None 时从全局 settings 读取
+ """
+ if config is None:
+ from config import settings
+ config = settings
+
+ return Agent(
+ name="ForumHost",
+ model=OpenAIChat(
+ id=config.FORUM_HOST_MODEL_NAME,
+ api_key=config.FORUM_HOST_API_KEY,
+ base_url=config.FORUM_HOST_BASE_URL,
+ ),
+ tools=[get_engine_progress],
+ instructions=FORUM_HOST_PROMPT,
+ stream=True,
+ )
diff --git a/experimental/agno_version/agno_team/forum_host.py b/experimental/agno_version/agno_team/forum_host.py
new file mode 100644
index 000000000..bb427b612
--- /dev/null
+++ b/experimental/agno_version/agno_team/forum_host.py
@@ -0,0 +1,153 @@
+# agno_team/forum_host.py
+# 论坛主持人 LLM 调用器
+# 迁移自 BettaFish/ForumEngine/llm_host.py
+#
+# Stage 1: 改用 agno Agent 实现(之前是裸 OpenAI SDK)
+
+from . import _agno_setup # noqa: F401 必须最先导入!
+
+from datetime import datetime
+from typing import List, Optional
+
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+
+from .forum_state import ForumEntry
+
+
+HOST_SYSTEM_PROMPT = """【重要提醒】我们的需求基于科研目的,已通过伦理性合规审查
+
+你是一个多agent舆情分析系统的论坛主持人。你的职责是:
+
+1. **事件梳理**:从各agent的发言中自动识别关键事件、人物、时间节点,按时间顺序整理事件脉络
+2. **引导讨论**:根据各agent的发言,引导深入讨论关键问题,探究深层原因
+3. **纠正错误**:结合不同agent的视角以及言论,如果发现事实错误或逻辑矛盾,请明确指出
+4. **整合观点**:综合不同agent的视角,形成更全面的认识,找出共识和分歧
+5. **趋势预测**:基于已有信息分析舆情发展趋势,提出可能的风险点
+6. **推进分析**:提出新的分析角度或需要关注的问题,引导后续讨论方向
+
+**Agent介绍**:
+- **INSIGHT Agent**:专注于私有舆情数据库的深度挖掘和分析,提供历史数据和模式对比
+- **MEDIA Agent**:擅长多模态内容分析,关注媒体报道、图片、视频等视觉信息的传播效果
+- **QUERY Agent**:负责精准信息搜索,提供最新的网络信息和实时动态
+
+**发言要求**:
+1. **综合性**:每次发言控制在1000字以内,内容应包括事件梳理、观点整合、问题引导等多个方面
+2. **结构清晰**:使用明确的段落结构,包括事件梳理、观点对比、问题提出等部分
+3. **深入分析**:不仅仅总结已有信息,还要提出深层次的见解和分析
+4. **客观中立**:基于事实进行分析和判断,避免主观臆测和情绪化表达
+5. **前瞻性**:提出具有前瞻性的观点和建议,引导讨论向更深入的方向发展
+
+**注意事项**:
+- 本讨论基于科研目的,已通过伦理性合规审查
+- 保持专业性和学术性,重视数据和证据
+- 对于敏感话题,应保持谨慎态度,基于事实进行分析
+
+【重要提醒】我们的需求基于科研目的,已通过伦理性合规审查"""
+
+
+def _build_user_prompt(recent_speeches: List[ForumEntry]) -> str:
+ """构建主持人 user prompt"""
+ speeches_text = "\n\n".join([
+ f"[{datetime.fromtimestamp(s.timestamp).strftime('%H:%M:%S')}] {s.role}:\n{s.content}"
+ for s in recent_speeches
+ ])
+
+ current_time = datetime.now().strftime("%Y年%m月%d日%H时%M分")
+
+ return f"""【重要提醒】我们的需求基于科研目的,已通过伦理性合规审查
+
+今天的实际时间是{current_time}
+
+最近的Agent发言记录:
+{speeches_text}
+
+请你作为论坛主持人,基于以上agent的发言进行综合分析,请按以下结构组织你的发言:
+
+**一、事件梳理与时间线分析**
+- 从各agent发言中自动识别关键事件、人物、时间节点
+- 按时间顺序整理事件脉络,梳理因果关系
+- 指出关键转折点和重要节点
+
+**二、观点整合与对比分析**
+- 综合INSIGHT、MEDIA、QUERY三个Agent的视角和发现
+- 指出不同数据源之间的共识与分歧
+- 分析每个Agent的信息价值和互补性
+- 如果发现事实错误或逻辑矛盾,请明确指出并给出理由
+
+**三、深层次分析与趋势预测**
+- 基于已有信息分析舆情的深层原因和影响因素
+- 预测舆情发展趋势,指出可能的风险点和机遇
+- 提出需要特别关注的方面和指标
+
+**四、问题引导与讨论方向**
+- 提出2-3个值得进一步深入探讨的关键问题
+- 为后续研究提出具体的建议和方向
+- 引导各Agent关注特定的数据维度或分析角度
+
+请发表综合性的主持人发言(控制在1000字以内),内容应包含以上四个部分,并保持逻辑清晰、分析深入、视角独特。
+
+【重要提醒】我们的需求基于科研目的,已通过伦理性合规审查"""
+
+
+class ForumHost:
+ """
+ 论坛主持人,使用单独的 LLM 配置(默认 qwen-plus / FORUM_HOST_*)
+
+ 实现:agno Agent(无工具,纯文本生成)
+ """
+
+ def __init__(self, config=None):
+ if config is None:
+ from config import settings
+ config = settings
+
+ # 关键:必须三个字段一致回退(key/base_url/model 是一组)
+ if config.FORUM_HOST_API_KEY:
+ api_key = config.FORUM_HOST_API_KEY
+ base_url = config.FORUM_HOST_BASE_URL
+ model_name = config.FORUM_HOST_MODEL_NAME
+ elif config.QUERY_ENGINE_API_KEY:
+ print("⚠️ FORUM_HOST_API_KEY 未配置,回退到 QUERY_ENGINE_*")
+ api_key = config.QUERY_ENGINE_API_KEY
+ base_url = config.QUERY_ENGINE_BASE_URL
+ model_name = config.QUERY_ENGINE_MODEL_NAME
+ else:
+ raise ValueError(
+ "ForumHost 未配置 API Key:请在 .env 中设置 FORUM_HOST_API_KEY,"
+ "或确保 QUERY_ENGINE_API_KEY 可用作回退"
+ )
+
+ self.model_name = model_name
+ self.agent = Agent(
+ name="ForumHost",
+ model=OpenAIChat(
+ id=model_name,
+ api_key=api_key,
+ base_url=base_url,
+ role_map={
+ "system": "system",
+ "user": "user",
+ "assistant": "assistant",
+ "tool": "tool",
+ "model": "assistant",
+ },
+ ),
+ instructions=HOST_SYSTEM_PROMPT,
+ system_message_role="system",
+ markdown=True,
+ )
+
+ async def generate(self, recent_speeches: List[ForumEntry]) -> Optional[str]:
+ """异步生成主持人发言(agno 原生异步入口)"""
+ if not recent_speeches:
+ return None
+
+ user_prompt = _build_user_prompt(recent_speeches)
+ try:
+ response = await self.agent.arun(user_prompt)
+ content = response.content if response else None
+ return content.strip() if content else None
+ except Exception as e:
+ print(f"⚠️ ForumHost (agno) 调用失败: {e}")
+ return None
diff --git a/experimental/agno_version/agno_team/forum_state.py b/experimental/agno_version/agno_team/forum_state.py
new file mode 100644
index 000000000..709c04d33
--- /dev/null
+++ b/experimental/agno_version/agno_team/forum_state.py
@@ -0,0 +1,135 @@
+# agno_team/forum_state.py
+# 论坛共享状态:替代 BettaFish 的 logs/forum.log 文件
+# 三个 Agent 通过这个对象传递段落总结,ForumHost 通过它获取上下文并写回引导发言
+
+from __future__ import annotations
+import asyncio
+import json
+import time
+from dataclasses import dataclass, field, asdict
+from datetime import datetime
+from pathlib import Path
+from typing import List, Optional, Callable, Awaitable
+
+
+@dataclass
+class ForumEntry:
+ """单条论坛发言"""
+ timestamp: float
+ role: str # "INSIGHT" / "MEDIA" / "QUERY" / "HOST" / "SYSTEM"
+ content: str
+
+ def format_human(self) -> str:
+ ts = datetime.fromtimestamp(self.timestamp).strftime("%H:%M:%S")
+ return f"[{ts}] [{self.role}] {self.content}"
+
+
+class ForumState:
+ """
+ 线程/协程安全的论坛状态。
+ 提供两个核心能力:
+ 1. write_to_forum(role, content): 任何 agent 都可以写入段落总结,
+ 自动触发 Host 阈值检查
+ 2. get_latest_host_speech(): 任何 agent 在生成 SummaryNode 前可以读取
+ 最新的主持人引导发言,塞进 prompt 前缀
+ """
+
+ def __init__(
+ self,
+ host_threshold: int = 5,
+ host_callback: Optional[Callable[[List[ForumEntry]], Awaitable[Optional[str]]]] = None,
+ ):
+ """
+ Args:
+ host_threshold: 累计多少条 agent 发言触发一次 Host 总结,默认 5
+ host_callback: Host LLM 调用函数,接收最近 N 条发言,返回主持人发言文本
+ """
+ self.entries: List[ForumEntry] = []
+ self.host_threshold = host_threshold
+ self.host_callback = host_callback
+
+ self._agent_speech_count = 0
+ self._host_lock = asyncio.Lock()
+ self._is_host_generating = False
+
+ async def write(self, role: str, content: str) -> None:
+ """
+ 写入一条发言。如果是 agent 发言(INSIGHT/MEDIA/QUERY),
+ 累加计数;累计到阈值时自动触发 Host 总结。
+ """
+ entry = ForumEntry(timestamp=time.time(), role=role, content=content)
+ self.entries.append(entry)
+ print(f"📝 [{role}] {content[:80]}...")
+
+ if role in ("INSIGHT", "MEDIA", "QUERY"):
+ self._agent_speech_count += 1
+
+ if (
+ self._agent_speech_count >= self.host_threshold
+ and not self._is_host_generating
+ and self.host_callback is not None
+ ):
+ # 触发 Host 总结
+ async with self._host_lock:
+ if self._agent_speech_count >= self.host_threshold and not self._is_host_generating:
+ self._is_host_generating = True
+ try:
+ recent = self._get_recent_agent_entries(self.host_threshold)
+ host_speech = await self.host_callback(recent)
+ if host_speech:
+ host_entry = ForumEntry(
+ timestamp=time.time(),
+ role="HOST",
+ content=host_speech,
+ )
+ self.entries.append(host_entry)
+ self._agent_speech_count = 0
+ print(f"\n🎤 [HOST] {host_speech[:200]}...\n")
+ finally:
+ self._is_host_generating = False
+
+ def get_latest_host_speech(self) -> Optional[str]:
+ """获取最新的 HOST 发言(供 agent 在 SummaryNode 前读取)"""
+ for entry in reversed(self.entries):
+ if entry.role == "HOST":
+ return entry.content
+ return None
+
+ def _get_recent_agent_entries(self, n: int) -> List[ForumEntry]:
+ """获取最近 n 条 agent 发言(不包括 HOST/SYSTEM)"""
+ result = []
+ for entry in reversed(self.entries):
+ if entry.role in ("INSIGHT", "MEDIA", "QUERY"):
+ result.append(entry)
+ if len(result) >= n:
+ break
+ result.reverse()
+ return result
+
+ def get_all_host_speeches(self) -> List[ForumEntry]:
+ """获取所有 HOST 发言"""
+ return [e for e in self.entries if e.role == "HOST"]
+
+ def format_full_log(self) -> str:
+ """格式化完整论坛日志为字符串(供 ReportAgent 消费)"""
+ return "\n".join(e.format_human() for e in self.entries)
+
+ def save_to_file(self, path: Path) -> None:
+ """落盘备份"""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ data = [asdict(e) for e in self.entries]
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def format_host_speech_for_prompt(host_speech: str) -> str:
+ """格式化 HOST 发言以插入 agent prompt 前缀(与 BettaFish forum_reader.py 一致)"""
+ if not host_speech:
+ return ""
+ return f"""
+### 论坛主持人最新总结
+以下是论坛主持人对各 Agent 讨论的最新总结和引导,请参考其中的观点和建议:
+
+{host_speech}
+
+---
+"""
diff --git a/experimental/agno_version/agno_team/opinion_team.py b/experimental/agno_version/agno_team/opinion_team.py
new file mode 100644
index 000000000..d36f61ce6
--- /dev/null
+++ b/experimental/agno_version/agno_team/opinion_team.py
@@ -0,0 +1,108 @@
+# agno_team/opinion_team.py
+# 舆情分析主调度器(路径3:asyncio + 内存共享 forum_state)
+#
+# 工作流程:
+# 1. 创建 ForumState(共享论坛状态 + Host 触发回调)
+# 2. asyncio.gather 三个 agent 并发执行
+# 3. 每个 agent 在生成段落总结时:
+# - 读取 ForumState 中最新的 HOST 发言并塞进 prompt
+# - 写入段落产出,自动触发 Host 阈值检查
+# 4. 三 agent 全部完成后,调用 ReportAgent 综合三份报告 + 论坛日志
+
+from __future__ import annotations
+import asyncio
+from typing import Dict, Any, List, Optional
+
+from .forum_state import ForumState
+from .forum_host import ForumHost
+from .agent_runner import run_agent_pipeline
+
+
+async def run_opinion_pipeline(
+ query: str,
+ host_threshold: int = 5,
+ config=None,
+) -> Dict[str, Any]:
+ """
+ 异步运行完整的舆情分析流程:三 agent 并发 + ForumHost 引导 + 最终汇总。
+
+ Args:
+ query: 分析主题
+ host_threshold: 累计多少条 agent 段落总结触发一次 Host 引导,默认 5
+ config: Settings 对象
+
+ Returns:
+ dict: {
+ "query": str,
+ "agent_results": {
+ "insight": {agent_results dict},
+ "media": {agent_results dict},
+ "query": {agent_results dict},
+ },
+ "forum_log": str (完整论坛日志的人类可读格式),
+ "host_speeches": List[str] (所有 HOST 引导发言),
+ }
+ """
+ # 初始化 ForumHost
+ host = ForumHost(config=config)
+
+ # 创建 ForumState,绑定 host 回调
+ forum_state = ForumState(
+ host_threshold=host_threshold,
+ host_callback=host.generate,
+ )
+
+ print(f"\n{'=' * 60}")
+ print(f" 舆情分析任务启动")
+ print(f" 主题: {query}")
+ print(f" Host 引导阈值: {host_threshold} 条")
+ print(f"{'=' * 60}\n")
+
+ # ⭐ 三 agent 并发执行
+ insight_task = asyncio.create_task(
+ run_agent_pipeline("insight", query, forum_state),
+ name="InsightAgent",
+ )
+ media_task = asyncio.create_task(
+ run_agent_pipeline("media", query, forum_state),
+ name="MediaAgent",
+ )
+ query_task = asyncio.create_task(
+ run_agent_pipeline("query", query, forum_state),
+ name="QueryAgent",
+ )
+
+ # gather 等待全部完成
+ results = await asyncio.gather(
+ insight_task, media_task, query_task,
+ return_exceptions=True,
+ )
+
+ # 处理可能的异常
+ agent_results = {}
+ for r in results:
+ if isinstance(r, Exception):
+ print(f"⚠️ Agent 执行失败: {type(r).__name__}: {r}")
+ continue
+ agent_results[r["agent_type"]] = r
+
+ print(f"\n{'=' * 60}")
+ print(f" 全部 Agent 执行完毕")
+ print(f" 论坛总条数: {len(forum_state.entries)}")
+ print(f" Host 引导次数: {len(forum_state.get_all_host_speeches())}")
+ print(f"{'=' * 60}\n")
+
+ return {
+ "query": query,
+ "agent_results": agent_results,
+ "forum_log": forum_state.format_full_log(),
+ "host_speeches": [e.content for e in forum_state.get_all_host_speeches()],
+ "_forum_state": forum_state, # 供 ReportAgent 进一步使用
+ }
+
+
+def run_opinion_analysis(query: str, host_threshold: int = 5, config=None) -> Dict[str, Any]:
+ """
+ 同步入口:包装 run_opinion_pipeline,便于命令行/Flask 调用。
+ """
+ return asyncio.run(run_opinion_pipeline(query, host_threshold=host_threshold, config=config))
diff --git a/experimental/agno_version/agno_tools/__init__.py b/experimental/agno_version/agno_tools/__init__.py
new file mode 100644
index 000000000..79b2e0492
--- /dev/null
+++ b/experimental/agno_version/agno_tools/__init__.py
@@ -0,0 +1,106 @@
+# agno_tools/__init__.py
+# 统一导出所有 tool 函数
+
+# QueryAgent 的 6 个 Tavily 新闻搜索工具(已实现)
+from .news_search_tools import (
+ basic_search_news,
+ deep_search_news,
+ search_news_last_24_hours,
+ search_news_last_week,
+ search_images_for_news,
+ search_news_by_date,
+ call_news_tool,
+ NEWS_TOOL_DISPATCH,
+)
+
+# MediaAgent 的 5 个 Bocha 多模态搜索工具(已实现)
+from .media_search_tools import (
+ comprehensive_search,
+ web_search_only,
+ search_for_structured_data,
+ search_last_24_hours,
+ search_last_week,
+ call_media_tool,
+ MEDIA_TOOL_DISPATCH,
+)
+
+# InsightAgent 的 6 个本地 DB 查询工具(中文社交媒体)
+from .db_query_tools import (
+ search_hot_content,
+ search_topic_globally,
+ search_topic_by_date,
+ get_comments_for_topic,
+ search_topic_on_platform,
+ analyze_sentiment,
+ call_insight_tool,
+)
+
+# 海外数据源工具(InsightAgent 国际版扩展)
+from .hackernews_tools import (
+ search_hackernews,
+ search_hackernews_recent,
+ search_hackernews_comments,
+ HN_TOOL_DISPATCH,
+)
+from .github_tools import (
+ search_github_repos,
+ search_github_issues,
+ search_github_code,
+ call_github_tool,
+)
+from .youtube_tools import (
+ search_youtube_videos,
+ get_youtube_comments,
+ search_youtube_with_comments,
+ call_youtube_tool,
+)
+from .reddit_tools import (
+ search_reddit,
+ get_subreddit_hot,
+ get_reddit_post_comments,
+ call_reddit_tool,
+)
+
+
+def call_overseas_tool(tool_name: str, **kwargs) -> str:
+ """统一的海外工具调度入口,自动路由到正确的子模块"""
+ hn_tools = {"search_hackernews", "search_hackernews_recent", "search_hackernews_comments"}
+ github_tools = {"search_github_repos", "search_github_issues", "search_github_code"}
+ youtube_tools = {"search_youtube_videos", "get_youtube_comments", "search_youtube_with_comments"}
+ reddit_tools = {"search_reddit", "get_subreddit_hot", "get_reddit_post_comments"}
+
+ if tool_name in hn_tools:
+ return HN_TOOL_DISPATCH[tool_name](**kwargs)
+ elif tool_name in github_tools:
+ return call_github_tool(tool_name, **kwargs)
+ elif tool_name in youtube_tools:
+ return call_youtube_tool(tool_name, **kwargs)
+ elif tool_name in reddit_tools:
+ return call_reddit_tool(tool_name, **kwargs)
+ return f"未知海外工具: {tool_name}"
+
+
+__all__ = [
+ # QueryAgent 工具
+ "basic_search_news", "deep_search_news",
+ "search_news_last_24_hours", "search_news_last_week",
+ "search_images_for_news", "search_news_by_date",
+ "call_news_tool", "NEWS_TOOL_DISPATCH",
+ # MediaAgent 工具
+ "comprehensive_search", "web_search_only",
+ "search_for_structured_data",
+ "search_last_24_hours", "search_last_week",
+ "call_media_tool", "MEDIA_TOOL_DISPATCH",
+ # InsightAgent 工具(中文)
+ "search_hot_content", "search_topic_globally",
+ "search_topic_by_date", "get_comments_for_topic",
+ "search_topic_on_platform", "analyze_sentiment",
+ "call_insight_tool",
+ # 海外数据源(InsightAgent 国际扩展)
+ "search_hackernews", "search_hackernews_recent", "search_hackernews_comments",
+ "search_github_repos", "search_github_issues", "search_github_code",
+ "search_youtube_videos", "get_youtube_comments", "search_youtube_with_comments",
+ "search_reddit", "get_subreddit_hot", "get_reddit_post_comments",
+ "call_github_tool", "call_youtube_tool", "call_reddit_tool",
+ "call_overseas_tool",
+]
diff --git a/experimental/agno_version/agno_tools/crawler_tools.py b/experimental/agno_version/agno_tools/crawler_tools.py
new file mode 100644
index 000000000..8431bf01c
--- /dev/null
+++ b/experimental/agno_version/agno_tools/crawler_tools.py
@@ -0,0 +1,34 @@
+# agno_tools/crawler_tools.py
+# TODO(A组): 迁移自 MindSpider/main.py
+# 将 MindSpider 类的启动和状态查询封装为 agno @tool 函数
+
+from agno.tools import tool
+from typing import List, Dict, Any
+
+
+@tool(description="启动MindSpider爬虫,采集指定平台的舆情数据并存入数据库")
+def start_crawler(
+ keyword: str,
+ platforms: List[str],
+ max_count: int = 100
+) -> Dict[str, Any]:
+ """
+ Args:
+ keyword: 采集关键词
+ platforms: 目标平台列表,如 ["weibo", "tieba", "zhihu"]
+ max_count: 每个平台最大采集条数
+ Returns:
+ {"task_id": str, "status": "started", "estimated_seconds": int}
+ """
+ raise NotImplementedError("TODO(A组): 迁移自 MindSpider/main.py MindSpider类")
+
+
+@tool(description="查询爬虫任务当前状态和进度")
+def get_crawler_status(task_id: str) -> Dict[str, Any]:
+ """
+ Args:
+ task_id: start_crawler 返回的任务ID
+ Returns:
+ {"task_id": str, "status": "running|done|failed", "progress": int, "collected": int}
+ """
+ raise NotImplementedError("TODO(A组): 迁移自 MindSpider/main.py MindSpider类")
diff --git a/experimental/agno_version/agno_tools/db_query_tools.py b/experimental/agno_version/agno_tools/db_query_tools.py
new file mode 100644
index 000000000..5ba91b6da
--- /dev/null
+++ b/experimental/agno_version/agno_tools/db_query_tools.py
@@ -0,0 +1,449 @@
+# agno_tools/db_query_tools.py
+# 迁移自 BettaFish/InsightEngine/tools/search.py
+# 6 个本地社交媒体数据库查询工具,供 InsightAgent 使用
+#
+# 数据库表来源:MediaCrawler 爬虫
+# 内容表:bilibili_video, douyin_aweme, kuaishou_video, weibo_note,
+# xhs_note, zhihu_content, tieba_note, daily_news
+# 评论表:bilibili_video_comment, douyin_aweme_comment, kuaishou_video_comment,
+# weibo_note_comment, xhs_note_comment, zhihu_comment, tieba_comment
+
+from __future__ import annotations
+import asyncio
+from urllib.parse import quote_plus
+from datetime import datetime, timedelta, date
+from typing import List, Dict, Any, Optional, Literal
+from dataclasses import dataclass, field
+
+from agno.tools import tool
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
+
+from config import settings
+
+
+# ===== 数据结构 =====
+
+@dataclass
+class QueryResult:
+ platform: str = ""
+ content_type: str = ""
+ title_or_content: str = ""
+ author_nickname: Optional[str] = None
+ url: Optional[str] = None
+ publish_time: Optional[datetime] = None
+ engagement: Dict[str, int] = field(default_factory=dict)
+ source_keyword: Optional[str] = None
+ hotness_score: float = 0.0
+ source_table: str = ""
+
+
+# ===== 异步数据库引擎 =====
+
+_engine: Optional[AsyncEngine] = None
+
+
+def _build_database_url() -> str:
+ dialect = (settings.DB_DIALECT or "mysql").lower()
+ user = quote_plus(settings.DB_USER or "")
+ password = quote_plus(settings.DB_PASSWORD or "")
+ host = settings.DB_HOST or ""
+ port = str(settings.DB_PORT or "")
+ db_name = settings.DB_NAME or ""
+
+ if dialect == "sqlite":
+ # SQLite: DB_NAME 应是文件路径,例如 data/mock_yuqing.db
+ return f"sqlite+aiosqlite:///{db_name}"
+ if dialect in ("postgresql", "postgres"):
+ return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db_name}"
+ return f"mysql+aiomysql://{user}:{password}@{host}:{port}/{db_name}"
+
+
+def _get_engine() -> AsyncEngine:
+ global _engine
+ if _engine is None:
+ _engine = create_async_engine(
+ _build_database_url(),
+ pool_pre_ping=True,
+ pool_recycle=1800,
+ )
+ return _engine
+
+
+async def _async_fetch_all(query: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
+ engine = _get_engine()
+ async with engine.connect() as conn:
+ result = await conn.execute(text(query), params or {})
+ rows = result.mappings().all()
+ return [dict(row) for row in rows]
+
+
+def _execute_query(query: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
+ """同步包装,自动管理 event loop"""
+ try:
+ try:
+ loop = asyncio.get_event_loop()
+ if loop.is_closed():
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ return loop.run_until_complete(_async_fetch_all(query, params))
+ except Exception as e:
+ return [{"_error": str(e)}]
+
+
+def _wrap_field(field: str) -> str:
+ """根据数据库方言包装字段名"""
+ dialect = (settings.DB_DIALECT or "").lower()
+ if dialect in ("postgresql", "postgres", "sqlite"):
+ return f'"{field}"'
+ return f'`{field}`'
+
+
+def _to_datetime(ts: Any) -> Optional[datetime]:
+ if not ts:
+ return None
+ try:
+ if isinstance(ts, datetime):
+ return ts
+ if isinstance(ts, date):
+ return datetime.combine(ts, datetime.min.time())
+ if isinstance(ts, (int, float)) or str(ts).isdigit():
+ val = float(ts)
+ return datetime.fromtimestamp(val / 1000 if val > 1_000_000_000_000 else val)
+ if isinstance(ts, str):
+ return datetime.fromisoformat(ts.split('+')[0].strip())
+ except (ValueError, TypeError):
+ return None
+
+
+def _extract_engagement(row: Dict[str, Any]) -> Dict[str, int]:
+ """从原始行提取互动数据"""
+ engagement = {}
+ mapping = {
+ 'likes': ['liked_count', 'like_count', 'voteup_count', 'comment_like_count'],
+ 'comments': ['video_comment', 'comments_count', 'comment_count', 'total_replay_num', 'sub_comment_count'],
+ 'shares': ['video_share_count', 'shared_count', 'share_count', 'total_forwards'],
+ 'views': ['video_play_count', 'viewd_count'],
+ 'favorites': ['video_favorite_count', 'collected_count'],
+ 'coins': ['video_coin_count'],
+ 'danmaku': ['video_danmaku'],
+ }
+ for key, cols in mapping.items():
+ for col in cols:
+ if col in row and row[col] is not None:
+ try:
+ engagement[key] = int(row[col])
+ except (ValueError, TypeError):
+ engagement[key] = 0
+ break
+ return engagement
+
+
+def _format_results(results: List[QueryResult], tool_name: str, params: Dict[str, Any]) -> str:
+ """将查询结果格式化为 LLM 可消费的字符串"""
+ lines = [f"工具: {tool_name}"]
+ lines.append(f"参数: {params}")
+ lines.append(f"找到 {len(results)} 条记录")
+
+ if not results:
+ lines.append("\n暂无相关内容。")
+ return "\n".join(lines)
+
+ lines.append("\n查询结果(最多前30条):\n")
+ for i, r in enumerate(results[:30], 1):
+ content = (r.title_or_content or "").replace("\n", " ")[:200]
+ author = r.author_nickname or "未知"
+ time_str = r.publish_time.strftime("%Y-%m-%d %H:%M") if r.publish_time else "未知时间"
+ engagement = ", ".join(f"{k}={v}" for k, v in r.engagement.items() if v) or "无数据"
+ lines.append(f"{i}. [{r.platform.upper()}/{r.content_type}] {content}")
+ lines.append(f" 作者: {author} | 时间: {time_str}")
+ lines.append(f" 互动: {{{engagement}}}")
+ if r.url:
+ lines.append(f" 链接: {r.url}")
+ if r.hotness_score > 0:
+ lines.append(f" 热度: {r.hotness_score:.2f}")
+ if r.source_keyword:
+ lines.append(f" 源关键词: {r.source_keyword}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+# ===== 6 个 Agent 工具 =====
+
+@tool(description="查找热点内容:获取最近一段时间内综合热度最高的内容(基于点赞/评论/分享/观看的加权算法)")
+def search_hot_content(time_period: str = "week", limit: int = 50) -> str:
+ """
+ Args:
+ time_period: 时间范围,'24h' / 'week' / 'year',默认 'week'
+ limit: 返回结果数量上限,默认 50
+ """
+ params_log = {"time_period": time_period, "limit": limit}
+ now = datetime.now()
+ days = {"24h": 1, "week": 7}.get(time_period, 365)
+ start_time = now - timedelta(days=days)
+
+ # 简化版:直接全局搜热门内容(按 id DESC 取最新作为热度近似)
+ # 完整版的 hotness_formulas 需要 MySQL 特定函数,PostgreSQL 移植复杂
+ # 这里用「最近内容 + 互动数据排序」的简化策略
+ tables = {
+ "weibo_note": {"type": "note", "title": "content", "time_col": "create_date_time"},
+ "xhs_note": {"type": "note", "title": "title", "time_col": "time"},
+ "zhihu_content": {"type": "content", "title": "title", "time_col": "created_time"},
+ "bilibili_video": {"type": "video", "title": "title", "time_col": "create_time"},
+ "douyin_aweme": {"type": "video", "title": "title", "time_col": "create_time"},
+ "kuaishou_video": {"type": "video", "title": "title", "time_col": "create_time"},
+ "tieba_note": {"type": "note", "title": "title", "time_col": "publish_time"},
+ }
+
+ all_results = []
+ for table, cfg in tables.items():
+ try:
+ query = f'SELECT * FROM {_wrap_field(table)} ORDER BY id DESC LIMIT :limit'
+ rows = _execute_query(query, {"limit": limit // len(tables) + 1})
+ if rows and "_error" in rows[0]:
+ continue
+ for row in rows:
+ content = row.get("title") or row.get("content") or row.get("desc", "")
+ time_key = row.get(cfg["time_col"])
+ pub_time = _to_datetime(time_key)
+ # 时间过滤
+ if pub_time and pub_time < start_time:
+ continue
+ eng = _extract_engagement(row)
+ hot = (
+ eng.get("likes", 0) * 1.0
+ + eng.get("comments", 0) * 5.0
+ + eng.get("shares", 0) * 10.0
+ + eng.get("views", 0) * 0.1
+ + eng.get("favorites", 0) * 10.0
+ )
+ all_results.append(QueryResult(
+ platform=table.split("_")[0],
+ content_type=cfg["type"],
+ title_or_content=content,
+ author_nickname=row.get("nickname") or row.get("user_nickname"),
+ url=row.get("video_url") or row.get("note_url") or row.get("content_url") or row.get("aweme_url"),
+ publish_time=pub_time,
+ engagement=eng,
+ hotness_score=hot,
+ source_keyword=row.get("source_keyword"),
+ source_table=table,
+ ))
+ except Exception:
+ continue
+
+ all_results.sort(key=lambda r: r.hotness_score, reverse=True)
+ return _format_results(all_results[:limit], "search_hot_content", params_log)
+
+
+# 各平台搜索配置
+_SEARCH_CONFIGS = {
+ "bilibili_video": {"fields": ["title", "desc", "source_keyword"], "type": "video"},
+ "bilibili_video_comment": {"fields": ["content"], "type": "comment"},
+ "douyin_aweme": {"fields": ["title", "desc", "source_keyword"], "type": "video"},
+ "douyin_aweme_comment": {"fields": ["content"], "type": "comment"},
+ "kuaishou_video": {"fields": ["title", "desc", "source_keyword"], "type": "video"},
+ "kuaishou_video_comment": {"fields": ["content"], "type": "comment"},
+ "weibo_note": {"fields": ["content", "source_keyword"], "type": "note"},
+ "weibo_note_comment": {"fields": ["content"], "type": "comment"},
+ "xhs_note": {"fields": ["title", "desc", "tag_list", "source_keyword"], "type": "note"},
+ "xhs_note_comment": {"fields": ["content"], "type": "comment"},
+ "zhihu_content": {"fields": ["title", "desc", "content_text", "source_keyword"], "type": "content"},
+ "zhihu_comment": {"fields": ["content"], "type": "comment"},
+ "tieba_note": {"fields": ["title", "desc", "source_keyword"], "type": "note"},
+ "tieba_comment": {"fields": ["content"], "type": "comment"},
+}
+
+
+def _search_topic_tables(topic: str, limit_per_table: int, configs: Dict[str, Any]) -> List[QueryResult]:
+ """统一的话题搜索辅助函数"""
+ search_term = f"%{topic}%"
+ all_results = []
+
+ for table, cfg in configs.items():
+ try:
+ param_dict = {"limit": limit_per_table}
+ where_clauses = []
+ for idx, fld in enumerate(cfg["fields"]):
+ pname = f"term_{idx}"
+ where_clauses.append(f'{_wrap_field(fld)} LIKE :{pname}')
+ param_dict[pname] = search_term
+ where_clause = " OR ".join(where_clauses)
+ query = f'SELECT * FROM {_wrap_field(table)} WHERE {where_clause} ORDER BY id DESC LIMIT :limit'
+
+ rows = _execute_query(query, param_dict)
+ if rows and "_error" in rows[0]:
+ continue
+
+ for row in rows:
+ content = (
+ row.get("title") or row.get("content")
+ or row.get("desc") or row.get("content_text", "")
+ )
+ time_key = (
+ row.get("create_time") or row.get("time") or row.get("created_time")
+ or row.get("publish_time") or row.get("create_date_time")
+ )
+ all_results.append(QueryResult(
+ platform=table.split("_")[0],
+ content_type=cfg["type"],
+ title_or_content=content,
+ author_nickname=row.get("nickname") or row.get("user_nickname") or row.get("user_name"),
+ url=row.get("video_url") or row.get("note_url") or row.get("content_url") or row.get("url") or row.get("aweme_url"),
+ publish_time=_to_datetime(time_key),
+ engagement=_extract_engagement(row),
+ source_keyword=row.get("source_keyword"),
+ source_table=table,
+ ))
+ except Exception:
+ continue
+
+ return all_results
+
+
+@tool(description="全局话题搜索:在数据库所有平台(微博/B站/抖音/快手/小红书/知乎/贴吧)的内容和评论中搜索指定话题")
+def search_topic_globally(topic: str, limit_per_table: int = 100) -> str:
+ """
+ Args:
+ topic: 搜索话题关键词
+ limit_per_table: 每个表的结果数上限,默认 100
+ """
+ params_log = {"topic": topic, "limit_per_table": limit_per_table}
+ results = _search_topic_tables(topic, limit_per_table, _SEARCH_CONFIGS)
+ return _format_results(results, "search_topic_globally", params_log)
+
+
+@tool(description="按日期范围搜索话题:在指定的历史日期范围内搜索特定话题的所有内容(追踪舆情演变)")
+def search_topic_by_date(topic: str, start_date: str, end_date: str, limit_per_table: int = 100) -> str:
+ """
+ Args:
+ topic: 搜索话题关键词
+ start_date: 开始日期,格式 YYYY-MM-DD
+ end_date: 结束日期,格式 YYYY-MM-DD
+ limit_per_table: 每个表的结果数上限,默认 100
+ """
+ params_log = {"topic": topic, "start_date": start_date, "end_date": end_date}
+ try:
+ start_dt = datetime.strptime(start_date, "%Y-%m-%d")
+ end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
+ except ValueError:
+ return f"日期格式错误,请使用 YYYY-MM-DD 格式。当前: start_date={start_date}, end_date={end_date}"
+
+ # 先全局搜,再按 publish_time 过滤
+ all_results = _search_topic_tables(topic, limit_per_table, _SEARCH_CONFIGS)
+ filtered = [
+ r for r in all_results
+ if r.publish_time and start_dt <= r.publish_time < end_dt
+ ]
+ return _format_results(filtered, "search_topic_by_date", params_log)
+
+
+@tool(description="获取话题评论:搜索所有平台中与话题相关的公众评论数据,深度挖掘网民真实态度")
+def get_comments_for_topic(topic: str, limit: int = 500) -> str:
+ """
+ Args:
+ topic: 搜索话题关键词
+ limit: 评论总数量上限,默认 500
+ """
+ params_log = {"topic": topic, "limit": limit}
+ comment_configs = {
+ k: v for k, v in _SEARCH_CONFIGS.items() if k.endswith("_comment")
+ }
+ results = _search_topic_tables(topic, limit // max(len(comment_configs), 1) + 1, comment_configs)
+ return _format_results(results[:limit], "get_comments_for_topic", params_log)
+
+
+@tool(description="平台定向搜索:在指定的单个社交媒体平台上精确搜索话题(适合分析特定平台用户群体的观点)")
+def search_topic_on_platform(
+ platform: str,
+ topic: str,
+ start_date: Optional[str] = None,
+ end_date: Optional[str] = None,
+ limit: int = 20,
+) -> str:
+ """
+ Args:
+ platform: 平台名称,必须是 bilibili / weibo / douyin / kuaishou / xhs / zhihu / tieba 之一
+ topic: 搜索话题关键词
+ start_date: 可选开始日期,格式 YYYY-MM-DD
+ end_date: 可选结束日期,格式 YYYY-MM-DD
+ limit: 返回结果数量上限,默认 20
+ """
+ params_log = {"platform": platform, "topic": topic, "start_date": start_date, "end_date": end_date}
+
+ platform_table_map = {
+ "bilibili": ["bilibili_video", "bilibili_video_comment"],
+ "douyin": ["douyin_aweme", "douyin_aweme_comment"],
+ "kuaishou": ["kuaishou_video", "kuaishou_video_comment"],
+ "weibo": ["weibo_note", "weibo_note_comment"],
+ "xhs": ["xhs_note", "xhs_note_comment"],
+ "zhihu": ["zhihu_content", "zhihu_comment"],
+ "tieba": ["tieba_note", "tieba_comment"],
+ }
+
+ if platform not in platform_table_map:
+ return f"不支持的平台: {platform}。支持: {list(platform_table_map.keys())}"
+
+ tables = platform_table_map[platform]
+ configs = {t: _SEARCH_CONFIGS[t] for t in tables if t in _SEARCH_CONFIGS}
+ results = _search_topic_tables(topic, limit, configs)
+
+ # 时间过滤
+ if start_date and end_date:
+ try:
+ start_dt = datetime.strptime(start_date, "%Y-%m-%d")
+ end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
+ results = [r for r in results if r.publish_time and start_dt <= r.publish_time < end_dt]
+ except ValueError:
+ pass
+
+ return _format_results(results[:limit], "search_topic_on_platform", params_log)
+
+
+@tool(description="多语言情感分析:对文本列表进行情感倾向分析,输出5级情感分类(非常负面/负面/中性/正面/非常正面),支持22种语言")
+def analyze_sentiment(texts: List[str]) -> str:
+ """
+ Args:
+ texts: 待分析的文本列表
+ """
+ try:
+ from .sentiment_tools import analyze_texts
+ return analyze_texts(texts)
+ except ImportError:
+ return "情感分析工具未实现:请确保 agno_tools/sentiment_tools.py 已就绪"
+ except Exception as e:
+ return f"情感分析失败: {e}"
+
+
+# ===== run_single_agent.py 用的 dispatch =====
+
+INSIGHT_TOOL_DISPATCH = {
+ "search_hot_content": lambda **kw: _format_results(
+ [], "search_hot_content", kw # 通过下面的 wrapper 实现
+ ),
+}
+
+
+def call_insight_tool(tool_name: str, **kwargs) -> str:
+ """根据工具名调度调用,供 run_single_agent.py 在多步流程中使用"""
+ # 直接调用 @tool 装饰的函数 (它们 .entrypoint 会暴露原始函数)
+ tool_funcs = {
+ "search_hot_content": search_hot_content,
+ "search_topic_globally": search_topic_globally,
+ "search_topic_by_date": search_topic_by_date,
+ "get_comments_for_topic": get_comments_for_topic,
+ "search_topic_on_platform": search_topic_on_platform,
+ "analyze_sentiment": analyze_sentiment,
+ }
+ if tool_name not in tool_funcs:
+ return f"未知工具: {tool_name}"
+ try:
+ fn = tool_funcs[tool_name]
+ # 取出原函数(agno @tool 装饰后会变成 Function 对象)
+ actual = getattr(fn, "entrypoint", None) or getattr(fn, "fn", None) or fn
+ return actual(**kwargs)
+ except Exception as e:
+ return f"工具 {tool_name} 调用失败: {e}"
diff --git a/experimental/agno_version/agno_tools/github_tools.py b/experimental/agno_version/agno_tools/github_tools.py
new file mode 100644
index 000000000..ebab9e8b9
--- /dev/null
+++ b/experimental/agno_version/agno_tools/github_tools.py
@@ -0,0 +1,133 @@
+# agno_tools/github_tools.py
+# GitHub 搜索工具,可选 GITHUB_TOKEN(无 token 60 req/h,有 token 5000 req/h)
+
+from typing import Optional
+import requests
+from agno.tools import tool
+
+from config import settings
+
+GITHUB_BASE = "https://api.github.com"
+
+
+def _get_headers() -> dict:
+ headers = {
+ "Accept": "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ }
+ token = getattr(settings, "GITHUB_TOKEN", None)
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ return headers
+
+
+def _do_github_search(endpoint: str, query: str, sort: str = "best-match", per_page: int = 10) -> dict:
+ try:
+ params = {"q": query, "per_page": per_page}
+ if sort != "best-match":
+ params["sort"] = sort
+ r = requests.get(f"{GITHUB_BASE}/search/{endpoint}", headers=_get_headers(), params=params, timeout=30)
+ r.raise_for_status()
+ return r.json()
+ except Exception as e:
+ return {"_error": str(e)}
+
+
+@tool(description="搜索 GitHub 上的开源仓库,返回 star 数、描述、语言等。适用于了解某技术工具的开源生态")
+def search_github_repos(query: str, max_results: int = 10) -> str:
+ """
+ Args:
+ query: 搜索关键词,可用 GitHub 搜索语法(如 "claude code language:python stars:>100")
+ max_results: 返回结果数
+ """
+ data = _do_github_search("repositories", query, sort="stars", per_page=max_results)
+ if "_error" in data:
+ return f"GitHub 仓库搜索失败: {data['_error']}"
+
+ items = data.get("items", [])
+ if not items:
+ return f"GitHub 未找到与「{query}」相关的仓库"
+
+ lines = [f"GitHub 仓库搜索结果(共 {len(items)} 个,按 star 排序):\n"]
+ for i, repo in enumerate(items, 1):
+ lines.append(f"{i}. ⭐ {repo.get('stargazers_count', 0)} | 🍴 {repo.get('forks_count', 0)} | {repo.get('full_name')}")
+ lines.append(f" 语言: {repo.get('language', 'N/A')} | 更新: {repo.get('updated_at', '')[:10]}")
+ if repo.get("description"):
+ lines.append(f" 描述: {repo['description'][:200]}")
+ lines.append(f" URL: {repo.get('html_url')}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+@tool(description="搜索 GitHub 上的 issues 和 discussions,能挖掘开发者对某工具/库的反馈、bug 报告和功能讨论")
+def search_github_issues(query: str, max_results: int = 10) -> str:
+ """
+ Args:
+ query: 搜索关键词,可用 GitHub 搜索语法(如 "claude code is:issue label:bug")
+ max_results: 返回结果数
+ """
+ data = _do_github_search("issues", query, sort="updated", per_page=max_results)
+ if "_error" in data:
+ return f"GitHub Issues 搜索失败: {data['_error']}"
+
+ items = data.get("items", [])
+ if not items:
+ return f"GitHub 未找到与「{query}」相关的 issue/discussion"
+
+ lines = [f"GitHub Issues/Discussions 搜索结果(共 {len(items)} 条):\n"]
+ for i, issue in enumerate(items, 1):
+ state = issue.get("state", "?")
+ emoji = "🟢" if state == "open" else "🔴"
+ repo_url = issue.get("repository_url", "").replace("https://api.github.com/repos/", "")
+ lines.append(f"{i}. {emoji} [{state}] {issue.get('title')}")
+ lines.append(f" 仓库: {repo_url} | 评论: {issue.get('comments', 0)} | 创建: {issue.get('created_at', '')[:10]}")
+ if issue.get("body"):
+ lines.append(f" 内容: {issue['body'][:300]}")
+ lines.append(f" URL: {issue.get('html_url')}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+@tool(description="搜索 GitHub 上的代码片段,能找到具体的实现示例和使用方式")
+def search_github_code(query: str, max_results: int = 5) -> str:
+ """
+ Args:
+ query: 搜索关键词
+ max_results: 返回结果数(代码搜索建议较少)
+ """
+ data = _do_github_search("code", query, per_page=max_results)
+ if "_error" in data:
+ return f"GitHub 代码搜索失败: {data['_error']}"
+
+ items = data.get("items", [])
+ if not items:
+ return f"GitHub 未找到与「{query}」相关的代码"
+
+ lines = [f"GitHub 代码搜索结果(共 {len(items)} 条):\n"]
+ for i, code in enumerate(items, 1):
+ lines.append(f"{i}. {code.get('name')} in {code.get('repository', {}).get('full_name')}")
+ lines.append(f" 路径: {code.get('path')}")
+ lines.append(f" URL: {code.get('html_url')}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+# ===== dispatch =====
+
+GITHUB_TOOL_DISPATCH = {
+ "search_github_repos": lambda **kw: search_github_repos.entrypoint(query=kw.get("query", ""), max_results=kw.get("max_results", 10))
+ if hasattr(search_github_repos, "entrypoint") else None,
+}
+
+
+def call_github_tool(tool_name: str, **kwargs) -> str:
+ funcs = {
+ "search_github_repos": search_github_repos,
+ "search_github_issues": search_github_issues,
+ "search_github_code": search_github_code,
+ }
+ if tool_name not in funcs:
+ return f"未知 GitHub 工具: {tool_name}"
+ fn = funcs[tool_name]
+ actual = getattr(fn, "entrypoint", None) or getattr(fn, "fn", None) or fn
+ return actual(**kwargs)
diff --git a/experimental/agno_version/agno_tools/hackernews_tools.py b/experimental/agno_version/agno_tools/hackernews_tools.py
new file mode 100644
index 000000000..085a147b4
--- /dev/null
+++ b/experimental/agno_version/agno_tools/hackernews_tools.py
@@ -0,0 +1,95 @@
+# agno_tools/hackernews_tools.py
+# Hacker News 搜索工具,无需任何认证
+# 使用 Algolia 的 HN Search API: https://hn.algolia.com/api
+
+from typing import Optional
+import requests
+from agno.tools import tool
+
+HN_SEARCH_URL = "https://hn.algolia.com/api/v1/search"
+HN_ITEM_URL = "https://hn.algolia.com/api/v1/items/{id}"
+
+
+def _format_story(story: dict, idx: int) -> str:
+ title = story.get("title") or story.get("story_title") or "(无标题)"
+ author = story.get("author", "anon")
+ points = story.get("points", 0)
+ num_comments = story.get("num_comments", 0)
+ url = story.get("url") or f"https://news.ycombinator.com/item?id={story.get('objectID')}"
+ created = story.get("created_at", "")[:10]
+ text_excerpt = (story.get("story_text") or story.get("comment_text") or "")[:300]
+
+ lines = [
+ f"{idx}. [{points}↑ {num_comments}💬] {title}",
+ f" 作者: {author} | 时间: {created}",
+ f" URL: {url}",
+ ]
+ if text_excerpt:
+ lines.append(f" 内容: {text_excerpt}")
+ return "\n".join(lines)
+
+
+def _do_hn_search(query: str, tags: str = "story", hits: int = 10, sort: str = "popular") -> str:
+ """
+ sort: 'popular' (按相关性) 或 'date' (按时间)
+ tags: 'story' (主帖) / 'comment' (评论) / 'story,comment'
+ """
+ endpoint = HN_SEARCH_URL if sort == "popular" else HN_SEARCH_URL + "_by_date"
+ try:
+ r = requests.get(endpoint, params={"query": query, "tags": tags, "hitsPerPage": hits}, timeout=30)
+ r.raise_for_status()
+ data = r.json()
+ except Exception as e:
+ return f"Hacker News 搜索失败: {e}"
+
+ hits_list = data.get("hits", [])
+ if not hits_list:
+ return f"Hacker News 未找到与「{query}」相关的内容"
+
+ lines = [f"Hacker News 搜索结果(共 {len(hits_list)} 条,按{'热度' if sort == 'popular' else '时间'}排序):\n"]
+ for i, story in enumerate(hits_list, 1):
+ lines.append(_format_story(story, i))
+ lines.append("")
+ return "\n".join(lines)
+
+
+@tool(description="搜索 Hacker News 上的相关帖子,返回标题、点数、评论数、链接。适用于获取国际程序员/科技圈对某主题的真实讨论")
+def search_hackernews(query: str, max_results: int = 10) -> str:
+ """
+ Args:
+ query: 搜索关键词(建议英文,HN 是英文社区)
+ max_results: 返回结果数,默认 10
+ """
+ return _do_hn_search(query, tags="story", hits=max_results, sort="popular")
+
+
+@tool(description="搜索 Hacker News 上的最新帖子(按时间排序),适用于追踪最新科技动态")
+def search_hackernews_recent(query: str, max_results: int = 10) -> str:
+ """
+ Args:
+ query: 搜索关键词
+ max_results: 返回结果数
+ """
+ return _do_hn_search(query, tags="story", hits=max_results, sort="date")
+
+
+@tool(description="搜索 Hacker News 上与话题相关的评论,能挖掘到深度讨论和技术细节")
+def search_hackernews_comments(query: str, max_results: int = 15) -> str:
+ """
+ Args:
+ query: 搜索关键词
+ max_results: 返回评论数
+ """
+ return _do_hn_search(query, tags="comment", hits=max_results, sort="popular")
+
+
+# ===== dispatch =====
+
+HN_TOOL_DISPATCH = {
+ "search_hackernews": lambda **kw: _do_hn_search(
+ kw.get("query", ""), tags="story", hits=kw.get("max_results", 10), sort="popular"),
+ "search_hackernews_recent": lambda **kw: _do_hn_search(
+ kw.get("query", ""), tags="story", hits=kw.get("max_results", 10), sort="date"),
+ "search_hackernews_comments": lambda **kw: _do_hn_search(
+ kw.get("query", ""), tags="comment", hits=kw.get("max_results", 15), sort="popular"),
+}
diff --git a/experimental/agno_version/agno_tools/keyword_tools.py b/experimental/agno_version/agno_tools/keyword_tools.py
new file mode 100644
index 000000000..e1c9b639b
--- /dev/null
+++ b/experimental/agno_version/agno_tools/keyword_tools.py
@@ -0,0 +1,18 @@
+# agno_tools/keyword_tools.py
+# TODO(A组): 迁移自 InsightEngine/tools/keyword_optimizer.py
+# 注意:此工具内部调用 LLM,但对外只是普通 tool 函数,不引入 agno Agent
+
+from agno.tools import tool
+from typing import List
+
+
+@tool(description="将用户查询优化扩展为多个数据库搜索关键词,提高搜索覆盖率")
+def optimize_keywords(query: str, num_keywords: int = 5) -> List[str]:
+ """
+ Args:
+ query: 用户原始查询,如 "特斯拉召回事件"
+ num_keywords: 需要生成的关键词数量,默认5个
+ Returns:
+ 优化后的关键词列表,如 ["特斯拉召回", "Model 3刹车", "特斯拉质量问题", ...]
+ """
+ raise NotImplementedError("TODO(A组): 迁移自 InsightEngine/tools/keyword_optimizer.py")
diff --git a/experimental/agno_version/agno_tools/media_search_tools.py b/experimental/agno_version/agno_tools/media_search_tools.py
new file mode 100644
index 000000000..18054914b
--- /dev/null
+++ b/experimental/agno_version/agno_tools/media_search_tools.py
@@ -0,0 +1,249 @@
+# agno_tools/media_search_tools.py
+# 迁移自 BettaFish/MediaEngine/tools/search.py
+# 5 个基于 Bocha API 的多模态搜索工具,供 MediaAgent 使用
+
+import json
+import requests
+from typing import List, Dict, Any, Optional
+from dataclasses import dataclass, field, asdict
+
+from agno.tools import tool
+from config import settings
+
+
+# ===== 数据结构 =====
+
+@dataclass
+class WebpageResult:
+ name: Optional[str] = None
+ url: Optional[str] = None
+ snippet: Optional[str] = None
+ display_url: Optional[str] = None
+ date_last_crawled: Optional[str] = None
+
+
+@dataclass
+class ImageResult:
+ name: Optional[str] = None
+ content_url: Optional[str] = None
+ host_page_url: Optional[str] = None
+ thumbnail_url: Optional[str] = None
+ width: Optional[int] = None
+ height: Optional[int] = None
+
+
+@dataclass
+class ModalCardResult:
+ """Bocha 特色:天气/股票/百科等结构化数据卡"""
+ card_type: str
+ content: Dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class BochaResponse:
+ query: str = ""
+ answer: Optional[str] = None
+ follow_ups: List[str] = field(default_factory=list)
+ webpages: List[WebpageResult] = field(default_factory=list)
+ images: List[ImageResult] = field(default_factory=list)
+ modal_cards: List[ModalCardResult] = field(default_factory=list)
+
+
+# ===== Bocha 客户端 =====
+
+_BOCHA_HEADERS = None
+
+
+def _get_headers():
+ """懒加载 Bocha 请求头"""
+ global _BOCHA_HEADERS
+ if _BOCHA_HEADERS is None:
+ api_key = settings.BOCHA_WEB_SEARCH_API_KEY
+ if not api_key:
+ raise ValueError("BOCHA_WEB_SEARCH_API_KEY 未在 .env 中配置")
+ _BOCHA_HEADERS = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ "Accept": "*/*",
+ }
+ return _BOCHA_HEADERS
+
+
+def _parse_bocha_response(response_dict: Dict[str, Any], query: str) -> BochaResponse:
+ """解析 Bocha API 返回结构"""
+ result = BochaResponse(query=query)
+ messages = response_dict.get("messages", [])
+ for msg in messages:
+ if msg.get("role") != "assistant":
+ continue
+
+ msg_type = msg.get("type")
+ content_type = msg.get("content_type")
+ content_str = msg.get("content", "{}")
+
+ try:
+ content_data = json.loads(content_str)
+ except json.JSONDecodeError:
+ content_data = content_str
+
+ if msg_type == "answer" and content_type == "text":
+ result.answer = content_data
+ elif msg_type == "follow_up" and content_type == "text":
+ result.follow_ups.append(content_data)
+ elif msg_type == "source":
+ if content_type == "webpage":
+ for item in content_data.get("value", []):
+ result.webpages.append(WebpageResult(
+ name=item.get("name"),
+ url=item.get("url"),
+ snippet=item.get("snippet"),
+ display_url=item.get("displayUrl"),
+ date_last_crawled=item.get("dateLastCrawled"),
+ ))
+ elif content_type == "image":
+ result.images.append(ImageResult(
+ name=content_data.get("name"),
+ content_url=content_data.get("contentUrl"),
+ host_page_url=content_data.get("hostPageUrl"),
+ thumbnail_url=content_data.get("thumbnailUrl"),
+ width=content_data.get("width"),
+ height=content_data.get("height"),
+ ))
+ else:
+ # 其他都视为模态卡
+ result.modal_cards.append(ModalCardResult(
+ card_type=content_type or "unknown",
+ content=content_data if isinstance(content_data, dict) else {"raw": content_data},
+ ))
+ return result
+
+
+def _do_bocha_search(**kwargs) -> BochaResponse:
+ """统一的 Bocha 搜索执行器"""
+ query = kwargs.get("query", "Unknown")
+ payload = {"stream": False}
+ payload.update(kwargs)
+ try:
+ base_url = settings.BOCHA_BASE_URL or "https://api.bocha.cn/v1/ai-search"
+ resp = requests.post(base_url, headers=_get_headers(), json=payload, timeout=60)
+ resp.raise_for_status()
+ data = resp.json()
+ if data.get("code") != 200:
+ return BochaResponse(query=query, answer=f"Bocha API 错误: {data.get('msg', '未知')}")
+ return _parse_bocha_response(data, query)
+ except Exception as e:
+ return BochaResponse(query=query, answer=f"搜索失败: {e}")
+
+
+def _format_bocha(resp: BochaResponse) -> str:
+ """将 BochaResponse 格式化为 LLM 可消费的字符串"""
+ lines = [f"搜索查询: {resp.query}"]
+ if resp.answer:
+ lines.append(f"\nAI 摘要: {resp.answer}")
+ if resp.modal_cards:
+ lines.append(f"\n结构化数据卡({len(resp.modal_cards)} 个):")
+ for i, card in enumerate(resp.modal_cards, 1):
+ lines.append(f"{i}. [{card.card_type}] {json.dumps(card.content, ensure_ascii=False)[:300]}")
+ if resp.webpages:
+ lines.append(f"\n网页结果({len(resp.webpages)} 条):")
+ for i, w in enumerate(resp.webpages, 1):
+ date = f" [{w.date_last_crawled}]" if w.date_last_crawled else ""
+ lines.append(f"\n{i}. {w.name}{date}")
+ lines.append(f" URL: {w.url}")
+ if w.snippet:
+ lines.append(f" 摘要: {w.snippet[:500]}")
+ if resp.images:
+ lines.append(f"\n相关图片({len(resp.images)} 张):")
+ for i, img in enumerate(resp.images, 1):
+ lines.append(f"{i}. {img.name or '无标题'} - {img.content_url}")
+ if resp.follow_ups:
+ # follow_ups 可能是 str 或 list,统一展平为 str
+ flat = []
+ for fu in resp.follow_ups:
+ if isinstance(fu, list):
+ flat.extend(str(x) for x in fu)
+ else:
+ flat.append(str(fu))
+ if flat:
+ lines.append(f"\n追问建议: {' | '.join(flat)}")
+ return "\n".join(lines)
+
+
+# ===== 5 个 Agent 工具 =====
+
+@tool(description="全面综合搜索:标准的多模态搜索,返回网页、图片、AI总结、追问建议和可能的模态卡")
+def comprehensive_search(query: str, max_results: int = 10) -> str:
+ """
+ Args:
+ query: 搜索查询词
+ max_results: 最大结果数,默认 10
+ """
+ resp = _do_bocha_search(query=query, count=max_results, answer=True)
+ return _format_bocha(resp)
+
+
+@tool(description="纯网页搜索:只获取网页链接和摘要,不请求 AI 总结,速度更快成本更低")
+def web_search_only(query: str, max_results: int = 15) -> str:
+ """
+ Args:
+ query: 搜索查询词
+ max_results: 最大结果数,默认 15
+ """
+ resp = _do_bocha_search(query=query, count=max_results, answer=False)
+ return _format_bocha(resp)
+
+
+@tool(description="结构化数据查询:专门用于查询天气/股票/汇率/百科/医疗等可触发模态卡的结构化信息")
+def search_for_structured_data(query: str) -> str:
+ """
+ Args:
+ query: 搜索查询词,应针对结构化数据,如「北京天气」「茅台股价」「阿司匹林副作用」
+ """
+ resp = _do_bocha_search(query=query, count=5, answer=True)
+ return _format_bocha(resp)
+
+
+@tool(description="搜索过去24小时内发布的最新信息,适用于追踪突发事件")
+def search_last_24_hours(query: str) -> str:
+ """
+ Args:
+ query: 搜索查询词
+ """
+ resp = _do_bocha_search(query=query, freshness="oneDay", answer=True)
+ return _format_bocha(resp)
+
+
+@tool(description="搜索过去一周内发布的主要报道,适用于近期趋势分析")
+def search_last_week(query: str) -> str:
+ """
+ Args:
+ query: 搜索查询词
+ """
+ resp = _do_bocha_search(query=query, freshness="oneWeek", answer=True)
+ return _format_bocha(resp)
+
+
+# ===== 给 run_single_agent.py 用的 dispatch =====
+
+MEDIA_TOOL_DISPATCH = {
+ "comprehensive_search": lambda **kw: _format_bocha(_do_bocha_search(
+ query=kw["query"], count=kw.get("max_results", 10), answer=True)),
+ "web_search_only": lambda **kw: _format_bocha(_do_bocha_search(
+ query=kw["query"], count=kw.get("max_results", 15), answer=False)),
+ "search_for_structured_data": lambda **kw: _format_bocha(_do_bocha_search(
+ query=kw["query"], count=5, answer=True)),
+ "search_last_24_hours": lambda **kw: _format_bocha(_do_bocha_search(
+ query=kw["query"], freshness="oneDay", answer=True)),
+ "search_last_week": lambda **kw: _format_bocha(_do_bocha_search(
+ query=kw["query"], freshness="oneWeek", answer=True)),
+}
+
+
+def call_media_tool(tool_name: str, **kwargs) -> str:
+ """根据工具名调度调用,供 run_single_agent.py 在多步流程中使用"""
+ if tool_name not in MEDIA_TOOL_DISPATCH:
+ return f"未知工具: {tool_name}"
+ try:
+ return MEDIA_TOOL_DISPATCH[tool_name](**kwargs)
+ except Exception as e:
+ return f"工具 {tool_name} 调用失败: {e}"
diff --git a/experimental/agno_version/agno_tools/news_search_tools.py b/experimental/agno_version/agno_tools/news_search_tools.py
new file mode 100644
index 000000000..a6b847f35
--- /dev/null
+++ b/experimental/agno_version/agno_tools/news_search_tools.py
@@ -0,0 +1,201 @@
+# agno_tools/news_search_tools.py
+# 迁移自 BettaFish/QueryEngine/tools/search.py
+# 6 个基于 Tavily API 的新闻搜索工具,供 QueryAgent 使用
+
+from typing import List, Dict, Any, Optional
+from dataclasses import dataclass, field, asdict
+
+from agno.tools import tool
+from tavily import TavilyClient
+
+from config import settings
+
+
+@dataclass
+class SearchResult:
+ title: Optional[str] = None
+ url: Optional[str] = None
+ content: Optional[str] = None
+ score: Optional[float] = None
+ published_date: Optional[str] = None
+
+
+@dataclass
+class ImageResult:
+ url: Optional[str] = None
+ description: Optional[str] = None
+
+
+@dataclass
+class TavilyResponse:
+ query: Optional[str] = None
+ answer: Optional[str] = None
+ results: List[SearchResult] = field(default_factory=list)
+ images: List[ImageResult] = field(default_factory=list)
+ response_time: Optional[float] = None
+
+
+_client: Optional[TavilyClient] = None
+
+
+def _get_client() -> TavilyClient:
+ """懒加载全局 Tavily 客户端"""
+ global _client
+ if _client is None:
+ api_key = settings.TAVILY_API_KEY
+ if not api_key:
+ raise ValueError("TAVILY_API_KEY 未在 .env 中配置")
+ _client = TavilyClient(api_key=api_key)
+ return _client
+
+
+def _do_search(**kwargs) -> TavilyResponse:
+ """内部统一执行函数"""
+ try:
+ client = _get_client()
+ kwargs.setdefault("topic", "general")
+ api_params = {k: v for k, v in kwargs.items() if v is not None}
+ resp = client.search(**api_params)
+
+ return TavilyResponse(
+ query=resp.get("query"),
+ answer=resp.get("answer"),
+ results=[
+ SearchResult(
+ title=item.get("title"),
+ url=item.get("url"),
+ content=item.get("content"),
+ score=item.get("score"),
+ published_date=item.get("published_date"),
+ )
+ for item in resp.get("results", [])
+ ],
+ images=[
+ ImageResult(url=item.get("url"), description=item.get("description"))
+ for item in resp.get("images", [])
+ ],
+ response_time=resp.get("response_time"),
+ )
+ except Exception as e:
+ return TavilyResponse(query=kwargs.get("query"), answer=f"搜索失败: {e}")
+
+
+def _format_response(resp: TavilyResponse) -> str:
+ """将 TavilyResponse 格式化为 LLM 可消费的字符串"""
+ lines = [f"搜索查询: {resp.query}"]
+ if resp.answer:
+ lines.append(f"\nAI 摘要: {resp.answer}")
+ if resp.results:
+ lines.append(f"\n搜索结果({len(resp.results)} 条):")
+ for i, r in enumerate(resp.results, 1):
+ date = f" [{r.published_date}]" if r.published_date else ""
+ lines.append(f"\n{i}. {r.title}{date}")
+ lines.append(f" URL: {r.url}")
+ if r.content:
+ lines.append(f" 内容: {r.content[:500]}")
+ if resp.images:
+ lines.append(f"\n相关图片({len(resp.images)} 张):")
+ for i, img in enumerate(resp.images, 1):
+ desc = f" - {img.description}" if img.description else ""
+ lines.append(f"{i}. {img.url}{desc}")
+ return "\n".join(lines)
+
+
+# ===== 6 个 Agent 工具 =====
+
+@tool(description="基础新闻搜索:标准、快速的通用新闻搜索,适用于不确定需要何种特定搜索时")
+def basic_search_news(query: str, max_results: int = 7) -> str:
+ """
+ Args:
+ query: 搜索查询词
+ max_results: 最大结果数,默认 7
+ """
+ resp = _do_search(query=query, max_results=max_results, search_depth="basic", include_answer=False)
+ return _format_response(resp)
+
+
+@tool(description="深度新闻分析:对一个主题进行最全面、最深入的搜索,返回 AI 高级摘要和最多20条最相关的新闻")
+def deep_search_news(query: str) -> str:
+ """
+ Args:
+ query: 搜索查询词
+ """
+ resp = _do_search(query=query, search_depth="advanced", max_results=20, include_answer="advanced")
+ return _format_response(resp)
+
+
+@tool(description="搜索过去24小时内发布的最新新闻,适用于追踪突发事件或最新进展")
+def search_news_last_24_hours(query: str) -> str:
+ """
+ Args:
+ query: 搜索查询词
+ """
+ resp = _do_search(query=query, time_range="d", max_results=10)
+ return _format_response(resp)
+
+
+@tool(description="搜索过去一周内发布的新闻报道,适用于周度舆情总结或回顾")
+def search_news_last_week(query: str) -> str:
+ """
+ Args:
+ query: 搜索查询词
+ """
+ resp = _do_search(query=query, time_range="w", max_results=10)
+ return _format_response(resp)
+
+
+@tool(description="查找与新闻主题相关的图片,返回图片链接及描述,适用于为报告配图")
+def search_images_for_news(query: str) -> str:
+ """
+ Args:
+ query: 搜索查询词
+ """
+ resp = _do_search(
+ query=query,
+ include_images=True,
+ include_image_descriptions=True,
+ max_results=5,
+ )
+ return _format_response(resp)
+
+
+@tool(description="按指定日期范围搜索新闻,适用于对特定历史时段的事件进行分析")
+def search_news_by_date(query: str, start_date: str, end_date: str) -> str:
+ """
+ Args:
+ query: 搜索查询词
+ start_date: 开始日期,格式 YYYY-MM-DD
+ end_date: 结束日期,格式 YYYY-MM-DD
+ """
+ resp = _do_search(query=query, start_date=start_date, end_date=end_date, max_results=15)
+ return _format_response(resp)
+
+
+# ===== 给 run_single_agent.py 用的纯函数版本(不带 @tool 装饰器)=====
+# 因为 @tool 装饰后不能直接当普通函数调用,这里提供一个 dispatch 字典
+
+NEWS_TOOL_DISPATCH = {
+ "basic_search_news": lambda **kw: _format_response(_do_search(
+ query=kw["query"], max_results=kw.get("max_results", 7),
+ search_depth="basic", include_answer=False)),
+ "deep_search_news": lambda **kw: _format_response(_do_search(
+ query=kw["query"], search_depth="advanced", max_results=20, include_answer="advanced")),
+ "search_news_last_24_hours": lambda **kw: _format_response(_do_search(
+ query=kw["query"], time_range="d", max_results=10)),
+ "search_news_last_week": lambda **kw: _format_response(_do_search(
+ query=kw["query"], time_range="w", max_results=10)),
+ "search_images_for_news": lambda **kw: _format_response(_do_search(
+ query=kw["query"], include_images=True, include_image_descriptions=True, max_results=5)),
+ "search_news_by_date": lambda **kw: _format_response(_do_search(
+ query=kw["query"], start_date=kw["start_date"], end_date=kw["end_date"], max_results=15)),
+}
+
+
+def call_news_tool(tool_name: str, **kwargs) -> str:
+ """根据工具名调度调用,供 run_single_agent.py 在多步流程中使用"""
+ if tool_name not in NEWS_TOOL_DISPATCH:
+ return f"未知工具: {tool_name}"
+ try:
+ return NEWS_TOOL_DISPATCH[tool_name](**kwargs)
+ except Exception as e:
+ return f"工具 {tool_name} 调用失败: {e}"
diff --git a/experimental/agno_version/agno_tools/reddit_tools.py b/experimental/agno_version/agno_tools/reddit_tools.py
new file mode 100644
index 000000000..b9f25736f
--- /dev/null
+++ b/experimental/agno_version/agno_tools/reddit_tools.py
@@ -0,0 +1,219 @@
+# agno_tools/reddit_tools.py
+# Reddit 搜索工具
+# 申请 OAuth: https://www.reddit.com/prefs/apps
+# 创建 "script" 类型应用,拿到 client_id (App ID) + client_secret
+
+import time
+from typing import Optional
+import requests
+from requests.auth import HTTPBasicAuth
+from agno.tools import tool
+
+from config import settings
+
+REDDIT_BASE = "https://oauth.reddit.com"
+REDDIT_AUTH_URL = "https://www.reddit.com/api/v1/access_token"
+
+# Token 缓存
+_token_cache = {"token": None, "expires_at": 0}
+
+
+def _get_token() -> Optional[str]:
+ """获取 OAuth access token,自动缓存"""
+ now = time.time()
+ if _token_cache["token"] and now < _token_cache["expires_at"]:
+ return _token_cache["token"]
+
+ client_id = getattr(settings, "REDDIT_CLIENT_ID", None)
+ client_secret = getattr(settings, "REDDIT_CLIENT_SECRET", None)
+ user_agent = getattr(settings, "REDDIT_USER_AGENT", "agno-mirofish/0.1")
+
+ if not client_id or not client_secret:
+ return None
+
+ try:
+ r = requests.post(
+ REDDIT_AUTH_URL,
+ auth=HTTPBasicAuth(client_id, client_secret),
+ data={"grant_type": "client_credentials"},
+ headers={"User-Agent": user_agent},
+ timeout=30,
+ )
+ r.raise_for_status()
+ data = r.json()
+ _token_cache["token"] = data["access_token"]
+ _token_cache["expires_at"] = now + data.get("expires_in", 3600) - 60
+ return _token_cache["token"]
+ except Exception as e:
+ print(f"⚠️ Reddit OAuth 失败: {e}")
+ return None
+
+
+def _get_headers() -> Optional[dict]:
+ token = _get_token()
+ if not token:
+ return None
+ user_agent = getattr(settings, "REDDIT_USER_AGENT", "agno-mirofish/0.1")
+ return {
+ "Authorization": f"bearer {token}",
+ "User-Agent": user_agent,
+ }
+
+
+def _format_post(post: dict, idx: int) -> str:
+ data = post.get("data", post)
+ title = data.get("title", "(无标题)")
+ subreddit = data.get("subreddit", "")
+ author = data.get("author", "[deleted]")
+ score = data.get("score", 0)
+ num_comments = data.get("num_comments", 0)
+ created = data.get("created_utc", 0)
+ url = "https://reddit.com" + data.get("permalink", "")
+ selftext = (data.get("selftext", "") or "")[:400]
+
+ from datetime import datetime
+ date_str = datetime.fromtimestamp(created).strftime("%Y-%m-%d") if created else ""
+
+ lines = [
+ f"{idx}. [{score}↑ {num_comments}💬] r/{subreddit} - {title}",
+ f" 作者: u/{author} | 时间: {date_str}",
+ f" URL: {url}",
+ ]
+ if selftext:
+ lines.append(f" 内容: {selftext}")
+ return "\n".join(lines)
+
+
+@tool(description="搜索 Reddit 上的相关帖子,返回 score、评论数、subreddit 等。适用于获取国际社区对某主题的真实讨论")
+def search_reddit(query: str, subreddit: Optional[str] = None, max_results: int = 10, sort: str = "relevance") -> str:
+ """
+ Args:
+ query: 搜索关键词
+ subreddit: 限定 subreddit(如 "programming"),不填则全站搜
+ max_results: 返回结果数
+ sort: 'relevance' / 'hot' / 'top' / 'new' / 'comments'
+ """
+ headers = _get_headers()
+ if not headers:
+ return "Reddit 工具未配置:请在 .env 中设置 REDDIT_CLIENT_ID 和 REDDIT_CLIENT_SECRET"
+
+ try:
+ if subreddit:
+ url = f"{REDDIT_BASE}/r/{subreddit}/search"
+ params = {"q": query, "limit": max_results, "sort": sort, "restrict_sr": "true"}
+ else:
+ url = f"{REDDIT_BASE}/search"
+ params = {"q": query, "limit": max_results, "sort": sort}
+
+ r = requests.get(url, headers=headers, params=params, timeout=30)
+ r.raise_for_status()
+ data = r.json()
+
+ children = data.get("data", {}).get("children", [])
+ if not children:
+ return f"Reddit 未找到与「{query}」相关的帖子"
+
+ scope = f"r/{subreddit}" if subreddit else "全站"
+ lines = [f"Reddit 搜索结果({scope},共 {len(children)} 条,按 {sort}):\n"]
+ for i, post in enumerate(children, 1):
+ lines.append(_format_post(post, i))
+ lines.append("")
+ return "\n".join(lines)
+
+ except Exception as e:
+ return f"Reddit 搜索失败: {e}"
+
+
+@tool(description="获取指定 subreddit 的热门帖子,适用于追踪某领域最近的话题热点")
+def get_subreddit_hot(subreddit: str, max_results: int = 10, time_filter: str = "week") -> str:
+ """
+ Args:
+ subreddit: subreddit 名称(不带 r/ 前缀,如 "programming")
+ max_results: 返回结果数
+ time_filter: 'hour' / 'day' / 'week' / 'month' / 'year' / 'all'
+ """
+ headers = _get_headers()
+ if not headers:
+ return "Reddit 工具未配置:请在 .env 中设置 REDDIT_CLIENT_ID 和 REDDIT_CLIENT_SECRET"
+
+ try:
+ r = requests.get(
+ f"{REDDIT_BASE}/r/{subreddit}/top",
+ headers=headers,
+ params={"limit": max_results, "t": time_filter},
+ timeout=30,
+ )
+ r.raise_for_status()
+ data = r.json()
+
+ children = data.get("data", {}).get("children", [])
+ if not children:
+ return f"r/{subreddit} 在 {time_filter} 内无热门帖子"
+
+ lines = [f"r/{subreddit} 热门帖子({time_filter},共 {len(children)} 条):\n"]
+ for i, post in enumerate(children, 1):
+ lines.append(_format_post(post, i))
+ lines.append("")
+ return "\n".join(lines)
+
+ except Exception as e:
+ return f"获取 r/{subreddit} 热门帖子失败: {e}"
+
+
+@tool(description="获取 Reddit 帖子的热门评论,深度挖掘社区对某话题的真实观点")
+def get_reddit_post_comments(post_id: str, subreddit: str, max_results: int = 20) -> str:
+ """
+ Args:
+ post_id: Reddit 帖子 ID(URL 中 /comments/{id}/ 的部分)
+ subreddit: 帖子所在的 subreddit
+ max_results: 返回评论数
+ """
+ headers = _get_headers()
+ if not headers:
+ return "Reddit 工具未配置:请在 .env 中设置 REDDIT_CLIENT_ID 和 REDDIT_CLIENT_SECRET"
+
+ try:
+ r = requests.get(
+ f"{REDDIT_BASE}/r/{subreddit}/comments/{post_id}",
+ headers=headers,
+ params={"limit": max_results, "sort": "top"},
+ timeout=30,
+ )
+ r.raise_for_status()
+ data = r.json()
+
+ if not isinstance(data, list) or len(data) < 2:
+ return f"帖子 {post_id} 没有评论"
+
+ comments = data[1].get("data", {}).get("children", [])
+ if not comments:
+ return f"帖子 {post_id} 评论为空"
+
+ lines = [f"Reddit 帖子评论(共 {len(comments)} 条):\n"]
+ for i, c in enumerate(comments[:max_results], 1):
+ cd = c.get("data", {})
+ if cd.get("body") in ("[removed]", "[deleted]", None):
+ continue
+ author = cd.get("author", "[deleted]")
+ score = cd.get("score", 0)
+ body = cd.get("body", "")[:500]
+ lines.append(f"{i}. [{score}↑] u/{author}")
+ lines.append(f" {body}")
+ lines.append("")
+ return "\n".join(lines)
+
+ except Exception as e:
+ return f"获取评论失败: {e}"
+
+
+def call_reddit_tool(tool_name: str, **kwargs) -> str:
+ funcs = {
+ "search_reddit": search_reddit,
+ "get_subreddit_hot": get_subreddit_hot,
+ "get_reddit_post_comments": get_reddit_post_comments,
+ }
+ if tool_name not in funcs:
+ return f"未知 Reddit 工具: {tool_name}"
+ fn = funcs[tool_name]
+ actual = getattr(fn, "entrypoint", None) or getattr(fn, "fn", None) or fn
+ return actual(**kwargs)
diff --git a/experimental/agno_version/agno_tools/sentiment_tools.py b/experimental/agno_version/agno_tools/sentiment_tools.py
new file mode 100644
index 000000000..c47f0147e
--- /dev/null
+++ b/experimental/agno_version/agno_tools/sentiment_tools.py
@@ -0,0 +1,137 @@
+# agno_tools/sentiment_tools.py
+# 迁移自 BettaFish/InsightEngine/tools/sentiment_analyzer.py
+# 多语言情感分析(基于 tabularisai/multilingual-sentiment-analysis 模型)
+
+from __future__ import annotations
+from typing import List, Dict, Any, Optional
+
+# 懒加载状态
+_torch = None
+_tokenizer = None
+_model = None
+_device = None
+_initialized = False
+_disabled = False
+_disable_reason: Optional[str] = None
+
+SENTIMENT_LABELS = {
+ 0: "非常负面",
+ 1: "负面",
+ 2: "中性",
+ 3: "正面",
+ 4: "非常正面",
+}
+
+
+def _initialize() -> bool:
+ """懒加载模型,首次调用时下载并加载到设备"""
+ global _torch, _tokenizer, _model, _device, _initialized, _disabled, _disable_reason
+
+ if _initialized:
+ return True
+ if _disabled:
+ return False
+
+ try:
+ import torch
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification
+ _torch = torch
+ except ImportError as e:
+ _disabled = True
+ _disable_reason = f"依赖缺失: {e}(需要安装 torch 和 transformers)"
+ return False
+
+ try:
+ model_name = "tabularisai/multilingual-sentiment-analysis"
+ print(f"[sentiment] 正在加载模型 {model_name}...")
+ _tokenizer = AutoTokenizer.from_pretrained(model_name)
+ _model = AutoModelForSequenceClassification.from_pretrained(model_name)
+
+ # 选择最佳设备
+ if torch.cuda.is_available():
+ _device = torch.device("cuda")
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
+ _device = torch.device("mps")
+ else:
+ _device = torch.device("cpu")
+
+ _model.to(_device)
+ _model.eval()
+ _initialized = True
+ print(f"[sentiment] 模型加载完成,使用设备: {_device}")
+ return True
+ except Exception as e:
+ _disabled = True
+ _disable_reason = f"模型加载失败: {e}"
+ print(f"[sentiment] {_disable_reason}")
+ return False
+
+
+def _analyze_one(text: str) -> Dict[str, Any]:
+ """分析单条文本"""
+ if not _initialize():
+ return {"label": "未执行", "confidence": 0.0, "error": _disable_reason}
+
+ text = (text or "").strip()
+ if not text:
+ return {"text": "", "label": "空文本", "confidence": 0.0}
+
+ try:
+ inputs = _tokenizer(
+ text, max_length=512, padding=True, truncation=True, return_tensors="pt"
+ )
+ inputs = {k: v.to(_device) for k, v in inputs.items()}
+
+ with _torch.no_grad():
+ outputs = _model(**inputs)
+ probs = _torch.softmax(outputs.logits, dim=1)
+ pred = int(_torch.argmax(probs, dim=1).item())
+
+ confidence = float(probs[0][pred].item())
+ return {
+ "text": text[:100],
+ "label": SENTIMENT_LABELS[pred],
+ "confidence": round(confidence, 4),
+ }
+ except Exception as e:
+ return {"text": text[:100], "label": "分析失败", "confidence": 0.0, "error": str(e)}
+
+
+def analyze_texts(texts: List[str]) -> str:
+ """
+ 批量情感分析,返回格式化字符串供 LLM 消费
+ """
+ if not texts:
+ return "情感分析: 无输入文本"
+
+ if not _initialize():
+ return f"情感分析未执行: {_disable_reason}"
+
+ results = [_analyze_one(t) for t in texts]
+
+ # 统计分布
+ distribution = {}
+ confidence_sum = 0.0
+ success_count = 0
+ for r in results:
+ label = r.get("label", "未知")
+ if label in SENTIMENT_LABELS.values():
+ distribution[label] = distribution.get(label, 0) + 1
+ confidence_sum += r.get("confidence", 0.0)
+ success_count += 1
+
+ avg_conf = confidence_sum / success_count if success_count else 0.0
+
+ lines = [f"情感分析结果(共 {len(texts)} 条,成功 {success_count} 条)"]
+ lines.append(f"平均置信度: {avg_conf:.4f}")
+ lines.append("\n情感分布:")
+ for label, count in sorted(distribution.items(), key=lambda x: -x[1]):
+ pct = count / success_count * 100 if success_count else 0
+ lines.append(f" - {label}: {count} 条 ({pct:.1f}%)")
+
+ lines.append("\n各条结果(最多前30条):")
+ for i, r in enumerate(results[:30], 1):
+ text_preview = (r.get("text") or "")[:80]
+ lines.append(f"{i}. [{r.get('label')}] (置信度 {r.get('confidence', 0):.3f}) {text_preview}")
+
+ return "\n".join(lines)
diff --git a/experimental/agno_version/agno_tools/shared_utils.py b/experimental/agno_version/agno_tools/shared_utils.py
new file mode 100644
index 000000000..1674fd451
--- /dev/null
+++ b/experimental/agno_version/agno_tools/shared_utils.py
@@ -0,0 +1,19 @@
+# agno_tools/shared_utils.py
+# TODO(A组): 迁移自各 Engine 的 utils/ 目录
+# 公共工具函数,供所有 tool 和 agent 使用
+
+from typing import List, Dict, Any
+
+
+def format_search_results(results: List[Dict[str, Any]], max_length: int = 500) -> str:
+ """
+ 将搜索结果列表格式化为供 LLM 阅读的字符串。
+ 迁移自 InsightEngine/utils/text_processing.py 的 format_search_results_for_prompt
+
+ Args:
+ results: search_* tool 返回的数据列表
+ max_length: 每条结果内容的最大字符数
+ Returns:
+ 格式化后的多行字符串
+ """
+ raise NotImplementedError("TODO(A组): 迁移自 InsightEngine/utils/")
diff --git a/experimental/agno_version/agno_tools/youtube_tools.py b/experimental/agno_version/agno_tools/youtube_tools.py
new file mode 100644
index 000000000..3e01bf570
--- /dev/null
+++ b/experimental/agno_version/agno_tools/youtube_tools.py
@@ -0,0 +1,188 @@
+# agno_tools/youtube_tools.py
+# YouTube Data API v3 工具
+# 申请 API Key: https://console.cloud.google.com/apis/library/youtube.googleapis.com
+# 免费配额: 10000 units/天 (search 100 units, comments 1 unit)
+
+from typing import Optional
+import requests
+from agno.tools import tool
+
+from config import settings
+
+YT_BASE = "https://www.googleapis.com/youtube/v3"
+
+
+def _check_key() -> Optional[str]:
+ key = getattr(settings, "YOUTUBE_API_KEY", None)
+ if not key:
+ return None
+ return key
+
+
+def _format_video(item: dict, idx: int, with_stats: bool = False) -> str:
+ snippet = item.get("snippet", {})
+ title = snippet.get("title", "(无标题)")
+ channel = snippet.get("channelTitle", "")
+ published = snippet.get("publishedAt", "")[:10]
+ desc = snippet.get("description", "")[:300]
+ video_id = item.get("id", {}).get("videoId") if isinstance(item.get("id"), dict) else item.get("id")
+
+ lines = [f"{idx}. {title}"]
+ lines.append(f" 频道: {channel} | 发布: {published}")
+
+ if with_stats and "statistics" in item:
+ stats = item["statistics"]
+ lines.append(
+ f" 播放: {stats.get('viewCount', 'N/A')} | "
+ f"点赞: {stats.get('likeCount', 'N/A')} | "
+ f"评论: {stats.get('commentCount', 'N/A')}"
+ )
+
+ if desc:
+ lines.append(f" 描述: {desc}")
+ lines.append(f" URL: https://youtube.com/watch?v={video_id}")
+ return "\n".join(lines)
+
+
+@tool(description="搜索 YouTube 上的视频,返回标题、频道、发布时间、描述。适用于发现海外视频内容对某主题的讨论")
+def search_youtube_videos(query: str, max_results: int = 10) -> str:
+ """
+ Args:
+ query: 搜索关键词
+ max_results: 返回结果数,默认 10
+ """
+ api_key = _check_key()
+ if not api_key:
+ return "YouTube 工具未配置:请在 .env 中设置 YOUTUBE_API_KEY"
+
+ try:
+ # 第一步:search 拿 video IDs
+ search_resp = requests.get(
+ f"{YT_BASE}/search",
+ params={
+ "part": "snippet",
+ "q": query,
+ "type": "video",
+ "maxResults": max_results,
+ "key": api_key,
+ "relevanceLanguage": "en",
+ "order": "relevance",
+ },
+ timeout=30,
+ )
+ search_resp.raise_for_status()
+ search_data = search_resp.json()
+ items = search_data.get("items", [])
+
+ if not items:
+ return f"YouTube 未找到与「{query}」相关的视频"
+
+ # 第二步:批量获取统计数据
+ video_ids = [it["id"]["videoId"] for it in items if "videoId" in it.get("id", {})]
+ stats_resp = requests.get(
+ f"{YT_BASE}/videos",
+ params={
+ "part": "statistics,snippet",
+ "id": ",".join(video_ids),
+ "key": api_key,
+ },
+ timeout=30,
+ )
+ stats_resp.raise_for_status()
+ videos = stats_resp.json().get("items", [])
+
+ lines = [f"YouTube 搜索结果(共 {len(videos)} 个):\n"]
+ for i, video in enumerate(videos, 1):
+ lines.append(_format_video(video, i, with_stats=True))
+ lines.append("")
+ return "\n".join(lines)
+
+ except Exception as e:
+ return f"YouTube 搜索失败: {e}"
+
+
+@tool(description="获取 YouTube 视频的热门评论,能挖掘海外观众对视频内容的真实反馈和情感")
+def get_youtube_comments(video_id: str, max_results: int = 20) -> str:
+ """
+ Args:
+ video_id: YouTube 视频 ID(URL 中 v= 后面的部分)
+ max_results: 返回评论数,默认 20
+ """
+ api_key = _check_key()
+ if not api_key:
+ return "YouTube 工具未配置:请在 .env 中设置 YOUTUBE_API_KEY"
+
+ try:
+ r = requests.get(
+ f"{YT_BASE}/commentThreads",
+ params={
+ "part": "snippet",
+ "videoId": video_id,
+ "maxResults": max_results,
+ "order": "relevance",
+ "key": api_key,
+ "textFormat": "plainText",
+ },
+ timeout=30,
+ )
+ r.raise_for_status()
+ data = r.json()
+ items = data.get("items", [])
+
+ if not items:
+ return f"视频 {video_id} 没有评论或评论已禁用"
+
+ lines = [f"YouTube 评论(视频 {video_id},共 {len(items)} 条):\n"]
+ for i, item in enumerate(items, 1):
+ top = item["snippet"]["topLevelComment"]["snippet"]
+ author = top.get("authorDisplayName", "")
+ text = top.get("textDisplay", "")[:500]
+ likes = top.get("likeCount", 0)
+ published = top.get("publishedAt", "")[:10]
+ lines.append(f"{i}. [{likes}👍] {author} ({published})")
+ lines.append(f" {text}")
+ lines.append("")
+ return "\n".join(lines)
+
+ except Exception as e:
+ return f"YouTube 评论获取失败: {e}"
+
+
+@tool(description="搜索 YouTube 视频并直接拉取热门视频的评论汇总,一步到位获取多模态社区反馈")
+def search_youtube_with_comments(query: str, max_videos: int = 3, comments_per_video: int = 10) -> str:
+ """
+ Args:
+ query: 搜索关键词
+ max_videos: 取多少个视频(默认 3)
+ comments_per_video: 每个视频取多少条评论
+ """
+ videos_text = search_youtube_videos.entrypoint(query=query, max_results=max_videos) \
+ if hasattr(search_youtube_videos, "entrypoint") \
+ else search_youtube_videos(query=query, max_results=max_videos)
+
+ if "未找到" in videos_text or "失败" in videos_text or "未配置" in videos_text:
+ return videos_text
+
+ # 提取 video IDs
+ import re
+ video_ids = re.findall(r"watch\?v=([\w-]+)", videos_text)
+
+ parts = [videos_text, "\n===== 各视频评论 =====\n"]
+ for vid in video_ids[:max_videos]:
+ get_fn = get_youtube_comments.entrypoint if hasattr(get_youtube_comments, "entrypoint") else get_youtube_comments
+ parts.append(get_fn(video_id=vid, max_results=comments_per_video))
+ parts.append("")
+ return "\n".join(parts)
+
+
+def call_youtube_tool(tool_name: str, **kwargs) -> str:
+ funcs = {
+ "search_youtube_videos": search_youtube_videos,
+ "get_youtube_comments": get_youtube_comments,
+ "search_youtube_with_comments": search_youtube_with_comments,
+ }
+ if tool_name not in funcs:
+ return f"未知 YouTube 工具: {tool_name}"
+ fn = funcs[tool_name]
+ actual = getattr(fn, "entrypoint", None) or getattr(fn, "fn", None) or fn
+ return actual(**kwargs)
diff --git a/experimental/agno_version/frontend-api.md b/experimental/agno_version/frontend-api.md
new file mode 100644
index 000000000..40295ee12
--- /dev/null
+++ b/experimental/agno_version/frontend-api.md
@@ -0,0 +1,64 @@
+# BettaFish 前端 HTTP / 实时接口说明
+
+面向主 Web 界面(`templates/index.html`)及同源前端,基础地址为 Flask 服务根路径(默认 `http://:9458`)。Report Engine 蓝图挂载在 `url_prefix='/api/report'`。
+
+---
+
+## 主应用(`app.py`)
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/` | 主页面 |
+| GET | `/auto-dashboard` | 自动仪表盘页 |
+| GET | `/api/status` | 各子应用运行状态 |
+| GET | `/api/start/` | 启动指定应用 |
+| GET | `/api/stop/` | 停止指定应用 |
+| GET | `/api/output/` | 拉取控制台输出 |
+| GET | `/api/test_log/` | 测试日志推送 |
+| GET | `/api/forum/start` | 启动论坛引擎 |
+| GET | `/api/forum/stop` | 停止论坛引擎 |
+| GET | `/api/forum/log` | 论坛日志 |
+| POST | `/api/forum/log/history` | 论坛历史日志 |
+| POST | `/api/search` | 搜索(联动各引擎) |
+| GET | `/api/config` | 读取配置 |
+| POST | `/api/config` | 保存配置 |
+| GET | `/api/system/status` | 系统是否已启动等 |
+| POST | `/api/system/start` | 保存配置并启动整套系统 |
+| POST | `/api/system/shutdown` | 关闭系统 |
+| GET | `/api/graph/` | 指定报告图谱数据 |
+| GET | `/api/graph/latest` | 最新报告图谱 |
+| POST | `/api/graph/query` | 图谱查询(GraphRAG) |
+| GET | `/graph-viewer` | 图谱查看页(含 `/graph-viewer/`、`/graph-viewer/`) |
+
+---
+
+## Report Engine(前缀 `/api/report`,`ReportEngine/flask_interface.py`)
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/report/status` | 报告任务状态(支持心跳等查询参数) |
+| POST | `/api/report/generate` | 触发生成报告 |
+| GET | `/api/report/progress/` | 任务进度 |
+| GET | `/api/report/stream/` | SSE 流式日志/事件 |
+| GET | `/api/report/result/` | 任务结果 |
+| GET | `/api/report/result//json` | 结果 JSON |
+| GET | `/api/report/download/` | 下载结果 |
+| POST | `/api/report/cancel/` | 取消任务 |
+| GET | `/api/report/templates` | 模板列表 |
+| GET | `/api/report/log` | 报告引擎日志 |
+| POST | `/api/report/log/clear` | 清空日志 |
+| GET | `/api/report/export/md/` | 导出 Markdown |
+| GET | `/api/report/export/pdf/` | 导出 PDF(如 `?optimize=true`) |
+| POST | `/api/report/export/pdf-from-ir` | 从 IR 导出 PDF |
+
+---
+
+## Socket.IO(非 REST)
+
+与 Flask 同源(默认与页面同一 `host:port`)。连接后可向服务端发送 `request_status`,服务端会推送 `status_update`、`console_output` 等事件。详见 `app.py` 中 `@socketio.on` 定义。
+
+---
+
+## 子引擎 Streamlit(非 Flask 路由)
+
+主界面嵌入 iframe 时访问 `http://:8501` / `8502` / `8503`(Insight / Media / Query),由独立 Streamlit 进程提供,不属于上表 REST 接口。
diff --git a/experimental/agno_version/interfaces.md b/experimental/agno_version/interfaces.md
new file mode 100644
index 000000000..4d6dd0a91
--- /dev/null
+++ b/experimental/agno_version/interfaces.md
@@ -0,0 +1,85 @@
+# 接口契约文档
+
+> **重要**:此文档由总负责人维护,接口签名一旦确认不得单方面修改。
+> 如需变更,需三组对齐后更新此文档。
+
+---
+
+## A → B 接口(tool 函数签名)
+
+A 组必须严格按照此签名实现,B 组按此签名调用。
+
+```python
+# 数据库查询
+search_weibo(keyword: str, limit: int = 20) -> List[Dict[str, Any]]
+# 返回字段:content, author, publish_time, likes
+
+search_forum(keyword: str, platform: str = "all", limit: int = 20) -> List[Dict[str, Any]]
+# 返回字段:content, author, publish_time, replies
+
+search_news(keyword: str, limit: int = 20) -> List[Dict[str, Any]]
+# 返回字段:title, content, source, publish_time
+
+# 情感分析
+analyze_sentiment(texts: List[str]) -> List[Dict[str, Any]]
+# 返回字段:sentiment("positive"|"negative"|"neutral"), confidence(float), language(str)
+
+# 关键词优化
+optimize_keywords(query: str, num_keywords: int = 5) -> List[str]
+
+# 爬虫
+start_crawler(keyword: str, platforms: List[str], max_count: int = 100) -> Dict[str, Any]
+# 返回字段:task_id(str), status("started"), estimated_seconds(int)
+
+get_crawler_status(task_id: str) -> Dict[str, Any]
+# 返回字段:task_id, status("running"|"done"|"failed"), progress(0~100), collected(int)
+```
+
+---
+
+## B → C 接口(agent 运行函数签名)
+
+B 组必须严格按照此签名实现,C 组按此签名调用。
+
+```python
+# 工厂函数
+create_insight_agent(config: Settings = None) -> Agent
+create_media_agent(config: Settings = None) -> Agent
+create_query_agent(config: Settings = None) -> Agent
+
+# 运行函数
+run_insight_analysis(query: str, config: Settings = None) -> str # 返回 Markdown 报告
+run_media_analysis(query: str, config: Settings = None) -> str # 返回 Markdown 报告
+run_query(query: str, config: Settings = None) -> str # 返回 Markdown 结果
+```
+
+---
+
+## C 对外接口(Team 运行函数)
+
+```python
+create_opinion_team(config: Settings = None) -> Team
+run_opinion_analysis(topic: str, config: Settings = None) -> str # 返回报告路径或内容
+run_report_generation(insight_report: str, media_report: str, query_report: str, config: Settings = None) -> str
+```
+
+---
+
+## SocketIO 事件(前端不变)
+
+| 方向 | 事件名 | 数据格式 |
+|---|---|---|
+| 前端→后端 | `start_analysis` | `{"topic": str}` |
+| 后端→前端 | `analysis_started` | `{"topic": str}` |
+| 后端→前端 | `analysis_progress` | `{"agent": str, "content": str}` |
+| 后端→前端 | `forum_message` | `{"speaker": "host", "content": str}` |
+| 后端→前端 | `analysis_complete` | `{"message": str}` |
+| 后端→前端 | `error` | `{"message": str}` |
+
+---
+
+## 变更记录
+
+| 日期 | 变更内容 | 影响方 |
+|---|---|---|
+| 2026-04-02 | 初始版本 | 全体 |
diff --git a/experimental/agno_version/main.py b/experimental/agno_version/main.py
new file mode 100644
index 000000000..ffc610675
--- /dev/null
+++ b/experimental/agno_version/main.py
@@ -0,0 +1,74 @@
+# main.py
+# 新应用入口,替代原 app.py
+# TODO(C组): 将原 app.py 中的配置管理路由、健康检查等逻辑迁移至此
+# 引擎调度部分替换为 agno Team 调用
+
+import os
+os.environ['PYTHONIOENCODING'] = 'utf-8'
+os.environ['PYTHONUTF8'] = '1'
+os.environ['PYTHONUNBUFFERED'] = '1'
+
+from flask import Flask, render_template, request, jsonify
+from flask_socketio import SocketIO, emit
+from loguru import logger
+from pathlib import Path
+
+app = Flask(__name__)
+app.config['SECRET_KEY'] = 'agno-mirofish-secret'
+socketio = SocketIO(app, cors_allowed_origins="*")
+
+LOG_DIR = Path('logs')
+LOG_DIR.mkdir(exist_ok=True)
+
+
+@app.route('/')
+def index():
+ return render_template('index.html')
+
+
+@app.route('/health')
+def health():
+ return jsonify({"status": "ok"})
+
+
+@socketio.on('start_analysis')
+def handle_start_analysis(data):
+ """
+ 接收前端的分析请求,启动 agno Team 分析流程,流式推送进度。
+ TODO(C组): 替换下方 mock 为真实的 agno Team 调用
+ """
+ topic = data.get('topic', '').strip()
+ if not topic:
+ emit('error', {'message': '请输入分析主题'})
+ return
+
+ logger.info(f"收到分析请求: {topic}")
+ emit('analysis_started', {'topic': topic})
+
+ # TODO(C组): 替换为真实 agno Team 流式调用
+ # from agno_team.opinion_team import create_opinion_team
+ # team = create_opinion_team()
+ # for chunk in team.run(f"请对以下主题进行全面的舆情分析:{topic}", stream=True):
+ # socketio.emit('analysis_progress', {
+ # 'agent': getattr(chunk, 'agent_name', 'system'),
+ # 'content': chunk.content,
+ # })
+
+ # Mock(开发阶段占位)
+ emit('analysis_progress', {'agent': 'system', 'content': f'[Mock] 正在分析主题:{topic}'})
+ emit('analysis_complete', {'message': '分析完成(Mock)'})
+
+
+@socketio.on('forum_subscribe')
+def handle_forum_subscribe():
+ """
+ 前端订阅论坛主持人发言。
+ TODO(C组): 接入 ForumAgent 的实时发言推送
+ """
+ emit('forum_message', {'speaker': 'host', 'content': '[Mock] 主持人发言占位'})
+
+
+if __name__ == '__main__':
+ from config import settings
+ logger.info(f"启动服务: http://{settings.HOST}:{settings.PORT}")
+ socketio.run(app, host=settings.HOST, port=settings.PORT, debug=False)
diff --git a/experimental/agno_version/requirements.txt b/experimental/agno_version/requirements.txt
new file mode 100644
index 000000000..3d1674be7
--- /dev/null
+++ b/experimental/agno_version/requirements.txt
@@ -0,0 +1,73 @@
+# ========================================
+# agno-mirofish 依赖包
+# 在原 BettaFish requirements.txt 基础上新增 agno
+# ========================================
+
+# ===== agno 框架(新增)=====
+agno>=1.7.0
+
+# ===== 核心Web框架 =====
+flask==2.3.3
+flask-socketio==5.3.6
+streamlit==1.28.1
+python-socketio==5.8.0
+eventlet==0.33.3
+
+# ===== HTTP请求和异步 =====
+requests==2.31.0
+httpx==0.28.1
+aiofiles==23.2.1
+aiohttp>=3.8.0
+
+# ===== LLM接口 =====
+openai>=1.3.0
+
+# ===== 搜索API =====
+tavily-python>=0.3.0
+
+# ===== 数据处理 =====
+pandas>=2.0.0
+numpy>=1.24.0
+regex>=2023.8.8
+jieba==0.42.1
+
+# ===== 数据库 =====
+pymysql==1.1.0
+SQLAlchemy==2.0.35
+asyncpg==0.29.0
+psycopg[binary]>=3.1.0
+
+# ===== 爬虫相关 =====
+playwright==1.45.0
+beautifulsoup4>=4.12.0
+lxml>=4.9.0
+
+# ===== 可视化 =====
+plotly>=5.17.0
+matplotlib==3.9.0
+wordcloud==1.9.3
+
+# ===== PDF生成 =====
+weasyprint>=60.0
+
+# ===== 机器学习 =====
+torch>=2.0.0
+transformers>=4.30.0
+sentence-transformers>=2.2.2
+scikit-learn>=1.3.0
+
+# ===== 工具库 =====
+python-dotenv>=1.0.0
+tenacity==8.2.2
+loguru>=0.7.0
+pydantic==2.5.2
+pydantic-settings==2.2.1
+json-repair==0.53.0
+tqdm>=4.65.0
+
+# ===== 开发工具 =====
+pytest>=7.4.0
+
+# ===== Web服务器 =====
+fastapi==0.110.2
+uvicorn==0.29.0
diff --git a/experimental/agno_version/run_full_pipeline.py b/experimental/agno_version/run_full_pipeline.py
new file mode 100644
index 000000000..9e76e0b4a
--- /dev/null
+++ b/experimental/agno_version/run_full_pipeline.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+"""
+完整的舆情分析流程:三 Agent 并发 + ForumHost 引导
+
+用法:
+ python run_full_pipeline.py "Claude Code 在中文程序员社区的舆情分析"
+ python run_full_pipeline.py "Claude Code" --threshold 3
+
+工作机制:
+ 1. InsightAgent / MediaAgent / QueryAgent 三个 agent 通过 asyncio 并发执行
+ 2. 每个 agent 内部仍然是「规划 → 段落(搜索→总结→反思→深化)→ 最终格式化」流程
+ 3. 段落总结产出后立即写入共享 ForumState
+ 4. ForumState 累计 N 条 agent 发言后自动触发 ForumHost LLM 调用
+ 5. Host 发言写回 ForumState,下一段段落总结时被 agent 读取并塞入 prompt
+ 6. 形成「Agent 段落产出 → Host 引导 → Agent 下段调整方向」的真实反馈循环
+ 7. 三 agent 全部完成后,输出三份独立报告 + 完整论坛日志
+"""
+
+import sys
+import os
+from pathlib import Path
+from datetime import datetime
+import json
+import argparse
+
+# 清理代理(避免 agno/httpx 走 SOCKS 代理)
+for _k in ["http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"]:
+ os.environ.pop(_k, None)
+
+# 让脚本能找到项目模块
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+
+def main():
+ parser = argparse.ArgumentParser(description="舆情分析全流程")
+ parser.add_argument("query", help="分析主题")
+ parser.add_argument("--threshold", type=int, default=5, help="Host 触发阈值(默认 5 条 agent 发言)")
+ parser.add_argument("--output", type=str, default="reports/full_pipeline", help="输出目录")
+ parser.add_argument("--no-report", action="store_true", help="跳过 ReportAgent 综合报告生成")
+ args = parser.parse_args()
+
+ from agno_team import run_opinion_analysis
+
+ result = run_opinion_analysis(
+ query=args.query,
+ host_threshold=args.threshold,
+ )
+
+ # ===== 落盘 =====
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ safe_query = args.query[:30].replace(" ", "_").replace("/", "_")
+ out_dir = Path(args.output) / f"{safe_query}_{timestamp}"
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ # 三份 agent 报告
+ for agent_type, agent_result in result["agent_results"].items():
+ md_path = out_dir / f"{agent_type}_report.md"
+ md_path.write_text(agent_result["final_report"], encoding="utf-8")
+ print(f" 📄 {agent_type:8} → {md_path}")
+
+ # 完整论坛日志
+ forum_path = out_dir / "forum_log.txt"
+ forum_path.write_text(result["forum_log"], encoding="utf-8")
+ print(f" 📋 forum → {forum_path}")
+
+ # Host 发言单独保存
+ host_path = out_dir / "host_speeches.md"
+ host_md = "\n\n---\n\n".join(
+ f"## Host 发言 #{i + 1}\n\n{s}" for i, s in enumerate(result["host_speeches"])
+ )
+ host_path.write_text(host_md, encoding="utf-8")
+ print(f" 🎤 host → {host_path}")
+
+ # 结构化数据(供后续 ReportAgent 消费)
+ summary_path = out_dir / "summary.json"
+ summary_data = {
+ "query": result["query"],
+ "agent_results": {
+ k: {
+ "agent_name": v["agent_name"],
+ "paragraphs": v["paragraphs"],
+ "final_report_chars": len(v["final_report"]),
+ }
+ for k, v in result["agent_results"].items()
+ },
+ "host_speeches_count": len(result["host_speeches"]),
+ "forum_entries_count": len(result["forum_log"].split("\n")),
+ }
+ summary_path.write_text(json.dumps(summary_data, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(f" 📊 summary → {summary_path}")
+
+ # ===== 调用 ReportAgent 生成最终综合报告 =====
+ if not args.no_report and result["agent_results"]:
+ from agno_agents import run_report_generation
+
+ report = run_report_generation(
+ query=args.query,
+ agent_results=result["agent_results"],
+ forum_log=result["forum_log"],
+ host_speeches=result["host_speeches"],
+ )
+
+ final_md_path = out_dir / "final_report.md"
+ final_md_path.write_text(report["markdown"], encoding="utf-8")
+ print(f" 📕 final.md → {final_md_path}")
+
+ final_html_path = out_dir / "final_report.html"
+ final_html_path.write_text(report["html"], encoding="utf-8")
+ print(f" 🌐 final.html → {final_html_path}")
+
+ print(f"\n📕 综合报告标题:{report['title']}")
+ print(f" 章节数:{report['stats']['chapter_count']}")
+ print(f" Markdown:{report['stats']['markdown_chars']} 字符")
+
+ print(f"\n✅ 全部输出已保存至: {out_dir}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/experimental/agno_version/run_single_agent.py b/experimental/agno_version/run_single_agent.py
new file mode 100644
index 000000000..e45e495da
--- /dev/null
+++ b/experimental/agno_version/run_single_agent.py
@@ -0,0 +1,342 @@
+#!/usr/bin/env python3
+"""
+单独运行三个 Agent 中的任意一个,生成独立报告。
+还原原项目的多步 Node 流程:规划结构 → 逐段搜索+总结+反思 → 格式化报告。
+
+用法:
+ python run_single_agent.py insight "某品牌产品质量危机"
+ python run_single_agent.py media "人工智能发展趋势"
+ python run_single_agent.py query "某新闻事件深度分析"
+"""
+
+import sys
+import os
+from pathlib import Path
+from datetime import datetime
+import json as json_module
+
+
+def create_client(config_prefix: str):
+ """创建 OpenAI 客户端"""
+ from openai import OpenAI
+ from config import settings
+
+ api_key = getattr(settings, f"{config_prefix}_API_KEY")
+ base_url = getattr(settings, f"{config_prefix}_BASE_URL")
+ model_name = getattr(settings, f"{config_prefix}_MODEL_NAME")
+
+ client = OpenAI(api_key=api_key, base_url=base_url, timeout=600)
+ return client, model_name
+
+
+def _parse_json(text: str):
+ """容错解析 LLM 返回的 JSON(兼容 ```json 代码块、混杂文本等)"""
+ import re
+ text = text.strip()
+ # 去掉 markdown 代码块标记
+ text = re.sub(r'^```(?:json)?\s*', '', text)
+ text = re.sub(r'\s*```$', '', text)
+ try:
+ return json_module.loads(text)
+ except json_module.JSONDecodeError:
+ # 提取第一个 JSON 对象
+ match = re.search(r'\{[\s\S]*?\}', text)
+ if match:
+ try:
+ return json_module.loads(match.group())
+ except json_module.JSONDecodeError:
+ pass
+ return None
+
+
+def call_llm(client, model_name: str, system_prompt: str, user_content: str, stream_output: bool = False) -> str:
+ """调用 LLM,支持流式输出"""
+ stream = client.chat.completions.create(
+ model=model_name,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_content},
+ ],
+ temperature=0.7,
+ stream=True,
+ )
+
+ chunks = []
+ for chunk in stream:
+ delta = chunk.choices[0].delta.content
+ if delta:
+ chunks.append(delta)
+ if stream_output:
+ print(delta, end="", flush=True)
+ if stream_output:
+ print()
+ return "".join(chunks)
+
+
+def run_pipeline(agent_type: str, query: str):
+ """
+ 还原原项目的多步 Node 调用流程:
+ 1. ReportStructureNode: 规划报告结构 → JSON
+ 2. 对每个段落:
+ a. FirstSearchNode: 生成搜索决策 → JSON(当前无工具,跳过实际搜索)
+ b. FirstSummaryNode: 根据段落主题撰写初稿 → JSON
+ c. ReflectionNode: 反思并决定补充搜索 → JSON
+ d. ReflectionSummaryNode: 深化段落内容 → JSON
+ 3. ReportFormattingNode: 汇总所有段落,生成最终 Markdown 报告
+ """
+ agent_config = {
+ "insight": "INSIGHT_ENGINE",
+ "media": "MEDIA_ENGINE",
+ "query": "QUERY_ENGINE",
+ }
+ config_prefix = agent_config[agent_type]
+ client, model_name = create_client(config_prefix)
+
+ # 动态导入各阶段 prompt
+ if agent_type == "insight":
+ from agno_agents.insight_agent import (
+ SYSTEM_PROMPT_REPORT_STRUCTURE,
+ SYSTEM_PROMPT_FIRST_SEARCH,
+ SYSTEM_PROMPT_FIRST_SUMMARY,
+ SYSTEM_PROMPT_REFLECTION,
+ SYSTEM_PROMPT_REFLECTION_SUMMARY,
+ SYSTEM_PROMPT_REPORT_FORMATTING,
+ )
+ elif agent_type == "media":
+ from agno_agents.media_agent import (
+ SYSTEM_PROMPT_REPORT_STRUCTURE,
+ SYSTEM_PROMPT_FIRST_SEARCH,
+ SYSTEM_PROMPT_FIRST_SUMMARY,
+ SYSTEM_PROMPT_REFLECTION,
+ SYSTEM_PROMPT_REFLECTION_SUMMARY,
+ SYSTEM_PROMPT_REPORT_FORMATTING,
+ )
+ else:
+ from agno_agents.query_agent import (
+ SYSTEM_PROMPT_REPORT_STRUCTURE,
+ SYSTEM_PROMPT_FIRST_SEARCH,
+ SYSTEM_PROMPT_FIRST_SUMMARY,
+ SYSTEM_PROMPT_REFLECTION,
+ SYSTEM_PROMPT_REFLECTION_SUMMARY,
+ SYSTEM_PROMPT_REPORT_FORMATTING,
+ )
+
+ # ===== 阶段一:规划报告结构 =====
+ print("\n📋 阶段一:规划报告结构...")
+ structure_raw = call_llm(client, model_name, SYSTEM_PROMPT_REPORT_STRUCTURE, query)
+ try:
+ paragraphs = json_module.loads(structure_raw)
+ except json_module.JSONDecodeError:
+ # 尝试提取 JSON 部分
+ import re
+ match = re.search(r'\[[\s\S]*\]', structure_raw)
+ paragraphs = json_module.loads(match.group()) if match else []
+
+ print(f" 规划了 {len(paragraphs)} 个段落:")
+ for i, p in enumerate(paragraphs, 1):
+ print(f" {i}. {p['title']}")
+
+ # ===== 阶段二:逐段搜索+总结+反思 =====
+ paragraph_results = []
+ for idx, para in enumerate(paragraphs, 1):
+ title = para["title"]
+ content = para.get("content", "")
+ para_input = json_module.dumps({"title": title, "content": content}, ensure_ascii=False)
+
+ print(f"\n🔍 段落 {idx}/{len(paragraphs)}:{title}")
+
+ # 2a. 首次搜索决策
+ print(" → 搜索决策...", end="", flush=True)
+ search_decision_raw = call_llm(client, model_name, SYSTEM_PROMPT_FIRST_SEARCH, para_input)
+ print(" 完成")
+
+ # 解析搜索决策并真正调用工具(query=Tavily, media=Bocha, insight=本地DB)
+ search_results_text = f"(暂无真实搜索结果,请基于已有知识分析:{title})"
+ decision = _parse_json(search_decision_raw)
+ if decision and agent_type in ("query", "media", "insight"):
+ tool_name = decision.get("search_tool", "")
+ search_query = decision.get("search_query", title)
+ tool_kwargs = {"query": search_query}
+
+ if agent_type == "query":
+ from agno_tools import call_news_tool as _call_tool
+ if tool_name == "search_news_by_date":
+ tool_kwargs["start_date"] = decision.get("start_date", "")
+ tool_kwargs["end_date"] = decision.get("end_date", "")
+ if not tool_name:
+ tool_name = "basic_search_news"
+ elif agent_type == "media":
+ from agno_tools import call_media_tool as _call_tool
+ if not tool_name:
+ tool_name = "comprehensive_search"
+ else: # insight
+ from agno_tools import call_insight_tool as _call_tool
+ # InsightAgent 的工具用 topic 而不是 query
+ tool_kwargs = {"topic": search_query}
+ if tool_name == "search_topic_by_date":
+ tool_kwargs["start_date"] = decision.get("start_date", "")
+ tool_kwargs["end_date"] = decision.get("end_date", "")
+ elif tool_name == "search_topic_on_platform":
+ tool_kwargs["platform"] = decision.get("platform", "weibo")
+ if decision.get("start_date"):
+ tool_kwargs["start_date"] = decision.get("start_date")
+ tool_kwargs["end_date"] = decision.get("end_date", "")
+ elif tool_name == "search_hot_content":
+ tool_kwargs = {"time_period": decision.get("time_period", "week")}
+ elif tool_name == "analyze_sentiment":
+ tool_kwargs = {"texts": decision.get("texts") or [search_query]}
+ if not tool_name:
+ tool_name = "search_topic_globally"
+
+ print(f" → 调用工具 {tool_name}({list(tool_kwargs.values())[0]})...", end="", flush=True)
+ search_results_text = _call_tool(tool_name, **tool_kwargs)
+ print(f" 完成({len(search_results_text)} 字符)")
+
+ # 2b. 首次总结
+ summary_input = json_module.dumps({
+ "title": title,
+ "content": content,
+ "search_query": query,
+ "search_results": [search_results_text]
+ }, ensure_ascii=False)
+
+ print(" → 撰写初稿...", end="", flush=True)
+ summary_raw = call_llm(client, model_name, SYSTEM_PROMPT_FIRST_SUMMARY, summary_input)
+ print(" 完成")
+
+ try:
+ summary_data = json_module.loads(summary_raw)
+ paragraph_state = summary_data.get("paragraph_latest_state", summary_raw)
+ except json_module.JSONDecodeError:
+ paragraph_state = summary_raw
+
+ # 2c. 反思
+ reflection_input = json_module.dumps({
+ "title": title,
+ "content": content,
+ "paragraph_latest_state": paragraph_state
+ }, ensure_ascii=False)
+
+ print(" → 反思分析...", end="", flush=True)
+ reflection_raw = call_llm(client, model_name, SYSTEM_PROMPT_REFLECTION, reflection_input)
+ print(" 完成")
+
+ # 反思阶段也调用真实工具补充搜索
+ reflection_results_text = f"(补充搜索结果模拟:{title} 的深度数据)"
+ ref_decision = _parse_json(reflection_raw)
+ if ref_decision and agent_type in ("query", "media", "insight"):
+ tool_name = ref_decision.get("search_tool", "")
+ search_query = ref_decision.get("search_query", title)
+ tool_kwargs = {"query": search_query}
+
+ if agent_type == "query":
+ from agno_tools import call_news_tool as _call_tool
+ if tool_name == "search_news_by_date":
+ tool_kwargs["start_date"] = ref_decision.get("start_date", "")
+ tool_kwargs["end_date"] = ref_decision.get("end_date", "")
+ if not tool_name:
+ tool_name = "deep_search_news"
+ elif agent_type == "media":
+ from agno_tools import call_media_tool as _call_tool
+ if not tool_name:
+ tool_name = "comprehensive_search"
+ else: # insight
+ from agno_tools import call_insight_tool as _call_tool
+ tool_kwargs = {"topic": search_query}
+ if tool_name == "search_topic_by_date":
+ tool_kwargs["start_date"] = ref_decision.get("start_date", "")
+ tool_kwargs["end_date"] = ref_decision.get("end_date", "")
+ elif tool_name == "search_topic_on_platform":
+ tool_kwargs["platform"] = ref_decision.get("platform", "weibo")
+ elif tool_name == "search_hot_content":
+ tool_kwargs = {"time_period": ref_decision.get("time_period", "week")}
+ elif tool_name == "analyze_sentiment":
+ tool_kwargs = {"texts": ref_decision.get("texts") or [search_query]}
+ if not tool_name:
+ tool_name = "get_comments_for_topic"
+
+ print(f" → 反思补充搜索 {tool_name}({list(tool_kwargs.values())[0]})...", end="", flush=True)
+ reflection_results_text = _call_tool(tool_name, **tool_kwargs)
+ print(f" 完成({len(reflection_results_text)} 字符)")
+
+ # 2d. 反思总结(深化内容)
+ reflection_summary_input = json_module.dumps({
+ "title": title,
+ "content": content,
+ "search_query": query,
+ "search_results": [reflection_results_text],
+ "paragraph_latest_state": paragraph_state
+ }, ensure_ascii=False)
+
+ print(" → 深化内容...", end="", flush=True)
+ ref_summary_raw = call_llm(client, model_name, SYSTEM_PROMPT_REFLECTION_SUMMARY, reflection_summary_input)
+ print(" 完成")
+
+ try:
+ ref_data = json_module.loads(ref_summary_raw)
+ final_state = ref_data.get("updated_paragraph_latest_state", paragraph_state)
+ except json_module.JSONDecodeError:
+ final_state = paragraph_state
+
+ paragraph_results.append({"title": title, "paragraph_latest_state": final_state})
+
+ # ===== 阶段三:最终报告格式化 =====
+ print("\n📝 阶段三:生成最终报告...")
+ formatting_input = json_module.dumps(paragraph_results, ensure_ascii=False)
+ final_report = call_llm(client, model_name, SYSTEM_PROMPT_REPORT_FORMATTING, formatting_input, stream_output=True)
+
+ return paragraph_results, final_report
+
+
+def main():
+ if len(sys.argv) < 3:
+ print(__doc__)
+ sys.exit(1)
+
+ agent_type = sys.argv[1].lower()
+ query = sys.argv[2]
+
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+ valid_types = ["insight", "media", "query"]
+ if agent_type not in valid_types:
+ print(f"未知的 agent 类型: {agent_type}")
+ print(f"可选: {', '.join(valid_types)}")
+ sys.exit(1)
+
+ agent_names = {"insight": "InsightAgent", "media": "MediaAgent", "query": "QueryAgent"}
+ print(f"启动 {agent_names[agent_type]},分析主题:{query}")
+ print("=" * 60)
+
+ paragraph_results, final_report = run_pipeline(agent_type, query)
+
+ # 保存报告
+ output_dir = Path("reports") / f"{agent_type}_reports"
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ safe_query = query[:20].replace(" ", "_").replace("/", "_")
+
+ md_path = output_dir / f"{safe_query}_{timestamp}.md"
+ md_path.write_text(final_report, encoding="utf-8")
+ print(f"\n报告已保存: {md_path}")
+
+ # 保存结构化数据
+ from agno_agents.models import AnalysisResult, ParagraphResult
+ result = AnalysisResult(
+ query=query,
+ paragraphs=[ParagraphResult(**p) for p in paragraph_results],
+ final_report=final_report,
+ )
+ json_path = output_dir / f"{safe_query}_{timestamp}.json"
+ json_path.write_text(json_module.dumps(result.model_dump(), indent=2, ensure_ascii=False), encoding="utf-8")
+ print(f"结构化数据: {json_path}")
+
+ print(f"\n段落数: {len(paragraph_results)}")
+ for i, p in enumerate(paragraph_results, 1):
+ print(f" {i}. {p['title']}")
+ print(f"\n报告字数: {len(final_report)}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/experimental/agno_version/scripts/init_mock_db.py b/experimental/agno_version/scripts/init_mock_db.py
new file mode 100644
index 000000000..a1ad09ed8
--- /dev/null
+++ b/experimental/agno_version/scripts/init_mock_db.py
@@ -0,0 +1,385 @@
+#!/usr/bin/env python3
+"""
+初始化 SQLite mock 数据库,模拟 BettaFish 的 MediaCrawler 表结构。
+塞入约 100 条假的社交媒体内容(围绕 Claude Code/AI 编程主题),
+让 InsightAgent 能跑通端到端流程。
+
+用法:
+ python scripts/init_mock_db.py
+"""
+
+import sqlite3
+import sys
+import random
+from pathlib import Path
+from datetime import datetime, timedelta
+
+# 确保能找到项目根
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+DB_PATH = Path(__file__).resolve().parent.parent / "data" / "mock_yuqing.db"
+DB_PATH.parent.mkdir(exist_ok=True)
+
+# ===== 表结构定义 =====
+# 简化版,只保留 db_query_tools.py 用到的字段
+
+SCHEMAS = {
+ # 内容表
+ "weibo_note": """
+ CREATE TABLE weibo_note (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT,
+ source_keyword TEXT,
+ create_date_time TEXT,
+ nickname TEXT,
+ note_url TEXT,
+ liked_count INTEGER,
+ comments_count INTEGER,
+ shared_count INTEGER
+ )
+ """,
+ "xhs_note": """
+ CREATE TABLE xhs_note (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT,
+ "desc" TEXT,
+ tag_list TEXT,
+ source_keyword TEXT,
+ time INTEGER,
+ nickname TEXT,
+ note_url TEXT,
+ liked_count INTEGER,
+ comment_count INTEGER,
+ share_count INTEGER,
+ collected_count INTEGER
+ )
+ """,
+ "zhihu_content": """
+ CREATE TABLE zhihu_content (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT,
+ "desc" TEXT,
+ content_text TEXT,
+ source_keyword TEXT,
+ created_time TEXT,
+ user_nickname TEXT,
+ content_url TEXT,
+ voteup_count INTEGER,
+ comment_count INTEGER
+ )
+ """,
+ "bilibili_video": """
+ CREATE TABLE bilibili_video (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT,
+ "desc" TEXT,
+ source_keyword TEXT,
+ create_time INTEGER,
+ nickname TEXT,
+ video_url TEXT,
+ liked_count INTEGER,
+ video_comment INTEGER,
+ video_share_count INTEGER,
+ video_play_count INTEGER,
+ video_favorite_count INTEGER,
+ video_coin_count INTEGER,
+ video_danmaku INTEGER
+ )
+ """,
+ "douyin_aweme": """
+ CREATE TABLE douyin_aweme (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT,
+ "desc" TEXT,
+ source_keyword TEXT,
+ create_time INTEGER,
+ nickname TEXT,
+ aweme_url TEXT,
+ liked_count INTEGER,
+ comment_count INTEGER,
+ share_count INTEGER,
+ collected_count INTEGER
+ )
+ """,
+ "kuaishou_video": """
+ CREATE TABLE kuaishou_video (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT,
+ "desc" TEXT,
+ source_keyword TEXT,
+ create_time INTEGER,
+ nickname TEXT,
+ video_url TEXT,
+ liked_count INTEGER,
+ viewd_count INTEGER
+ )
+ """,
+ "tieba_note": """
+ CREATE TABLE tieba_note (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT,
+ "desc" TEXT,
+ source_keyword TEXT,
+ publish_time TEXT,
+ nickname TEXT,
+ url TEXT
+ )
+ """,
+
+ # 评论表
+ "weibo_note_comment": """
+ CREATE TABLE weibo_note_comment (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT,
+ user_nickname TEXT,
+ create_date_time TEXT,
+ comment_like_count INTEGER
+ )
+ """,
+ "xhs_note_comment": """
+ CREATE TABLE xhs_note_comment (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT,
+ user_nickname TEXT,
+ create_time INTEGER,
+ like_count INTEGER
+ )
+ """,
+ "zhihu_comment": """
+ CREATE TABLE zhihu_comment (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT,
+ user_nickname TEXT,
+ create_time INTEGER,
+ like_count INTEGER
+ )
+ """,
+ "bilibili_video_comment": """
+ CREATE TABLE bilibili_video_comment (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT,
+ user_nickname TEXT,
+ create_time INTEGER,
+ like_count INTEGER
+ )
+ """,
+ "douyin_aweme_comment": """
+ CREATE TABLE douyin_aweme_comment (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT,
+ user_nickname TEXT,
+ create_time INTEGER,
+ like_count INTEGER
+ )
+ """,
+ "kuaishou_video_comment": """
+ CREATE TABLE kuaishou_video_comment (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT,
+ user_nickname TEXT,
+ create_time INTEGER,
+ like_count INTEGER
+ )
+ """,
+ "tieba_comment": """
+ CREATE TABLE tieba_comment (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT,
+ nickname TEXT,
+ publish_time TEXT,
+ like_count INTEGER
+ )
+ """,
+}
+
+
+# ===== Mock 数据生成 =====
+
+# 围绕 Claude Code 主题的内容池
+WEIBO_POSTS = [
+ ("Claude Code 真的太香了!终于不用自己一个个调代码了,AI 直接给我写好测试用例", "weibo_user_001", 8521, 1234, 567),
+ ("用 Claude Code 重构了一个 5000 行的老项目,效率提升至少 10 倍。Anthropic 牛逼", "码农日记", 12453, 2341, 891),
+ ("Claude Code 还是比 Cursor 强一些,至少不会乱删我代码 😅 #AI编程#", "前端小张", 3421, 567, 123),
+ ("吐槽一下 Claude Code 的定价,订阅费有点贵,希望能出学生版", "穷学生程序猿", 567, 234, 45),
+ ("Claude Code 能直接跑命令行真的爽,比 GitHub Copilot 强多了", "后端老王", 9821, 1567, 432),
+ ("AI 编程工具大乱斗:Claude Code vs Cursor vs Copilot,谁更值得?详见正文", "AI测评师", 23456, 4321, 1567),
+]
+
+XHS_POSTS = [
+ ("Claude Code 入门指南|小白也能看懂", "Anthropic 出品的 AI 编程神器使用教程", "AI编程,Claude,程序员,效率工具", "美少女程序媛", 12345, 2341, 456, 5678),
+ ("Claude Code 一周使用感受💻", "从零开始用 Claude Code 完成了一个完整的 Web 项目", "AI工具,前端开发,Claude Code", "技术宅小美", 8765, 1234, 234, 3456),
+ ("我用 Claude Code 做了一个网站,全程没写一行代码🚀", "给所有不会编程的姐妹分享AI建站经验", "AI建站,无代码,Claude", "互联网创业者Lily", 23456, 4567, 890, 7890),
+]
+
+ZHIHU_QUESTIONS = [
+ ("如何看待 Anthropic 推出的 Claude Code?", "近期 Anthropic 推出了 Claude Code,能在终端直接运行的 AI 编程助手", "Claude Code 是 Anthropic 在 AI 编程赛道的关键产品。它的特点是可以直接在命令行中运行,深度集成 Git 和 Bash...", "AI研究员", 5678, 234),
+ ("Claude Code 和 Cursor 哪个更好用?", "都是热门的 AI 编程工具,想了解实际差异", "我两个都深度用过半年。Cursor 更适合 IDE 用户,UI 友好;Claude Code 更适合命令行用户和自动化场景...", "全栈工程师老李", 12345, 567),
+ ("Claude Code 能完全取代程序员吗?", "AI 编程已经这么强了,未来还需要写代码的人吗?", "不能。Claude Code 极大提升了生产力,但代码审查、架构设计、需求理解仍然需要人...", "技术总监 Tom", 23456, 1234),
+]
+
+BILIBILI_VIDEOS = [
+ ("【AI编程】Claude Code 完整教程,从入门到精通", "保姆级教程,30分钟学会用 Claude Code 写完一个项目", "技术宇宙UP主", 56789, 3456, 1234, 234567, 5678, 1234, 8901),
+ ("我用 Claude Code 一晚上写完了毕业设计😱", "程序员的福音!AI 编程工具实战分享", "毕设拯救者", 78901, 5678, 2345, 345678, 8901, 2345, 12345),
+ ("Claude Code vs Cursor 横向对比测评", "两大 AI 编程神器深度对比,看完就知道选哪个", "AI工具评测", 23456, 1234, 567, 123456, 2345, 567, 4567),
+]
+
+TIEBA_POSTS = [
+ ("Claude Code 真有那么神吗?吧里大佬来评测一下", "听说很厉害,但订阅费有点贵想问问值不值", "贴吧吃瓜群众"),
+ ("Claude Code 怎么破解?求大神指点", "想试用一下但是不想付费,有没有破解方法", "白嫖党党魁"),
+]
+
+# 评论数据池
+COMMENTS_POSITIVE = [
+ "Claude Code 真的太牛了!", "用了之后再也回不去了 😭", "Anthropic yyds", "效率直接起飞 🚀",
+ "终于不用 Stack Overflow 了", "Claude Code 比 ChatGPT 写代码强多了",
+ "支持!这才是 AI 编程的未来", "用了一个月,已经爱上",
+]
+COMMENTS_NEGATIVE = [
+ "太贵了,订阅不起 😩", "Cursor 不香吗,何必用这个", "Claude Code 经常给我写 bug",
+ "API 限流恶心,体验差", "国内访问不了真的离谱", "还不如自己写",
+]
+COMMENTS_NEUTRAL = [
+ "占楼围观,看看效果", "求个使用教程", "这个和 Cursor 比怎么样?",
+ "刚开始学编程,能用吗?", "等大佬测评", "默默 mark 一下",
+]
+
+
+def _ts_now(days_ago=0, ms=False):
+ """生成时间戳"""
+ dt = datetime.now() - timedelta(days=days_ago, hours=random.randint(0, 23), minutes=random.randint(0, 59))
+ if ms:
+ return int(dt.timestamp() * 1000)
+ return int(dt.timestamp())
+
+
+def _date_str(days_ago=0):
+ return (datetime.now() - timedelta(days=days_ago)).strftime("%Y-%m-%d %H:%M:%S")
+
+
+def init_database():
+ if DB_PATH.exists():
+ DB_PATH.unlink()
+ print(f"删除旧数据库: {DB_PATH}")
+
+ conn = sqlite3.connect(DB_PATH)
+ cur = conn.cursor()
+
+ # 创建所有表
+ for table_name, sql in SCHEMAS.items():
+ cur.execute(sql)
+ print(f"创建表: {table_name}")
+
+ # ===== 插入内容数据 =====
+
+ # 微博
+ for content, nick, likes, comments, shares in WEIBO_POSTS:
+ cur.execute(
+ "INSERT INTO weibo_note (content, source_keyword, create_date_time, nickname, note_url, liked_count, comments_count, shared_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ (content, "Claude Code", _date_str(random.randint(0, 7)), nick, f"https://weibo.com/{random.randint(1000, 9999)}", likes, comments, shares),
+ )
+
+ # 小红书
+ for title, desc, tags, nick, likes, comments, shares, favs in XHS_POSTS:
+ cur.execute(
+ "INSERT INTO xhs_note (title, \"desc\", tag_list, source_keyword, time, nickname, note_url, liked_count, comment_count, share_count, collected_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (title, desc, tags, "Claude Code", _ts_now(random.randint(0, 7), ms=True), nick, f"https://xiaohongshu.com/{random.randint(1000, 9999)}", likes, comments, shares, favs),
+ )
+
+ # 知乎
+ for title, desc, content, nick, likes, comments in ZHIHU_QUESTIONS:
+ cur.execute(
+ "INSERT INTO zhihu_content (title, \"desc\", content_text, source_keyword, created_time, user_nickname, content_url, voteup_count, comment_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (title, desc, content, "Claude Code", str(_ts_now(random.randint(0, 7))), nick, f"https://zhihu.com/question/{random.randint(100000, 999999)}", likes, comments),
+ )
+
+ # B站
+ for title, desc, nick, likes, comments, shares, plays, favs, coins, danmaku in BILIBILI_VIDEOS:
+ cur.execute(
+ "INSERT INTO bilibili_video (title, \"desc\", source_keyword, create_time, nickname, video_url, liked_count, video_comment, video_share_count, video_play_count, video_favorite_count, video_coin_count, video_danmaku) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (title, desc, "Claude Code", _ts_now(random.randint(0, 7)), nick, f"https://bilibili.com/video/BV{random.randint(10000, 99999)}", likes, comments, shares, plays, favs, coins, danmaku),
+ )
+
+ # 抖音(简化数据)
+ douyin_titles = [
+ ("AI编程神器Claude Code实测", "30秒带你看完Claude Code的强大功能", "AI达人小王", 45678, 2345, 1234, 5678),
+ ("程序员必看:Claude Code使用技巧", "省下90%开发时间的秘密", "码农生活", 23456, 1234, 567, 2345),
+ ]
+ for title, desc, nick, likes, comments, shares, favs in douyin_titles:
+ cur.execute(
+ "INSERT INTO douyin_aweme (title, \"desc\", source_keyword, create_time, nickname, aweme_url, liked_count, comment_count, share_count, collected_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (title, desc, "Claude Code", _ts_now(random.randint(0, 7), ms=True), nick, f"https://douyin.com/video/{random.randint(1000, 9999)}", likes, comments, shares, favs),
+ )
+
+ # 快手
+ cur.execute(
+ "INSERT INTO kuaishou_video (title, \"desc\", source_keyword, create_time, nickname, video_url, liked_count, viewd_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ ("Claude Code 教学", "AI 编程 一看就会", "Claude Code", "草根程序员", _ts_now(2, ms=True), f"https://kuaishou.com/{random.randint(1000, 9999)}", 12345, 567890),
+ )
+
+ # 贴吧
+ for title, desc, nick in TIEBA_POSTS:
+ cur.execute(
+ "INSERT INTO tieba_note (title, \"desc\", source_keyword, publish_time, nickname, url) VALUES (?, ?, ?, ?, ?, ?)",
+ (title, desc, "Claude Code", _date_str(random.randint(0, 7)), nick, f"https://tieba.baidu.com/p/{random.randint(1000000, 9999999)}"),
+ )
+
+ # ===== 插入评论数据 =====
+
+ def _gen_comments(count, positive_ratio=0.5, negative_ratio=0.3):
+ """生成混合情感的评论"""
+ result = []
+ for _ in range(count):
+ r = random.random()
+ if r < positive_ratio:
+ result.append(random.choice(COMMENTS_POSITIVE))
+ elif r < positive_ratio + negative_ratio:
+ result.append(random.choice(COMMENTS_NEGATIVE))
+ else:
+ result.append(random.choice(COMMENTS_NEUTRAL))
+ return result
+
+ # 给每个评论表插入 15 条评论(关键词关联 Claude Code)
+ comment_tables_meta = {
+ "weibo_note_comment": ("user_nickname", "create_date_time", "comment_like_count", "date"),
+ "xhs_note_comment": ("user_nickname", "create_time", "like_count", "ts"),
+ "zhihu_comment": ("user_nickname", "create_time", "like_count", "ts"),
+ "bilibili_video_comment": ("user_nickname", "create_time", "like_count", "ts"),
+ "douyin_aweme_comment": ("user_nickname", "create_time", "like_count", "ts"),
+ "kuaishou_video_comment": ("user_nickname", "create_time", "like_count", "ts"),
+ "tieba_comment": ("nickname", "publish_time", "like_count", "date"),
+ }
+
+ for table, (author_col, time_col, like_col, time_format) in comment_tables_meta.items():
+ comments = _gen_comments(15)
+ # 添加 Claude Code 关键词到部分评论
+ for i, c in enumerate(comments):
+ if random.random() < 0.6:
+ comments[i] = c + "(Claude Code 相关)"
+
+ for c in comments:
+ time_val = _date_str(random.randint(0, 14)) if time_format == "date" else _ts_now(random.randint(0, 14))
+ nick = f"用户{random.randint(1000, 9999)}"
+ likes = random.randint(0, 5000)
+ cur.execute(
+ f'INSERT INTO {table} (content, "{author_col}", "{time_col}", "{like_col}") VALUES (?, ?, ?, ?)',
+ (c, nick, time_val, likes),
+ )
+
+ conn.commit()
+
+ # 统计
+ print("\n===== 数据统计 =====")
+ for table in SCHEMAS.keys():
+ cur.execute(f'SELECT COUNT(*) FROM "{table}"')
+ count = cur.fetchone()[0]
+ print(f" {table}: {count} 条")
+
+ conn.close()
+ print(f"\n✅ Mock 数据库已创建: {DB_PATH}")
+ print(f"\n请确保 .env 中配置:")
+ print(f" DB_DIALECT=sqlite")
+ print(f" DB_NAME={DB_PATH}")
+
+
+if __name__ == "__main__":
+ init_database()
diff --git a/experimental/agno_version/test_integration.py b/experimental/agno_version/test_integration.py
new file mode 100644
index 000000000..e2ae87c98
--- /dev/null
+++ b/experimental/agno_version/test_integration.py
@@ -0,0 +1,95 @@
+# test_integration.py
+# 集成测试 - 由总负责人维护
+# 验证三条开发线合并后的整体功能
+
+import pytest
+
+
+class TestToolLayer:
+ """A组交付验收:工具层测试"""
+
+ def test_tools_importable(self):
+ """所有 tool 函数可以正常 import"""
+ from agno_tools import (
+ search_weibo, search_forum, search_news,
+ analyze_sentiment, optimize_keywords,
+ )
+ assert all([search_weibo, search_forum, search_news, analyze_sentiment, optimize_keywords])
+
+ def test_sentiment_tool_basic(self):
+ """情感分析工具基础功能"""
+ from agno_tools import analyze_sentiment
+ results = analyze_sentiment(["今天天气真好", "这件事太糟糕了"])
+ assert len(results) == 2
+ assert results[0]["sentiment"] in ["positive", "negative", "neutral"]
+ assert 0 <= results[0]["confidence"] <= 1
+
+ def test_keyword_tool_basic(self):
+ """关键词优化工具基础功能"""
+ from agno_tools import optimize_keywords
+ keywords = optimize_keywords("特斯拉召回事件", num_keywords=3)
+ assert isinstance(keywords, list)
+ assert len(keywords) >= 1
+
+ def test_db_query_tool_basic(self):
+ """数据库查询工具基础功能(需要真实数据库连接)"""
+ pytest.skip("需要数据库连接,在集成环境中运行")
+
+
+class TestAgentLayer:
+ """B组交付验收:Agent层测试"""
+
+ def test_agents_instantiable(self):
+ """三个核心 Agent 可以无报错实例化"""
+ from agno_agents import create_insight_agent, create_media_agent, create_query_agent
+ insight = create_insight_agent()
+ media = create_media_agent()
+ query = create_query_agent()
+ assert insight.name == "InsightAgent"
+ assert media.name == "MediaAgent"
+ assert query.name == "QueryAgent"
+
+ def test_insight_agent_run(self):
+ """InsightAgent 完整运行测试(需要真实 API Key)"""
+ pytest.skip("需要 API Key,在集成环境中运行")
+
+ def test_agent_stream_support(self):
+ """Agent 支持流式输出"""
+ from agno_agents import create_insight_agent
+ agent = create_insight_agent()
+ assert agent.stream is True
+
+
+class TestTeamLayer:
+ """C组交付验收:编排层测试"""
+
+ def test_team_instantiable(self):
+ """Opinion Team 可以无报错实例化"""
+ from agno_team.opinion_team import create_opinion_team
+ team = create_opinion_team()
+ assert team.name == "微舆舆情分析团队"
+
+ def test_full_pipeline(self):
+ """完整流水线运行测试(需要所有 API Key)"""
+ pytest.skip("需要完整环境,在集成环境中运行")
+
+
+class TestAppLayer:
+ """应用入口测试"""
+
+ def test_app_starts(self):
+ """Flask app 可以正常创建"""
+ from main import app
+ assert app is not None
+
+ def test_health_endpoint(self):
+ """健康检查接口正常"""
+ from main import app
+ client = app.test_client()
+ response = client.get('/health')
+ assert response.status_code == 200
+ assert response.get_json()["status"] == "ok"
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git "a/experimental/agno_version/\344\273\273\345\212\241\344\271\246_A_\346\225\260\346\215\256\345\261\202\344\270\216\345\267\245\345\205\267\345\261\202\350\277\201\347\247\273.md" "b/experimental/agno_version/\344\273\273\345\212\241\344\271\246_A_\346\225\260\346\215\256\345\261\202\344\270\216\345\267\245\345\205\267\345\261\202\350\277\201\347\247\273.md"
new file mode 100644
index 000000000..e024d1416
--- /dev/null
+++ "b/experimental/agno_version/\344\273\273\345\212\241\344\271\246_A_\346\225\260\346\215\256\345\261\202\344\270\216\345\267\245\345\205\267\345\261\202\350\277\201\347\247\273.md"
@@ -0,0 +1,224 @@
+# 任务书 A:数据层与工具层迁移
+
+**项目**:BettaFish (微舆) → agno 框架重构
+**负责人**:A
+**依赖关系**:本任务是 B、C 两组的前置依赖,工具接口定义需优先完成
+
+---
+
+## 背景与目标
+
+BettaFish 是一个多 Agent 舆情分析系统,原项目完全自研,不依赖任何 Agent 框架。本次重构目标是将其迁移至 [agno](https://github.com/agno-agi/agno) 框架。
+
+你负责**数据层和工具层**:将所有数据采集、数据库查询、情感分析能力,统一封装为 agno 标准的 `@tool` 函数,供 B 组的 Agent 直接调用。
+
+---
+
+## 需要阅读的原始代码
+
+在开始前,请先通读以下文件:
+
+| 文件路径 | 说明 |
+|---|---|
+| `InsightEngine/tools/search.py` | 数据库查询工具(MediaCrawlerDB,5种查询方法)|
+| `InsightEngine/tools/keyword_optimizer.py` | 关键词优化工具 |
+| `InsightEngine/tools/sentiment_analyzer.py` | 情感分析工具(支持22种语言)|
+| `MediaEngine/tools/search.py` | 媒体数据查询工具 |
+| `QueryEngine/tools/search.py` | 通用查询工具 |
+| `MindSpider/main.py` | 爬虫主入口(MindSpider类)|
+| `MindSpider/config.py` | 爬虫配置 |
+| `SentimentAnalysisModel/` | 情感分析模型(transformers)|
+| `InsightEngine/utils/` | 文本处理工具函数 |
+| `MediaEngine/utils/` | 媒体引擎工具函数 |
+| `config.py` | 全局配置(数据库连接、API Key 等)|
+
+---
+
+## 交付物结构
+
+在项目根目录新建 `agno_tools/` 目录,按以下结构交付:
+
+```
+agno_tools/
+├── __init__.py # 统一导出所有 tool
+├── db_query_tools.py # 数据库查询工具(来自各 Engine/tools/search.py)
+├── crawler_tools.py # 爬虫工具(来自 MindSpider)
+├── sentiment_tools.py # 情感分析工具(来自 SentimentAnalysisModel + InsightEngine/tools/sentiment_analyzer.py)
+├── keyword_tools.py # 关键词工具(来自 InsightEngine/tools/keyword_optimizer.py)
+└── shared_utils.py # 公共工具函数(来自各 Engine/utils/)
+```
+
+---
+
+## 详细工作说明
+
+### 1. 数据库查询工具(`db_query_tools.py`)
+
+原始代码中,`InsightEngine/tools/search.py` 里有一个 `MediaCrawlerDB` 类,包含 5 种查询方法(微博、论坛、新闻等)。
+
+**迁移方式**:将每个查询方法独立为一个 agno `@tool` 函数。
+
+```python
+# agno_tools/db_query_tools.py
+from agno.tools import tool
+from typing import List, Dict, Any
+
+@tool(description="查询微博数据库,按关键词搜索微博内容")
+def search_weibo(keyword: str, limit: int = 20) -> List[Dict[str, Any]]:
+ """
+ Args:
+ keyword: 搜索关键词
+ limit: 返回结果数量上限
+ Returns:
+ 微博数据列表,每条包含 content, author, publish_time, likes 等字段
+ """
+ # 保留原 MediaCrawlerDB 的数据库连接和查询逻辑
+ ...
+
+@tool(description="查询论坛数据库,按关键词搜索帖子")
+def search_forum(keyword: str, platform: str = "all", limit: int = 20) -> List[Dict[str, Any]]:
+ ...
+
+# 按照原 MediaCrawlerDB 中有多少查询方法,就拆出多少个 @tool 函数
+```
+
+**注意**:
+- 数据库连接配置从 `config.py` 的 `Settings` 对象读取,不要硬编码
+- 原代码使用 SQLAlchemy + asyncpg,迁移时可保持同步版本(agno tool 不强制异步)
+
+---
+
+### 2. 爬虫工具(`crawler_tools.py`)
+
+原始代码 `MindSpider/main.py` 中的 `MindSpider` 类负责触发爬虫任务(调用 playwright 爬取各平台数据)。
+
+**迁移方式**:将 MindSpider 的启动和状态查询封装为 tool。
+
+```python
+# agno_tools/crawler_tools.py
+from agno.tools import tool
+
+@tool(description="启动MindSpider爬虫,采集指定平台的舆情数据")
+def start_crawler(
+ keyword: str,
+ platforms: List[str], # 如 ["weibo", "forum", "news"]
+ max_count: int = 100
+) -> Dict[str, Any]:
+ """触发爬取任务,返回任务ID和预估完成时间"""
+ ...
+
+@tool(description="查询爬虫任务状态")
+def get_crawler_status(task_id: str) -> Dict[str, Any]:
+ """返回任务进度、已采集数量、是否完成"""
+ ...
+```
+
+---
+
+### 3. 情感分析工具(`sentiment_tools.py`)
+
+原始代码 `InsightEngine/tools/sentiment_analyzer.py` 中有 `multilingual_sentiment_analyzer`,基于 transformers 模型,支持 22 种语言。
+
+**迁移方式**:直接封装为 tool,模型懒加载(首次调用时初始化)。
+
+```python
+# agno_tools/sentiment_tools.py
+from agno.tools import tool
+
+# 模块级懒加载,避免 import 时就加载大模型
+_analyzer = None
+
+def _get_analyzer():
+ global _analyzer
+ if _analyzer is None:
+ # 保留原始 SentimentAnalysisModel 的加载逻辑
+ from SentimentAnalysisModel import load_model
+ _analyzer = load_model()
+ return _analyzer
+
+@tool(description="对文本列表进行多语言情感分析,返回正/负/中性及置信度")
+def analyze_sentiment(texts: List[str]) -> List[Dict[str, Any]]:
+ """
+ Args:
+ texts: 待分析文本列表
+ Returns:
+ 每条文本的分析结果,含 sentiment (positive/negative/neutral), confidence, language
+ """
+ analyzer = _get_analyzer()
+ return analyzer.batch_analyze(texts)
+```
+
+---
+
+### 4. 关键词工具(`keyword_tools.py`)
+
+原始代码 `InsightEngine/tools/keyword_optimizer.py` 中的 `keyword_optimizer`,用 LLM 将用户查询扩展为多个搜索关键词。
+
+**注意**:这个工具本身调用 LLM,迁移时需要从 `config.py` 读取 LLM 配置,使用 openai 兼容接口即可,**不要引入 agno 的 Agent**,保持它只是一个普通的工具函数。
+
+```python
+@tool(description="将用户查询优化为多个数据库搜索关键词组合")
+def optimize_keywords(query: str, num_keywords: int = 5) -> List[str]:
+ """返回针对该查询优化后的关键词列表"""
+ ...
+```
+
+---
+
+### 5. `__init__.py` 统一导出
+
+```python
+# agno_tools/__init__.py
+from .db_query_tools import search_weibo, search_forum, search_news # 按实际方法名导出
+from .crawler_tools import start_crawler, get_crawler_status
+from .sentiment_tools import analyze_sentiment
+from .keyword_tools import optimize_keywords
+from .shared_utils import format_search_results # 来自原 InsightEngine/utils/
+
+__all__ = [
+ "search_weibo", "search_forum", "search_news",
+ "start_crawler", "get_crawler_status",
+ "analyze_sentiment",
+ "optimize_keywords",
+ "format_search_results",
+]
+```
+
+---
+
+## 接口约定(必须遵守,B 组依赖此)
+
+1. **所有 `@tool` 函数必须有完整的 docstring**,说明参数和返回值格式
+2. **返回值统一为 `List[Dict]` 或 `Dict`**,不要返回自定义类对象
+3. **不要在 tool 函数中 `print`**,统一用 `loguru.logger`
+4. **tool 函数签名中不要有默认为 `None` 的必填参数**,agno 会将 tool 签名暴露给 LLM
+5. 完成接口定义(可以是空实现)后,**尽快通知 B 组**,让他们可以开始开发
+
+---
+
+## 环境准备
+
+```bash
+pip install agno
+# agno 安装文档:https://docs.agno.com/introduction
+```
+
+agno tool 的写法参考:
+```python
+from agno.tools import tool
+
+@tool
+def my_tool(param: str) -> str:
+ """工具描述,LLM 会读取这段文字来决定何时调用此工具"""
+ return f"result: {param}"
+```
+
+---
+
+## 验收标准
+
+- [ ] 所有 tool 函数可以被独立 `import` 并调用(不依赖 agno Agent 运行时)
+- [ ] `agno_tools/__init__.py` 可以无报错 `from agno_tools import *`
+- [ ] 情感分析 tool 通过单元测试:输入 ["今天天气真好", "这件事太糟糕了"],返回正确的 positive/negative 结果
+- [ ] 数据库查询 tool 通过集成测试:能查询到真实数据(需要配置 `.env`)
+- [ ] 不破坏原有 `InsightEngine`、`MediaEngine`、`QueryEngine` 的正常运行(可以两套代码并存)
diff --git "a/experimental/agno_version/\344\273\273\345\212\241\344\271\246_B_\346\240\270\345\277\203Agent\350\277\201\347\247\273.md" "b/experimental/agno_version/\344\273\273\345\212\241\344\271\246_B_\346\240\270\345\277\203Agent\350\277\201\347\247\273.md"
new file mode 100644
index 000000000..5a13d794a
--- /dev/null
+++ "b/experimental/agno_version/\344\273\273\345\212\241\344\271\246_B_\346\240\270\345\277\203Agent\350\277\201\347\247\273.md"
@@ -0,0 +1,272 @@
+# 任务书 B:核心 Agent 迁移(InsightEngine / MediaEngine / QueryEngine)
+
+**项目**:BettaFish (微舆) → agno 框架重构
+**负责人**:B
+**前置依赖**:需等 A 组完成 `agno_tools/__init__.py` 的接口定义(空实现即可)后开始
+
+---
+
+## 背景与目标
+
+BettaFish 有三个核心分析引擎,每个引擎手写了一套 `nodes/` + `state/` + `llms/` 的 Agent 框架。
+
+你的目标是用 **agno `Agent`** 重写这三个引擎:
+
+| 原引擎 | 功能 | 对应 LLM |
+|---|---|---|
+| `InsightEngine` | 社交媒体舆情分析(本地数据库,含反思循环)| kimi-k2 |
+| `MediaEngine` | 多模态网页内容分析(综合搜索/图片/结构化数据)| gemini-2.5-pro |
+| `QueryEngine` | 新闻深度分析(多源核实/事实核查)| deepseek-chat |
+
+---
+
+## 需要阅读的原始代码
+
+**先通读以下文件,理解每个引擎的工作流程,再开始写代码:**
+
+| 文件路径 | 说明 |
+|---|---|
+| `InsightEngine/agent.py` | DeepSearchAgent 主类,含聚类采样和反思循环逻辑 |
+| `InsightEngine/nodes/search_node.py` | 生成搜索查询(FirstSearchNode + ReflectionNode)|
+| `InsightEngine/nodes/summary_node.py` | 总结搜索结果(FirstSummaryNode + ReflectionSummaryNode)|
+| `InsightEngine/nodes/report_structure_node.py` | 规划报告结构 |
+| `InsightEngine/nodes/formatting_node.py` | 最终报告格式化 |
+| `InsightEngine/state/state.py` | State / Paragraph / Research 数据结构 |
+| `InsightEngine/prompts/` | 所有 system prompt |
+| `MediaEngine/agent.py` | 媒体分析 Agent(结构类似 InsightEngine)|
+| `QueryEngine/agent.py` | 查询 Agent |
+| `config.py` | 各 Engine 的 API Key / Base URL / Model Name 配置 |
+
+**重点理解 InsightEngine 的反思循环**(最复杂的部分):
+1. `ReportStructureNode`:根据用户查询,规划报告的段落标题和大纲
+2. `FirstSearchNode`:为每个段落生成搜索关键词
+3. 调用工具查询数据库,得到原始数据
+4. `FirstSummaryNode`:对搜索结果做初步总结
+5. `ReflectionNode`:判断总结是否充分,若不足则生成追加查询
+6. 重复步骤 3-5,直到满足条件或达到最大迭代次数
+7. `ReportFormattingNode`:将所有段落汇总为完整报告
+
+---
+
+## 交付物结构
+
+在项目根目录新建 `agno_agents/` 目录,按以下结构交付:
+
+```
+agno_agents/
+├── __init__.py
+├── insight_agent.py # InsightEngine → agno Agent
+├── media_agent.py # MediaEngine → agno Agent
+└── query_agent.py # QueryEngine → agno Agent
+```
+
+---
+
+## 详细工作说明
+
+### 1. agno Agent 的核心写法
+
+```python
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat # openai 兼容接口
+from agno_tools import search_weibo, search_forum, analyze_sentiment
+
+agent = Agent(
+ name="InsightAgent",
+ model=OpenAIChat(
+ id="kimi-k2-0711-preview",
+ api_key=settings.INSIGHT_ENGINE_API_KEY,
+ base_url=settings.INSIGHT_ENGINE_BASE_URL,
+ ),
+ tools=[search_weibo, search_forum, analyze_sentiment, optimize_keywords],
+ instructions="""你是一个舆情深度分析专家...(从 InsightEngine/prompts/ 迁移)""",
+ markdown=True,
+)
+```
+
+---
+
+### 2. InsightEngine → `insight_agent.py`(重点任务)
+
+InsightEngine 的核心是**反思循环**,迁移思路如下:
+
+**方案A(推荐):用 agno 的多轮 `run()` 模拟反思循环**
+
+agno Agent 天然支持多轮对话,可以利用这一点实现反思:
+
+```python
+# agno_agents/insight_agent.py
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+from agno.run.response import RunResponse
+from config import Settings, settings
+from agno_tools import (
+ search_weibo, search_forum, search_news,
+ analyze_sentiment, optimize_keywords, format_search_results
+)
+
+INSIGHT_SYSTEM_PROMPT = """
+你是一个专业的舆情深度分析专家。你的工作流程:
+1. 收到分析主题后,先规划报告结构(3-5个段落,每个段落有明确主题)
+2. 针对每个段落,调用搜索工具获取相关数据
+3. 分析数据充分性:如果数据不足,换用不同关键词再次搜索
+4. 对每个段落的数据进行情感分析和观点总结
+5. 将所有段落汇总,输出完整的结构化分析报告
+
+注意:
+- 每个段落至少搜索2次,确保数据充分
+- 情感分析必须覆盖所有关键数据
+- 报告需包含:事件概述、舆论倾向、关键观点、预测走向
+"""
+# (继续从 InsightEngine/prompts/ 中迁移完整 prompt)
+
+def create_insight_agent(config: Settings = settings) -> Agent:
+ return Agent(
+ name="InsightAgent",
+ model=OpenAIChat(
+ id=config.INSIGHT_ENGINE_MODEL_NAME,
+ api_key=config.INSIGHT_ENGINE_API_KEY,
+ base_url=config.INSIGHT_ENGINE_BASE_URL,
+ ),
+ tools=[search_weibo, search_forum, search_news, analyze_sentiment, optimize_keywords],
+ instructions=INSIGHT_SYSTEM_PROMPT,
+ markdown=True,
+ show_tool_calls=True,
+ # agno 支持流式输出
+ stream=True,
+ )
+
+def run_insight_analysis(query: str, config: Settings = settings) -> str:
+ """
+ 对外接口:接收用户查询,返回完整分析报告。
+ 替代原 DeepSearchAgent.run(query) 方法。
+ """
+ agent = create_insight_agent(config)
+ response: RunResponse = agent.run(query)
+ return response.content
+```
+
+**关于聚类采样逻辑**(`InsightEngine/agent.py` 中的 `ENABLE_CLUSTERING`):
+
+原代码用 `sentence-transformers` + KMeans 对大量搜索结果做聚类,只取代表性样本。
+迁移时有两个选择:
+- **选择1(简单)**:在 tool 函数层面(A 组负责)处理聚类,tool 返回时就已经是精简后的结果。和 A 组对齐这个接口。
+- **选择2(保留)**:在 `run_insight_analysis` 中,先调用 tool 获取原始数据,做聚类后再喂给 agent。
+
+建议选择1,更符合 agno 的设计理念。
+
+---
+
+### 3. MediaEngine → `media_agent.py`
+
+MediaEngine 结构与 InsightEngine 相似,但 prompt 和工具不同,分析对象侧重媒体报道(非社交媒体)。
+
+```python
+# agno_agents/media_agent.py
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+from config import settings
+from agno_tools import search_news # 媒体引擎主要使用新闻查询工具
+
+MEDIA_SYSTEM_PROMPT = """...""" # 从 MediaEngine/prompts/ 迁移
+
+def create_media_agent(config=settings) -> Agent:
+ return Agent(
+ name="MediaAgent",
+ model=OpenAIChat(
+ id=config.MEDIA_ENGINE_MODEL_NAME,
+ api_key=config.MEDIA_ENGINE_API_KEY,
+ base_url=config.MEDIA_ENGINE_BASE_URL,
+ ),
+ tools=[search_news, analyze_sentiment],
+ instructions=MEDIA_SYSTEM_PROMPT,
+ markdown=True,
+ stream=True,
+ )
+
+def run_media_analysis(query: str, config=settings) -> str:
+ agent = create_media_agent(config)
+ return agent.run(query).content
+```
+
+---
+
+### 4. QueryEngine → `query_agent.py`
+
+QueryEngine 是相对简单的查询 Agent,负责根据用户问题从数据库中检索数据并给出简洁答复。
+
+```python
+# agno_agents/query_agent.py
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+from config import settings
+from agno_tools import search_weibo, search_forum, search_news
+
+QUERY_SYSTEM_PROMPT = """...""" # 从 QueryEngine/prompts/ 迁移
+
+def create_query_agent(config=settings) -> Agent:
+ return Agent(
+ name="QueryAgent",
+ model=OpenAIChat(
+ id=config.QUERY_ENGINE_MODEL_NAME,
+ api_key=config.QUERY_ENGINE_API_KEY,
+ base_url=config.QUERY_ENGINE_BASE_URL,
+ ),
+ tools=[search_weibo, search_forum, search_news],
+ instructions=QUERY_SYSTEM_PROMPT,
+ markdown=True,
+ stream=True,
+ )
+
+def run_query(query: str, config=settings) -> str:
+ agent = create_query_agent(config)
+ return agent.run(query).content
+```
+
+---
+
+### 5. `agno_agents/__init__.py`
+
+```python
+from .insight_agent import create_insight_agent, run_insight_analysis
+from .media_agent import create_media_agent, run_media_analysis
+from .query_agent import create_query_agent, run_query
+
+__all__ = [
+ "create_insight_agent", "run_insight_analysis",
+ "create_media_agent", "run_media_analysis",
+ "create_query_agent", "run_query",
+]
+```
+
+---
+
+## Prompt 迁移说明
+
+原项目的 prompt 分散在各 Engine 的 `prompts/` 目录下(`SYSTEM_PROMPT_FIRST_SEARCH`、`SYSTEM_PROMPT_REFLECTION` 等)。
+
+迁移时,将这些 prompt **合并为一个 `instructions` 字符串**传给 agno Agent。合并策略:
+- 将"首次搜索"和"反思"两个 system prompt 整合为一段完整指令
+- 指令中说明工作流程(先搜索,评估充分性,不足则继续搜索)
+- 删除原 prompt 中关于"JSON 格式输出"的硬性要求(agno 有自己的结构化输出机制)
+
+---
+
+## 与 State 对象的对应关系
+
+原 `InsightEngine/state/state.py` 中的 `State` 对象(含 `Paragraph`、`Research`、`Search`)用于在节点间传递数据。
+
+迁移到 agno 后,**这些状态由 agno Agent 的对话历史自动维护**,不需要手动管理 State 对象。
+
+如果 C 组的编排层需要查询分析进度,通过 agno 的 `AgentSession` 接口获取即可。
+
+---
+
+## 验收标准
+
+- [ ] 三个 `create_*_agent()` 函数可以无报错实例化(即使没有真实 API Key,也应能创建对象)
+- [ ] `run_insight_analysis("2024年某热点事件舆情分析")` 能完整运行并返回报告字符串
+- [ ] `run_media_analysis()` 和 `run_query()` 同上
+- [ ] 支持流式输出(`stream=True`),C 组编排层可以实时获取 token
+- [ ] 对外接口签名(`run_*` 函数)与原引擎的 `agent.run(query)` 保持兼容
+- [ ] 三个 agent 的 prompt 内容与原 `prompts/` 目录保持一致,不遗漏关键指令
diff --git "a/experimental/agno_version/\344\273\273\345\212\241\344\271\246_C_\347\274\226\346\216\222\345\261\202\344\270\216\345\272\224\347\224\250\345\205\245\345\217\243\350\277\201\347\247\273.md" "b/experimental/agno_version/\344\273\273\345\212\241\344\271\246_C_\347\274\226\346\216\222\345\261\202\344\270\216\345\272\224\347\224\250\345\205\245\345\217\243\350\277\201\347\247\273.md"
new file mode 100644
index 000000000..fc1969065
--- /dev/null
+++ "b/experimental/agno_version/\344\273\273\345\212\241\344\271\246_C_\347\274\226\346\216\222\345\261\202\344\270\216\345\272\224\347\224\250\345\205\245\345\217\243\350\277\201\347\247\273.md"
@@ -0,0 +1,375 @@
+# 任务书 C:编排层、报告生成与应用入口迁移
+
+**项目**:BettaFish (微舆) → agno 框架重构
+**负责人**:C
+**前置依赖**:
+- A 组完成工具层接口定义后可开始 ReportAgent 开发
+- B 组完成三个核心 Agent 后,开始 Team 编排
+
+---
+
+## 背景与目标
+
+你负责整个系统的"顶层"部分:
+
+1. **ReportEngine 迁移**:将报告生成引擎重写为 agno Agent
+2. **ForumEngine 迁移**:将日志监控+主持人发言机制重写为 agno Agent
+3. **编排层(最重要)**:用 agno `Team` 把 A/B 组的所有 Agent 组装成一个协作整体
+4. **应用入口**:替换 `app.py`,保留 Flask + SocketIO 的实时流式接口
+
+---
+
+## 需要阅读的原始代码
+
+**先通读以下文件:**
+
+| 文件路径 | 说明 |
+|---|---|
+| `app.py` | Flask 主入口,含 SocketIO 实时推送、多引擎调度逻辑(重点)|
+| `ReportEngine/agent.py` | ReportAgent 主类,串联模板选择→布局→章节生成→渲染 |
+| `ReportEngine/nodes/template_selection_node.py` | 根据内容选择报告模板 |
+| `ReportEngine/nodes/document_layout_node.py` | 生成报告布局/大纲 |
+| `ReportEngine/nodes/chapter_generation_node.py` | 逐章生成报告内容 |
+| `ReportEngine/nodes/word_budget_node.py` | 控制各章节字数预算 |
+| `ReportEngine/renderers/` | HTML 渲染器 |
+| `ReportEngine/report_template/` | 报告模板文件 |
+| `ForumEngine/monitor.py` | 日志监控器(监控三个引擎的 log 输出)|
+| `ForumEngine/llm_host.py` | 论坛主持人(LLM 生成串联发言)|
+
+**重点理解 `app.py` 的调度逻辑**:
+- 用户通过 Web 界面提交分析任务
+- `app.py` 同时启动 InsightEngine、MediaEngine、QueryEngine
+- ForumEngine 监控三个引擎的日志,生成主持人串联发言,推送到前端
+- 三个引擎完成后,ReportEngine 读取它们的输出,生成综合报告
+- 全程通过 SocketIO 向前端推送实时进度
+
+---
+
+## 交付物结构
+
+```
+agno_agents/
+└── report_agent.py # ReportEngine → agno Agent
+
+agno_team/
+├── __init__.py
+├── forum_agent.py # ForumEngine → agno Agent
+└── opinion_team.py # agno Team:编排所有 Agent
+
+main.py # 新应用入口,替换 app.py(保留 Flask + SocketIO)
+```
+
+---
+
+## 详细工作说明
+
+### 1. ReportEngine → `agno_agents/report_agent.py`
+
+ReportEngine 接收三个引擎的 Markdown 分析报告,生成一份完整的综合 HTML 报告。
+
+原始流程:模板选择 → 布局规划 → 字数预算 → 逐章生成 → HTML 渲染
+
+**迁移思路**:将这个流程用 agno Agent 的多步骤 `instructions` 驱动,HTML 渲染逻辑保留原代码。
+
+```python
+# agno_agents/report_agent.py
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+from agno.tools import tool
+from pathlib import Path
+from config import settings
+
+# 把 ReportEngine 的渲染能力封装为 tool
+@tool(description="将报告 Markdown 内容渲染为 HTML 文件,返回文件路径")
+def render_report_to_html(
+ report_markdown: str,
+ template_name: str,
+ output_filename: str
+) -> str:
+ """
+ 保留原 ReportEngine/renderers/ 的 HTML 渲染逻辑
+ 返回生成的 HTML 文件路径
+ """
+ from ReportEngine.renderers import HTMLRenderer # 直接复用原渲染器
+ renderer = HTMLRenderer(template_name)
+ output_path = renderer.render(report_markdown, output_filename)
+ return str(output_path)
+
+@tool(description="列出可用的报告模板")
+def list_report_templates() -> list:
+ """返回 ReportEngine/report_template/ 下的所有模板名称"""
+ from ReportEngine.report_template import get_available_templates
+ return get_available_templates()
+
+REPORT_SYSTEM_PROMPT = """
+你是一个专业的报告生成专家。你会收到来自三个分析引擎的原始分析内容:
+- InsightEngine 的深度搜索报告
+- MediaEngine 的媒体分析报告
+- QueryEngine 的数据查询结果
+
+你的任务:
+1. 先调用 list_report_templates 了解可用模板
+2. 根据内容特点选择最合适的模板
+3. 规划报告结构(章节标题和字数分配)
+4. 逐章撰写高质量报告内容
+5. 调用 render_report_to_html 生成最终 HTML 报告
+6. 返回 HTML 文件路径
+
+报告要求:结构清晰、语言专业、数据支撑充分、有预测走向和决策建议。
+"""
+# 继续从 ReportEngine/nodes/ 的各 prompt 补充完整指令
+
+def create_report_agent(config=settings) -> Agent:
+ return Agent(
+ name="ReportAgent",
+ model=OpenAIChat(
+ id=config.REPORT_ENGINE_MODEL_NAME,
+ api_key=config.REPORT_ENGINE_API_KEY,
+ base_url=config.REPORT_ENGINE_BASE_URL,
+ ),
+ tools=[render_report_to_html, list_report_templates],
+ instructions=REPORT_SYSTEM_PROMPT,
+ markdown=True,
+ stream=True,
+ )
+
+def run_report_generation(
+ insight_report: str,
+ media_report: str,
+ query_report: str,
+ config=settings
+) -> str:
+ """
+ 对外接口:接收三个引擎的报告,生成综合 HTML 报告路径。
+ """
+ agent = create_report_agent(config)
+ combined_input = f"""
+## InsightEngine 分析报告
+{insight_report}
+
+## MediaEngine 媒体分析报告
+{media_report}
+
+## QueryEngine 查询结果
+{query_report}
+"""
+ response = agent.run(combined_input)
+ return response.content
+```
+
+---
+
+### 2. ForumEngine → `agno_team/forum_agent.py`
+
+原 ForumEngine 有两个功能:
+- `LogMonitor`:监控三个引擎的日志文件,提取关键输出
+- `LLMHost`:用 LLM 生成论坛主持人的串联发言,推送到前端
+
+迁移思路:ForumEngine 本质是一个"旁观者",不做核心分析,只负责汇总其他 Agent 的进度并生成活跃的"主持人"发言。
+
+```python
+# agno_team/forum_agent.py
+from agno.agent import Agent
+from agno.models.openai import OpenAIChat
+from agno.tools import tool
+from config import settings
+
+# 日志监控功能:保留原 LogMonitor 逻辑,封装为 tool
+@tool(description="获取当前各分析引擎的最新进度摘要")
+def get_engine_progress() -> dict:
+ """
+ 读取各引擎日志,返回当前进度:
+ {
+ "insight": {"status": "running", "latest_output": "...", "progress": 60},
+ "media": {"status": "done", "latest_output": "...", "progress": 100},
+ "query": {"status": "running", "latest_output": "...", "progress": 40},
+ }
+ """
+ from ForumEngine.monitor import LogMonitor # 复用原监控逻辑
+ monitor = LogMonitor()
+ return monitor.get_current_status()
+
+FORUM_HOST_PROMPT = """
+你是一个舆情分析论坛的主持人,风格活跃、专业。
+你需要根据当前各分析引擎的进展,生成简短的串联发言(1-2句话),
+让用户感受到分析正在有条不紊地进行。
+
+规则:
+- 发言要体现你对各引擎输出内容的理解
+- 语气像真正的分析会议主持人
+- 每次发言不超过50字
+- 当某个引擎有新进展时,及时点评
+"""
+
+def create_forum_agent(config=settings) -> Agent:
+ return Agent(
+ name="ForumHost",
+ model=OpenAIChat(
+ id=config.FORUM_ENGINE_MODEL_NAME,
+ api_key=config.FORUM_ENGINE_API_KEY,
+ base_url=config.FORUM_ENGINE_BASE_URL,
+ ),
+ tools=[get_engine_progress],
+ instructions=FORUM_HOST_PROMPT,
+ stream=True,
+ )
+```
+
+---
+
+### 3. 编排层(核心任务)→ `agno_team/opinion_team.py`
+
+这是整个迁移中**架构最重要的部分**,用 agno `Team` 把所有 Agent 组装起来。
+
+原 `app.py` 的调度模式是:并行启动三个分析引擎 → 等待完成 → 启动报告引擎。
+对应 agno 的 `Team` 模式应该是 **`coordinate`(协调模式)**,由一个协调者 Agent 决定调用顺序。
+
+```python
+# agno_team/opinion_team.py
+from agno.agent import Agent
+from agno.team import Team
+from agno.models.openai import OpenAIChat
+from agno_agents import create_insight_agent, create_media_agent, create_query_agent
+from agno_agents.report_agent import create_report_agent
+from agno_team.forum_agent import create_forum_agent
+from config import settings
+
+def create_opinion_team(config=settings) -> Team:
+ """
+ 创建舆情分析 Team。
+
+ 工作流程:
+ 1. 协调者收到用户的分析主题
+ 2. 并行触发 InsightAgent、MediaAgent、QueryAgent
+ 3. 三个分析完成后,触发 ReportAgent 生成综合报告
+ 4. ForumHost 在整个过程中持续生成主持人发言
+ """
+
+ insight_agent = create_insight_agent(config)
+ media_agent = create_media_agent(config)
+ query_agent = create_query_agent(config)
+ report_agent = create_report_agent(config)
+
+ # 协调者:决定调用哪个 Agent、何时调用
+ team = Team(
+ name="微舆舆情分析团队",
+ mode="coordinate", # 协调模式:由 team leader 决定任务分配
+ model=OpenAIChat( # Team leader 使用的模型
+ id=config.INSIGHT_ENGINE_MODEL_NAME,
+ api_key=config.INSIGHT_ENGINE_API_KEY,
+ base_url=config.INSIGHT_ENGINE_BASE_URL,
+ ),
+ members=[insight_agent, media_agent, query_agent, report_agent],
+ instructions="""
+你是舆情分析团队的协调者。收到分析主题后:
+1. 同时向 InsightAgent、MediaAgent、QueryAgent 发布分析任务
+2. 等待三个 Agent 完成分析
+3. 将三份报告汇总后,交给 ReportAgent 生成综合报告
+4. 返回最终报告路径给用户
+""",
+ markdown=True,
+ stream=True,
+ show_tool_calls=True,
+ )
+ return team
+
+def run_opinion_analysis(topic: str, config=settings) -> str:
+ """
+ 对外接口:接收分析主题,返回综合报告路径。
+ 替代原 app.py 中的任务调度逻辑。
+ """
+ team = create_opinion_team(config)
+ response = team.run(f"请对以下主题进行全面的舆情分析:{topic}")
+ return response.content
+```
+
+---
+
+### 4. 应用入口 → `main.py`
+
+替换原 `app.py`,保留 Flask + SocketIO,但把引擎调度部分替换为 agno Team 调用。
+
+```python
+# main.py
+from flask import Flask, render_template, request, jsonify
+from flask_socketio import SocketIO, emit
+from agno_team.opinion_team import create_opinion_team, run_opinion_analysis
+from agno_team.forum_agent import create_forum_agent
+from config import settings
+from loguru import logger
+
+app = Flask(__name__)
+app.config['SECRET_KEY'] = settings.SECRET_KEY
+socketio = SocketIO(app, cors_allowed_origins="*")
+
+@socketio.on('start_analysis')
+def handle_start_analysis(data):
+ topic = data.get('topic', '')
+ if not topic:
+ emit('error', {'message': '请输入分析主题'})
+ return
+
+ emit('analysis_started', {'topic': topic})
+
+ team = create_opinion_team()
+
+ # agno 流式输出,每收到 token 就推送到前端
+ for chunk in team.run(f"请对以下主题进行全面的舆情分析:{topic}", stream=True):
+ socketio.emit('analysis_progress', {
+ 'agent': chunk.agent_name if hasattr(chunk, 'agent_name') else 'system',
+ 'content': chunk.content,
+ })
+
+ emit('analysis_complete', {'message': '分析完成'})
+
+# 保留原 app.py 中的其他路由(配置管理、健康检查等)
+@app.route('/')
+def index():
+ return render_template('index.html')
+
+if __name__ == '__main__':
+ socketio.run(app, host=settings.HOST, port=settings.PORT, debug=False)
+```
+
+**注意**:原 `app.py` 中有大量配置管理路由(读写 `config.py`、管理 Streamlit 子进程等),这些逻辑可以先直接从原文件复制过来,不需要重写,只替换引擎调度的部分。
+
+---
+
+## 接口约定(与 B 组对齐)
+
+C 组调用 B 组交付物的方式:
+
+```python
+from agno_agents import run_insight_analysis, run_media_analysis, run_query
+from agno_agents.report_agent import run_report_generation
+```
+
+如果 B 组还没完成,可以先用 mock 函数替代:
+```python
+def run_insight_analysis(query: str) -> str:
+ return f"[Mock] InsightAgent 对 '{query}' 的分析结果"
+```
+
+---
+
+## 与前端的兼容性
+
+原项目前端(`templates/` 和 `static/`)通过 SocketIO 事件与后端通信,事件名称包括:
+- `start_analysis` → 触发分析
+- `analysis_progress` → 实时进度推送
+- `forum_message` → 主持人发言
+- `analysis_complete` → 分析完成
+
+迁移后,这些事件名称**保持不变**,确保前端无需修改。
+
+---
+
+## 验收标准
+
+- [ ] `create_opinion_team()` 可以无报错实例化
+- [ ] `run_opinion_analysis("某热点事件")` 可以完整运行(使用真实 API Key)
+- [ ] SocketIO 流式推送正常:前端能实时收到每个 Agent 的输出
+- [ ] 原前端页面功能正常:提交主题 → 实时显示进度 → 最终展示报告
+- [ ] `main.py` 启动后,访问 `http://localhost:5000` 正常显示
+- [ ] ForumHost 主持人发言在前端正常展示
+- [ ] 原 `app.py` 的配置管理路由在 `main.py` 中保留完整