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)}
+
+
分析主题:{query_safe}
+
生成时间:{date}
+
数据来源:InsightAgent · MediaAgent · QueryAgent
+
主持人引导:ForumHost
+
+
+ + {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""" +
+ {title_html} +
+ +
+ +
+""" + + +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` 中保留完整