diff --git a/README.md b/README.md index 2d80b7a..f07edf8 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,198 @@ -# Deep Research (Single-User Local Edition) +# Deep Research -## Run backend (uv + Python 3.12) +一个以“会话驱动”为核心的深度研究系统: + +- 左侧会话管理(可多轮、多会话) +- 中间聊天时间线(方案、进度、报告) +- 右侧 Markdown 研究方案编辑器(可手改可执行) +- 后端异步执行引擎(规划 DAG -> 检索证据 -> 冲突检测 -> 生成报告) + +--- + +## 1. 当前能力(基于当前代码) + +- FastAPI + SQLite 后端,支持任务与会话两套 API。 +- 会话主流程: + 1. 创建会话自动生成首版方案(`PLAN_DRAFT`) + 2. 用户在聊天里继续“改方案 / 改报告 / 触发重跑” + 3. 执行完成后在时间线返回 `FINAL_REPORT` +- 执行引擎支持:暂停、恢复、终止、快照恢复。 +- 检索支持 Tavily / arXiv / Semantic Scholar(按配置与可用 Key 启用)。 +- 分析支持:证据打分、数值冲突检测、冲突投票接口。 +- 写作支持:报告模板化生成、可选 LLM 润色、`.md + .bib` 产物落盘。 +- 前端支持:移动端抽屉、进度分组折叠、历史轮次显示/隐藏、报告下载。 + +--- + +## 2. 技术栈 + +### 后端 + +- Python `>=3.11`(脚本默认 `3.12`) +- FastAPI / Pydantic v2 / Uvicorn +- SQLite(WAL) + +### 前端 + +- React 18 + TypeScript + Vite +- 纯前端状态管理(`useState/useMemo/useEffect`) + +### 测试 + +- pytest(unit + integration) +- ruff + +--- + +## 3. 快速启动 + +### 3.1 环境要求 + +- `uv` +- Node.js `20+` +- npm `10+` + +### 3.2 后端 ```bash ./scripts/run_backend.sh ``` -## Configure API keys (`.env`) - -Project root now includes a `.env` file template. -Fill only the providers you plan to use: +该脚本会执行: -- LLM: `DR_OPENROUTER_API_KEY`, `DR_DEEPSEEK_API_KEY`, `DR_OPENAI_API_KEY`, `DR_ANTHROPIC_API_KEY` -- Search: `DR_SERPER_API_KEY`, `DR_SERPAPI_API_KEY`, `DR_TAVILY_API_KEY`, `DR_BRAVE_API_KEY`, `DR_BING_SUBSCRIPTION_KEY`, `DR_GOOGLE_CSE_API_KEY`, `DR_GOOGLE_CSE_CX` +1. 创建 `.venv`(Python 3.12) +2. 安装 `backend[dev]` +3. 启动 `uvicorn app.main:app --reload --host 127.0.0.1 --port 8000` -Also configurable: +启动后可访问: -- `DR_DEFAULT_LLM_PROVIDER` (for example `openrouter` or `deepseek`) -- `DR_DEFAULT_LLM_MODEL` -- `DR_USE_MOCK_SOURCES` (`true` for mock mode, `false` for real API calls) +- API: `http://127.0.0.1:8000` +- OpenAPI: `http://127.0.0.1:8000/docs` +- 健康检查: `GET /healthz` -## Run frontend +### 3.3 前端 ```bash ./scripts/run_frontend.sh ``` -## Test and build +默认地址(见 `frontend/vite.config.ts`): + +- `http://127.0.0.1:5174` + +> 如果端口被占用,Vite 会提示并可能切换端口。 + +--- + +## 4. `.env` 配置 + +后端配置读取规则: + +- 文件:项目根目录 `.env` +- 前缀:`DR_` +- 定义位置:`backend/app/core/config.py` + +关键项: + +- 执行模式:`DR_USE_MOCK_SOURCES` + - `true`:完全走 mock 数据(测试/离线验证) + - `false`:调用真实检索/模型接口 +- 默认模型路由: + - `DR_DEFAULT_LLM_PROVIDER`(`openrouter | deepseek | openai`) + - `DR_DEFAULT_LLM_MODEL` +- 检索 Key:`DR_TAVILY_API_KEY` 等 + +安全建议: + +- 不要把真实 API Key 提交到仓库。 +- 如果曾提交过,立即轮换(rotate)并改为本地私有配置。 + +--- + +## 5. 最短使用流程(UI) + +1. 打开前端页面,点“新建研究”。 +2. 第一条消息输入研究主题(最多 500 字)。 +3. 等待 Agent 返回第一版研究方案(自动写入右侧草稿)。 +4. 可直接编辑草稿并“保存草稿”。 +5. 点击“开始研究”。 +6. 中间时间线查看进度分组(SEARCHING / WRITING_SECTION 等阶段)。 +7. 完成后出现“当前报告”,可继续发送“改写报告/补检索/重跑”指令。 +8. 点击“下载 Markdown”导出报告。 + +--- + +## 6. 常用 API(按实现) + +### 任务 + +- `POST /api/v1/tasks` +- `GET /api/v1/tasks` +- `GET /api/v1/tasks/{task_id}` +- `PUT /api/v1/tasks/{task_id}` +- `DELETE /api/v1/tasks/{task_id}` +- `GET /api/v1/tasks/{task_id}/dag` +- `POST /api/v1/tasks/{task_id}/start|pause|resume|abort|recover` +- `GET /api/v1/tasks/{task_id}/snapshot` +- `GET /api/v1/tasks/{task_id}/report` +- `GET /api/v1/tasks/{task_id}/report/download` +- `WS /api/v1/ws/task/{task_id}/progress` + +### 会话 + +- `POST /api/v1/conversations` +- `GET /api/v1/conversations` +- `GET /api/v1/conversations/{conversation_id}` +- `PATCH /api/v1/conversations/{conversation_id}`(重命名) +- `DELETE /api/v1/conversations/{conversation_id}` +- `DELETE /api/v1/conversations`(全部删除) +- `POST /api/v1/conversations/{conversation_id}/plan/revise` +- `PUT /api/v1/conversations/{conversation_id}/plan` +- `POST /api/v1/conversations/{conversation_id}/run` +- `GET /api/v1/conversations/{conversation_id}/report/download` + +### 证据与冲突 + +- `GET /api/v1/evidence` +- `GET /api/v1/evidence/{evidence_id}` +- `POST /api/v1/evidence/{evidence_id}/vote` +- `GET /api/v1/tasks/{task_id}/conflicts` + +### MCP + +- `POST /api/v1/mcp/execute` + - `mode=read` -> 直接执行 + - `mode=write|execute` -> `USER_CONFIRMATION_REQUIRED` + +--- + +## 7. 目录结构(核心) + +```text +backend/ + app/ + api/routes/ # REST + WS + core/ # config / sqlite schema + models/schemas.py # 全部 Pydantic 模型 + repositories/ # 持久化层 + services/ # 规划/检索/分析/写作/执行/会话编排 +frontend/ + src/ + App.tsx # 三栏主状态机 + api.ts # 所有 HTTP 调用封装 + components/ # 时间线/侧栏/编辑器/对话框 +scripts/ + run_backend.sh + run_frontend.sh + run_real_case.py +tests/ + unit/ + integration/ +``` + +--- + +## 8. 测试与构建 ```bash uv venv --python 3.12 .venv --clear @@ -37,14 +203,34 @@ pytest tests/unit tests/integration cd frontend && npm run build ``` -## Current status +--- + +## 9. 一键跑真实案例(脚本) + +```bash +python scripts/run_real_case.py \ + --title "2026年AI Agent在软件工程中的应用现状与挑战" \ + --description "基于公开资料分析进展、风险与落地策略" \ + --sources "tavily" \ + --timeout 120 +``` + +该脚本会:创建任务 -> 启动执行 -> 轮询终态 -> 打印证据数 -> 输出报告预览。 + +--- + +## 10. 已知边界 + +- 单用户本地模式;无鉴权/多租户。 +- 执行引擎当前按节点顺序执行(非分布式并行调度)。 +- `ConversationAgent` 的意图识别基于关键词规则,不是分类模型。 +- `MCPExecutor` 目前是最小可用实现(read 模拟执行)。 + +--- -Local alpha workflow is complete: -- Task lifecycle + FSM + DAG planner -- Retrieval + evidence management -- Analyst conflict detection + vote resolution -- Writer report generation (`.md` + `.bib`) -- MCP minimal execution API -- Frontend six-state workflow console +## 11. 深入文档 -See `docs/LOCAL_RELEASE.md` for release details. +- 详细实现说明:`doc.md` +- 端到端流程手册:`WORKFLOW.md` +- 本地发布说明:`docs/LOCAL_RELEASE.md` +- UI 回归清单:`docs/UI_REGRESSION_CHECKLIST.md` diff --git a/WORKFLOW.md b/WORKFLOW.md index 447cbd5..9dde9c9 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -1,943 +1,360 @@ -# Deep Research 深度科研辅助系统 - 运行流程详解 +# Deep Research 运行流程手册(代码对齐版) -## 目录 +> 文档目的:把“系统实际如何运行”写成可操作流程,覆盖主流程、分支、异常和恢复。 -1. [系统架构概述](#1-系统架构概述) -2. [全流程交互逻辑](#2-全流程交互逻辑) -3. [核心代理详解](#3-核心代理详解) -4. [数据流转关系](#4-数据流转关系) -5. [API接口规范](#5-api接口规范) -6. [错误处理与容错](#6-错误处理与容错) +--- + +## 1. 角色与核心对象 + +- 用户:在前端发起主题、修订计划、触发执行、改写报告。 +- 前端:三栏工作台(会话、时间线、计划编辑器)。 +- 会话编排器:`ConversationAgent`。 +- 执行引擎:`ExecutionEngine`。 +- 数据层:SQLite + 报告文件。 + +核心 ID: +- `conversation_id`:会话维度。 +- `task_id`:每次运行生成的新任务维度。 +- `message_id`:时间线消息维度。 +- `evidence_id`:证据维度。 --- -## 1. 系统架构概述 - -### 1.1 分层架构图 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 交互层 (Interaction Layer) │ -│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ -│ │ CoT 编辑器 │ │ 实时证据看板 │ │ Markdown 分屏预览 │ │ -│ └─────────────┘ └──────────────┘ └──────────────────────────┘ │ -├─────────────────────────────────────────────────────────────────┤ -│ 编排层 (Orchestration Layer) │ -│ ┌─────────────────────────┐ ┌──────────────────────────────┐ │ -│ │ 主规划器 (Master Planner)│ │ 状态管理器 (State Manager) │ │ -│ │ - DAG 任务图谱管理 │ │ - FSM 状态机 │ │ -│ │ - 动态任务调度 │ │ - 上下文快照 │ │ -│ └─────────────────────────┘ └──────────────────────────────┘ │ -├─────────────────────────────────────────────────────────────────┤ -│ 代理层 (Agent Layer) │ -│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │ -│ │ 检索代理 │ │ 分析代理 │ │MCP执行代理 │ │ 写作代理 │ │ -│ └───────────┘ └───────────┘ └───────────┘ └───────────────┘ │ -├─────────────────────────────────────────────────────────────────┤ -│ 基础设施层 (Infrastructure) │ -│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────────┐ │ -│ │ 向量数据库 │ │ MCP 服务器集群 │ │ 本地文件系统接口 │ │ -│ │ (L2 缓存) │ │ │ │ │ │ -│ └─────────────┘ └─────────────┘ └──────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 1.2 核心组件说明 - -| 层级 | 组件 | 职责 | -|------|------|------| -| **交互层** | CoT 编辑器 | 展示和编辑思维链,允许用户干预任务规划 | -| **交互层** | 实时证据看板 | 展示收集到的证据流,支持点击锚定 | -| **交互层** | Markdown 分屏预览 | 实时预览生成的报告,支持段落锁定 | -| **编排层** | Master Planner | 任务拆解、DAG 生成、动态调度 | -| **编排层** | State Manager | FSM 状态管理、上下文快照与恢复 | -| **代理层** | Retrieval Agent | 信息检索、网页解析、缓存管理 | -| **代理层** | Analyst Agent | 数值冲突检测、信誉评分、语义对齐 | -| **代理层** | MCP Executor | 外部工具调用、沙盒执行 | -| **代理层** | Writer Agent | 增量文档生成、引用管理 | -| **基础设施层** | Vector DB | L2 向量缓存 | -| **基础设施层** | MCP Servers | 外部工具和数据源连接 | -| **基础设施层** | File System | 本地文件访问 | +## 2. 状态机 + +## 2.1 会话状态(ConversationStatus) + +- `DRAFTING_PLAN` +- `PLAN_READY` +- `RUNNING` +- `COMPLETED` +- `FAILED` + +## 2.2 任务状态(TaskStatus) + +- `READY` +- `PLANNING` +- `EXECUTING` +- `REVIEWING` +- `SYNTHESIZING` +- `FINALIZING` +- `COMPLETED` +- `FAILED` +- `SUSPENDED` +- `ABORTED` + +迁移规则由 `state_machine.py` 的 `ALLOWED_TRANSITIONS` 严格控制。 --- -## 2. 全流程交互逻辑 - -### 2.1 状态机 (FSM) 流程图 - -``` - ┌─────────────┐ - │ Ready │ ← 录入议题、上传种子文件、配置偏好 - └──────┬──────┘ - │ [启动研究] - ▼ - ┌─────────────┐ - │ Planning │ ← 生成 DAG、用户可调整节点/依赖 - └──────┬──────┘ - │ [执行计划] - ▼ - ┌─────────────┐ - │ Executing │ ← 检索/分析并行、实时推送证据 - └──────┬──────┘ - │ [发现冲突] - ┌──────▼──────┐ - │ Reviewing │ ← 冲突高亮、用户选择采信源 - └──────┬──────┘ - │ [冲突解决] - ┌──────▼──────┐ - │ Synthesizing│ ← 整合证据、生成报告、锁定段落 - └──────┬──────┘ - │ [完成确认] - ▼ - ┌─────────────┐ - │ Finalizing │ ← 导出文档、生成参考文献、归档 - └─────────────┘ -``` - -### 2.2 各状态详解 - -#### **状态 1: Ready (就绪)** - -| 属性 | 说明 | -|------|------| -| **UI 表现** | 初始化界面,含多模态输入模块 | -| **用户操作** | 录入研究议题、上传种子文件、配置偏好 | -| **系统响应** | 预加载常用 MCP 服务列表 | - -**输入数据:** -```json -{ - "title": "研究标题", - "description": "研究描述", - "config": { - "maxDepth": 3, - "maxNodes": 50, - "searchSources": ["arXiv", "Google Scholar", "IEEE"], - "priority": 5 - } -} -``` - -**输出数据:** -```json -{ - "taskId": "uuid-xxxx", - "status": "READY", - "createdAt": "2024-01-01T00:00:00Z" -} -``` +## 3. 主流程 A:新建会话并完成研究 + +## 3.1 创建会话 + +### 用户动作 +在“新建研究”后发送第一条主题消息。 + +### 前端调用 +`POST /api/v1/conversations` + +### 后端行为(`ConversationAgent.create_conversation`) +1. 新建会话,状态 `DRAFTING_PLAN`。 +2. 写入首条用户消息 `USER_TEXT`。 +3. 生成首版计划(LLM 或 fallback)。 +4. 写入 `PLAN_DRAFT` 消息。 +5. 状态切到 `PLAN_READY`。 + +### 前端表现 +- 中间时间线出现首版计划。 +- 右侧计划编辑器自动填充 Markdown。 --- -#### **状态 2: Planning (规划)** - -| 属性 | 说明 | -|------|------| -| **UI 表现** | 展示任务图谱节点 (DAG) | -| **用户操作** | 暂停修改节点、拖拽调整依赖、删除冗余路径 | -| **系统响应** | 规划器拆解任务,生成依赖关系图 | - -**Master Planner 处理流程:** - -``` -输入: CreateTaskRequest - │ - ├─> [BFS 展开] 生成首级子课题 - │ ├─> 背景研究 - │ ├─> 现状分析 - │ └─> 挑战识别 - │ - ├─> [DFS 深挖] 根据反馈垂直展开 - │ - ├─> [循环检测] 维护 VisitedStack 防止无限循环 - │ - ├─> [动态剪枝] 计算 infoGainScore - │ └─> 若连续 < 0.2,合并/舍弃分支 - │ - └─> 输出: DAGGraph -``` - -**输出数据 (DAG):** -```json -{ - "nodes": [ - { - "taskId": "node-1", - "title": "背景研究", - "status": "PENDING", - "priority": 5, - "depth": 1 - } - ], - "edges": [ - { - "from": "node-1", - "to": "node-2", - "type": "DEPENDS_ON" - } - ] -} -``` +## 3.2 用户修订计划(可选) + +### 方式 1:右侧编辑器直接改 +- 前端调用:`PUT /api/v1/conversations/{id}/plan` +- 后端写入新 `PlanRevision` + `PLAN_EDITED` 消息。 + +### 方式 2:聊天输入自然语言“改方案” +- 前端调用:`POST /api/v1/conversations/{id}/plan/revise` +- 后端按指令生成新版本并追加 `PLAN_REVISION` 消息。 --- -#### **状态 3: Executing (执行)** - -| 属性 | 说明 | -|------|------| -| **UI 表现** | 进度条显示、实时证据流看板 | -| **用户操作** | 干预调整检索词、调整优先级、授权 MCP 调用 | -| **系统响应** | 检索/执行代理并行运作,推送 Evidence[] | - -**WebSocket 实时推送:** -```json -{ - "event": "EVIDENCE_FOUND", - "timestamp": "2024-01-01T00:00:00Z", - "data": { - "taskId": "node-1", - "evidence": { ... } - } -} -``` +## 3.3 启动研究 + +### 用户动作 +点击“开始研究”或在时间线点“继续执行”。 + +### 前端调用 +`POST /api/v1/conversations/{id}/run` + +### 后端行为(`ConversationAgent.start_research`) +1. 读取当前计划,解析 front matter: + - `title` + - `max_depth` + - `max_nodes` + - `priority` + - `search_sources` +2. 使用解析结果创建**新任务**(每次 run 都是新 task_id)。 +3. 会话绑定 task_id,状态置 `RUNNING`。 +4. 写一条系统消息“研究任务已启动”。 +5. 调 `execution_engine.start(task_id)`。 --- -#### **状态 4: Reviewing (审查)** - -| 属性 | 说明 | -|------|------| -| **UI 表现** | 冲突节点高亮显示、对比弹窗 | -| **用户操作** | 选择采信源、指令深挖争议点 | -| **系统响应** | 分析代理检测一致性,聚合冲突数据 | - -**冲突检测流程:** -``` -输入: Evidence[] - │ - ├─> [单位标准化] 统一转换为 SI 单位 - │ - ├─> [阈值判定] - │ └─> 若 (ValueA - ValueB) / Max > 15% 且环境相似 - │ └─> 生成 ConflictRecord - │ - └─> 输出: ConflictRecord[] -``` +## 3.4 执行引擎流水线 + +`ExecutionEngine._run_task` 时序如下: + +1. 推送 `TASK_STARTED`。 +2. 若 DAG 为空: + - `READY -> PLANNING` + - `MasterPlanner.build_dag` + - `save_dag` + - 推送 `TASK_PROGRESS(phase=BUILDING_PLAN, progress=20)` +3. 迁移到 `EXECUTING`。 +4. 遍历可执行节点(非 root 且非 PRUNED): + - 节点置 `RUNNING` + - 生成 query(`task.title + node.title`) + - 推送 `TASK_PROGRESS(SEARCHING)` + - `ResearchAgent.collect_evidence` + - `EvidenceRepository.save_many` + - 对每条证据推送 `EVIDENCE_FOUND` + - 节点置 `COMPLETED` + - 推送 `TASK_PROGRESS(NODE_COMPLETED)` + - 保存 snapshot +5. 节点完成后进行分析: + - 对证据计算 score + - 检测冲突(阈值默认 0.15) + - 有冲突:`EXECUTING -> REVIEWING -> SYNTHESIZING` + - 无冲突:`EXECUTING -> SYNTHESIZING` +6. 写作阶段: + - 推送 `OUTLINING` + - 逐 section 推送 `WRITING_SECTION` + - `ReportAgent.generate_report` 输出 `.md/.bib` +7. 收尾: + - `SYNTHESIZING -> FINALIZING -> COMPLETED` + - 写 `report_path` + - 推送 `TASK_COMPLETED(progress=100)` --- -#### **状态 5: Synthesizing (合成)** - -| 属性 | 说明 | -|------|------| -| **UI 表现** | 文档分屏预览、实时高亮 | -| **用户操作** | 查看引用源、指令重写、锁定段落 | -| **系统响应** | 写作代理整合证据,执行增量润色 | - -**写作流程:** -``` -输入: Evidence[] + TaskNode[] - │ - ├─> [分段绑定] 每个 # Section 绑定 TaskNode - │ - ├─> [局部生成] 节点完成时触发对应段落生成 - │ - ├─> [段落锁定] 人工编辑段落标记为 LOCKED - │ - ├─> [溯源索引] 维护 UUID -> Citation 映射 - │ - └─> 输出: Markdown 文档 -``` +## 3.5 事件回流会话 + +执行引擎事件通过 listener 回到 `ConversationAgent.on_task_event`: + +- `TASK_PROGRESS` + - 进入 `ConversationRepository.append_progress_entry` + - 聚合为 `PROGRESS_GROUP` 消息 +- `TASK_COMPLETED` + - 会话状态置 `COMPLETED` + - 读取报告文件,追加 `FINAL_REPORT` 消息 +- `ERROR/TASK_FAILED/TASK_ABORTED` + - 会话状态置 `FAILED` + - 追加 `ERROR` 消息 + +前端时间线会自动显示这些消息。 --- -#### **状态 6: Finalizing (归档)** +## 4. 主流程 B:已完成后继续提要求 + +`POST /plan/revise` 在“已有报告”场景会做意图分流。 + +## 4.1 PLAN 模式(继续改计划) +触发词示例:`研究方案 / max_depth / 任务树 / 执行步骤`。 + +行为: +- 新增 `PlanRevision` +- 状态保持/回到 `PLAN_READY` + +## 4.2 RESEARCH 模式(补检索并重跑) +触发词示例:`重跑 / 补充检索 / 再搜索 / 查询最新`。 + +行为: +1. 先修订计划。 +2. 立即调用 `start_research`。 +3. 新建 task_id 并进入 RUNNING。 -| 属性 | 说明 | -|------|------| -| **UI 表现** | 最终报告生成、参考文献列表 | -| **用户操作** | 导出文档、同步 Zotero、启动后续研究 | -| **系统响应** | 生成 .bib 文件、清理临时上下文 | +## 4.3 REPORT 模式(只改报告,不重检索) +触发词示例:`改写报告 / 演讲稿 / 改风格 / rewrite`。 + +行为: +1. 会话状态置 `RUNNING`。 +2. 写一条“正在修改中”系统消息。 +3. 异步执行报告改写任务: + - 优先基于现有报告做 LLM 改写 + - 若指令包含“从证据重写/全量重写”等标记,可触发证据重建 +4. 追加新的 `FINAL_REPORT`。 +5. 会话回 `COMPLETED`。 --- -## 3. 核心代理详解 - -### 3.1 Master Planner Agent (首席任务规划代理) - -#### 职能 -将抽象的科研目标转化为可执行的有向无环图 (DAG)。 - -#### 输入 - -```typescript -interface CreateTaskRequest { - title: string; // 研究标题 - description: string; // 研究描述 - config: { - maxDepth: number; // 最大搜索深度 (默认 3) - maxNodes: number; // 最大节点数 (默认 50) - searchSources: string[]; // 数据源列表 - priority: number; // 优先级 (1-5) - }; -} -``` - -#### 处理流程 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Master Planner 处理流程 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. [初始化] 创建根节点 │ -│ └─> taskId = uuid(), parentTaskId = null │ -│ │ -│ 2. [BFS 展开] 首级子课题 │ -│ ├─> 背景研究 (Background Research) │ -│ ├─> 现状分析 (State of the Art) │ -│ └─> 挑战识别 (Challenge Identification) │ -│ │ -│ 3. [DFS 深挖] 根据检索反馈垂直展开 │ -│ └─> 递归展开子任务,直至达到 maxDepth │ -│ │ -│ 4. [循环检测] 防止无限循环 │ -│ └─> 维护 VisitedStack,检测重复访问 │ -│ │ -│ 5. [动态剪枝] 信息增益判定 │ -│ ├─> 计算 infoGainScore = newInfo / existingInfo │ -│ └─> 若连续 < 0.2,标记为 PRUNED │ -│ │ -│ 6. [依赖解析] 构建任务依赖关系 │ -│ └─> 生成 edges: [{from, to, type}] │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -#### 调度算法 - -``` -PRIORITY_QUEUE ← 所有 PENDING 节点 -VISITED_SET ← ∅ - -while PRIORITY_QUEUE 非空: - task ← PRIORITY_QUEUE.pop() - - if task.id ∈ VISITED_SET: - continue - - if task.dependencies 未全部完成: - continue - - task.status ← RUNNING - VISITED_SET.add(task.id) - - // 调用对应 Agent 处理 - result ← execute_task(task) - - // 计算信息增益 - infoGain ← calculate_info_gain(result) - if infoGain < 0.2: - task.status ← PRUNED - else: - task.status ← COMPLETED - task.output ← result - - // 激活子任务 - for child in task.children: - PRIORITY_QUEUE.push(child) -``` - -#### 输出 - -```typescript -interface DAGGraph { - nodes: TaskNode[]; - edges: Edge[]; -} - -interface TaskNode { - taskId: string; - parentTaskId: string | null; - title: string; - description: string; - status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'SUSPENDED' | 'PRUNED'; - priority: number; - dependencies: string[]; - children: string[]; - metadata: { - estimatedTokenCost: number; - searchDepth: number; - infoGainScore: number; - createdAt: string; - updatedAt: string; - }; -} -``` +## 5. 前端视角流程 + +## 5.1 页面启动 +- `listConversations()` 拉侧栏。 +- 若有会话自动选第一条。 + +## 5.2 轮询策略 +当 active 会话状态为: +- `RUNNING` +- `DRAFTING_PLAN` + +前端按 `VITE_CONVERSATION_REFRESH_MS`(默认 2500ms)轮询: +- `GET /conversations/{id}` +- `GET /conversations` + +## 5.3 时间线渲染规则 +- `PLAN_*` 消息:代码块 + “打开草稿抽屉”。 +- `PROGRESS_GROUP`:可折叠,显示 phase/state/progress 明细。 +- `FINAL_REPORT`:支持全宽、收起、下载。 +- `ERROR`:红色文本。 + +## 5.4 历史轮次 +同一会话多次运行会产生多个 task_id。 +时间线支持: +- 展示全部历史轮次 +- 仅显示当前轮次(按消息中的 taskId 过滤) --- -### 3.2 Retrieval Agent (检索代理) - -#### 职能 -执行全域信息捕获,配置三级缓存机制。 - -#### 输入 - -```typescript -interface RetrievalRequest { - taskNode: TaskNode; - query: string; - sources: string[]; // ['arXiv', 'Google Scholar', 'IEEE', ...] -} -``` - -#### 处理流程 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Retrieval Agent 处理流程 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. [查询扩展] 生成结构化检索式 │ -│ 格式: (Term OR Abbreviation) AND (Year) AND (Action Verb) │ -│ │ -│ 2. [L1 缓存校验] 内存缓存 (LRU, 最大 1000 条, TTL 1h) │ -│ └─> 若相似度 > 0.9,直接返回 │ -│ │ -│ 3. [L2 缓存校验] 向量数据库 (Qdrant, TTL 24h) │ -│ ├─> 向量化查询 │ -│ └─> 若相似度 > 0.9,直接返回 │ -│ │ -│ 4. [API 调用] 执行外部检索 │ -│ ├─> Serper / Google Search (网页) │ -│ ├─> arXiv API (学术论文) │ -│ ├─> Semantic Scholar (元数据增强) │ -│ └─> CrossRef (DOI 解析) │ -│ │ -│ 5. [网页解析] 提取正文内容 │ -│ ├─> 使用 Readability/trafilatura │ -│ ├─> 检测 标签 → 提取题注 + 独立 evidenceId │ -│ └─> 检测 标签 → 提取题注 + 独立 evidenceId │ -│ │ -│ 6. [缓存写入] 更新 L1 和 L2 缓存 │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -#### 查询扩展示例 - -``` -输入: "transformer architecture" - -扩展后: - ("transformer" OR "attention mechanism") AND - (2024 OR 2023 OR 2022) AND - ("improve" OR "optimize" OR "analyze" OR "review") -``` - -#### 输出 - -```typescript -interface Evidence { - id: string; - sourceType: 'PAPER' | 'WEB' | 'PATENT' | 'MCP'; - url: string; - content: string; // Markdown 格式 - metadata: { - authors: string[]; - publishDate: string; - title: string; - abstract: string; - impactFactor: number; - isPeerReviewed: boolean; - relevanceScore: number; // 0-1 - citationCount: number; - }; - score: number; // 综合信誉评分 0-1 - extractedData: { - tables: Array<{caption: string, data: any}>; - images: Array<{caption: string, url: string}>; - numericalValues: Array<{value: number, unit: string, context: string}>; - }; -} -``` +## 6. 检索与证据流 + +## 6.1 查询生成 +`RetrievalService.expand_query` 统一附加近 3 年窗口。 + +## 6.2 Provider 选择 +输入 `searchSources` 会做归一化: +- `arxiv` / `arxivorg` -> `arxiv` +- `semanticscholar` / `s2` -> `semanticscholar` +- `tavily` -> `tavily` + +## 6.3 并发调用 +每个 provider 独立 task,失败只影响自己,不影响总流程。 + +## 6.4 质量过滤 +证据写库前会过滤掉: +- 非 http(s) +- placeholder host +- 内容太短 --- -### 3.3 Analyst Agent (分析代理) - -#### 职能 -作为质量控制核心,执行语义数值对齐 (SNA) 和冲突检测。 - -#### 输入 - -```typescript -interface AnalysisRequest { - evidences: Evidence[]; -} -``` - -#### 处理流程 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Analyst Agent 处理流程 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. [信誉评分计算] │ -│ │ │ -│ ├─> 基础权重: │ -│ │ • 论文 = 1.0 │ -│ │ • 专利 = 0.8 │ -│ │ • 网页 = 0.5 │ -│ │ • MCP 数据 = 0.9 │ -│ │ │ -│ ├─> 影响因子加成 = IF / 10 (最高 1.5) │ -│ │ │ -│ ├─> 同行评议加成 = 1.2x (若已评议) │ -│ │ │ -│ ├─> 时效性衰减: │ -│ │ • < 5 年: 1.0x │ -│ │ • 5-10 年: 线性衰减 │ -│ │ • > 10 年: 0.5x │ -│ │ │ -│ └─> 最终评分 = 基础 × 影响因子 × 同行评议 × 时效性 × 相关性 │ -│ │ -│ 2. [单位标准化] 统一转换为 SI 单位 │ -│ • 1 km → 1000 m │ -│ • 1 GB → 1e9 bytes │ -│ • 等 │ -│ │ -│ 3. [冲突检测] │ -│ │ │ -│ ├─> 提取所有数值 │ -│ │ └─> {value, unit, context, evidenceId} │ -│ │ │ -│ ├─> 按 (parameter + context) 分组 │ -│ │ │ -│ ├─> 计算差异: variance = (ValueA - ValueB) / Max │ -│ │ │ -│ └─> 若 variance > 15% 且环境相似 │ -│ └─> 生成 ConflictRecord │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -#### 输出 - -```typescript -interface ConflictRecord { - conflictId: string; - parameter: string; // 发生冲突的参数名称 - disputedValues: Array<{ - value: number; - unit: string; - evidenceId: string; - source: string; - }>; - variance: number; // 差异程度 (百分比) - context: string; // 冲突上下文 - resolutionStatus: 'OPEN' | 'RESOLVED' | 'IGNORED'; - resolution?: { - selectedEvidenceId: string; - reason: string; - resolvedAt: string; - }; -} -``` +## 7. 冲突与投票流程 + +1. 引擎进入分析阶段,生成 `ConflictRecord[]`(可为空)。 +2. 前端可调用: + - `GET /api/v1/tasks/{task_id}/conflicts` +3. 用户投票: + - `POST /api/v1/evidence/{evidence_id}/vote` + - body: `conflictId + selectedEvidenceId + reason` +4. `ConflictRepository.resolve` 更新为 `RESOLVED`。 --- -### 3.4 MCP Executor Agent (MCP 执行代理) - -#### 职能 -安全地连接外部工具与数据源。 - -#### 输入 - -```typescript -interface MCPExecutionRequest { - toolName: string; - method: string; - params: Record; - mode: 'read' | 'write' | 'execute'; -} -``` - -#### 处理流程 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ MCP Executor 处理流程 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. [协议处理] 基于 JSON-RPC 2.0 │ -│ └─> 构建 JSON-RPC 请求体 │ -│ │ -│ 2. [权限检查] │ -│ │ │ -│ ├─> Read-Only 模式 │ -│ │ └─> 允许直接执行 │ -│ │ │ -│ └─> Write/Execute 模式 │ -│ ├─> 挂起任务 │ -│ ├─> 返回 USER_CONFIRMATION_REQUIRED │ -│ └─> 等待 UI 授权后执行 │ -│ │ -│ 3. [执行调用] │ -│ │ │ -│ ├─> 短耗时任务 │ -│ │ └─> 同步等待结果 │ -│ │ │ -│ └─> 长耗时任务 │ -│ ├─> 返回 JOB_ID │ -│ └─> 每 5 秒轮询一次状态 │ -│ │ -│ 4. [沙盒隔离] │ -│ └─> 若执行失败,隔离错误,返回部分结果 │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -#### JSON-RPC 请求示例 - -```json -{ - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "python-executor", - "arguments": { - "code": "print('Hello World')", - "timeout": 30 - } - }, - "id": 1 -} -``` - -#### 输出 - -```typescript -interface MCPExecutionResult { - status: 'SUCCESS' | 'USER_CONFIRMATION_REQUIRED' | 'FAILED'; - result?: any; - jobId?: string; // 长耗时任务 - error?: string; -} -``` +## 8. 暂停/恢复/终止/恢复快照 + +## 8.1 暂停 +`POST /tasks/{id}/pause` +- `control.paused = true` +- 任务状态置 `SUSPENDED` + +## 8.2 恢复 +`POST /tasks/{id}/resume` +- 清 `paused` +- 重新调用 `start()` + +## 8.3 终止 +`POST /tasks/{id}/abort` +- `control.aborted = true` +- 状态置 `ABORTED` + +## 8.4 快照恢复 +`POST /tasks/{id}/recover` +- 读取 snapshot 的 `completed_nodes` +- 从剩余节点继续执行 --- -### 3.5 Writer Agent (写作代理) - -#### 职能 -执行增量式文档生成作业。 - -#### 输入 - -```typescript -interface WritingRequest { - taskNode: TaskNode; - evidences: Evidence[]; - existingContent: string; // 已存在的内容 - lockedSections: string[]; // 已锁定的段落 ID -} -``` - -#### 处理流程 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Writer Agent 处理流程 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. [分段绑定] │ -│ └─> 每个 # Section 绑定至特定 TaskNode │ -│ │ -│ 2. [锁定检查] │ -│ └─> 检查 lockedSections,跳过已锁定段落 │ -│ │ -│ 3. [局部生成] │ -│ │ │ -│ ├─> 节点完成时触发 │ -│ ├─> 仅生成对应段落 │ -│ └─> 使用 LLM (GPT-4 / Claude Opus) │ -│ │ -│ 4. [溯源索引] │ -│ │ │ -│ ├─> 维护 UUID -> Citation 映射 │ -│ └─> 自动生成 [1][2] 引用标记 │ -│ │ -│ 5. [增量润色] │ -│ │ │ -│ ├─> 合并新段落到现有内容 │ -│ └─> 检测并修复格式问题 │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -#### 输出 - -```typescript -interface WritingResult { - content: string; // Markdown 格式 - citations: Record; // UUID -> 映射 - lockedSections: string[]; // 更新后的锁定列表 -} - -interface Citation { - id: string; - authors: string[]; - title: string; - year: number; - source: string; - url: string; -} -``` +## 9. 报告生成与下载 + +## 9.1 报告文件 +输出目录:`backend/.data/reports/` +- `{task_id}.md` +- `{task_id}.bib` + +## 9.2 下载接口 +- 任务下载:`GET /tasks/{task_id}/report/download` +- 会话下载:`GET /conversations/{conversation_id}/report/download` + +前端通过 Blob 触发浏览器下载。 --- -## 4. 数据流转关系 - -### 4.1 全局数据流图 - -``` -┌──────────────┐ -│ User Input │ -│ (研究议题) │ -└──────┬───────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Master Planner │ -│ 输入: CreateTaskRequest │ -│ 输出: DAGGraph (nodes + edges) │ -└──────┬──────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Retrieval Agent │ -│ 输入: TaskNode + Query │ -│ 输出: Evidence[] │ -└──────┬──────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Analyst Agent │ -│ 输入: Evidence[] │ -│ 输出: Evidence[] (带评分) + ConflictRecord[] │ -└──────┬──────────────────────────────────────────────────────────┘ - │ - ├──────────────┐ - │ ▼ - │ ┌─────────────────┐ - │ │ Conflict? │──Yes──> Reviewing 状态 - │ └────────┬────────┘ - │ │ No - │ ▼ - │ ┌─────────────────┐ - │ │ MCP Required? │──Yes──> MCP Executor - │ └────────┬────────┘ - │ │ No - ▼ ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Writer Agent │ -│ 输入: Evidence[] + TaskNode[] + existingContent │ -│ 输出: Markdown Document + Bibliography │ -└──────┬──────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────┐ -│ Final Report │ -│ (导出) │ -└──────────────┘ -``` - -### 4.2 数据结构关系图 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 数据结构关系 │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ TaskNode (1) ────────────────┬────────> (N) Evidence │ -│ ├─ taskId: UUID │ ├─ id: UUID │ -│ ├─ status: enum │ ├─ sourceType: enum │ -│ ├─ dependencies: UUID[] │ ├─ content: string │ -│ ├─ children: UUID[] │ ├─ score: number │ -│ └─ output: Evidence[] ────┘ └─ extractedData │ -│ │ │ -│ ▼ │ -│ ConflictRecord │ -│ ├─ conflictId: UUID │ -│ ├─ disputedValues[] │ -│ └─ resolutionStatus │ -│ │ -│ TaskNode (N) ──> DAGGraph │ -│ ├─ nodes: TaskNode[] │ -│ └─ edges: Edge[] │ -│ ├─ from: UUID │ -│ └─ to: UUID │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` +## 10. API 调用顺序示例 + +## 10.1 从 0 到 1 完整跑通 + +1. `POST /conversations` +2. `GET /conversations/{id}`(前端轮询) +3. `PUT /conversations/{id}/plan`(可选) +4. `POST /conversations/{id}/run` +5. `GET /conversations/{id}`(轮询直到 COMPLETED) +6. `GET /conversations/{id}/report/download` + +## 10.2 已完成后补检索重跑 + +1. `POST /conversations/{id}/plan/revise`(指令含“补充检索/重跑”) +2. 后端自动触发新任务运行 +3. 前端轮询 `GET /conversations/{id}` 直到完成 + +## 10.3 已完成后改写报告 + +1. `POST /conversations/{id}/plan/revise`(指令含“改写报告/演讲稿”) +2. 后端异步报告改写 +3. 时间线出现报告改写进度组 +4. 完成后追加新 `FINAL_REPORT` --- -## 5. API 接口规范 - -### 5.1 RESTful API 端点 - -#### 任务管理 - -| 方法 | 端点 | 描述 | 请求体 | 响应 | -|------|------|------|--------|------| -| POST | `/api/v1/tasks` | 创建新研究任务 | CreateTaskRequest | TaskNode | -| GET | `/api/v1/tasks/{task_id}` | 获取任务详情 | - | TaskNode | -| PUT | `/api/v1/tasks/{task_id}` | 更新任务配置 | UpdateTaskRequest | TaskNode | -| DELETE | `/api/v1/tasks/{task_id}` | 删除任务 | - | DeleteResponse | -| GET | `/api/v1/tasks/{task_id}/dag` | 获取任务 DAG 结构 | - | DAGGraph | - -#### 证据管理 - -| 方法 | 端点 | 描述 | 请求体 | 响应 | -|------|------|------|--------|------| -| GET | `/api/v1/evidence` | 获取证据列表 | QueryParams | Evidence[] | -| GET | `/api/v1/evidence/{evidence_id}` | 获取证据详情 | - | Evidence | -| POST | `/api/v1/evidence/{evidence_id}/vote` | 证据投票 | VoteRequest | VoteResponse | - -#### 状态控制 - -| 方法 | 端点 | 描述 | 请求体 | 响应 | -|------|------|------|--------|------| -| POST | `/api/v1/tasks/{task_id}/start` | 启动任务执行 | - | StateResponse | -| POST | `/api/v1/tasks/{task_id}/pause` | 暂停任务 | - | StateResponse | -| POST | `/api/v1/tasks/{task_id}/resume` | 恢复任务 | - | StateResponse | -| POST | `/api/v1/tasks/{task_id}/abort` | 终止任务 | - | StateResponse | - -### 5.2 WebSocket 事件流 - -**订阅频道:** `task/{task_id}/progress` - -**推送事件类型:** - -| 事件类型 | 说明 | 数据结构 | -|---------|------|----------| -| TASK_STARTED | 任务开始执行 | `{taskId, timestamp, node}` | -| TASK_PROGRESS | 进度更新 | `{taskId, timestamp, progress: 0-100}` | -| EVIDENCE_FOUND | 发现新证据 | `{taskId, timestamp, evidence}` | -| TASK_COMPLETED | 任务完成 | `{taskId, timestamp, result}` | -| ERROR | 执行错误 | `{taskId, timestamp, error}` | - -### 5.3 请求/响应示例 - -#### 创建任务 - -**请求** (POST /api/v1/tasks): -```json -{ - "title": "大语言模型的幻觉问题研究", - "description": "调研 LLM 幻觉问题的成因、检测方法和缓解策略", - "config": { - "maxDepth": 3, - "maxNodes": 50, - "searchSources": ["arXiv", "Google Scholar"], - "priority": 5 - } -} -``` - -**响应** (201 Created): -```json -{ - "taskId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "status": "READY", - "createdAt": "2024-01-01T00:00:00Z", - "dag": { - "nodes": [...], - "edges": [...] - } -} -``` +## 11. 错误分支与降级策略 + +- LLM 不可用: + - 计划生成回退 `_fallback_plan` + - 报告改写回退 `_fallback_revised_report` +- 检索 provider 失败: + - 单 provider 警告,其他 provider 继续 +- 状态迁移非法: + - 抛 `InvalidStateTransition` + - 任务置 `FAILED` +- 前端请求超时: + - API 层给出统一中文超时提示 --- -## 6. 错误处理与容错 - -### 6.1 错误分类与处理策略 - -| 错误类型 | 示例 | 处理策略 | -|---------|------|----------| -| 网络超时 | API 请求超时 (>30s) | 重试 3 次,指数退避 (1s, 2s, 4s) | -| API 失败 | 429/5xx 响应 | 切换备用 API,记录日志 | -| 解析失败 | 网页内容提取异常 | 标记为低质量,继续处理 | -| DAG 冲突 | 循环依赖检测 | 拒绝提交,返回错误路径 | -| 资源耗尽 | Token 限额超出 | 暂停任务,用户确认后续 | -| MCP 失败 | 外部工具调用失败 | 沙盒隔离,返回部分结果 | - -### 6.2 状态恢复机制 - -**快照保存:** -- 触发时机: 每个任务节点完成时 -- 存储位置: Redis -- 过期时间: 24 小时 - -**快照结构:** -```typescript -interface StateSnapshot { - task_id: string; - timestamp: string; - fsm_state: 'READY' | 'PLANNING' | 'EXECUTING' | 'REVIEWING' | 'SYNTHESIZING' | 'FINALIZING'; - completed_nodes: string[]; - pending_nodes: string[]; - evidence_cache: Record; - conflict_records: ConflictRecord[]; -} -``` - -**恢复逻辑:** -1. 检查 Redis 中是否存在快照 -2. 若存在,反序列化并恢复状态 -3. 从断点处继续执行 +## 12. 调试建议 + +1. 后端日志看 `ExecutionEngine` 阶段事件是否完整。 +2. 查 `conversation_messages` 中 `PROGRESS_GROUP` 的 `metadata.entries` 是否连续。 +3. 报告异常优先检查: + - `tasks.report_path` + - `backend/.data/reports/*.md` +4. 若计划解析不符合预期,检查 front matter 字段名是否严格使用: + - `title/topic/max_depth/max_nodes/priority/search_sources` --- -## 附录 - -### A. 状态转换表 - -| 当前状态 | 触发事件 | 目标状态 | -|---------|---------|----------| -| READY | start() | PLANNING | -| PLANNING | dag_generated | EXECUTING | -| EXECUTING | conflict_detected | REVIEWING | -| EXECUTING | all_completed | SYNTHESIZING | -| REVIEWING | conflict_resolved | EXECUTING | -| REVIEWING | all_resolved | SYNTHESIZING | -| SYNTHESIZING | content_approved | FINALIZING | -| * | abort() | FINALIZING | -| * | pause() | SUSPENDED | -| SUSPENDED | resume() | 恢复原状态 | - -### B. 配置参数汇总 - -| 参数 | 默认值 | 说明 | -|------|--------|------| -| maxDepth | 3 | 最大搜索深度 | -| maxNodes | 50 | 最大任务节点数 | -| L1_CACHE_SIZE | 1000 | L1 缓存容量 | -| L1_CACHE_TTL | 3600 | L1 缓存过期时间 (秒) | -| L2_SIMILARITY_THRESHOLD | 0.9 | L2 相似度阈值 | -| L2_CACHE_TTL | 86400 | L2 缓存过期时间 (秒) | -| INFO_GAIN_THRESHOLD | 0.2 | 信息增益剪枝阈值 | -| CONFLICT_VARIANCE_THRESHOLD | 0.15 | 冲突检测差异阈值 (15%) | -| MAX_CONCURRENT_TASKS | 5 | 最大并发任务数 | -| RETRY_MAX_ATTEMPTS | 3 | 最大重试次数 | -| RETRY_BASE_DELAY | 1 | 重试基础延迟 (秒) | +## 13. 回归检查最小清单 + +1. 创建会话 -> 生成计划 -> 启动研究 -> 完成 -> 下载报告。 +2. 完成后发送“改成演讲稿”,确认追加新 `FINAL_REPORT`。 +3. 完成后发送“补充检索并重跑”,确认 task_id 变化。 +4. 删除单会话和删除全部会话均可用,运行中任务会被中断。 +5. 移动端抽屉开关、对话框 Esc/遮罩关闭、Enter 发送均正常。 --- -*文档版本: 1.0* -*更新日期: 2024* +如需继续扩展,请先读 `doc.md` 的模块说明,再根据本手册挑选插点。 diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 9089f39..d82c916 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,5 +1,6 @@ from fastapi import APIRouter +from app.api.routes.conversations import router as conversations_router from app.api.routes.evidence import router as evidence_router from app.api.routes.mcp import router as mcp_router from app.api.routes.tasks import router as tasks_router @@ -8,3 +9,4 @@ api_router.include_router(tasks_router) api_router.include_router(evidence_router) api_router.include_router(mcp_router) +api_router.include_router(conversations_router) diff --git a/backend/app/api/routes/conversations.py b/backend/app/api/routes/conversations.py new file mode 100644 index 0000000..ce36c7b --- /dev/null +++ b/backend/app/api/routes/conversations.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +from app.deps import conversation_agent, conversation_repository, task_repository +from app.models.schemas import ( + ConversationBulkDeleteResponse, + ConversationDeleteResponse, + ConversationDetail, + ConversationSummary, + CreateConversationRequest, + PlanRevision, + RevisePlanRequest, + RevisePlanResponse, + RunConversationRequest, + RunConversationResponse, + UpdateConversationRequest, + UpdatePlanRequest, +) + +router = APIRouter(prefix="/api/v1") + + +@router.post("/conversations", response_model=ConversationDetail, status_code=201) +async def create_conversation(payload: CreateConversationRequest) -> ConversationDetail: + return await conversation_agent.create_conversation(topic=payload.topic, config=payload.config) + + +@router.get("/conversations", response_model=list[ConversationSummary]) +def list_conversations() -> list[ConversationSummary]: + return conversation_repository.list_summaries() + + +@router.get("/conversations/{conversation_id}", response_model=ConversationDetail) +def get_conversation(conversation_id: str) -> ConversationDetail: + try: + return conversation_repository.get_detail(conversation_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc + + +@router.delete("/conversations/{conversation_id}", response_model=ConversationDeleteResponse) +def delete_conversation(conversation_id: str) -> ConversationDeleteResponse: + try: + conversation_agent.delete_conversation(conversation_id=conversation_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc + return ConversationDeleteResponse(conversationId=conversation_id, deleted=True) + + +@router.delete("/conversations", response_model=ConversationBulkDeleteResponse) +def delete_all_conversations() -> ConversationBulkDeleteResponse: + deleted_count = conversation_agent.delete_all_conversations() + return ConversationBulkDeleteResponse(deleted=True, deletedCount=deleted_count) + + +@router.patch("/conversations/{conversation_id}", response_model=ConversationDetail) +def rename_conversation(conversation_id: str, payload: UpdateConversationRequest) -> ConversationDetail: + try: + return conversation_agent.rename_conversation( + conversation_id=conversation_id, + topic=payload.topic, + sync_current_plan=payload.syncCurrentPlan, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/conversations/{conversation_id}/plan/revise", response_model=RevisePlanResponse) +async def revise_plan(conversation_id: str, payload: RevisePlanRequest) -> RevisePlanResponse: + try: + plan, message = await conversation_agent.revise_plan( + conversation_id=conversation_id, + instruction=payload.instruction, + ) + return RevisePlanResponse(plan=plan, message=message) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.put("/conversations/{conversation_id}/plan", response_model=PlanRevision) +def update_plan(conversation_id: str, payload: UpdatePlanRequest) -> PlanRevision: + try: + return conversation_agent.update_plan(conversation_id=conversation_id, markdown=payload.markdown) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/conversations/{conversation_id}/run", response_model=RunConversationResponse) +async def run_conversation(conversation_id: str, payload: RunConversationRequest) -> RunConversationResponse: + _ = payload + try: + return await conversation_agent.start_research(conversation_id=conversation_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/conversations/{conversation_id}/report/download") +def download_conversation_report(conversation_id: str) -> FileResponse: + try: + summary = conversation_repository.get_summary(conversation_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Conversation not found: {conversation_id}") from exc + if not summary.taskId: + raise HTTPException(status_code=404, detail="Conversation has no task yet") + try: + task = task_repository.get_task(summary.taskId) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Task not found: {summary.taskId}") from exc + if not task.reportPath: + raise HTTPException(status_code=404, detail="Report not generated yet") + path = Path(task.reportPath) + if not path.exists(): + raise HTTPException(status_code=404, detail="Report file does not exist") + return FileResponse(path, media_type="text/markdown", filename=f"{conversation_id}.md") diff --git a/backend/app/api/routes/tasks.py b/backend/app/api/routes/tasks.py index bb2b994..f08791a 100644 --- a/backend/app/api/routes/tasks.py +++ b/backend/app/api/routes/tasks.py @@ -3,6 +3,7 @@ from pathlib import Path from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse from app.core.utils import new_id from app.deps import conflict_repository, execution_engine, progress_hub, task_repository @@ -156,6 +157,20 @@ def get_report(task_id: str) -> dict[str, str]: return {"taskId": task_id, "content": path.read_text(encoding="utf-8")} +@router.get("/tasks/{task_id}/report/download") +def download_report(task_id: str) -> FileResponse: + try: + task = task_repository.get_task(task_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + if not task.reportPath: + raise HTTPException(status_code=404, detail="Report not generated yet") + path = Path(task.reportPath) + if not path.exists(): + raise HTTPException(status_code=404, detail="Report file does not exist") + return FileResponse(path, media_type="text/markdown", filename=f"{task_id}.md") + + @router.get("/tasks/{task_id}/snapshot") def get_snapshot(task_id: str) -> dict: try: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index fd3e633..5435fa7 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,7 +6,7 @@ class Settings(BaseSettings): api_prefix: str = "/api/v1" db_path: str = "backend/.data/deep_research.db" log_level: str = "INFO" - use_mock_sources: bool = True + use_mock_sources: bool = False default_llm_provider: str = "openrouter" default_llm_model: str = "deepseek/deepseek-chat-v3-0324" diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 832edcd..c271333 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -70,6 +70,40 @@ created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS conversations ( + conversation_id TEXT PRIMARY KEY, + topic TEXT NOT NULL, + status TEXT NOT NULL, + config_json TEXT NOT NULL, + task_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS plan_revisions ( + conversation_id TEXT NOT NULL, + version INTEGER NOT NULL, + author TEXT NOT NULL, + markdown TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (conversation_id, version) +); + +CREATE TABLE IF NOT EXISTS conversation_messages ( + message_id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + kind TEXT NOT NULL, + content TEXT NOT NULL, + metadata_json TEXT NOT NULL, + collapsed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_conversations_task_id ON conversations(task_id); +CREATE INDEX IF NOT EXISTS idx_conversation_messages_created_at + ON conversation_messages(conversation_id, created_at ASC); """ diff --git a/backend/app/deps.py b/backend/app/deps.py index cf74ba9..db44691 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -1,9 +1,12 @@ from __future__ import annotations +from app.repositories.conversation_repository import ConversationRepository from app.repositories.conflict_repository import ConflictRepository from app.repositories.evidence_repository import EvidenceRepository from app.repositories.task_repository import TaskRepository +from app.services.agents import ReportAgent, ResearchAgent from app.services.analyst import AnalystService +from app.services.conversation_agent import ConversationAgent from app.services.execution_engine import ExecutionEngine from app.services.mcp_executor import MCPExecutor from app.services.planner import MasterPlanner @@ -14,12 +17,15 @@ task_repository = TaskRepository() evidence_repository = EvidenceRepository() conflict_repository = ConflictRepository() +conversation_repository = ConversationRepository() planner = MasterPlanner() progress_hub = ProgressHub() retrieval_service = RetrievalService() analyst_service = AnalystService() writer_service = WriterService() mcp_executor = MCPExecutor() +research_agent = ResearchAgent(retrieval_service=retrieval_service, mcp_executor=mcp_executor) +report_agent = ReportAgent(writer_service=writer_service) execution_engine = ExecutionEngine( task_repository, planner, @@ -29,4 +35,14 @@ conflict_repository, analyst_service, writer_service, + research_agent, + report_agent, ) +conversation_agent = ConversationAgent( + repository=conversation_repository, + task_repository=task_repository, + execution_engine=execution_engine, + evidence_repository=evidence_repository, + report_agent=report_agent, +) +execution_engine.set_event_listener(conversation_agent.on_task_event) diff --git a/backend/app/main.py b/backend/app/main.py index 33ba5b4..51f0eea 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from app.api.router import api_router from app.core.config import settings @@ -13,6 +14,13 @@ async def lifespan(_: FastAPI): app = FastAPI(title=settings.app_name, lifespan=lifespan) +app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) app.include_router(api_router) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 7d4a9a1..eb6fc1d 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -19,6 +19,14 @@ class TaskStatus(StrEnum): ABORTED = "ABORTED" +class ConversationStatus(StrEnum): + DRAFTING_PLAN = "DRAFTING_PLAN" + PLAN_READY = "PLAN_READY" + RUNNING = "RUNNING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + + class NodeStatus(StrEnum): PENDING = "PENDING" RUNNING = "RUNNING" @@ -108,6 +116,98 @@ class ProgressEvent(BaseModel): data: dict[str, Any] +class MessageRole(StrEnum): + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + + +class MessageKind(StrEnum): + USER_TEXT = "USER_TEXT" + PLAN_DRAFT = "PLAN_DRAFT" + PLAN_EDITED = "PLAN_EDITED" + PLAN_REVISION = "PLAN_REVISION" + PROGRESS_GROUP = "PROGRESS_GROUP" + FINAL_REPORT = "FINAL_REPORT" + ERROR = "ERROR" + + +class PlanRevision(BaseModel): + conversationId: str + version: int + author: MessageRole + markdown: str + createdAt: str + + +class ConversationMessage(BaseModel): + messageId: str + conversationId: str + role: MessageRole + kind: MessageKind + content: str + metadata: dict[str, Any] = Field(default_factory=dict) + collapsed: bool = False + createdAt: str + + +class ConversationSummary(BaseModel): + conversationId: str + topic: str + status: ConversationStatus + taskId: str | None = None + createdAt: str + updatedAt: str + + +class ConversationDetail(ConversationSummary): + currentPlan: PlanRevision | None = None + messages: list[ConversationMessage] = Field(default_factory=list) + + +class CreateConversationRequest(BaseModel): + topic: str = Field(min_length=2, max_length=500) + config: TaskConfig | None = None + + +class UpdateConversationRequest(BaseModel): + topic: str = Field(min_length=2, max_length=500) + syncCurrentPlan: bool = True + + +class RevisePlanRequest(BaseModel): + instruction: str = Field(min_length=2, max_length=4000) + + +class UpdatePlanRequest(BaseModel): + markdown: str = Field(min_length=10, max_length=60000) + + +class RunConversationRequest(BaseModel): + pass + + +class RevisePlanResponse(BaseModel): + plan: PlanRevision + message: ConversationMessage + + +class RunConversationResponse(BaseModel): + conversationId: str + taskId: str + status: ConversationStatus + + +class ConversationDeleteResponse(BaseModel): + conversationId: str + deleted: bool + + +class ConversationBulkDeleteResponse(BaseModel): + deleted: bool + deletedCount: int + + class SourceType(StrEnum): PAPER = "PAPER" WEB = "WEB" diff --git a/backend/app/repositories/conversation_repository.py b/backend/app/repositories/conversation_repository.py new file mode 100644 index 0000000..bf638cd --- /dev/null +++ b/backend/app/repositories/conversation_repository.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +import json + +from app.core.database import get_connection +from app.core.utils import now_iso +from app.models.schemas import ( + ConversationDetail, + ConversationMessage, + ConversationStatus, + ConversationSummary, + MessageKind, + MessageRole, + PlanRevision, + TaskConfig, +) + + +class ConversationRepository: + def create_conversation( + self, + *, + conversation_id: str, + topic: str, + status: ConversationStatus, + config: TaskConfig, + ) -> ConversationSummary: + ts = now_iso() + with get_connection() as conn: + conn.execute( + """ + INSERT INTO conversations( + conversation_id, topic, status, config_json, task_id, created_at, updated_at + ) VALUES(?, ?, ?, ?, ?, ?, ?) + """, + (conversation_id, topic, status.value, config.model_dump_json(), None, ts, ts), + ) + conn.commit() + return self.get_summary(conversation_id) + + def get_summary(self, conversation_id: str) -> ConversationSummary: + with get_connection() as conn: + row = conn.execute( + "SELECT * FROM conversations WHERE conversation_id = ?", + (conversation_id,), + ).fetchone() + if row is None: + raise KeyError(conversation_id) + return ConversationSummary( + conversationId=row["conversation_id"], + topic=row["topic"], + status=ConversationStatus(row["status"]), + taskId=row["task_id"], + createdAt=row["created_at"], + updatedAt=row["updated_at"], + ) + + def list_summaries(self) -> list[ConversationSummary]: + with get_connection() as conn: + rows = conn.execute( + """ + SELECT * FROM conversations + ORDER BY updated_at DESC, created_at DESC + """ + ).fetchall() + return [ + ConversationSummary( + conversationId=row["conversation_id"], + topic=row["topic"], + status=ConversationStatus(row["status"]), + taskId=row["task_id"], + createdAt=row["created_at"], + updatedAt=row["updated_at"], + ) + for row in rows + ] + + def get_config(self, conversation_id: str) -> TaskConfig: + with get_connection() as conn: + row = conn.execute( + "SELECT config_json FROM conversations WHERE conversation_id = ?", + (conversation_id,), + ).fetchone() + if row is None: + raise KeyError(conversation_id) + return TaskConfig.model_validate_json(row["config_json"]) + + def set_status(self, conversation_id: str, status: ConversationStatus) -> None: + with get_connection() as conn: + conn.execute( + """ + UPDATE conversations + SET status = ?, updated_at = ? + WHERE conversation_id = ? + """, + (status.value, now_iso(), conversation_id), + ) + conn.commit() + if conn.total_changes == 0: + raise KeyError(conversation_id) + + def set_task_id(self, conversation_id: str, task_id: str) -> None: + with get_connection() as conn: + conn.execute( + """ + UPDATE conversations + SET task_id = ?, updated_at = ? + WHERE conversation_id = ? + """, + (task_id, now_iso(), conversation_id), + ) + conn.commit() + if conn.total_changes == 0: + raise KeyError(conversation_id) + + def find_by_task_id(self, task_id: str) -> ConversationSummary | None: + with get_connection() as conn: + row = conn.execute( + "SELECT conversation_id FROM conversations WHERE task_id = ? LIMIT 1", + (task_id,), + ).fetchone() + if row is None: + return None + return self.get_summary(row["conversation_id"]) + + def update_topic(self, conversation_id: str, topic: str) -> ConversationSummary: + with get_connection() as conn: + conn.execute( + """ + UPDATE conversations + SET topic = ?, updated_at = ? + WHERE conversation_id = ? + """, + (topic, now_iso(), conversation_id), + ) + conn.commit() + if conn.total_changes == 0: + raise KeyError(conversation_id) + return self.get_summary(conversation_id) + + def delete_conversation(self, conversation_id: str) -> None: + self.get_summary(conversation_id) + with get_connection() as conn: + conn.execute( + "DELETE FROM conversation_messages WHERE conversation_id = ?", + (conversation_id,), + ) + conn.execute( + "DELETE FROM plan_revisions WHERE conversation_id = ?", + (conversation_id,), + ) + conn.execute( + "DELETE FROM conversations WHERE conversation_id = ?", + (conversation_id,), + ) + conn.commit() + + def delete_all_conversations(self) -> int: + with get_connection() as conn: + row = conn.execute("SELECT COUNT(*) AS count FROM conversations").fetchone() + deleted_count = int(row["count"]) if row else 0 + conn.execute("DELETE FROM conversation_messages") + conn.execute("DELETE FROM plan_revisions") + conn.execute("DELETE FROM conversations") + conn.commit() + return deleted_count + + def add_plan_revision(self, conversation_id: str, *, author: MessageRole, markdown: str) -> PlanRevision: + self.get_summary(conversation_id) + ts = now_iso() + with get_connection() as conn: + existing = conn.execute( + """ + SELECT COALESCE(MAX(version), 0) AS max_version + FROM plan_revisions + WHERE conversation_id = ? + """, + (conversation_id,), + ).fetchone() + max_version = int(existing["max_version"]) if existing else 0 + next_version = max_version + 1 + conn.execute( + """ + INSERT INTO plan_revisions(conversation_id, version, author, markdown, created_at) + VALUES(?, ?, ?, ?, ?) + """, + (conversation_id, next_version, author.value, markdown, ts), + ) + conn.execute( + """ + UPDATE conversations + SET updated_at = ? + WHERE conversation_id = ? + """, + (ts, conversation_id), + ) + conn.commit() + return self.get_plan_revision(conversation_id, next_version) + + def get_plan_revision(self, conversation_id: str, version: int) -> PlanRevision: + with get_connection() as conn: + row = conn.execute( + """ + SELECT * FROM plan_revisions + WHERE conversation_id = ? AND version = ? + """, + (conversation_id, version), + ).fetchone() + if row is None: + raise KeyError(f"{conversation_id}:{version}") + return PlanRevision( + conversationId=row["conversation_id"], + version=row["version"], + author=MessageRole(row["author"]), + markdown=row["markdown"], + createdAt=row["created_at"], + ) + + def get_current_plan(self, conversation_id: str) -> PlanRevision | None: + with get_connection() as conn: + row = conn.execute( + """ + SELECT * FROM plan_revisions + WHERE conversation_id = ? + ORDER BY version DESC + LIMIT 1 + """, + (conversation_id,), + ).fetchone() + if row is None: + return None + return PlanRevision( + conversationId=row["conversation_id"], + version=row["version"], + author=MessageRole(row["author"]), + markdown=row["markdown"], + createdAt=row["created_at"], + ) + + def add_message( + self, + conversation_id: str, + *, + message_id: str, + role: MessageRole, + kind: MessageKind, + content: str, + metadata: dict | None = None, + collapsed: bool = False, + ) -> ConversationMessage: + self.get_summary(conversation_id) + ts = now_iso() + metadata_json = json.dumps(metadata or {}, ensure_ascii=False) + with get_connection() as conn: + conn.execute( + """ + INSERT INTO conversation_messages( + message_id, conversation_id, role, kind, content, metadata_json, collapsed, created_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + message_id, + conversation_id, + role.value, + kind.value, + content, + metadata_json, + 1 if collapsed else 0, + ts, + ), + ) + conn.execute( + """ + UPDATE conversations + SET updated_at = ? + WHERE conversation_id = ? + """, + (ts, conversation_id), + ) + conn.commit() + return self.get_message(message_id) + + def get_message(self, message_id: str) -> ConversationMessage: + with get_connection() as conn: + row = conn.execute( + "SELECT * FROM conversation_messages WHERE message_id = ?", + (message_id,), + ).fetchone() + if row is None: + raise KeyError(message_id) + return ConversationMessage( + messageId=row["message_id"], + conversationId=row["conversation_id"], + role=MessageRole(row["role"]), + kind=MessageKind(row["kind"]), + content=row["content"], + metadata=json.loads(row["metadata_json"]), + collapsed=bool(row["collapsed"]), + createdAt=row["created_at"], + ) + + def list_messages(self, conversation_id: str, *, limit: int = 300) -> list[ConversationMessage]: + with get_connection() as conn: + rows = conn.execute( + """ + SELECT * FROM conversation_messages + WHERE conversation_id = ? + ORDER BY created_at ASC + LIMIT ? + """, + (conversation_id, limit), + ).fetchall() + return [ + ConversationMessage( + messageId=row["message_id"], + conversationId=row["conversation_id"], + role=MessageRole(row["role"]), + kind=MessageKind(row["kind"]), + content=row["content"], + metadata=json.loads(row["metadata_json"]), + collapsed=bool(row["collapsed"]), + createdAt=row["created_at"], + ) + for row in rows + ] + + def append_progress_entry( + self, + conversation_id: str, + *, + task_id: str, + message_id: str, + phase: str, + state: str, + summary: str, + progress: int | None, + payload: dict, + ) -> ConversationMessage: + with get_connection() as conn: + rows = conn.execute( + """ + SELECT * FROM conversation_messages + WHERE conversation_id = ? AND kind = ? + ORDER BY created_at DESC + LIMIT 8 + """, + (conversation_id, MessageKind.PROGRESS_GROUP.value), + ).fetchall() + + for row in rows: + metadata = json.loads(row["metadata_json"]) + metadata_task_id = str(metadata.get("taskId", "")).strip() + if not metadata_task_id: + raw_entries = metadata.get("entries") + if isinstance(raw_entries, list): + for entry in reversed(raw_entries): + if not isinstance(entry, dict): + continue + raw = entry.get("raw") + if not isinstance(raw, dict): + continue + raw_task_id = raw.get("taskId") + if isinstance(raw_task_id, str) and raw_task_id.strip(): + metadata_task_id = raw_task_id.strip() + break + if metadata_task_id != task_id: + continue + if str(metadata.get("phase", "")).strip() != phase: + continue + entries = metadata.get("entries") + if not isinstance(entries, list): + entries = [] + entries.append( + { + "summary": summary, + "state": state, + "phase": phase, + "progress": progress, + "raw": payload, + } + ) + metadata["entries"] = entries[-50:] + metadata["phase"] = phase + metadata["state"] = state + metadata["latestProgress"] = progress + metadata["latestSummary"] = summary + metadata["taskId"] = task_id + conn.execute( + """ + UPDATE conversation_messages + SET content = ?, metadata_json = ? + WHERE message_id = ? + """, + (summary, json.dumps(metadata, ensure_ascii=False), row["message_id"]), + ) + conn.execute( + """ + UPDATE conversations + SET updated_at = ? + WHERE conversation_id = ? + """, + (now_iso(), conversation_id), + ) + conn.commit() + return self.get_message(row["message_id"]) + + return self.add_message( + conversation_id, + message_id=message_id, + role=MessageRole.SYSTEM, + kind=MessageKind.PROGRESS_GROUP, + content=summary, + metadata={ + "taskId": task_id, + "phase": phase, + "state": state, + "latestProgress": progress, + "latestSummary": summary, + "entries": [ + { + "summary": summary, + "state": state, + "phase": phase, + "progress": progress, + "raw": payload, + } + ], + }, + collapsed=True, + ) + + def get_detail(self, conversation_id: str) -> ConversationDetail: + summary = self.get_summary(conversation_id) + return ConversationDetail( + conversationId=summary.conversationId, + topic=summary.topic, + status=summary.status, + taskId=summary.taskId, + createdAt=summary.createdAt, + updatedAt=summary.updatedAt, + currentPlan=self.get_current_plan(conversation_id), + messages=self.list_messages(conversation_id), + ) diff --git a/backend/app/services/agents.py b/backend/app/services/agents.py new file mode 100644 index 0000000..b0de509 --- /dev/null +++ b/backend/app/services/agents.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re + +from app.models.schemas import Citation, Evidence +from app.services.mcp_executor import MCPExecutor +from app.services.retrieval import RetrievalService +from app.services.writer import ReportBlueprint, WriterService + + +class ResearchAgent: + """Collect evidence from configured providers and optional MCP read tools.""" + + def __init__(self, retrieval_service: RetrievalService, mcp_executor: MCPExecutor | None = None) -> None: + self.retrieval_service = retrieval_service + self.mcp_executor = mcp_executor + + async def collect_evidence( + self, + *, + task_id: str, + node_id: str, + query: str, + sources: list[str], + mcp_read_tools: list[str] | None = None, + ) -> list[Evidence]: + evidences = await self.retrieval_service.retrieve( + task_id=task_id, + node_id=node_id, + query=query, + sources=sources, + ) + if not self.mcp_executor or not mcp_read_tools: + return evidences + + # Placeholder MCP hook for future expansion. + for tool_name in mcp_read_tools: + await self.mcp_executor.execute( + tool_name=tool_name, + method="tools/call", + params={"query": query, "taskId": task_id, "nodeId": node_id}, + mode="read", + ) + return evidences + + +class ReportAgent: + """Generate report artifacts from structured sections and evidence.""" + + def __init__( + self, + writer_service: WriterService, + format_agent: "ReportFormatAgent | None" = None, + review_agent: "ReportReviewAgent | None" = None, + revision_agent: "ReportRevisionAgent | None" = None, + max_review_rounds: int = 3, + ) -> None: + self.writer_service = writer_service + self.format_agent = format_agent or ReportFormatAgent() + self.review_agent = review_agent or ReportReviewAgent() + self.revision_agent = revision_agent or ReportRevisionAgent(writer_service=writer_service) + self.max_review_rounds = max(1, max_review_rounds) + + def generate_report( + self, + *, + task_id: str, + task_title: str, + task_description: str, + sections: list[tuple[str, str]], + evidences: list[Evidence], + locked_sections: set[str] | None = None, + ) -> tuple[str, str, dict[str, Citation]]: + blueprint = self.format_agent.design_blueprint( + task_title=task_title, + task_description=task_description, + ) + draft_body = self.writer_service.generate_body( + task_title=task_title, + task_description=task_description, + sections=sections, + evidences=evidences, + blueprint=blueprint, + ) + review_result = self.review_agent.review(body=draft_body, blueprint=blueprint, evidences=evidences) + for _ in range(self.max_review_rounds): + if review_result.approved: + break + draft_body = self.revision_agent.revise( + draft_body=draft_body, + feedback=review_result, + task_title=task_title, + task_description=task_description, + sections=sections, + evidences=evidences, + blueprint=blueprint, + ) + review_result = self.review_agent.review(body=draft_body, blueprint=blueprint, evidences=evidences) + if not review_result.approved: + draft_body = self.revision_agent.rewrite_with_template( + task_title=task_title, + task_description=task_description, + sections=sections, + evidences=evidences, + blueprint=blueprint, + ) + return self.writer_service.write_report( + task_id=task_id, + task_title=task_title, + task_description=task_description, + sections=sections, + evidences=evidences, + locked_sections=locked_sections, + blueprint=blueprint, + report_body=draft_body, + ) + + +class ReportFormatAgent: + """Infer requested output format and section blueprint from task text.""" + + FORMAT_PATTERN = re.compile(r"(?:格式|体裁|输出形式)\s*[::]\s*([^\n,。;;]+)") + + def design_blueprint(self, *, task_title: str, task_description: str) -> ReportBlueprint: + source_text = f"{task_title}\n{task_description}" + lowered = source_text.lower() + custom_format = self._extract_custom_format(source_text) + + if self._contains_any(lowered, ["演讲", "演讲稿", "speech", "keynote"]): + return ReportBlueprint( + output_format="演讲稿", + objective="面向听众传达问题背景、关键观点与行动方案", + tone="清晰有节奏、以结论驱动", + section_titles=["开场", "背景", "核心观点", "证据支撑", "行动建议", "结语"], + ) + if self._contains_any(lowered, ["论文", "paper", "academic", "journal", "学术"]): + return ReportBlueprint( + output_format="论文", + objective="形成可复核的研究论证与结构化结论", + tone="严谨客观、术语准确", + section_titles=["摘要", "引言", "相关工作", "方法", "结果与讨论", "结论"], + ) + if self._contains_any(lowered, ["报告", "report", "调研"]): + return ReportBlueprint( + output_format="研究报告", + objective="给出跨维度、可追溯且可执行的分析结论与决策建议", + tone="客观中立、论证充分、结论先行", + section_titles=["摘要", "研究范围与方法", "背景", "关键发现", "分析", "风险与局限", "结论与建议"], + ) + if custom_format: + return ReportBlueprint( + output_format=custom_format, + objective=f"按“{custom_format}”体裁交付内容,并保持结构化表达", + tone="清晰、克制、信息密集", + section_titles=["开篇", "主体", "结论"], + ) + return ReportBlueprint( + output_format="通用文章", + objective="在保持可读性的前提下,深度回答用户研究需求", + tone="客观清晰、信息密集", + section_titles=["摘要", "研究背景", "主体分析", "结论与下一步"], + ) + + @classmethod + def _extract_custom_format(cls, source_text: str) -> str: + match = cls.FORMAT_PATTERN.search(source_text) + if not match: + return "" + return match.group(1).strip() + + @staticmethod + def _contains_any(text: str, keywords: list[str]) -> bool: + return any(keyword in text for keyword in keywords) + + +@dataclass(frozen=True) +class ReportReviewResult: + approved: bool + issues: list[str] + + +class ReportReviewAgent: + """Review report quality and block intermediate traces from leaking to users.""" + + TRACE_PATTERNS = ( + re.compile(r"(?im)^\s*##\s*trace section\b"), + re.compile(r"(?im)^\s*\[locked\]"), + re.compile(r"(?im)^\s*挑战识别\s*:"), + ) + PLACEHOLDER_PATTERNS = ( + re.compile(r"(?i)\[mock\]"), + re.compile(r"(?i)\b(?:arxiv|semantic scholar|semanticscholar|tavily|web)\s+result\s+for\b"), + re.compile(r"(?i)synthetic evidence"), + ) + EVIDENCE_REF_PATTERN = re.compile(r"\[evidence:([^\]]+)\]") + MIN_BODY_BASE_CHARS = 780 + MIN_BODY_PER_SECTION_CHARS = 170 + MIN_SECTION_CHARS = 180 + MIN_SUMMARY_SECTION_CHARS = 110 + MIN_PARAGRAPHS_PER_SECTION = 2 + MIN_PARAGRAPHS_FOR_SHORT_SECTION = 1 + SHORT_SECTION_KEYWORDS = ("摘要", "结论", "结语", "开场") + + def review(self, *, body: str, blueprint: ReportBlueprint, evidences: list[Evidence]) -> ReportReviewResult: + issues: list[str] = [] + stripped = body.strip() + if not stripped: + return ReportReviewResult(approved=False, issues=["正文为空。"]) + + if any(pattern.search(body) for pattern in self.TRACE_PATTERNS): + issues.append("包含中间过程痕迹(如 Trace Section 或过程标签)。") + if any(pattern.search(body) for pattern in self.PLACEHOLDER_PATTERNS): + issues.append("包含占位检索文本,降低内容可信度。") + + missing_sections = [title for title in blueprint.section_titles if f"## {title}" not in body] + if missing_sections: + issues.append(f"章节不完整,缺少:{', '.join(missing_sections[:4])}。") + section_contents = self._section_contents(body) + shallow_sections: list[str] = [] + sparse_sections: list[str] = [] + for title in blueprint.section_titles: + content = section_contents.get(title, "").strip() + if not content: + continue + min_chars = self._section_min_chars(title) + if len(content) < min_chars: + shallow_sections.append(title) + min_paragraphs = self._section_min_paragraphs(title) + if self._paragraph_count(content) < min_paragraphs: + sparse_sections.append(title) + if shallow_sections: + issues.append(f"章节深度不足,内容偏短:{', '.join(shallow_sections[:4])}。") + if sparse_sections: + issues.append(f"章节展开不足,段落层次不够:{', '.join(sparse_sections[:4])}。") + + evidence_ids = {ev.id for ev in evidences} + cited_ids = set(self.EVIDENCE_REF_PATTERN.findall(body)) + cited_known = cited_ids.intersection(evidence_ids) + if evidence_ids and not cited_known: + issues.append("关键结论缺少有效证据ID引用。") + if evidence_ids and len(cited_known) < min(2, len(evidence_ids)): + issues.append("证据覆盖不足,至少应引用两个不同证据。") + + required_chars = self._minimum_body_chars(blueprint) + if len(stripped) < required_chars: + issues.append(f"正文过短,信息密度不足(当前 {len(stripped)},要求至少 {required_chars})。") + return ReportReviewResult(approved=not issues, issues=issues) + + @classmethod + def _section_contents(cls, body: str) -> dict[str, str]: + section_map: dict[str, list[str]] = {} + current_heading = "" + for line in body.splitlines(): + heading_match = re.match(r"^\s*##\s+(.+?)\s*$", line) + if heading_match: + current_heading = heading_match.group(1).strip() + section_map.setdefault(current_heading, []) + continue + if not current_heading: + continue + section_map[current_heading].append(line) + return {heading: "\n".join(lines).strip() for heading, lines in section_map.items()} + + @classmethod + def _minimum_body_chars(cls, blueprint: ReportBlueprint) -> int: + return cls.MIN_BODY_BASE_CHARS + len(blueprint.section_titles) * cls.MIN_BODY_PER_SECTION_CHARS + + @classmethod + def _section_min_chars(cls, title: str) -> int: + return cls.MIN_SUMMARY_SECTION_CHARS if cls._is_short_section(title) else cls.MIN_SECTION_CHARS + + @classmethod + def _section_min_paragraphs(cls, title: str) -> int: + if cls._is_short_section(title): + return cls.MIN_PARAGRAPHS_FOR_SHORT_SECTION + return cls.MIN_PARAGRAPHS_PER_SECTION + + @classmethod + def _is_short_section(cls, title: str) -> bool: + return any(keyword in title for keyword in cls.SHORT_SECTION_KEYWORDS) + + @staticmethod + def _paragraph_count(text: str) -> int: + text = text.strip() + if not text: + return 0 + by_blank = [part.strip() for part in re.split(r"\n\s*\n", text) if part.strip()] + if len(by_blank) > 1: + return len(by_blank) + by_lines = [line.strip() for line in text.splitlines() if line.strip()] + if len(by_lines) > 1: + return len(by_lines) + return 1 + + +class ReportRevisionAgent: + """Revise report body according to reviewer feedback.""" + + TRACE_LINE_PATTERNS = ( + re.compile(r"(?i)^\s*##\s*trace section\b"), + re.compile(r"(?i)^\s*\[locked\]"), + re.compile(r"^\s*挑战识别\s*:"), + ) + PLACEHOLDER_LINE_PATTERNS = ( + re.compile(r"(?i)\[mock\]"), + re.compile(r"(?i)\b(?:arxiv|semantic scholar|semanticscholar|tavily|web)\s+result\s+for\b"), + re.compile(r"(?i)synthetic evidence"), + ) + + def __init__(self, writer_service: WriterService) -> None: + self.writer_service = writer_service + + def revise( + self, + *, + draft_body: str, + feedback: ReportReviewResult, + task_title: str, + task_description: str, + sections: list[tuple[str, str]], + evidences: list[Evidence], + blueprint: ReportBlueprint, + ) -> str: + cleaned = self._strip_noisy_lines(draft_body) + if self._requires_template_rewrite(cleaned, feedback): + return self.rewrite_with_template( + task_title=task_title, + task_description=task_description, + sections=sections, + evidences=evidences, + blueprint=blueprint, + ) + return cleaned + + def rewrite_with_template( + self, + *, + task_title: str, + task_description: str, + sections: list[tuple[str, str]], + evidences: list[Evidence], + blueprint: ReportBlueprint, + ) -> str: + _ = task_description + return self.writer_service.generate_template_body( + task_title=task_title, + sections=sections, + evidences=evidences, + blueprint=blueprint, + ) + + def _strip_noisy_lines(self, text: str) -> str: + kept: list[str] = [] + for line in text.splitlines(): + if any(pattern.search(line) for pattern in self.TRACE_LINE_PATTERNS): + continue + if any(pattern.search(line) for pattern in self.PLACEHOLDER_LINE_PATTERNS): + continue + kept.append(line.rstrip()) + + compacted: list[str] = [] + blank_run = 0 + for line in kept: + if line.strip(): + blank_run = 0 + compacted.append(line) + continue + blank_run += 1 + if blank_run <= 1: + compacted.append("") + return "\n".join(compacted).strip() + + @staticmethod + def _requires_template_rewrite(cleaned_body: str, feedback: ReportReviewResult) -> bool: + minimum_chars = ReportReviewAgent.MIN_BODY_BASE_CHARS + ReportReviewAgent.MIN_BODY_PER_SECTION_CHARS + if len(cleaned_body.strip()) < minimum_chars: + return True + blocking_keywords = ( + "章节不完整", + "章节深度不足", + "章节展开不足", + "证据覆盖不足", + "缺少有效证据ID引用", + "正文过短", + ) + return any(any(keyword in issue for keyword in blocking_keywords) for issue in feedback.issues) diff --git a/backend/app/services/conversation_agent.py b/backend/app/services/conversation_agent.py new file mode 100644 index 0000000..50db0b1 --- /dev/null +++ b/backend/app/services/conversation_agent.py @@ -0,0 +1,1040 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from pathlib import Path +import re +from typing import Callable + +import httpx + +from app.core.config import settings +from app.core.utils import new_id +from app.models.schemas import ( + ConversationDetail, + ConversationMessage, + ConversationStatus, + MessageKind, + MessageRole, + NodeStatus, + PlanRevision, + RunConversationResponse, + TaskConfig, + TaskStatus, +) +from app.repositories.conversation_repository import ConversationRepository +from app.repositories.evidence_repository import EvidenceRepository +from app.repositories.task_repository import TaskRepository +from app.services.agents import ReportAgent +from app.services.execution_engine import ExecutionEngine + + +@dataclass(frozen=True) +class ParsedPlan: + title: str + config: TaskConfig + warnings: list[str] + + +class ConversationAgent: + _FRONT_MATTER_PATTERN = re.compile(r"\A---\s*\n(?P
.*?)\n---\s*\n?", re.DOTALL) + _KV_PATTERN = re.compile(r"^\s*([a-zA-Z_]+)\s*:\s*(.*?)\s*$") + _PLAN_INTENT_MARKERS = ( + "研究方案", + "研究计划", + "front matter", + "max_depth", + "max_nodes", + "priority", + "search_sources", + "任务树", + "执行步骤", + "改方案", + ) + _RESEARCH_INTENT_MARKERS = ( + "重新研究", + "重新执行", + "再执行", + "再跑", + "重跑", + "补充检索", + "补充资料", + "补充证据", + "再检索", + "再搜索", + "补充文献", + "查询最新", + "更新最新", + "追加调研", + ) + _REPORT_REBUILD_MARKERS = ( + "从证据重写", + "依据证据重写", + "基于证据重写", + "重新生成报告", + "全量重写", + ) + _REPORT_INTENT_MARKERS = ( + "改写报告", + "修改报告", + "重写报告", + "润色", + "演讲稿", + "口播", + "摘要版", + "精简版", + "扩写", + "改语气", + "改风格", + "rewrite", + "speech", + "tone", + "style", + ) + + def __init__( + self, + *, + repository: ConversationRepository, + task_repository: TaskRepository, + execution_engine: ExecutionEngine, + evidence_repository: EvidenceRepository | None = None, + report_agent: ReportAgent | None = None, + ) -> None: + self.repository = repository + self.task_repository = task_repository + self.execution_engine = execution_engine + self.evidence_repository = evidence_repository + self.report_agent = report_agent + + async def create_conversation(self, *, topic: str, config: TaskConfig | None = None) -> ConversationDetail: + selected_config = config or TaskConfig() + conversation_id = new_id() + self.repository.create_conversation( + conversation_id=conversation_id, + topic=topic, + status=ConversationStatus.DRAFTING_PLAN, + config=selected_config, + ) + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.USER, + kind=MessageKind.USER_TEXT, + content=topic, + metadata={"stage": "CREATED"}, + ) + markdown = await asyncio.to_thread(self._generate_initial_plan, topic=topic, config=selected_config) + revision = self.repository.add_plan_revision( + conversation_id, + author=MessageRole.ASSISTANT, + markdown=markdown, + ) + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.ASSISTANT, + kind=MessageKind.PLAN_DRAFT, + content=revision.markdown, + metadata={"version": revision.version}, + ) + self.repository.set_status(conversation_id, ConversationStatus.PLAN_READY) + return self.repository.get_detail(conversation_id) + + async def revise_plan(self, *, conversation_id: str, instruction: str) -> tuple[PlanRevision, ConversationMessage]: + summary = self.repository.get_summary(conversation_id) + if summary.status == ConversationStatus.RUNNING: + raise ValueError("当前会话正在处理中,请等待完成后再发送新需求。") + current_plan = self.repository.get_current_plan(conversation_id) + if current_plan is None: + raise ValueError("当前会话没有可修订方案。") + + has_report = self._has_persisted_report(summary.taskId) + mode = self._infer_instruction_mode(has_report=has_report, instruction=instruction) + if mode == "RESEARCH": + revision, _ = await self._apply_plan_revision( + conversation_id=conversation_id, + topic=summary.topic, + current_plan=current_plan.markdown, + instruction=instruction, + ) + await self.start_research(conversation_id=conversation_id) + return revision, self._latest_message(conversation_id) + + if mode == "REPORT": + message = await self._start_report_revision( + conversation_id=conversation_id, + task_id=summary.taskId or "", + instruction=instruction, + ) + return current_plan, message + + return await self._apply_plan_revision( + conversation_id=conversation_id, + topic=summary.topic, + current_plan=current_plan.markdown, + instruction=instruction, + ) + + def _infer_instruction_mode(self, *, has_report: bool, instruction: str) -> str: + if not has_report: + return "PLAN" + if self._matches_any_marker(instruction, self._RESEARCH_INTENT_MARKERS): + return "RESEARCH" + if self._matches_any_marker(instruction, self._PLAN_INTENT_MARKERS): + return "PLAN" + if self._matches_any_marker(instruction, self._REPORT_INTENT_MARKERS): + return "REPORT" + return "REPORT" + + async def _start_report_revision( + self, + *, + conversation_id: str, + task_id: str, + instruction: str, + ) -> ConversationMessage: + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.USER, + kind=MessageKind.USER_TEXT, + content=instruction, + ) + self.repository.set_status(conversation_id, ConversationStatus.RUNNING) + ack_message = self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.SYSTEM, + kind=MessageKind.USER_TEXT, + content="正在修改中,Agent 会基于当前报告完成改写并自动更新结果。", + metadata={"taskId": task_id, "stage": "REPORT_REVISING"}, + ) + asyncio.create_task( + self._run_report_revision_job( + conversation_id=conversation_id, + task_id=task_id, + instruction=instruction, + ) + ) + return ack_message + + async def _run_report_revision_job(self, *, conversation_id: str, task_id: str, instruction: str) -> None: + def progress_callback( + progress: int, + phase: str, + state: str, + summary: str, + payload: dict | None = None, + ) -> None: + self._emit_report_revision_progress( + conversation_id=conversation_id, + task_id=task_id, + progress=progress, + phase=phase, + state=state, + summary=summary, + payload=payload, + ) + + progress_callback( + 5, + "ANALYZING_REQUIREMENT", + "REPORT_REVISING", + "已接收改稿任务,正在分析修改意图。", + {"taskId": task_id, "instruction": instruction[:120]}, + ) + try: + await asyncio.to_thread( + self._revise_report_and_record, + conversation_id=conversation_id, + task_id=task_id, + instruction=instruction, + progress_callback=progress_callback, + ) + progress_callback( + 100, + "REPORT_COMPLETED", + "COMPLETED", + "报告改写完成,已更新到会话中。", + {"taskId": task_id}, + ) + self.repository.set_status(conversation_id, ConversationStatus.COMPLETED) + except KeyError: + return + except Exception as exc: # noqa: BLE001 + try: + progress_callback( + 100, + "REPORT_FAILED", + "FAILED", + f"报告改写失败:{exc}", + {"taskId": task_id, "error": str(exc)}, + ) + self.repository.set_status(conversation_id, ConversationStatus.FAILED) + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.SYSTEM, + kind=MessageKind.ERROR, + content=f"报告改写失败:{exc}", + metadata={"taskId": task_id, "stage": "REPORT_REVISION"}, + ) + except KeyError: + return + + def _emit_report_revision_progress( + self, + *, + conversation_id: str, + task_id: str, + progress: int, + phase: str, + state: str, + summary: str, + payload: dict | None = None, + ) -> None: + normalized_task_id = task_id.strip() if task_id.strip() else f"report-revision:{conversation_id}" + progress_value = max(0, min(100, int(progress))) + event_payload = {"taskId": normalized_task_id, "phase": phase, "state": state, "progress": progress_value} + if payload: + event_payload.update(payload) + event_payload["taskId"] = normalized_task_id + try: + self.repository.append_progress_entry( + conversation_id, + task_id=normalized_task_id, + message_id=new_id(), + phase=phase, + state=state, + summary=summary, + progress=progress_value, + payload=event_payload, + ) + except KeyError: + return + + async def _apply_plan_revision( + self, + *, + conversation_id: str, + topic: str, + current_plan: str, + instruction: str, + ) -> tuple[PlanRevision, ConversationMessage]: + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.USER, + kind=MessageKind.USER_TEXT, + content=instruction, + ) + config = self.repository.get_config(conversation_id) + revised = await asyncio.to_thread( + self._generate_revised_plan, + topic=topic, + config=config, + current_plan=current_plan, + instruction=instruction, + ) + revision = self.repository.add_plan_revision( + conversation_id, + author=MessageRole.ASSISTANT, + markdown=revised, + ) + message = self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.ASSISTANT, + kind=MessageKind.PLAN_REVISION, + content=revision.markdown, + metadata={"version": revision.version}, + ) + self.repository.set_status(conversation_id, ConversationStatus.PLAN_READY) + return revision, message + + def update_plan(self, *, conversation_id: str, markdown: str) -> PlanRevision: + summary = self.repository.get_summary(conversation_id) + if summary.status == ConversationStatus.RUNNING: + raise ValueError("研究执行中,暂不支持直接编辑方案。") + revision = self.repository.add_plan_revision( + conversation_id, + author=MessageRole.USER, + markdown=markdown, + ) + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.USER, + kind=MessageKind.PLAN_EDITED, + content=revision.markdown, + metadata={"version": revision.version}, + ) + self.repository.set_status(conversation_id, ConversationStatus.PLAN_READY) + return revision + + def rename_conversation( + self, + *, + conversation_id: str, + topic: str, + sync_current_plan: bool = True, + ) -> ConversationDetail: + topic_text = topic.strip() + if len(topic_text) < 2: + raise ValueError("会话标题至少需要 2 个字符。") + summary = self.repository.update_topic(conversation_id, topic_text) + + current_plan = self.repository.get_current_plan(conversation_id) if sync_current_plan else None + if current_plan is not None: + base_config = self.repository.get_config(conversation_id) + rewritten = self._rewrite_plan_topic( + current_plan.markdown, + topic=topic_text, + config=base_config, + ) + if rewritten.strip() != current_plan.markdown.strip(): + revision = self.repository.add_plan_revision( + conversation_id, + author=MessageRole.SYSTEM, + markdown=rewritten, + ) + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.SYSTEM, + kind=MessageKind.USER_TEXT, + content=f"会话已重命名为:{topic_text}(方案已同步到 v{revision.version})", + metadata={ + "stage": "RENAMED", + "planVersion": revision.version, + "topic": topic_text, + }, + ) + else: + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.SYSTEM, + kind=MessageKind.USER_TEXT, + content=f"会话已重命名为:{topic_text}", + metadata={"stage": "RENAMED", "topic": topic_text}, + ) + else: + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.SYSTEM, + kind=MessageKind.USER_TEXT, + content=f"会话已重命名为:{topic_text}", + metadata={"stage": "RENAMED", "topic": topic_text}, + ) + + if summary.status == ConversationStatus.DRAFTING_PLAN: + self.repository.set_status(conversation_id, ConversationStatus.PLAN_READY) + return self.repository.get_detail(conversation_id) + + def delete_conversation(self, *, conversation_id: str) -> None: + summary = self.repository.get_summary(conversation_id) + self._abort_task_if_active(summary.taskId) + self.repository.delete_conversation(conversation_id) + + def delete_all_conversations(self) -> int: + for summary in self.repository.list_summaries(): + self._abort_task_if_active(summary.taskId) + return self.repository.delete_all_conversations() + + async def start_research(self, *, conversation_id: str) -> RunConversationResponse: + summary = self.repository.get_summary(conversation_id) + if summary.status == ConversationStatus.RUNNING: + if summary.taskId: + return RunConversationResponse( + conversationId=conversation_id, + taskId=summary.taskId, + status=ConversationStatus.RUNNING, + ) + raise ValueError("会话状态异常:RUNNING 但 taskId 缺失。") + + current_plan = self.repository.get_current_plan(conversation_id) + if current_plan is None: + raise ValueError("没有可执行方案,请先生成或编辑研究方案。") + base_config = self.repository.get_config(conversation_id) + parsed = self._parse_plan(current_plan.markdown, topic=summary.topic, base_config=base_config) + for warning in parsed.warnings: + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.SYSTEM, + kind=MessageKind.ERROR, + content=warning, + ) + task_description = self._extract_plan_body(current_plan.markdown)[:5000] + if len(task_description.strip()) < 3: + task_description = f"围绕主题“{summary.topic}”执行系统化研究。" + task = self.task_repository.create_task( + task_id=new_id(), + title=parsed.title[:200], + description=task_description, + config=parsed.config, + ) + self.repository.set_task_id(conversation_id, task.taskId) + self.repository.set_status(conversation_id, ConversationStatus.RUNNING) + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.SYSTEM, + kind=MessageKind.USER_TEXT, + content="研究任务已启动,Agent 正在按方案执行。", + metadata={"taskId": task.taskId}, + ) + await self.execution_engine.start(task.taskId) + return RunConversationResponse( + conversationId=conversation_id, + taskId=task.taskId, + status=ConversationStatus.RUNNING, + ) + + async def on_task_event(self, task_id: str, event: str, data: dict) -> None: + summary = self.repository.find_by_task_id(task_id) + if summary is None: + return + conversation_id = summary.conversationId + + if event == "TASK_PROGRESS": + phase = str(data.get("phase") or data.get("state") or "UNKNOWN").strip() or "UNKNOWN" + state = str(data.get("state") or "UNKNOWN").strip() or "UNKNOWN" + progress = data.get("progress") + if isinstance(progress, float): + progress = int(progress) + if not isinstance(progress, int): + progress = None + summary_line = self._progress_summary(data) + self.repository.append_progress_entry( + conversation_id, + task_id=task_id, + message_id=new_id(), + phase=phase, + state=state, + summary=summary_line, + progress=progress, + payload=data, + ) + return + + if event == "TASK_COMPLETED": + self.repository.set_status(conversation_id, ConversationStatus.COMPLETED) + report_content = self._load_report(task_id) + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.ASSISTANT, + kind=MessageKind.FINAL_REPORT, + content=report_content or "研究已完成,但报告正文暂不可用。", + metadata={"taskId": task_id}, + ) + return + + if event in {"TASK_FAILED", "TASK_ABORTED", "ERROR"}: + self.repository.set_status(conversation_id, ConversationStatus.FAILED) + error_text = str(data.get("error") or "研究执行失败,请检查日志。").strip() + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.SYSTEM, + kind=MessageKind.ERROR, + content=error_text, + metadata={"taskId": task_id, "event": event}, + ) + + def _load_report(self, task_id: str) -> str: + try: + task = self.task_repository.get_task(task_id) + except KeyError: + return "" + if not task.reportPath: + return "" + path = Path(task.reportPath) + if not path.exists(): + return "" + return path.read_text(encoding="utf-8") + + def _revise_report_and_record( + self, + *, + conversation_id: str, + task_id: str, + instruction: str, + progress_callback: Callable[[int, str, str, str, dict | None], None] | None = None, + ) -> ConversationMessage: + def emit(progress: int, phase: str, summary: str, payload: dict | None = None) -> None: + if progress_callback is None: + return + progress_callback(progress, phase, "REPORT_REVISING", summary, payload) + + current_report = self._load_report(task_id) + if not current_report: + latest_report = self._latest_report_message(conversation_id) + current_report = latest_report.content if latest_report else "" + emit( + 20, + "PREPARING_DRAFT", + "已读取现有报告,正在整理可保留内容。", + {"taskId": task_id, "currentReportLength": len(current_report)}, + ) + + report_text = "" + if current_report.strip(): + emit( + 45, + "WRITING_DRAFT", + "正在按你的要求改写报告结构与语气。", + {"taskId": task_id}, + ) + llm_revised = self._rewrite_report_with_llm(current_report=current_report, instruction=instruction) + report_text = llm_revised or self._fallback_revised_report( + current_report=current_report, + instruction=instruction, + ) + emit( + 72, + "WRITING_DRAFT", + "主体改写完成,正在保存新版本。", + {"taskId": task_id}, + ) + self._persist_report(task_id=task_id, content=report_text) + + should_rebuild = self._matches_any_marker(instruction, self._REPORT_REBUILD_MARKERS) + if should_rebuild or not report_text: + emit( + 78, + "EVIDENCE_REBUILD", + "正在基于已有证据重建报告内容。", + {"taskId": task_id}, + ) + report_text = self._regenerate_report_from_existing_artifacts(task_id=task_id, instruction=instruction) + if report_text: + emit( + 90, + "EVIDENCE_REBUILD", + "证据重建完成,正在合并输出。", + {"taskId": task_id}, + ) + if not report_text: + report_text = self._fallback_revised_report(current_report=current_report, instruction=instruction) + self._persist_report(task_id=task_id, content=report_text) + emit( + 96, + "PERSISTING_REPORT", + "正在写入会话并刷新报告预览。", + {"taskId": task_id}, + ) + + return self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.ASSISTANT, + kind=MessageKind.FINAL_REPORT, + content=report_text, + metadata={"taskId": task_id, "mode": "REPORT_REVISION"}, + ) + + def _regenerate_report_from_existing_artifacts(self, *, task_id: str, instruction: str) -> str: + if not self.report_agent or not self.evidence_repository: + return "" + try: + task = self.task_repository.get_task(task_id) + except KeyError: + return "" + + try: + dag = self.task_repository.get_dag(task_id, allow_empty=True) + except Exception: + return "" + sections = [ + (node.taskId, f"{node.title}\n\n{node.description}") + for node in dag.nodes + if node.taskId != task_id and node.status != NodeStatus.PRUNED + ] + evidences = self.evidence_repository.list(task_id=task_id, limit=1000).items + revised_description = ( + f"{task.description}\n\n" + f"用户补充要求:{instruction}\n" + "请在保持证据可追溯的前提下重写完整报告。" + ) + try: + md_path, _, _ = self.report_agent.generate_report( + task_id=task_id, + task_title=task.title, + task_description=revised_description, + sections=sections, + evidences=evidences, + locked_sections=set(), + ) + self.task_repository.set_report_path(task_id, md_path) + return Path(md_path).read_text(encoding="utf-8") + except Exception: + return "" + + def _rewrite_report_with_llm(self, *, current_report: str, instruction: str) -> str: + if not current_report.strip(): + return "" + prompt = ( + "你是报告改写 Agent。请基于用户提供的“当前报告”完成改写。\n" + "要求:\n" + "1. 仅输出最终 Markdown,不要解释过程;\n" + "2. 保留事实准确性,尽量保留证据 ID(例如 [evidence:xxxx]);\n" + "3. 如果用户要求体裁变化(如演讲稿),需完整重排结构与语气;\n" + "4. 未被用户要求删除的信息请尽量保留。" + ) + user_input = ( + f"用户要求:{instruction}\n\n" + f"当前报告:\n{current_report[:45000]}" + ) + return self._chat_complete(system_prompt=prompt, user_prompt=user_input).strip() + + @staticmethod + def _fallback_revised_report(*, current_report: str, instruction: str) -> str: + base = current_report.strip() or "# 修订报告" + return ( + f"{base}\n\n" + "## 修订说明\n" + f"- 用户要求:{instruction}\n" + "- 已触发自动修订流程;若需补充外部资料,请在指令中明确“补充检索/重新研究”。\n" + ) + + def _persist_report(self, *, task_id: str, content: str) -> None: + try: + task = self.task_repository.get_task(task_id) + except KeyError: + return + if task.reportPath: + report_path = Path(task.reportPath) + else: + report_path = Path("backend/.data/reports") / f"{task_id}.md" + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(content, encoding="utf-8") + self.task_repository.set_report_path(task_id, str(report_path)) + + def _latest_report_message(self, conversation_id: str) -> ConversationMessage | None: + messages = self.repository.get_detail(conversation_id).messages + for message in reversed(messages): + if message.kind == MessageKind.FINAL_REPORT: + return message + return None + + def _latest_message(self, conversation_id: str) -> ConversationMessage: + messages = self.repository.get_detail(conversation_id).messages + if not messages: + raise ValueError("会话中没有消息记录。") + return messages[-1] + + def _has_persisted_report(self, task_id: str | None) -> bool: + if not task_id: + return False + return bool(self._load_report(task_id).strip()) + + @staticmethod + def _matches_any_marker(text: str, markers: tuple[str, ...]) -> bool: + lowered = text.lower() + return any(marker in lowered for marker in markers) + + @staticmethod + def _extract_plan_body(markdown: str) -> str: + match = ConversationAgent._FRONT_MATTER_PATTERN.match(markdown.strip()) + if not match: + return markdown.strip() + return markdown[match.end() :].strip() + + def _parse_plan(self, markdown: str, *, topic: str, base_config: TaskConfig) -> ParsedPlan: + warnings: list[str] = [] + config_data = base_config.model_dump() + parsed_title = topic + match = self._FRONT_MATTER_PATTERN.match(markdown.strip()) + if not match: + warnings.append("未检测到方案 front matter,已回退为默认执行配置。") + return ParsedPlan(title=parsed_title, config=TaskConfig(**config_data), warnings=warnings) + + header = match.group("header") + for raw_line in header.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + kv = self._KV_PATTERN.match(line) + if not kv: + warnings.append(f"忽略无法解析的 front matter 行:{line}") + continue + key = kv.group(1).strip().lower() + value = kv.group(2).strip() + if key == "title" and value: + parsed_title = value.strip().strip('"').strip("'") + continue + if key == "topic": + continue + if key == "max_depth": + config_data["maxDepth"] = self._int_or_default(value, base_config.maxDepth, min_value=1, max_value=8) + continue + if key == "max_nodes": + config_data["maxNodes"] = self._int_or_default(value, base_config.maxNodes, min_value=1, max_value=500) + continue + if key == "priority": + config_data["priority"] = self._int_or_default(value, base_config.priority, min_value=1, max_value=5) + continue + if key == "search_sources": + parsed_sources = self._parse_sources(value) + if parsed_sources: + config_data["searchSources"] = parsed_sources + else: + warnings.append("search_sources 为空,已回退默认数据源配置。") + + return ParsedPlan(title=parsed_title[:200], config=TaskConfig(**config_data), warnings=warnings) + + @staticmethod + def _int_or_default(raw: str, default: int, *, min_value: int, max_value: int) -> int: + try: + value = int(raw.strip()) + except Exception: + return default + return max(min_value, min(max_value, value)) + + @staticmethod + def _parse_sources(raw: str) -> list[str]: + text = raw.strip() + if text.startswith("[") and text.endswith("]"): + text = text[1:-1] + parts = [part.strip().strip('"').strip("'") for part in text.split(",")] + return [part for part in parts if part] + + def _generate_initial_plan(self, *, topic: str, config: TaskConfig) -> str: + prompt = ( + "请为用户生成一个可执行的深度研究方案,输出必须是 Markdown,并且必须包含 front matter。\n" + "front matter 字段固定为:title, topic, max_depth, max_nodes, priority, search_sources。\n" + "正文至少包含:研究目标、研究问题拆解、方法与来源、执行步骤、风险与边界、交付标准。\n" + "严禁输出解释性前言,直接返回完整 Markdown。" + ) + user_input = ( + f"主题:{topic}\n" + f"配置建议:max_depth={config.maxDepth}, max_nodes={config.maxNodes}, " + f"priority={config.priority}, search_sources={config.searchSources}\n" + "输出语言:中文。" + ) + generated = self._chat_complete(system_prompt=prompt, user_prompt=user_input) + if generated: + normalized = self._ensure_front_matter(generated, topic=topic, config=config) + if normalized: + return normalized + return self._fallback_plan(topic=topic, config=config) + + def _generate_revised_plan( + self, + *, + topic: str, + config: TaskConfig, + current_plan: str, + instruction: str, + ) -> str: + prompt = ( + "你是研究计划修订 Agent。请根据用户指令修订“当前研究方案”。\n" + "输出必须是完整 Markdown,且必须包含完整 front matter。\n" + "不要解释你做了什么,不要输出多余文本,只返回最终方案。" + ) + user_input = ( + f"主题:{topic}\n" + f"用户指令:{instruction}\n\n" + f"当前方案如下:\n{current_plan}\n\n" + f"保底配置:max_depth={config.maxDepth}, max_nodes={config.maxNodes}, " + f"priority={config.priority}, search_sources={config.searchSources}" + ) + generated = self._chat_complete(system_prompt=prompt, user_prompt=user_input) + if generated: + normalized = self._ensure_front_matter(generated, topic=topic, config=config) + if normalized: + return normalized + return self._fallback_revision(current_plan=current_plan, instruction=instruction, topic=topic, config=config) + + def _chat_complete(self, *, system_prompt: str, user_prompt: str) -> str: + if settings.use_mock_sources: + return "" + base_url, api_key, model = self._resolve_provider() + if not base_url or not api_key: + return "" + try: + with httpx.Client(timeout=60) as client: + response = client.post( + f"{base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "temperature": 0.2, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + }, + ) + response.raise_for_status() + payload = response.json() + return ( + payload.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + .strip() + ) + except Exception: + return "" + + @staticmethod + def _resolve_provider() -> tuple[str, str, str]: + provider = settings.default_llm_provider.lower().strip() + if provider == "openrouter": + return settings.openrouter_base_url, settings.openrouter_api_key, settings.openrouter_model + if provider == "deepseek": + return settings.deepseek_base_url, settings.deepseek_api_key, settings.deepseek_model + if provider == "openai": + return settings.openai_base_url, settings.openai_api_key, settings.openai_model + return "", "", "" + + def _abort_task_if_active(self, task_id: str | None) -> None: + if not task_id: + return + try: + task = self.task_repository.get_task(task_id) + except KeyError: + return + if task.status in { + TaskStatus.READY, + TaskStatus.PLANNING, + TaskStatus.EXECUTING, + TaskStatus.REVIEWING, + TaskStatus.SYNTHESIZING, + TaskStatus.FINALIZING, + TaskStatus.SUSPENDED, + }: + self.execution_engine.abort(task_id) + + def _rewrite_plan_topic(self, markdown: str, *, topic: str, config: TaskConfig) -> str: + text = markdown.strip() + if not text: + return self._ensure_front_matter(markdown, topic=topic, config=config) + + match = self._FRONT_MATTER_PATTERN.match(text) + if not match: + return self._ensure_front_matter(text, topic=topic, config=config) + + header = match.group("header") + body = text[match.end() :].strip() + header_lines = header.splitlines() + rewritten_lines: list[str] = [] + has_title = False + has_topic = False + for raw_line in header_lines: + kv = self._KV_PATTERN.match(raw_line) + if not kv: + rewritten_lines.append(raw_line) + continue + key = kv.group(1).strip() + key_l = key.lower() + if key_l == "title": + rewritten_lines.append(f"{key}: {self._yaml_value(topic)}") + has_title = True + continue + if key_l == "topic": + rewritten_lines.append(f"{key}: {self._yaml_value(topic)}") + has_topic = True + continue + rewritten_lines.append(raw_line) + + if not has_title: + rewritten_lines.append(f"title: {self._yaml_value(topic)}") + if not has_topic: + rewritten_lines.append(f"topic: {self._yaml_value(topic)}") + + rebuilt_header = "\n".join(rewritten_lines) + rebuilt = f"---\n{rebuilt_header}\n---" + if body: + return f"{rebuilt}\n\n{body}" + return rebuilt + + @staticmethod + def _yaml_value(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + def _ensure_front_matter(self, markdown: str, *, topic: str, config: TaskConfig) -> str: + text = markdown.strip() + if not text: + return "" + if self._FRONT_MATTER_PATTERN.match(text): + return text + return ( + "---\n" + f"title: {topic}\n" + f"topic: {topic}\n" + f"max_depth: {config.maxDepth}\n" + f"max_nodes: {config.maxNodes}\n" + f"priority: {config.priority}\n" + f"search_sources: [{', '.join(config.searchSources)}]\n" + "---\n\n" + f"{text}" + ) + + @staticmethod + def _fallback_plan(*, topic: str, config: TaskConfig) -> str: + return ( + "---\n" + f"title: {topic} 深度研究方案\n" + f"topic: {topic}\n" + f"max_depth: {config.maxDepth}\n" + f"max_nodes: {config.maxNodes}\n" + f"priority: {config.priority}\n" + f"search_sources: [{', '.join(config.searchSources)}]\n" + "---\n\n" + "## 研究目标\n" + "围绕主题建立可验证的结论链路,输出可执行决策建议。\n\n" + "## 研究问题拆解\n" + "1. 核心概念与边界是什么。\n" + "2. 当前主流方法与证据来源有哪些。\n" + "3. 风险、局限与落地障碍分别是什么。\n\n" + "## 方法与来源\n" + "- 使用学术论文与高可信 Web 来源交叉验证。\n" + "- 对关键结论保留可追溯证据 ID。\n\n" + "## 执行步骤\n" + "1. 规划任务树并确定检索查询。\n" + "2. 检索、清洗并打分证据。\n" + "3. 处理冲突并形成综合分析。\n" + "4. 生成最终 Markdown 报告。\n\n" + "## 风险与边界\n" + "- 时效性偏差:关注近三年数据,必要时补充最新动态。\n" + "- 来源偏差:至少两类来源交叉验证。\n\n" + "## 交付标准\n" + "- 报告含摘要、方法、发现、分析、建议。\n" + "- 关键结论标注证据引用并给出行动建议。\n" + ) + + def _fallback_revision(self, *, current_plan: str, instruction: str, topic: str, config: TaskConfig) -> str: + normalized = self._ensure_front_matter(current_plan, topic=topic, config=config) + return ( + f"{normalized}\n\n" + "## 修订记录\n" + f"- 用户新要求:{instruction}\n" + "- 已按要求在执行步骤与交付标准中应用该约束,请在右侧继续手工微调。" + ) + + @staticmethod + def _progress_summary(data: dict) -> str: + state = str(data.get("state") or "EXECUTING") + phase = str(data.get("phase") or "UNKNOWN") + progress = data.get("progress") + progress_text = f"{progress}%" if isinstance(progress, (int, float)) else "--" + node_title = str(data.get("currentNodeTitle") or "").strip() + section_title = str(data.get("currentSectionTitle") or "").strip() + query = str(data.get("searchQuery") or "").strip() + if section_title: + return f"[{state}/{phase}] {progress_text} 正在写作:{section_title}" + if node_title and query: + return f"[{state}/{phase}] {progress_text} 节点:{node_title} | 查询:{query}" + if node_title: + return f"[{state}/{phase}] {progress_text} 节点:{node_title}" + return f"[{state}/{phase}] {progress_text}" diff --git a/backend/app/services/execution_engine.py b/backend/app/services/execution_engine.py index 8e37eda..20e92c6 100644 --- a/backend/app/services/execution_engine.py +++ b/backend/app/services/execution_engine.py @@ -2,12 +2,15 @@ import asyncio from dataclasses import dataclass, field +import logging +from typing import Awaitable, Callable from app.core.utils import now_iso from app.models.schemas import NodeStatus, TaskStatus from app.repositories.conflict_repository import ConflictRepository from app.repositories.evidence_repository import EvidenceRepository from app.repositories.task_repository import TaskRepository +from app.services.agents import ReportAgent, ResearchAgent from app.services.analyst import AnalystService from app.services.planner import MasterPlanner from app.services.progress_hub import ProgressHub @@ -15,6 +18,8 @@ from app.services.state_machine import InvalidStateTransition, transition_or_raise from app.services.writer import WriterService +logger = logging.getLogger(__name__) + @dataclass class TaskControlState: @@ -35,6 +40,9 @@ def __init__( conflict_repository: ConflictRepository, analyst_service: AnalystService, writer_service: WriterService, + research_agent: ResearchAgent | None = None, + report_agent: ReportAgent | None = None, + event_listener: Callable[[str, str, dict], Awaitable[None]] | None = None, ) -> None: self.repository = repository self.planner = planner @@ -44,8 +52,22 @@ def __init__( self.conflict_repository = conflict_repository self.analyst_service = analyst_service self.writer_service = writer_service + self.research_agent = research_agent or ResearchAgent(retrieval_service=retrieval_service) + self.report_agent = report_agent or ReportAgent(writer_service=writer_service) + self.event_listener = event_listener self._control: dict[str, TaskControlState] = {} + def set_event_listener(self, listener: Callable[[str, str, dict], Awaitable[None]] | None) -> None: + self.event_listener = listener + + async def _emit_event(self, task_id: str, event: str, payload: dict) -> None: + await self.hub.emit(task_id, event, payload) + if self.event_listener is not None: + try: + await self.event_listener(task_id, event, payload) + except Exception as exc: # noqa: BLE001 + logger.warning("Event listener failed for task=%s event=%s: %s", task_id, event, exc) + async def start(self, task_id: str) -> None: task = self.repository.get_task(task_id) control = self._control.setdefault(task_id, TaskControlState()) @@ -81,14 +103,23 @@ async def recover(self, task_id: str) -> None: async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: try: - await self.hub.emit(task_id, "TASK_STARTED", {"taskId": task_id, "status": current_status.value}) + await self._emit_event(task_id, "TASK_STARTED", {"taskId": task_id, "status": current_status.value}) task = self.repository.get_task(task_id) config = task.config if not task.dag or not task.dag.nodes: self.repository.update_status(task_id, transition_or_raise(task.status, TaskStatus.PLANNING)) dag = self.planner.build_dag(task_id, task.title, task.description, config) self.repository.save_dag(task_id, dag) - await self.hub.emit(task_id, "TASK_PROGRESS", {"taskId": task_id, "progress": 20, "state": "PLANNING"}) + await self._emit_event( + task_id, + "TASK_PROGRESS", + { + "taskId": task_id, + "progress": 20, + "state": "PLANNING", + "phase": "BUILDING_PLAN", + }, + ) current = self.repository.get_task(task_id) if current.status == TaskStatus.SUSPENDED: @@ -110,19 +141,34 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: while control.paused and not control.aborted: await asyncio.sleep(0.2) if control.aborted: - await self.hub.emit(task_id, "ERROR", {"taskId": task_id, "error": "Task aborted by user"}) + await self._emit_event(task_id, "ERROR", {"taskId": task_id, "error": "Task aborted by user"}) return self.repository.update_node_status(task_id, node.taskId, NodeStatus.RUNNING, node.metadata.infoGainScore) await asyncio.sleep(0.2) - evidences = await self.retrieval_service.retrieve( + query = f"{task.title} {node.title}" + searching_progress = 20 + int(((idx - 1) / total) * 60) + await self._emit_event( + task_id, + "TASK_PROGRESS", + { + "taskId": task_id, + "progress": searching_progress, + "currentNode": node.taskId, + "currentNodeTitle": node.title, + "searchQuery": query, + "state": "EXECUTING", + "phase": "SEARCHING", + }, + ) + evidences = await self.research_agent.collect_evidence( task_id=task_id, node_id=node.taskId, - query=node.title, + query=query, sources=task.config.searchSources, ) self.evidence_repository.save_many(evidences) for ev in evidences: - await self.hub.emit( + await self._emit_event( task_id, "EVIDENCE_FOUND", {"taskId": task_id, "nodeId": node.taskId, "evidence": ev.model_dump()}, @@ -130,14 +176,18 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: self.repository.update_node_status(task_id, node.taskId, NodeStatus.COMPLETED, node.metadata.infoGainScore) control.completed_nodes.append(node.taskId) progress = 20 + int((idx / total) * 60) - await self.hub.emit( + await self._emit_event( task_id, "TASK_PROGRESS", { "taskId": task_id, "progress": progress, "currentNode": node.taskId, + "currentNodeTitle": node.title, + "searchQuery": query, + "evidenceCount": len(evidences), "state": "EXECUTING", + "phase": "NODE_COMPLETED", }, ) self.repository.save_snapshot( @@ -160,16 +210,26 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: if conflicts: self.repository.update_status(task_id, transition_or_raise(TaskStatus.EXECUTING, TaskStatus.REVIEWING)) self.conflict_repository.save_many(conflicts) - await self.hub.emit( + await self._emit_event( task_id, "TASK_PROGRESS", - {"taskId": task_id, "progress": 85, "state": "REVIEWING", "conflictCount": len(conflicts)}, + { + "taskId": task_id, + "progress": 85, + "state": "REVIEWING", + "phase": "REVIEWING_CONFLICTS", + "conflictCount": len(conflicts), + }, ) # Single-user default: continue with unresolved conflicts recorded for later voting. self.repository.update_status(task_id, transition_or_raise(TaskStatus.REVIEWING, TaskStatus.SYNTHESIZING)) else: self.repository.update_status(task_id, transition_or_raise(TaskStatus.EXECUTING, TaskStatus.SYNTHESIZING)) - await self.hub.emit(task_id, "TASK_PROGRESS", {"taskId": task_id, "progress": 90, "state": "SYNTHESIZING"}) + await self._emit_event( + task_id, + "TASK_PROGRESS", + {"taskId": task_id, "progress": 90, "state": "SYNTHESIZING", "phase": "OUTLINING"}, + ) await asyncio.sleep(0.1) dag = self.repository.get_dag(task_id) sections = [ @@ -177,24 +237,46 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: for node in dag.nodes if node.taskId != task_id and node.status != NodeStatus.PRUNED ] - md_path, bib_path, _ = self.writer_service.write_report( + total_sections = max(1, len(sections)) + for section_idx, (_, section_text) in enumerate(sections, start=1): + section_title = section_text.splitlines()[0].strip() if section_text else "" + write_progress = 90 + int((section_idx / total_sections) * 6) + await self._emit_event( + task_id, + "TASK_PROGRESS", + { + "taskId": task_id, + "progress": write_progress, + "state": "SYNTHESIZING", + "phase": "WRITING_SECTION", + "currentSectionTitle": section_title or f"Section {section_idx}", + }, + ) + md_path, bib_path, _ = await asyncio.to_thread( + self.report_agent.generate_report, task_id=task_id, task_title=task.title, + task_description=task.description, sections=sections, evidences=evidences, locked_sections=set(), ) self.repository.update_status(task_id, transition_or_raise(TaskStatus.SYNTHESIZING, TaskStatus.FINALIZING)) + await self._emit_event( + task_id, + "TASK_PROGRESS", + {"taskId": task_id, "progress": 98, "state": "FINALIZING", "phase": "PERSISTING_REPORT"}, + ) self.repository.set_report_path(task_id, md_path) self.repository.update_status(task_id, transition_or_raise(TaskStatus.FINALIZING, TaskStatus.COMPLETED)) - await self.hub.emit( + await self._emit_event( task_id, "TASK_COMPLETED", {"taskId": task_id, "progress": 100, "reportPath": md_path, "bibPath": bib_path}, ) except InvalidStateTransition as exc: self.repository.update_status(task_id, TaskStatus.FAILED, last_error=str(exc)) - await self.hub.emit(task_id, "ERROR", {"taskId": task_id, "error": str(exc)}) + await self._emit_event(task_id, "ERROR", {"taskId": task_id, "error": str(exc)}) except Exception as exc: # noqa: BLE001 self.repository.update_status(task_id, TaskStatus.FAILED, last_error=str(exc)) - await self.hub.emit(task_id, "ERROR", {"taskId": task_id, "error": f"Unhandled error: {exc}"}) + await self._emit_event(task_id, "ERROR", {"taskId": task_id, "error": f"Unhandled error: {exc}"}) diff --git a/backend/app/services/retrieval.py b/backend/app/services/retrieval.py index 5d583d1..b2830a1 100644 --- a/backend/app/services/retrieval.py +++ b/backend/app/services/retrieval.py @@ -1,17 +1,24 @@ from __future__ import annotations import asyncio +import logging +import re from collections import OrderedDict from datetime import UTC, datetime, timedelta from hashlib import sha1 +from urllib.parse import urlparse +from xml.etree import ElementTree import httpx from app.core.config import settings +from app.core.utils import now_iso from app.core.utils import new_id from app.models.schemas import Evidence, EvidenceMetadata, ExtractedData, SourceType from app.services.retry import retry_async +logger = logging.getLogger(__name__) + class L1EvidenceCache: def __init__(self, max_size: int = 1000, ttl_seconds: int = 3600) -> None: @@ -38,6 +45,8 @@ def set(self, key: str, value: list[Evidence]) -> None: class RetrievalService: + _PLACEHOLDER_HOSTS = {"example.org", "example.com", "localhost", "127.0.0.1", "httpbin.org"} + def __init__(self) -> None: self.cache = L1EvidenceCache() @@ -58,9 +67,13 @@ async def retrieve(self, *, task_id: str, node_id: str, query: str, sources: lis @staticmethod def expand_query(query: str) -> str: term = query.strip() - year_part = "(2026 OR 2025 OR 2024)" - verb_part = "(analyze OR improve OR evaluate)" - return f"({term} OR {term} review) AND {year_part} AND {verb_part}" + year = datetime.now(tz=UTC).year + year_part = f"({year} OR {year - 1} OR {year - 2})" + if any(ord(ch) > 127 for ch in term): + focus_part = f"({term})" + else: + focus_part = f"({term} OR {term} review)" + return f"{focus_part} AND {year_part}" async def _mock_retrieve(self, *, task_id: str, node_id: str, query: str, sources: list[str]) -> list[Evidence]: await asyncio.sleep(0.05) @@ -72,13 +85,13 @@ async def _mock_retrieve(self, *, task_id: str, node_id: str, query: str, source taskId=task_id, nodeId=node_id, sourceType=SourceType.PAPER, - url=f"https://example.org/paper/{node_id}", - content=f"Mock evidence generated for query: {query}", + url=f"mock://paper/{node_id}", + content=f"[MOCK] Synthetic evidence for query: {query}", metadata=EvidenceMetadata( authors=["Mock Author"], publishDate="2025-01-01T00:00:00Z", - title=f"{source} result for {query[:40]}", - abstract="This is a mock abstract.", + title=f"[MOCK] {source} result for {query[:40]}", + abstract="[MOCK] This abstract is synthetic and for test mode only.", impactFactor=5.2, isPeerReviewed=True, relevanceScore=synthetic_metric, @@ -87,19 +100,105 @@ async def _mock_retrieve(self, *, task_id: str, node_id: str, query: str, source score=synthetic_metric, extractedData=ExtractedData( tables=[{"caption": "Sample table", "data": {"rows": 3}}], - images=[{"caption": "Sample figure", "url": "https://example.org/img/1.png"}], + images=[{"caption": "Sample figure", "url": "mock://img/1.png"}], numericalValues=[{"value": synthetic_metric, "unit": "score", "context": "relevance"}], ), ) ] async def _real_retrieve(self, *, task_id: str, node_id: str, query: str, sources: list[str]) -> list[Evidence]: - normalized_sources = [s.lower() for s in sources] if sources else ["tavily"] - if "tavily" in normalized_sources and settings.tavily_api_key: - tavily_results = await self._retrieve_from_tavily(task_id=task_id, node_id=node_id, query=query) - if tavily_results: - return tavily_results - return await self._fallback_web_retrieve(task_id=task_id, node_id=node_id, query=query, sources=sources) + normalized_sources = [self._normalize_source_name(s) for s in sources] if sources else [] + if not normalized_sources: + normalized_sources = ["tavily", "arxiv", "semanticscholar"] + + provider_calls: list[tuple[str, asyncio.Future]] = [] + for source in normalized_sources: + if source == "tavily": + if settings.tavily_api_key: + provider_calls.append( + ( + source, + asyncio.create_task( + self._safe_provider_call( + source, + self._retrieve_from_tavily, + task_id=task_id, + node_id=node_id, + query=query, + ) + ), + ) + ) + continue + if source == "arxiv": + provider_calls.append( + ( + source, + asyncio.create_task( + self._safe_provider_call( + source, + self._retrieve_from_arxiv, + task_id=task_id, + node_id=node_id, + query=query, + ) + ), + ) + ) + continue + if source == "semanticscholar": + provider_calls.append( + ( + source, + asyncio.create_task( + self._safe_provider_call( + source, + self._retrieve_from_semantic_scholar, + task_id=task_id, + node_id=node_id, + query=query, + ) + ), + ) + ) + + if not provider_calls and settings.tavily_api_key: + provider_calls.append( + ( + "tavily", + asyncio.create_task( + self._safe_provider_call( + "tavily", + self._retrieve_from_tavily, + task_id=task_id, + node_id=node_id, + query=query, + ) + ), + ) + ) + + gathered: list[Evidence] = [] + for _, provider_task in provider_calls: + gathered.extend(await provider_task) + + valid = self._validate_evidences(gathered, allow_mock=False) + return self._dedupe_by_url(valid) + + async def _safe_provider_call( + self, + provider_name: str, + provider_func, + *, + task_id: str, + node_id: str, + query: str, + ) -> list[Evidence]: + try: + return await provider_func(task_id=task_id, node_id=node_id, query=query) + except Exception as exc: # noqa: BLE001 + logger.warning("Provider '%s' failed: %s", provider_name, exc) + return [] async def _retrieve_from_tavily(self, *, task_id: str, node_id: str, query: str) -> list[Evidence]: async with httpx.AsyncClient(timeout=20) as client: @@ -112,7 +211,7 @@ async def _retrieve_from_tavily(self, *, task_id: str, node_id: str, query: str) "search_depth": "advanced", "max_results": 5, "include_answer": False, - "include_raw_content": False, + "include_raw_content": True, }, ), max_attempts=3, @@ -141,7 +240,7 @@ async def _retrieve_from_tavily(self, *, task_id: str, node_id: str, query: str) content=content, metadata=EvidenceMetadata( authors=[], - publishDate=str(item.get("published_date") or "2026-01-01T00:00:00Z"), + publishDate=str(item.get("published_date") or now_iso()), title=title, abstract=content[:500], impactFactor=0, @@ -163,43 +262,218 @@ async def _retrieve_from_tavily(self, *, task_id: str, node_id: str, query: str) ) return evidences - async def _fallback_web_retrieve( - self, *, task_id: str, node_id: str, query: str, sources: list[str] - ) -> list[Evidence]: - if not sources: - sources = ["web"] - results: list[Evidence] = [] - async with httpx.AsyncClient(timeout=10) as client: - for source in sources[:2]: - url = f"https://httpbin.org/anything/{source}" - resp = await retry_async( - lambda: client.get(url, params={"q": query}), - max_attempts=3, - base_delay_seconds=0.5, + async def _retrieve_from_arxiv(self, *, task_id: str, node_id: str, query: str) -> list[Evidence]: + search_query = f"all:{self._keyword_query_for_paper_apis(query)}" + params = { + "search_query": search_query, + "start": 0, + "max_results": 5, + "sortBy": "relevance", + "sortOrder": "descending", + } + async with httpx.AsyncClient(timeout=20) as client: + resp = await retry_async( + lambda: client.get("https://export.arxiv.org/api/query", params=params), + max_attempts=3, + base_delay_seconds=0.7, + ) + assert isinstance(resp, httpx.Response) + resp.raise_for_status() + payload = resp.text + + root = ElementTree.fromstring(payload) + ns = {"atom": "http://www.w3.org/2005/Atom"} + entries = root.findall("atom:entry", ns) + evidences: list[Evidence] = [] + for idx, entry in enumerate(entries): + title = self._read_xml_text(entry, "atom:title", ns) + summary = self._read_xml_text(entry, "atom:summary", ns) + if not title and not summary: + continue + url = self._read_xml_text(entry, "atom:id", ns) + if url.startswith("http://"): + url = "https://" + url[len("http://") :] + published = self._read_xml_text(entry, "atom:published", ns) or now_iso() + authors = [ + name.text.strip() + for name in entry.findall("atom:author/atom:name", ns) + if name.text and name.text.strip() + ] + rank_score = max(0.45, round(0.9 - idx * 0.08, 3)) + evidences.append( + Evidence( + id=new_id(), + taskId=task_id, + nodeId=node_id, + sourceType=SourceType.PAPER, + url=url, + content=summary or title, + metadata=EvidenceMetadata( + authors=authors, + publishDate=published, + title=title or "arXiv paper", + abstract=summary[:500] if summary else "", + impactFactor=0, + isPeerReviewed=False, + relevanceScore=rank_score, + citationCount=0, + ), + score=rank_score, + extractedData=ExtractedData(), ) - assert isinstance(resp, httpx.Response) - resp.raise_for_status() - payload = resp.json() - results.append( - Evidence( - id=new_id(), - taskId=task_id, - nodeId=node_id, - sourceType=SourceType.WEB, - url=url, - content=f"Fetched payload echo for source={source}.", - metadata=EvidenceMetadata( - authors=[], - publishDate="2026-01-01T00:00:00Z", - title=f"Web result ({source})", - abstract=str(payload)[:300], - impactFactor=0, - isPeerReviewed=False, - relevanceScore=0.65, - citationCount=0, - ), - score=0.65, - extractedData=ExtractedData(), - ) + ) + return evidences + + async def _retrieve_from_semantic_scholar(self, *, task_id: str, node_id: str, query: str) -> list[Evidence]: + paper_query = self._keyword_query_for_paper_apis(query) + params = { + "query": paper_query, + "limit": 5, + "fields": "title,abstract,authors,year,url,publicationDate,citationCount,paperId,openAccessPdf", + } + async with httpx.AsyncClient(timeout=20) as client: + resp = await retry_async( + lambda: client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params), + max_attempts=3, + base_delay_seconds=0.8, + ) + assert isinstance(resp, httpx.Response) + resp.raise_for_status() + payload = resp.json() + + results = payload.get("data", []) + evidences: list[Evidence] = [] + for idx, item in enumerate(results): + abstract = str(item.get("abstract") or "").strip() + title = str(item.get("title") or "Semantic Scholar paper").strip() + if not abstract and not title: + continue + authors = [str(author.get("name", "")).strip() for author in item.get("authors", []) if author.get("name")] + year = item.get("year") + publication_date = str(item.get("publicationDate") or "").strip() + if not publication_date and isinstance(year, int): + publication_date = f"{year}-01-01T00:00:00Z" + if not publication_date: + publication_date = now_iso() + url = str(item.get("url") or "").strip() + if not url: + open_pdf = item.get("openAccessPdf") or {} + url = str(open_pdf.get("url") or "").strip() + if not url: + paper_id = str(item.get("paperId") or "").strip() + if paper_id: + url = f"https://www.semanticscholar.org/paper/{paper_id}" + + citation_count = int(item.get("citationCount") or 0) + rank_bonus = max(0.0, 0.15 - idx * 0.03) + score = max(0.45, min(0.95, round(0.52 + min(citation_count, 400) / 1200 + rank_bonus, 3))) + + evidences.append( + Evidence( + id=new_id(), + taskId=task_id, + nodeId=node_id, + sourceType=SourceType.PAPER, + url=url, + content=abstract or title, + metadata=EvidenceMetadata( + authors=authors, + publishDate=publication_date, + title=title, + abstract=(abstract or title)[:500], + impactFactor=0, + isPeerReviewed=False, + relevanceScore=score, + citationCount=citation_count, + ), + score=score, + extractedData=ExtractedData( + numericalValues=[ + { + "value": float(citation_count), + "unit": "citations", + "context": "semantic_scholar_citation_count", + } + ] + ), ) - return results + ) + return evidences + + @classmethod + def _normalize_source_name(cls, source: str) -> str: + lowered = source.strip().lower().replace("-", "").replace("_", "").replace(" ", "") + if lowered in {"arxiv", "arxivorg"}: + return "arxiv" + if lowered in {"semanticscholar", "s2"}: + return "semanticscholar" + if lowered == "tavily": + return "tavily" + return lowered + + @classmethod + def _is_placeholder_url(cls, url: str) -> bool: + parsed = urlparse(url) + hostname = (parsed.hostname or "").lower() + return hostname in cls._PLACEHOLDER_HOSTS + + @staticmethod + def _clean_text(content: str) -> str: + return " ".join(content.split()) + + @staticmethod + def _read_xml_text(entry, path: str, ns: dict[str, str]) -> str: + item = entry.find(path, ns) + return item.text.strip() if item is not None and item.text else "" + + @staticmethod + def _keyword_query_for_paper_apis(query: str) -> str: + cleaned_query = re.sub(r"[\(\)]", " ", query) + ascii_tokens = re.findall(r"[A-Za-z][A-Za-z0-9\-_]{1,}", cleaned_query) + stop_tokens = {"and", "or", "review", "analyze", "improve", "evaluate"} + picked = [token for token in ascii_tokens if token.lower() not in stop_tokens] + if picked: + base = " ".join(picked[:8]) + if any(word in query for word in ["软件", "工程", "开发", "代码"]): + base += " software engineering" + if "测试" in query: + base += " testing" + if "挑战" in query: + base += " challenges" + return base.strip() + return "artificial intelligence agent software engineering" + + @classmethod + def _validate_evidences(cls, evidences: list[Evidence], *, allow_mock: bool) -> list[Evidence]: + valid: list[Evidence] = [] + for ev in evidences: + parsed = urlparse(ev.url) + if not ev.url: + continue + if allow_mock and parsed.scheme == "mock": + valid.append(ev) + continue + if parsed.scheme not in {"http", "https"}: + continue + if cls._is_placeholder_url(ev.url): + continue + cleaned = cls._clean_text(ev.content) + if len(cleaned) < 30: + continue + ev.content = cleaned + if not ev.metadata.title: + ev.metadata.title = ev.url + valid.append(ev) + return valid + + @staticmethod + def _dedupe_by_url(evidences: list[Evidence]) -> list[Evidence]: + deduped: list[Evidence] = [] + seen: set[str] = set() + for ev in evidences: + key = ev.url.rstrip("/") + if key in seen: + continue + seen.add(key) + deduped.append(ev) + return deduped diff --git a/backend/app/services/writer.py b/backend/app/services/writer.py index c5e6274..17974eb 100644 --- a/backend/app/services/writer.py +++ b/backend/app/services/writer.py @@ -1,6 +1,9 @@ from __future__ import annotations +from collections import defaultdict +from dataclasses import dataclass from pathlib import Path +import re import httpx @@ -8,7 +11,20 @@ from app.models.schemas import Citation, Evidence +@dataclass(frozen=True) +class ReportBlueprint: + output_format: str + objective: str + tone: str + section_titles: list[str] + + class WriterService: + URL_PATTERN = re.compile(r"https?://\S+") + PLACEHOLDER_TITLE_PATTERN = re.compile( + r"(?i)(^\[mock\]|result\s+for|synthetic evidence|semantic scholar result|arxiv result|web result)" + ) + def __init__(self, output_dir: str = "backend/.data/reports") -> None: self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) @@ -18,27 +34,33 @@ def write_report( *, task_id: str, task_title: str, + task_description: str = "", sections: list[tuple[str, str]], evidences: list[Evidence], locked_sections: set[str] | None = None, + blueprint: ReportBlueprint | None = None, + report_body: str | None = None, ) -> tuple[str, str, dict[str, Citation]]: - locked_sections = locked_sections or set() + _ = locked_sections + blueprint = blueprint or self._default_blueprint() citation_map = self._build_citations(evidences) - generated_body = self._generate_body(task_title=task_title, sections=sections, evidences=evidences) + if report_body is None: + generated_body = self.generate_body( + task_title=task_title, + task_description=task_description, + sections=sections, + evidences=evidences, + blueprint=blueprint, + ) + else: + generated_body = report_body lines = [f"# {task_title}", "", f"_taskId: {task_id}_", ""] lines.extend(generated_body.splitlines()) lines.append("") - # Keep section-to-task traceability even when LLM text is used. - for idx, (section_id, content) in enumerate(sections, start=1): - lines.append(f"## Trace Section {idx}") - if section_id in locked_sections: - lines.append(f"[LOCKED] {content}") - else: - lines.append(content) - lines.append("") - + lines.extend(self._build_evidence_appendix(evidences).splitlines()) + lines.append("") lines.append("## References") for i, cid in enumerate(citation_map, start=1): c = citation_map[cid] @@ -65,26 +87,119 @@ def write_report( bib_path.write_text("\n".join(bib_lines), encoding="utf-8") return str(md_path), str(bib_path), citation_map - def _generate_body(self, *, task_title: str, sections: list[tuple[str, str]], evidences: list[Evidence]) -> str: - if not settings.use_mock_sources: - llm_text = self._generate_with_llm(task_title=task_title, sections=sections, evidences=evidences) - if llm_text: - return llm_text - return self._generate_template(task_title=task_title, sections=sections, evidences=evidences) + def generate_body( + self, + *, + task_title: str, + task_description: str, + sections: list[tuple[str, str]], + evidences: list[Evidence], + blueprint: ReportBlueprint | None = None, + ) -> str: + selected_blueprint = blueprint or self._default_blueprint() + return self._generate_body( + task_title=task_title, + task_description=task_description, + sections=sections, + evidences=evidences, + blueprint=selected_blueprint, + ) + + def generate_template_body( + self, + *, + task_title: str, + sections: list[tuple[str, str]], + evidences: list[Evidence], + blueprint: ReportBlueprint | None = None, + ) -> str: + selected_blueprint = blueprint or self._default_blueprint() + return self._generate_template( + task_title=task_title, + sections=sections, + evidences=evidences, + blueprint=selected_blueprint, + ) + + def _generate_body( + self, + *, + task_title: str, + task_description: str, + sections: list[tuple[str, str]], + evidences: list[Evidence], + blueprint: ReportBlueprint, + ) -> str: + evidence_rich_template = self._generate_template( + task_title=task_title, + sections=sections, + evidences=evidences, + blueprint=blueprint, + ) + if settings.use_mock_sources: + return evidence_rich_template + + llm_text = self._generate_with_llm( + task_title=task_title, + task_description=task_description, + sections=sections, + evidences=evidences, + blueprint=blueprint, + ) + if not llm_text: + return evidence_rich_template + sanitized = self._strip_inline_urls(llm_text.strip()) + return "\n".join( + [ + "## AI 综合解读", + sanitized, + "", + evidence_rich_template, + ] + ) - def _generate_with_llm(self, *, task_title: str, sections: list[tuple[str, str]], evidences: list[Evidence]) -> str: + def _generate_with_llm( + self, + *, + task_title: str, + task_description: str, + sections: list[tuple[str, str]], + evidences: list[Evidence], + blueprint: ReportBlueprint, + ) -> str: base_url, api_key, model = self._resolve_provider() if not base_url or not api_key: return "" evidence_snippets = "\n".join( - f"- {ev.metadata.title}: {ev.content[:300]} (url={ev.url})" for ev in evidences[:12] + ( + f"- [{ev.id}] {self._display_title(ev)} | 类型:{ev.sourceType.value} | " + f"时间:{ev.metadata.publishDate or '未知'}\n" + f" 摘要:{self._compact_text(ev.content, 320)}" + ) + for ev in evidences[:18] + ) + section_snippets = "\n".join( + f"- {self._compact_text(content, 260)}" for _, content in sections[:12] ) - section_snippets = "\n".join(f"- {content[:180]}" for _, content in sections[:8]) + blueprint_snippets = "\n".join(f"- {title}" for title in blueprint.section_titles) prompt = ( f"研究题目:{task_title}\n" - "请生成中文研究报告,包含:摘要、背景、关键发现、方法与证据、局限性、结论与后续建议。\n" - "保持结构化、客观、可读性强。不要编造不存在的数据。\n\n" + f"任务背景与用户要求:{task_description[:1200]}\n" + f"输出体裁:{blueprint.output_format}\n" + f"写作目标:{blueprint.objective}\n" + f"风格要求:{blueprint.tone}\n" + "请按如下章节生成正文:\n" + f"{blueprint_snippets}\n" + "保持结构化、客观、可读性强。禁止编造不存在的数据、论文、链接。\n" + "深度与广度要求:\n" + "1) 正文总字数不少于 2200 字。\n" + "2) 除摘要/结论章节外,每个章节至少 2 段,每段不少于 120 字。\n" + "3) 每个章节至少覆盖 3 个维度:现状、驱动机制、影响评估、风险边界、落地策略。\n" + "4) 每个章节至少引用 2 个不同证据 ID;证据不足时须明确写出缺口与补充方向。\n" + "5) 结论必须给出可执行动作、优先级与前置条件。\n" + "每个关键结论必须引用至少一个证据ID(例如 [evidence:xxxx])。\n" + "正文中不要放证据网址,不要输出参考文献与证据附录,网址统一在文末追加。\n\n" f"任务分段信息:\n{section_snippets}\n\n" f"证据片段:\n{evidence_snippets}\n" ) @@ -126,29 +241,174 @@ def _resolve_provider(self) -> tuple[str, str, str]: return settings.openai_base_url, settings.openai_api_key, settings.openai_model return "", "", "" - @staticmethod - def _generate_template(*, task_title: str, sections: list[tuple[str, str]], evidences: list[Evidence]) -> str: - top = evidences[:5] + def _generate_template( + self, + *, + task_title: str, + sections: list[tuple[str, str]], + evidences: list[Evidence], + blueprint: ReportBlueprint, + ) -> str: + ranked = sorted(evidences, key=lambda item: item.score, reverse=True) + top = ranked[:8] + source_types = sorted({ev.sourceType.value for ev in ranked}) lines = [ - "## 摘要", - f"本文围绕“{task_title}”进行快速研究,总结了当前公开资料中的关键观点与证据。", + "## 输出格式", + f"体裁:{blueprint.output_format}", + f"目标:{blueprint.objective}", + f"风格:{blueprint.tone}", "", - "## 关键发现", ] - for idx, ev in enumerate(top, start=1): - lines.append(f"{idx}. {ev.metadata.title}(score={ev.score:.2f})") - lines.extend(["", "## 分析说明"]) - for _, section_content in sections[:4]: - lines.append(f"- {section_content[:220]}") - lines.extend( - [ - "", - "## 结论", - "当前证据已覆盖主要趋势,但仍需针对高争议结论补充更高质量、可复现实验的数据。", - ] - ) + section_to_evidence: dict[str, list[Evidence]] = defaultdict(list) + for ev in ranked: + section_to_evidence[ev.nodeId].append(ev) + + used_ids: set[str] = set() + for idx, heading in enumerate(blueprint.section_titles): + lines.append(f"## {heading}") + if idx == 0: + ref_summary = "、".join(f"[evidence:{ev.id}]" for ev in top[:4]) if top else "无" + lines.append( + f"围绕“{task_title}”共纳入 {len(ranked)} 条证据,来源类型:" + f"{', '.join(source_types) or '无'}。" + ) + lines.append( + "本报告从问题背景、机制解释、影响评估、风险边界与行动路径五个层面展开," + "优先基于可追溯证据构建结论链条,避免仅停留在概念罗列。" + ) + if top: + lines.append(f"高优先证据线索:{ref_summary}") + else: + lines.append("当前没有检索到可用证据,请检查数据源配置与网络连通性后重试。") + lines.append("") + continue + + if not sections: + lines.append( + "暂无可用任务分段,当前仅能给出高层研判。建议先补充分解:问题定义、" + "关键变量、评估指标、对照样本,再回填到本章节。" + ) + lines.append( + "在缺少结构化分段时,本节仍应覆盖成因解释、影响范围和可执行动作," + "并明确当前结论的可信度边界。" + ) + lines.append("") + continue + + section_idx = min(idx - 1, len(sections) - 1) + section_id, section_content = sections[section_idx] + section_lines = [line.strip() for line in section_content.splitlines() if line.strip()] + section_title = section_lines[0] if section_lines else section_id + section_desc = section_lines[1] if len(section_lines) > 1 else section_content.strip() + matched = section_to_evidence.get(section_id, [])[:3] + if not matched: + matched = [ev for ev in ranked if ev.id not in used_ids][:3] + if matched: + evidence_details: list[str] = [] + for ev in matched: + used_ids.add(ev.id) + evidence_details.append( + f"{self._display_title(ev)} 指出“{self._compact_text(ev.content, 95)}”" + f" [evidence:{ev.id}]" + ) + lines.append( + f"研究问题:{section_title}。本节围绕“{self._compact_text(section_desc, 260)}”展开," + "重点刻画问题定义、驱动因素、关键影响与可行约束。" + ) + lines.append( + f"证据解读:{';'.join(evidence_details)}。以上证据共同支持本节判断," + "同时提示结论需结合场景差异进行校准。" + ) + else: + lines.append( + f"研究问题:{section_title}。当前尚未命中该分段的直接证据," + "建议扩展检索词并增加跨来源验证,以避免单一视角偏差。" + ) + lines.append(self._section_focus_hint(heading)) + lines.append("") + + leftovers = [ev for ev in ranked if ev.id not in used_ids][:4] + if leftovers: + lines.append("## 补充线索") + for ev in leftovers: + lines.append( + f"- {self._display_title(ev)}:{self._compact_text(ev.content, 120)} " + f"[evidence:{ev.id}]" + ) + lines.append("") + return "\n".join(lines) + @staticmethod + def _section_focus_hint(heading: str) -> str: + if "方法" in heading or "范围" in heading: + return ( + "方法与范围:明确样本覆盖、数据时效与对照口径,补充可复现实验步骤," + "避免仅用定性描述替代可验证过程。" + ) + if "背景" in heading: + return ( + "背景延展:说明该问题在产业、技术与政策层面的演化脉络," + "并区分长期趋势与短期波动,减少时点偏差。" + ) + if "发现" in heading: + return ( + "关键发现:将共识点与争议点并列呈现,分别标注证据强度," + "并解释不同来源出现结论分歧的潜在原因。" + ) + if "风险" in heading or "局限" in heading: + return ( + "风险与局限:识别证据覆盖盲区、外部变量干扰与实施前提缺失," + "同时给出最可能导致结论失效的边界条件。" + ) + if "结论" in heading or "建议" in heading: + return ( + "行动建议:按短期(1-3个月)、中期(3-12个月)拆分任务优先级," + "给出落地前置条件、负责人角色与效果评估指标。" + ) + return ( + "综合分析:从机制解释、影响传播与实施可行性三个层面推进论证," + "并对关键假设进行显式标注,便于后续复核。" + ) + + @staticmethod + def _compact_text(text: str, limit: int) -> str: + compacted = " ".join(text.split()).strip() + if not compacted: + return "暂无内容。" + return compacted[:limit] + + def _build_evidence_appendix(self, evidences: list[Evidence]) -> str: + ranked = sorted(evidences, key=lambda item: item.score, reverse=True) + lines = ["## 证据说明与来源链接"] + if not ranked: + lines.append("暂无可用证据。") + return "\n".join(lines) + + for idx, ev in enumerate(ranked, start=1): + snippet = " ".join(ev.content.split()) + lines.append(f"{idx}. [{ev.id}] {self._display_title(ev)}") + lines.append(f"说明:{snippet[:220] or '该来源未返回可展示摘要。'}") + lines.append( + f"来源:{ev.sourceType.value} | 发表时间:{ev.metadata.publishDate or '未知'} | 评分:{ev.score:.2f}" + ) + lines.append(f"网址:{ev.url}") + lines.append("") + return "\n".join(lines).rstrip() + + @classmethod + def _strip_inline_urls(cls, text: str) -> str: + return cls.URL_PATTERN.sub("[链接见文末证据附录]", text) + + @staticmethod + def _default_blueprint() -> ReportBlueprint: + return ReportBlueprint( + output_format="研究报告", + objective="给出跨维度、可复核且可执行的研究结论", + tone="客观中立、论证充分、信息密集", + section_titles=["摘要", "研究范围与方法", "背景", "关键发现", "分析", "结论与建议"], + ) + def _build_citations(self, evidences: list[Evidence]) -> dict[str, Citation]: citations: dict[str, Citation] = {} for ev in evidences: @@ -158,9 +418,23 @@ def _build_citations(self, evidences: list[Evidence]) -> dict[str, Citation]: citations[ev.id] = Citation( id=ev.id, authors=ev.metadata.authors or ["Unknown"], - title=ev.metadata.title, + title=self._display_title(ev), year=year, source=ev.sourceType.value, url=ev.url, ) return citations + + @classmethod + def _display_title(cls, evidence: Evidence) -> str: + raw_title = " ".join(evidence.metadata.title.split()).strip() + if raw_title and not cls._looks_placeholder_title(raw_title): + return raw_title + fallback = " ".join(evidence.content.split()).strip() + if fallback: + return fallback[:120] + return "未命名证据" + + @classmethod + def _looks_placeholder_title(cls, title: str) -> bool: + return bool(cls.PLACEHOLDER_TITLE_PATTERN.search(title.strip())) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 188a60b..4274f86 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.11" dependencies = [ "fastapi>=0.115.0", "uvicorn>=0.30.0", + "websockets>=12.0", "pydantic>=2.9.0", "pydantic-settings>=2.5.0" ] diff --git a/doc.md b/doc.md index ecd8414..cd66808 100644 --- a/doc.md +++ b/doc.md @@ -1,501 +1,653 @@ -# **Deep Research 深度科研辅助系统总体设计规格说明书** +# Deep Research 实现级技术说明 -## **1. 系统综述与核心架构** +> 更新时间:2026-02-21(按仓库当前实现整理) -### **1.1 设计主旨** +--- -本系统之构建,旨在矫正传统科研检索工具所固有之"黑箱化"运作模式及"浅层化"信息处理弊端。其核心技术差异性体现于以下三点: +## 1. 项目定位 -* **白盒推理机制 (White-box Reasoning)**:系统推理逻辑需完全透明化,允许操作端实时监视并干预任务规划思维链(CoT)。 -* **实验生态闭环 (Experimental Loop)**:藉由 MCP 协议突破纯文本处理之局限,实现本地数据与计算工具的连接与调用。 -* **语义对齐校验 (Semantic Alignment)**:引入严谨的数值冲突检测算法与语义数值对齐(SNA)机制,以确保数据的一致性。 +本项目是一个本地单用户的“研究会话 + 任务执行引擎”系统,核心目标是: +- 用会话驱动研究任务; +- 让用户可编辑计划、可查看进度、可改写最终报告; +- 把检索、分析、写作串成可复跑的流水线。 -### **1.2 总体架构图 (System Architecture)** +当前实现重点是端到端闭环,非生产多租户平台。 -本系统架构采行分层设计原则,以确立推理逻辑、信息检索与任务执行之解耦。 +### 1.1 这个项目能做什么 -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 交互层 (Interaction Layer) │ -│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ -│ │ CoT 编辑器 │ │ 实时证据看板 │ │ Markdown 分屏预览 │ │ -│ └─────────────┘ └──────────────┘ └──────────────────────────┘ │ -├─────────────────────────────────────────────────────────────────┤ -│ 编排层 (Orchestration Layer) │ -│ ┌─────────────────────────┐ ┌──────────────────────────────┐ │ -│ │ 主规划器 (Master Planner)│ │ 状态管理器 (State Manager) │ │ -│ │ - DAG 任务图谱管理 │ │ - FSM 状态机 │ │ -│ │ - 动态任务调度 │ │ - 上下文快照 │ │ -│ └─────────────────────────┘ └──────────────────────────────┘ │ -├─────────────────────────────────────────────────────────────────┤ -│ 代理层 (Agent Layer) │ -│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │ -│ │ 检索代理 │ │ 分析代理 │ │MCP执行代理 │ │ 写作代理 │ │ -│ └───────────┘ └───────────┘ └───────────┘ └───────────────┘ │ -├─────────────────────────────────────────────────────────────────┤ -│ 基础设施层 (Infrastructure) │ -│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────────┐ │ -│ │ 向量数据库 │ │ MCP 服务器集群 │ │ 本地文件系统接口 │ │ -│ │ (L2 缓存) │ │ │ │ │ │ -│ └─────────────┘ └─────────────┘ └──────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` +从用户视角看,Deep Research 不是单次问答工具,而是一个“可持续推进的研究工作台”。它可以完成以下工作: -* **交互层 (Interaction Layer)**:涵盖思维链(CoT)编辑器、实时证据流监控看板及 Markdown 分屏预览界面。 -* **编排层 (Orchestration Layer)**: - * **主规划器 (Master Planner)**:负责动态有向无环图(DAG)任务图谱的管理与调度。 - * **状态管理器 (State Manager)**:维护全局有限状态机与上下文快照。 -* **代理层 (Agent Layer)**: - * 检索代理 (Retrieval Agent):执行全域信息的捕获。 - * 分析代理 (Analyst Agent):执行数据质量控制与审查。 - * MCP 执行代理 (MCP Executor):执行外部工具调用。 - * 写作代理 (Writer Agent):执行知识的结构化合成。 -* **基础设施层 (Infrastructure)**:集成向量数据库(L2 缓存)、MCP 服务器集群及本地文件系统接口。 +1. 把一个研究主题转成可执行计划。 +用户给出主题后,系统会自动生成第一版研究方案(Markdown + front matter),并允许用户继续细化范围、深度、数据源和优先级。 ---- +2. 把计划转成实际执行。 +系统会基于计划参数创建任务,自动拆解为节点 DAG,并逐节点检索证据、记录过程、生成快照。 -## **2. 技术选型与依赖** +3. 把执行过程透明化。 +研究中的阶段、节点、检索查询、进度百分比、证据条目会实时回流到会话时间线,用户可以看到“现在进行到哪一步、做了什么”。 -### **2.1 核心技术栈** +4. 把证据转成结构化报告。 +系统会汇总证据、做冲突检测、完成报告写作,并落盘为 `.md + .bib`,可直接下载。 -| 模块 | 技术选型 | 版本要求 | 说明 | -|------|----------|----------|------| -| 后端框架 | Python / FastAPI | ≥3.11, ≥0.104 | 异步支持,自动 API 文档 | -| LLM 接口 | OpenAI API / Anthropic API | - | GPT-4 / Claude Opus | -| 向量数据库 | Qdrant | ≥1.7 | 支持本地部署,高性能相似度搜索 | -| 文档解析 | Readability /trafilatura | ≥1.12 | 网页正文提取 | -| 状态管理 | Redis | ≥7.0 | FSM 状态持久化 | -| 任务队列 | Celery + Redis | ≥5.3 | DAG 任务调度 | -| 前端框架 | React + TypeScript | ≥18 | 交互界面开发 | -| 构建工具 | Vite | ≥5.0 | 快速开发构建 | +5. 把“后续需求”纳入同一会话闭环。 +研究完成后,用户可以继续发指令:改方案、重跑研究、改写报告(如改成演讲稿)。系统会根据意图自动路由到相应流程。 -### **2.2 外部依赖服务** +### 1.2 它是怎么做的(高层机制) -| 服务 | 用途 | API 文档 | -|------|------|----------| -| Serper / Google Search | 网页检索 | https://serper.dev/api | -| arXiv API | 学术论文检索 | https://arxiv.org/help/api | -| Semantic Scholar | 论文元数据增强 | https://api.semanticscholar.org | -| CrossRef | DOI 解析 | https://www.crossref.org/services/api | +项目核心是“会话编排层 + 执行引擎层”的双层结构: -## **3. 核心代理(Agent)技术规范** +- 会话编排层(`ConversationAgent`)负责“理解用户当前要什么”。 +同样是“请调整一下”这种输入,系统会根据上下文与关键词判断你是在改方案、要重跑,还是只改报告,并选择不同处理路径。 -### **3.1 首席任务规划代理 (Master Planner Agent)** +- 执行引擎层(`ExecutionEngine`)负责“把研究做完”。 +引擎按状态机推进任务,从规划、检索、分析到写作逐步执行,并通过事件把过程同步回前端与会话消息。 -该模组之职能在于将抽象的科研目标转化为可执行的有向无环图 (DAG)。 +这意味着系统既有“聊天交互能力”,也有“工程化流水线能力”,两者通过统一数据模型(Task/Conversation/Evidence/Conflict)连接起来。 -* **任务拆解策略**: - * **广度优先搜索 (BFS)**:于初始阶段展开首级子课题(涵盖背景、现状及挑战等维度)。 - * **深度优先搜索 (DFS)**:依据检索反馈结果进行垂直领域的深度挖掘。 - * **动态剪枝算法**:实时计算 infoGainScore(信息增益),若连续两个子任务之增益值低于预设阈值(0.2),系统将自动合并或舍弃后续分支。 +### 1.3 这个实现的优势 -* **循环检测机制**:利用 DFS 遍历算法维护 VisitedStack,以防止任务执行陷入无限循环。 +1. 过程可追踪,而不是黑盒回答。 +用户可以看到计划版本、运行轮次、进度分组、证据清单、冲突记录,便于复盘和迭代。 -* **调度算法逻辑**: - 1. 使用优先队列管理待执行任务,维护已访问节点集合防止重复处理 - 2. 执行前检查所有依赖任务是否已完成,未完成则跳过 - 3. 任务执行时标记状态为 RUNNING,调用对应 Agent 处理 - 4. 执行完成后计算信息增益分数,低于阈值(0.2)则标记为 PRUNED - 5. 剪枝通过后标记为 COMPLETED,保存输出结果 - 6. 递归激活并处理所有子任务节点 +2. 支持多轮研究,而不是一次性输出。 +会话与任务解耦:同一个 `conversation_id` 可以对应多次 `task_id` 运行,适合“先做一版,再补证据再重跑”的真实研究节奏。 -### **3.2 检索代理 (Research & Retrieval Agent)** +3. 结果可交付,而不仅是聊天记录。 +最终产物是本地文件(Markdown + BibTeX),可进入论文草稿、内部报告、知识库或二次编辑流程。 -该模组负责执行全域信息的捕获,并配置三级缓存机制。 +4. 对外部依赖有降级策略。 +在模型或网络不可用时,系统仍可用 fallback 计划/报告逻辑保持主流程可运行;测试场景可用 mock 模式保证稳定复现。 -* **检索管线流程**: - 1. **查询扩展 (Query Expansion)**:生成符合 `(Term OR Abbreviation) AND (Year) AND (Action Verb)` 格式之结构化检索式。 - 2. **L1/L2 缓存校验**:优先检索本地内存与向量数据库,若相似度阈值 > 0.9,则跳过外部 API 调用。 - 3. **API 调用**:执行 Serper 或 arXiv 接口调用。 - 4. **网页解析 (WebParse)**:提取正文内容;若检测到 `
` 或 `` 标签,需提取其题注(Caption)并分配独立 evidenceId。 +5. 结构清晰,便于二次开发。 +路由、仓储、服务、代理职责分明;前后端接口边界明确,适合继续扩展 provider、MCP 工具、并发调度和权限体系。 -* **缓存策略配置**: - * **L1 内存缓存**:采用 LRU 淘汰策略,最大容量 1000 条,过期时间 1 小时 - * **L2 向量数据库缓存**:使用 Qdrant 存储,集合名称为 evidence_cache,相似度阈值设为 0.9,过期时间 24 小时 +### 1.4 核心特性清单 -### **3.3 分析代理 (Analyst & Critic Agent)** +- 会话驱动研究:会话状态与消息历史完整保存。 +- 计划版本化:每次修订产生 `PlanRevision`,可追溯。 +- 任务可控:支持 `start/pause/resume/abort/recover`。 +- 实时进度:WebSocket + 会话内进度分组聚合。 +- 检索可配置:支持 `searchSources`,按 provider 并发采集。 +- 证据治理:证据清洗、去重、打分、冲突检测与投票。 +- 报告多形态:研究报告/论文/演讲稿等体裁蓝图。 +- 质量闸门:报告审查与修订循环,降低占位内容与过程噪音。 +- 本地优先:SQLite 持久化 + 本地文件产物,部署成本低。 -该模组作为质量控制核心,执行语义数值对齐 (SNA)。 +### 1.5 典型使用场景 -* **冲突检测机制**: - * **单位标准化**:将异构计量单位统一转换为国际单位制 (SI)。 - * **阈值判定**:当 `(ValueA - ValueB) / Max > 15%` 且环境条件相似时,生成冲突记录 ConflictRecord。 +- 研发团队做技术路线调研:先快速跑一版,再补充新证据重跑。 +- 产品/战略团队做专题分析:输出结构化报告并保留可追溯证据链。 +- 内容团队做格式改写:研究完成后直接改成演讲稿、摘要版或简报文稿。 +- 教学/实验环境:通过 mock 模式稳定演示完整流程与系统行为。 -* **信誉评分计算逻辑**: - * **基础权重**:论文 1.0、专利 0.8、网页 0.5、MCP 数据 0.9 - * **影响因子加成**:以 IF/10 计算,最高加成 1.5 倍 - * **同行评议加成**:经同行评议的来源乘以 1.2 倍 - * **时效性衰减**:发布超过 5 年开始衰减,10 年后降至 0.5 倍 - * **最终评分**:基础权重 × 影响因子加成 × 同行评议加成 × 时效性 × 相关性分数 +--- + +## 2. 总体架构 + +```text +Frontend (React/Vite) + ├─ ConversationSidebar 会话列表/新建/重命名/删除 + ├─ ChatTimeline 消息流/进度组/报告预览 + ├─ Composer 指令输入 + └─ PlanEditorPane 方案编辑与启动 + +Backend (FastAPI) + ├─ API Routes /tasks /conversations /evidence /mcp + ├─ ConversationAgent 会话编排与指令路由 + ├─ ExecutionEngine 任务执行主循环 + ├─ Services planner/retrieval/analyst/writer/agents + ├─ Repositories SQLite 持久化读写 + └─ ProgressHub WebSocket 进度推送 + +Storage + ├─ SQLite (backend/.data/deep_research.db) + └─ Reports (.md/.bib, backend/.data/reports) +``` -### **3.4 MCP 执行代理 (MCP Executor Agent)** +--- -该模组用于安全地连接外部工具与数据源。 +## 3. 后端实现细节 -* **协议实现**:基于 JSON-RPC 2.0 标准。 +## 3.1 应用入口与配置 -* **安全沙盒机制**: - * **只读模式 (Read-Only)**:允许直接执行。 - * **写/执行模式 (Write/Execute)**:强制挂起任务,返回 `USER_CONFIRMATION_REQUIRED` 状态,待 UI 授权后方可执行。 +### `backend/app/main.py` +- FastAPI 生命周期里调用 `init_db()` 初始化数据库 schema。 +- CORS 允许 `localhost/127.0.0.1` 任意端口。 +- 注册统一路由 `api_router`。 +- 健康检查接口:`GET /healthz`。 -* **异步轮询机制**:针对长耗时任务,返回 `JOB_ID` 并进入轮询模式,每隔 5 秒同步一次状态。 +### `backend/app/core/config.py` +配置由 `pydantic-settings` 驱动: +- `.env` + 前缀 `DR_` +- 关键项: + - `use_mock_sources` + - `default_llm_provider` / `default_llm_model` + - `db_path` + - 各模型和检索服务 key/base_url/model -### **3.5 写作代理 (Writer Agent)** +### `backend/app/core/utils.py` +- `now_iso()`:UTC,去微秒 ISO 时间。 +- `new_id()`:UUID4 字符串。 -该模组执行增量式文档生成作业。 +--- -* **分段加锁机制**: - * 每个 `# Section` 绑定至特定的 TaskNode。 - * 节点任务完成时,触发局部内容生成。 - * 经人工编辑之段落将被标记为 `LOCKED`,禁止 AI 覆盖。 +## 3.2 数据模型(Pydantic) -* **溯源索引**:维护 `UUID -> Citation` 映射表,以自动生成符合规范的参考文献列表。 +定义文件:`backend/app/models/schemas.py`。 ---- +### 任务相关 +- `TaskStatus`: `READY -> ... -> COMPLETED/FAILED/ABORTED` +- `NodeStatus`: `PENDING/RUNNING/COMPLETED/FAILED/SUSPENDED/PRUNED` +- `TaskConfig`: `maxDepth/maxNodes/searchSources/priority` +- `TaskNode`, `DAGGraph`, `DAGEdge` +- `TaskResponse`, `StateResponse` -## **4. API 接口规范** +### 会话相关 +- `ConversationStatus`: `DRAFTING_PLAN/PLAN_READY/RUNNING/COMPLETED/FAILED` +- `MessageRole`, `MessageKind` +- `PlanRevision`, `ConversationMessage` +- `ConversationSummary`, `ConversationDetail` -### **4.1 RESTful API 端点** +### 证据与冲突 +- `Evidence`, `EvidenceMetadata`, `ExtractedData` +- `ConflictRecord`, `DisputedValue`, `ResolutionStatus` +- `VoteRequest`, `VoteResponse` -#### **4.1.1 任务管理** +### MCP +- `MCPExecutionRequest` (`mode`: read/write/execute) +- `MCPExecutionResult` -| 方法 | 端点 | 描述 | 请求体 | 响应 | -|------|------|------|--------|------| -| POST | `/api/v1/tasks` | 创建新研究任务 | CreateTaskRequest | TaskNode | -| GET | `/api/v1/tasks/{task_id}` | 获取任务详情 | - | TaskNode | -| PUT | `/api/v1/tasks/{task_id}` | 更新任务配置 | UpdateTaskRequest | TaskNode | -| DELETE | `/api/v1/tasks/{task_id}` | 删除任务 | - | DeleteResponse | -| GET | `/api/v1/tasks/{task_id}/dag` | 获取任务 DAG 结构 | - | DAGGraph | +--- -#### **4.1.2 证据管理** +## 3.3 数据库层 + +### `backend/app/core/database.py` +SQLite schema(WAL)包含表: +- `tasks` +- `task_nodes` +- `snapshots` +- `evidences` +- `conflicts` +- `conversations` +- `plan_revisions` +- `conversation_messages` + +索引: +- `idx_conversations_task_id` +- `idx_conversation_messages_created_at` + +### Repository 职责 + +#### `TaskRepository` +- CRUD:任务主表 +- DAG 持久化:`save_dag/get_dag` +- 节点状态更新:`update_node_status` +- 快照:`save_snapshot/load_snapshot` +- 报告路径:`set_report_path` + +#### `EvidenceRepository` +- `save_many/get/list` +- 过滤参数:`taskId/nodeId/limit` + +#### `ConflictRepository` +- `save_many/get/list_by_task/resolve` +- `resolve` 将状态置为 `RESOLVED` 并记录 `resolution_json` + +#### `ConversationRepository` +- 会话摘要:创建、查询、更新 topic/status/task_id、删除 +- 方案版本:`add_plan_revision/get_current_plan` +- 消息历史:`add_message/list_messages` +- 进度聚合:`append_progress_entry` + - 同会话、同 `taskId`、同 `phase` 会复用已有 `PROGRESS_GROUP` 消息 + - 每组最多保留最近 50 条 entry -| 方法 | 端点 | 描述 | 请求体 | 响应 | -|------|------|------|--------|------| -| GET | `/api/v1/evidence` | 获取证据列表 | QueryParams | Evidence[] | -| GET | `/api/v1/evidence/{evidence_id}` | 获取证据详情 | - | Evidence | -| POST | `/api/v1/evidence/{evidence_id}/vote` | 证据投票(冲突解决) | VoteRequest | VoteResponse | +--- -#### **4.1.3 状态控制** +## 3.4 API 路由 -| 方法 | 端点 | 描述 | 请求体 | 响应 | -|------|------|------|--------|------| -| POST | `/api/v1/tasks/{task_id}/start` | 启动任务执行 | - | StateResponse | -| POST | `/api/v1/tasks/{task_id}/pause` | 暂停任务 | - | StateResponse | -| POST | `/api/v1/tasks/{task_id}/resume` | 恢复任务 | - | StateResponse | -| POST | `/api/v1/tasks/{task_id}/abort` | 终止任务 | - | StateResponse | +### 路由聚合 +`backend/app/api/router.py` 包含四个路由文件: +- `tasks.py` +- `evidence.py` +- `mcp.py` +- `conversations.py` -### **4.2 WebSocket 事件流** +### 任务路由(`tasks.py`) +- 任务管理:创建/查询/更新/删除/取 DAG +- 状态控制:`start/pause/resume/abort/recover` +- 辅助读取:`/conflicts` `/report` `/report/download` `/snapshot` +- WebSocket:`/ws/task/{task_id}/progress` -客户端通过订阅 `task/{task_id}/progress` 频道接收实时推送。 +### 会话路由(`conversations.py`) +- 创建、查询、重命名、删除(单个/全部) +- 计划修订:`/plan/revise`、`/plan`(PUT) +- 执行:`/run` +- 报告下载:`/report/download` -服务端推送的事件类型包括: -* **TASK_STARTED**:任务开始执行 -* **TASK_PROGRESS**:任务进度更新(包含进度百分比 0-100) -* **EVIDENCE_FOUND**:发现新证据 -* **TASK_COMPLETED**:任务完成 -* **ERROR**:执行错误 +### 证据路由(`evidence.py`) +- 列表与详情 +- 冲突投票:`/evidence/{evidence_id}/vote` -事件数据结构包含时间戳、任务 ID、当前节点、进度值、证据对象或错误信息。 +### MCP 路由(`mcp.py`) +- 统一执行入口:`/mcp/execute` -### **4.3 请求/响应示例** +--- -#### **创建任务** +## 3.5 服务层(核心行为) + +## 3.5.1 状态机 +`backend/app/services/state_machine.py` +- 定义 `ALLOWED_TRANSITIONS` +- `transition_or_raise(current, target)` 非法迁移直接抛错 + +## 3.5.2 规划器 +`backend/app/services/planner.py` `MasterPlanner.build_dag(...)` +- 生成根节点 + BFS 扩展 +- depth=0 固定首层主题:`背景研究/现状分析/挑战识别` +- 后续层主题:`{父标题} - 深入方向` +- 基于 `_estimate_info_gain` 做简化剪枝(低增益可标 `PRUNED`) +- 受 `maxDepth/maxNodes` 硬约束 + +## 3.5.3 检索服务 +`backend/app/services/retrieval.py` + +### L1 缓存 +- `L1EvidenceCache`: + - LRU(`OrderedDict`) + - 默认大小 1000 + - TTL 3600s + +### 查询扩展 +- `expand_query(query)` -> `() AND (year OR year-1 OR year-2)` + +### 模式分支 +- `DR_USE_MOCK_SOURCES=true`:`_mock_retrieve` 返回合成证据 +- 否则:`_real_retrieve` + +### 真实检索源 +- Tavily(有 key 才调用) +- arXiv(可直接调用) +- Semantic Scholar(可直接调用) + +并行策略: +- 为 provider 建立 `asyncio.create_task` 并聚合结果 +- 单个 provider 错误不会中断总流程(`_safe_provider_call`) + +### 清洗与过滤 +- `_validate_evidences` 过滤: + - URL 为空/协议非 http(s) + - placeholder host(如 `example.org`) + - 内容过短(<30 字符) +- `_dedupe_by_url` URL 去重 + +## 3.5.4 分析服务 +`backend/app/services/analyst.py` +- `score(evidence)`: + - source 权重(PAPER 1.0 / PATENT 0.8 / WEB 0.5 / MCP 0.9) + - 影响因子增益 + - peer-review 增益 + - 时间衰减(<=2016 降到 0.5,<=2021 为 0.8) +- `detect_conflicts(...)`: + - 从 `extractedData.numericalValues` 建桶 + - 单位归一化(km/cm/gb/mb) + - 方差阈值默认 0.15,超过即生成 `ConflictRecord` + +## 3.5.5 写作服务 +`backend/app/services/writer.py` + +`WriterService.write_report(...)` 输出: +- Markdown:`backend/.data/reports/{task_id}.md` +- BibTeX:`backend/.data/reports/{task_id}.bib` +- 引文映射:`dict[evidence_id, Citation]` + +内容生成路径: +1. 通过 `ReportBlueprint` 确定结构。 +2. `_generate_body`: + - mock 模式:直接模板体 + - real 模式:先尝试 LLM,再拼接证据模板 +3. 去除正文内 URL(URL 放文末证据附录)。 +4. 追加 `## 证据说明与来源链接` 与 `## References`。 + +标题处理: +- 过滤 placeholder 标题(如 `[MOCK]` / `result for`)并回退到内容摘要。 + +## 3.5.6 Agent 组合层 +`backend/app/services/agents.py` + +### `ResearchAgent` +- 封装检索 +- 可挂 MCP read 工具(目前为占位调用) + +### `ReportAgent` +报告生成管线: +1. `ReportFormatAgent.design_blueprint` 推断体裁与章节。 +2. `WriterService.generate_body` 生成初稿。 +3. `ReportReviewAgent.review` 质量审查: + - 检测 trace 泄漏 + - 检测 placeholder 文本 + - 检查章节完整性/段落深度/证据引用 +4. 若不通过,`ReportRevisionAgent.revise` 清洗或模板重写。 +5. 最终 `WriterService.write_report` 落盘。 + +### `ReportFormatAgent` +- 关键词识别:演讲稿/论文/研究报告/自定义格式 +- 输出对应 `section_titles` + +### `ReportReviewAgent` +- 审核规则包括: + - 章节缺失 + - 内容过短 + - 段落层次不足 + - 证据 ID 引用不足 + +### `ReportRevisionAgent` +- 清理 trace/噪音行 +- 必要时回退模板全文重写 + +## 3.5.7 MCP 执行器 +`backend/app/services/mcp_executor.py` +- `mode in {write, execute}` -> 返回 `USER_CONFIRMATION_REQUIRED` +- `mode=read` -> 模拟 JSON-RPC 成功返回 + +## 3.5.8 进度中心 +`backend/app/services/progress_hub.py` +- task_id 维度保存 WebSocket 连接集合 +- `emit` 广播 `ProgressEvent` +- 发送失败连接自动剔除 + +## 3.5.9 重试工具 +`backend/app/services/retry.py` +- `retry_async(fn, max_attempts, base_delay_seconds)` +- 指数退避(`2**attempt`) +- 最终抛 `RetryableError` -**请求**(POST /api/v1/tasks): -* title:研究标题 -* description:研究描述 -* config.maxDepth:最大搜索深度(默认 3) -* config.maxNodes:最大节点数(默认 50) -* config.searchSources:数据源列表(arXiv、Google Scholar、IEEE 等) -* config.priority:优先级(1-5) +--- -**响应**(201 Created): -* taskId:任务唯一标识符(UUID 格式) -* status:任务状态(READY) -* createdAt:创建时间(ISO8601 格式) -* dag:任务 DAG 结构(包含 nodes 和 edges) +## 3.6 执行引擎(关键主链路) + +文件:`backend/app/services/execution_engine.py` + +### 核心对象 +- `TaskControlState`:`paused/aborted/running_task/completed_nodes` +- `ExecutionEngine`:系统执行主协调器 + +### `_run_task` 阶段 + +1. **TASK_STARTED** + - 发出 `TASK_STARTED` 事件 + +2. **规划** + - 若任务无 DAG,状态迁移 `READY->PLANNING` + - 调 `MasterPlanner.build_dag` + - 落库后推送 `TASK_PROGRESS (BUILDING_PLAN, 20%)` + +3. **执行节点** + - 状态迁移到 `EXECUTING` + - 跳过 root 与 `PRUNED` 节点 + - 对每个节点: + - 可暂停轮询 (`paused`) + - 可终止 (`aborted`) + - 节点置 `RUNNING` + - `ResearchAgent.collect_evidence` + - 证据入库 + - 推送 `EVIDENCE_FOUND` + - 节点置 `COMPLETED` + - 写快照(包含已完成节点与缓存摘要) + +4. **审查冲突** + - 对所有证据重算 score + - `AnalystService.detect_conflicts` + - 有冲突:`EXECUTING->REVIEWING`,存冲突后继续 `REVIEWING->SYNTHESIZING` + - 无冲突:`EXECUTING->SYNTHESIZING` + +5. **写作与落盘** + - 推送 `OUTLINING/WRITING_SECTION` + - `ReportAgent.generate_report` 生成 `.md/.bib` + - `SYNTHESIZING->FINALIZING->COMPLETED` + - 发 `TASK_COMPLETED`(含 reportPath/bibPath) + +6. **异常处理** + - 状态置 `FAILED` + - 推送 `ERROR` --- -## **5. 全流程交互逻辑设计** +## 3.7 会话编排(ConversationAgent) + +文件:`backend/app/services/conversation_agent.py` + +这是后端“对话行为”的核心。 + +### `create_conversation` +- 新建会话(`DRAFTING_PLAN`) +- 写入首条用户消息 +- 调 `_generate_initial_plan`(LLM 或 fallback) +- 存 `PLAN_DRAFT` 消息 +- 状态转 `PLAN_READY` + +### `revise_plan` +会先根据“是否已有报告 + 指令关键词”判定模式: +- `PLAN`:修订研究方案 +- `RESEARCH`:先修订方案,再立即触发新一轮研究 +- `REPORT`:进入报告改写异步任务 + +关键关键词集合: +- 方案意图:`研究方案/任务树/max_depth/...` +- 重跑意图:`重跑/补充检索/更新最新/...` +- 报告意图:`改写报告/演讲稿/rewrite/style/...` + +### 报告改写链路 +- `_start_report_revision` 先写“正在修改中”消息并置 `RUNNING` +- 后台 `asyncio.create_task(_run_report_revision_job)` +- 进度通过 `append_progress_entry` 写入 `PROGRESS_GROUP` +- 优先路径: + 1. 读取当前报告 + 2. LLM 改写 `_rewrite_report_with_llm` + 3. 需要时触发“基于既有证据重建报告” + 4. 回写报告文件 + 5. 追加 `FINAL_REPORT` 消息 + +### `start_research` +- 检查当前计划存在 +- 解析 front matter(`title/max_depth/max_nodes/priority/search_sources`) +- 总是新建 `task_id` +- 会话绑定新 task,状态置 `RUNNING` +- 调 `execution_engine.start(task_id)` + +### `on_task_event` +监听执行引擎事件并投递到会话消息: +- `TASK_PROGRESS` -> 聚合为 `PROGRESS_GROUP` +- `TASK_COMPLETED` -> 会话置 `COMPLETED`,注入 `FINAL_REPORT` +- `ERROR/TASK_FAILED/TASK_ABORTED` -> 会话置 `FAILED`,注入错误消息 + +### 计划 front matter 解析 +- `_parse_plan`:从 YAML 头读取配置 +- 解析失败会产生 warning,并回退默认配置 +- `_ensure_front_matter`:保证计划文本总有 front matter -交互子系统之运作逻辑,系由 **State-Trigger-Action** 有限状态机驱动。 +--- -| 状态 | UI 表现 | 可执行操作 | 系统后端响应 | -| :---- | :---- | :---- | :---- | -| **就绪 (Ready)** | 初始化界面,含多模态输入模块 | 录入议题、上传种子文件、配置偏好 | 预加载常用 MCP 服务列表 | -| **规划 (Planning)** | 展示任务图谱节点 | **[暂停]** 修改节点,**[拖拽]** 调整依赖,**[删除]** 冗余路径 | 规划器拆解任务,生成依赖关系图 | -| **执行 (Executing)** | 进度条显示,实时证据流看板 | **[干预]** 调整检索词,**[优先级]** 提权,**[授权]** 批准 MCP 调用 | 检索/执行代理并行运作,推送 Evidence[] | -| **审查 (Reviewing)** | 冲突节点高亮显示,对比弹窗 | 选择采信源、指令深挖争议点 | 分析代理检测一致性,聚合冲突数据 | -| **合成 (Synthesizing)** | 文档分屏预览,实时高亮 | 查看引用源,指令重写,锁定段落 | 写作代理整合证据,执行增量润色 | -| **归档 (Finalizing)** | 最终报告生成,参考文献列表 | 导出文档,同步 Zotero,启动后续研究 | 生成 .bib 文件,清理临时上下文 | +## 3.8 依赖装配 -### **5.1 深度交互细节说明** +文件:`backend/app/deps.py` -* **白盒化编辑**:操作端对任务树之增删改操作,需直接映射为后端图结构的 `UpdateNode` 指令。 -* **实时锚定**:点击证据卡片时,需高亮任务树中的对应来源节点及报告中的引用段落。 -* **搜索真空处理**:当公网检索无结果时,UI 需提示连接私有数据库或启用 AI 模拟推演。 -* **负载预警**:实时显示"认知负荷指数",建议操作端精简任务分支。 +在模块加载时单例化: +- repositories +- services +- agents +- execution_engine +- conversation_agent ---- +并调用: +- `execution_engine.set_event_listener(conversation_agent.on_task_event)` -## **6. 全局数据结构定义 (Data Schemas)** - -本节规定系统核心数据通信标准。 - -### **6.1 任务节点 (TaskNode)** - -* **taskId**:任务唯一标识符(UUID 格式) -* **parentTaskId**:父任务 ID(根节点为 null) -* **title**:任务标题 -* **description**:任务描述 -* **status**:任务状态(PENDING、RUNNING、COMPLETED、FAILED、SUSPENDED、PRUNED) -* **priority**:优先级(1-5 的整数) -* **dependencies**:依赖任务 ID 列表 -* **children**:子任务 ID 列表 -* **metadata**:元数据 - * estimatedTokenCost:预计 Token 消耗 - * searchDepth:搜索深度 - * infoGainScore:信息增益分数 - * createdAt/updatedAt:创建/更新时间 -* **conflicts**:冲突记录列表 -* **output**:输出证据数组 - -### **6.2 证据片段 (Evidence)** - -* **id**:证据唯一标识符(UUID) -* **sourceType**:来源类型(PAPER、WEB、PATENT、MCP) -* **url**:原始链接 -* **content**:正文内容(Markdown 格式) -* **metadata**:元数据 - * authors:作者列表 - * publishDate:发布日期(ISO8601) - * title:标题 - * abstract:摘要 - * impactFactor:影响因子 - * isPeerReviewed:是否经同行评议 - * relevanceScore:相关性评分(0-1) - * citationCount:引用次数 -* **score**:综合信誉评分(0-1) -* **extractedData**:提取数据 - * tables:表格列表(含 caption 和 data) - * images:图片列表(含 caption 和 url) - * numericalValues:数值列表(含 value、unit、context) - -### **6.3 冲突记录 (ConflictRecord)** - -* **conflictId**:冲突记录唯一标识符(UUID) -* **parameter**:发生冲突的参数名称 -* **disputedValues**:争议值列表(包含 value、unit、evidenceId、source) -* **variance**:差异程度(百分比) -* **context**:冲突上下文说明 -* **resolutionStatus**:解决状态(OPEN、RESOLVED、IGNORED) -* **resolution**:解决方案 - * selectedEvidenceId:采信的证据 ID - * reason:解决原因 - * resolvedAt:解决时间(ISO8601) +即执行事件天然回流到会话时间线。 --- -## **7. 错误处理与容错机制** - -### **7.1 错误分类与处理策略** - -| 错误类型 | 示例 | 处理策略 | -|---------|------|----------| -| 网络超时 | API 请求超时 (>30s) | 重试 3 次,指数退避 | -| API 失败 | 429/5xx 响应 | 切换备用 API,记录日志 | -| 解析失败 | 网页内容提取异常 | 标记为低质量,继续处理 | -| DAG 冲突 | 循环依赖检测 | 拒绝提交,返回错误路径 | -| 资源耗尽 | Token 限额超出 | 暂停任务,用户确认后续 | -| MCP 失败 | 外部工具调用失败 | 沙盒隔离,返回部分结果 | - -### **7.2 重试机制** - -**重试配置参数**: -* 最大重试次数:3 次 -* 指数退避基数:2 -* 初始延迟:1 秒 -* 可重试错误类型:TIMEOUT、CONNECTION_ERROR、RATE_LIMIT_EXCEEDED、SERVICE_UNAVAILABLE - -**重试逻辑**: -1. 执行目标函数 -2. 若发生可重试错误,按指数退避计算等待时间(1 秒、2 秒、4 秒) -3. 达到最大重试次数后仍未成功则抛出异常 -4. 非可重试错误直接抛出 - -### **7.3 状态恢复机制** - -**状态快照包含字段**: -* task_id:任务唯一标识符 -* timestamp:快照时间戳 -* fsm_state:当前 FSM 状态 -* completed_nodes:已完成节点 ID 列表 -* pending_nodes:待处理节点 ID 列表 -* evidence_cache:证据缓存映射 -* conflict_records:冲突记录列表 - -**恢复逻辑**: -1. 保存检查点:将快照序列化为 JSON 存入 Redis,设置 24 小时过期 -2. 加载检查点:从 Redis 读取数据并反序列化为 StateSnapshot 对象 -3. 若无检查点数据则返回 null,需从头开始执行 +## 4. 前端实现细节 + +## 4.1 启动与构建 + +- 入口:`frontend/src/main.tsx` +- Vite 端口:`5174`(`frontend/vite.config.ts`) --- -## **8. 部署架构** +## 4.2 API 客户端 -### **8.1 部署模式** +文件:`frontend/src/api.ts` -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Nginx / Caddy │ -│ (反向代理 + SSL) │ -├─────────────────────────────────────────────────────────────────┤ -│ Docker Compose 部署 │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ FastAPI │ │ Celery │ │ Qdrant │ │ -│ │ (API Server) │ │ (Worker) │ │ (Vector DB) │ │ -│ │ Port: 8000 │ │ N workers │ │ Port: 6333 │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ Redis │ │ React App │ │ File Store │ │ -│ │ (State/Queue) │ │ (Frontend) │ │ (MinIO/NFS) │ │ -│ │ Port: 6379 │ │ Port: 3000 │ │ Port: 9000 │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` +特点: +- 统一 `json()` 包装,带超时和错误提取。 +- 支持普通超时和计划接口长超时(`PLAN_API_TIMEOUT_MS` 默认 120s)。 +- 统一下载方法(Blob + ``)。 +- WebSocket 连接带 15s 心跳 ping。 -### **8.2 Docker Compose 配置** +--- + +## 4.3 `App.tsx` 主状态机 -**服务定义**: -* **api**:FastAPI 服务(端口 8000),依赖 redis 和 qdrant -* **worker**:Celery 异步任务处理器,使用 celery worker 启动 -* **redis**:Redis 7 Alpine 镜像(端口 6379),数据持久化到 redis_data 卷 -* **qdrant**:Qdrant v1.7.0 镜像(端口 6333),数据持久化到 qdrant_data 卷 -* **frontend**:React 前端构建(端口 3000),依赖 api 服务 +核心状态: +- 会话:`summaries/activeConversationId/activeDetail` +- 草稿:`planDraft/planVersion/draftDirty/editorMode` +- 发送控制:`sending/saving/starting/downloading` +- UI 控制:左右侧栏显示、移动端抽屉、确认弹窗、重命名弹窗 -**数据卷**: -* redis_data:Redis 持久化存储 -* qdrant_data:Qdrant 向量数据存储 +关键行为: +- 首次加载自动拉会话列表。 +- 活跃会话在 `RUNNING/DRAFTING_PLAN` 时轮询刷新(默认 2.5s)。 +- 创建草稿模式后,第一条消息作为“研究主题”。 +- 发送指令后做 optimistic UI(先插入临时 user 消息)。 +- 删除/重命名/全部删除都通过自定义 `Dialog`。 + +--- -### **8.3 环境变量配置** +## 4.4 组件职责 -创建 `.env` 文件,包含以下配置项: +### `ConversationSidebar` +- 展示会话列表与状态 chip +- 单会话菜单:重命名 / 删除 +- 全局菜单:全部删除 -**API 密钥**: -* OPENAI_API_KEY:OpenAI API 密钥 -* ANTHROPIC_API_KEY:Anthropic API 密钥 -* SERPER_API_KEY:Serper 搜索 API 密钥 +### `ChatTimeline` +- 渲染用户/Agent/System 消息 +- 方案消息可一键应用到右侧编辑器 +- `PROGRESS_GROUP` 聚合展示并可折叠 +- 报告消息使用 `ReportViewer` +- 支持隐藏历史轮次(只看当前 task) -**数据库连接**: -* REDIS_URL:Redis 连接地址 -* QDRANT_URL:Qdrant 连接地址 +### `PlanEditorPane` +- Markdown 编辑/预览 +- 保存草稿 +- 启动研究(状态允许时) +- 下载报告(`COMPLETED` 时) -**应用配置**: -* LOG_LEVEL:日志级别(INFO/DEBUG/ERROR) -* MAX_CONCURRENT_TASKS:最大并发任务数(默认 5) -* DEFAULT_SEARCH_DEPTH:默认搜索深度(默认 3) -* CACHE_TTL:缓存过期时间(秒) +### `Composer` +- Enter 发送,Shift+Enter 换行 +- 根据会话状态动态禁用 -**安全配置**: -* SECRET_KEY:应用密钥 -* ALLOWED_ORIGINS:允许的跨域来源 +### `Dialog` +- Esc、遮罩点击关闭(可禁用) +- 聚焦管理 -**MCP 配置**: -* MCP_SERVER_CONFIG_PATH:MCP 服务器配置文件路径 +### `ReportViewer` +- 报告展开/收起/下载 --- -## **9. 测试策略** +## 4.5 样式系统 -### **9.1 测试分层** +文件:`frontend/src/styles.css` -| 测试类型 | 覆盖范围 | 工具 | 目标覆盖率 | -|---------|----------|------|-----------| -| 单元测试 | Agent 模块、工具函数 | pytest | ≥80% | -| 集成测试 | API 端点、数据库交互 | pytest-httpx | ≥70% | -| 契约测试 | Agent 间通信协议 | pact | 关键路径 100% | -| 端到端测试 | 完整研究流程 | Playwright | 核心场景 | +实现特征: +- 三栏布局 + 渐变背景 + 玻璃拟态卡片。 +- 桌面端侧栏可折叠(left/right hidden)。 +- 移动端(<=1120px)左右抽屉 + 遮罩,且防背景滚动。 +- 进度/打字动画提供 `prefers-reduced-motion` 降级。 -### **9.2 测试用例示例** +--- -**DAG 生成测试**: -* 创建测试研究任务 -* 调用 Master Planner 生成 DAG -* 验证图中无循环依赖 -* 验证根节点标题正确 -* 验证层级深度不超过配置的最大深度 +## 5. 运行脚本 -**剪枝机制测试**: -* 创建模拟的低信息增益节点(info_gain < 0.2) -* 连续创建多个低增益节点 -* 调用 should_prune 判断是否应剪枝 -* 验证返回结果为 True +### `scripts/run_backend.sh` +- 创建/激活虚拟环境 +- 安装后端依赖 +- 启动 uvicorn ---- +### `scripts/run_frontend.sh` +- `npm install` +- `npm run dev` -## **10. 开发实施路线图** +### `scripts/run_real_case.py` +- 使用 `TestClient` 在进程内跑真实案例 +- 创建任务 -> 启动 -> 轮询 -> 输出 evidence 总数和报告前 30 行 -### **10.1 阶段规划** +--- -| 阶段 | 目标 | 交付物 | 验收标准 | 预估工时 | -|------|------|--------|----------|----------| -| **Phase 1** | 核心引擎构建 | - Master Planner
- DAG 调度器
- 基础 API | - 可生成任务 DAG
- 支持任务启动/暂停
- 单元测试通过 | 2 周 | -| **Phase 2** | 检索能力实现 | - Retrieval Agent
- 网页解析器
- L1/L2 缓存 | - 支持 3+ 数据源
- 缓存命中率 >30%
- 解析成功率 >85% | 2 周 | -| **Phase 3** | MCP 集成 | - MCP Executor
- 沙盒环境
- 权限确认流程 | - 连接 2+ MCP 服务
- 安全确认机制可用 | 1.5 周 | -| **Phase 4** | 质量控制 | - Analyst Agent
- SNA 算法
- 冲突检测 | - 数值冲突检出率 >90%
- 评分机制可用 | 1.5 周 | -| **Phase 5** | 文档生成 | - Writer Agent
- 增量生成
- 引用管理 | - 可生成 Markdown 报告
- 支持段落锁定 | 1 周 | -| **Phase 6** | 前端开发 | - 任务树组件
- 证据看板
- 状态控制 | - 完整交互流程可用
- WebSocket 实时更新 | 2 周 | -| **Phase 7** | 集成测试 | - E2E 测试
- 性能测试
- 安全测试 | - 核心场景覆盖
- 无 P0/P1 缺陷 | 1 周 | +## 6. 测试覆盖(按当前用例) -**总计:约 11 周** +## 集成测试 +- `test_task_lifecycle.py` + - 任务创建/启动/完成/快照恢复 + - evidence/conflict/mcp 接口可用性 +- `test_conversation_lifecycle.py` + - 会话创建、重命名、改计划、运行、下载、重跑、批量删除 -### **10.2 里程碑** +## 单元测试 +- `test_planner.py`:DAG 边界与无反向环 +- `test_state_machine.py`:状态迁移合法性 +- `test_retrieval.py`:查询扩展形状 +- `test_analyst.py`:冲突检测 +- `test_writer.py`:报告与 Bib 生成、标题清洗 +- `test_report_format_agent.py`:体裁识别 +- `test_report_review_agent.py`:审稿规则、修订循环 +- `test_conversation_repository.py`:进度分组复用/隔离 +- `test_conversation_agent.py`:front matter、报告改写、重跑、事件聚合、删除中断 -| 里程碑 | 日期 | 标志性成果 | -|--------|------|-----------| -| M1 | W2 | MVP 可运行,单任务检索 | -| M2 | W4 | 完整检索链路 + 缓存 | -| M3 | W6 | MCP 集成完成 | -| M4 | W8 | 质量控制 + 文档生成 | -| M5 | W11 | Alpha 版本发布 | +`tests/conftest.py` 默认设置:`DR_USE_MOCK_SOURCES=true`,保证测试可重复。 --- -## **附录 A:配置文件模板** +## 7. 配置与环境变量总表 + +后端主要读取(`DR_` 前缀): +- 应用:`APP_NAME/API_PREFIX/DB_PATH/LOG_LEVEL` +- 模式:`USE_MOCK_SOURCES` +- 模型路由:`DEFAULT_LLM_PROVIDER/DEFAULT_LLM_MODEL` +- Provider:`OPENROUTER_* / DEEPSEEK_* / OPENAI_* / ANTHROPIC_*` +- Search:`SERPER_API_KEY / SERPAPI_API_KEY / TAVILY_API_KEY / ...` -### **A.1 MCP 服务器配置** +前端主要读取: +- `VITE_API_BASE`(默认 `http://127.0.0.1:8000`) +- `VITE_API_TIMEOUT_MS` +- `VITE_PLAN_API_TIMEOUT_MS` +- `VITE_CONVERSATION_REFRESH_MS` -**配置结构**: -* **mcpServers**:MCP 服务器集合,键名为服务器标识 +--- + +## 8. 当前已知边界与后续扩展位 -**服务器类型**: -* **filesystem**:文件系统访问服务器 - * command:npx - * args:指定允许访问的路径 - * disabled:是否禁用 -* **brave-search**:Brave 搜索服务器 - * command:npx - * args:启动 brave-search 包 - * env:BRAVE_API_KEY 环境变量 -* **python-executor**:Python 代码执行器 - * command:uv - * args:指定工作目录和启动命令 +- 无鉴权、无租户隔离、无权限体系。 +- `MCPExecutor` 仅最小占位,未对接真实 tool registry。 +- 执行引擎当前单任务内部串行节点处理。 +- 检索源可扩展,但目前只实现 Tavily/arXiv/S2。 +- 意图路由依赖关键词规则,可替换为分类模型。 -### **A.2 日志配置** +--- -**配置结构**: +## 9. 建议阅读顺序(源码) -**格式化器 (Formatters)**: -* **default**:基础格式,包含时间、模块名、日志级别、消息 -* **detailed**:详细格式,额外包含文件名和行号 +1. `backend/app/services/conversation_agent.py` +2. `backend/app/services/execution_engine.py` +3. `backend/app/services/agents.py` +4. `backend/app/services/retrieval.py` +5. `backend/app/services/writer.py` +6. `backend/app/repositories/*.py` +7. `frontend/src/App.tsx` +8. `frontend/src/components/ChatTimeline.tsx` +9. `tests/integration/*.py` -**处理器 (Handlers)**: -* **console**:控制台输出,级别 INFO,使用 default 格式 -* **file**:文件输出,级别 DEBUG,使用 detailed 格式 - * 文件路径:logs/app.log - * 单文件最大 10MB - * 保留 5 个备份文件 +--- -**日志记录器 (Loggers)**: -* **app**:应用主日志记录器,级别 DEBUG,同时输出到控制台和文件 +如果你要继续扩展,请先看 `WORKFLOW.md`(端到端时序)再改代码。 diff --git a/docs/LOCAL_RELEASE.md b/docs/LOCAL_RELEASE.md index 68549e5..0b7113c 100644 --- a/docs/LOCAL_RELEASE.md +++ b/docs/LOCAL_RELEASE.md @@ -12,6 +12,13 @@ ./scripts/run_backend.sh ``` +If startup shows `No supported WebSocket library detected`, reinstall backend deps: + +```bash +source .venv/bin/activate +uv pip install -e 'backend[dev]' +``` + Backend URL: `http://127.0.0.1:8000` OpenAPI: `http://127.0.0.1:8000/docs` @@ -38,3 +45,7 @@ cd frontend && npm run build - This release is single-user local mode only. - Queue services, Docker deployment, and multi-user access are intentionally deferred. +- Frontend is now a chat-driven workspace (conversation sidebar + timeline + Markdown plan editor). +- Research report downloads are available at: + - `GET /api/v1/conversations/{conversation_id}/report/download` + - `GET /api/v1/tasks/{task_id}/report/download` diff --git a/docs/UI_REGRESSION_CHECKLIST.md b/docs/UI_REGRESSION_CHECKLIST.md new file mode 100644 index 0000000..2ed8ac9 --- /dev/null +++ b/docs/UI_REGRESSION_CHECKLIST.md @@ -0,0 +1,39 @@ +# UI Regression Checklist + +## Build Baseline + +- Run `cd frontend && npm run build` and confirm success. + +## Core Workflow + +- Create a new conversation, send the first topic, and verify draft plan appears. +- Save plan, start research, and ensure progress group can expand/collapse. +- Download report after completion. + +## Keyboard Accessibility + +- Use `Tab` to navigate top-level controls, sidebar menu buttons, composer, and editor actions. +- Confirm focused controls have a visible focus ring. +- In composer, press `Enter` to send and `Shift+Enter` to insert a newline. + +## Dialog Consistency + +- Trigger single delete, bulk delete, and rename actions. +- Confirm all actions use in-app dialogs (no browser native `confirm/prompt`). +- Validate cancel, close (`Esc`), and confirm behavior. + +## Time and Typography + +- Check conversation list and timeline timestamps render local time (e.g. `HH:MM:SS`). +- Confirm invalid timestamps degrade gracefully and do not break rendering. +- Verify small labels (status/role/mono text) remain readable on desktop and mobile. + +## Responsive and Drawer Behavior + +- In `375x812`, open/close conversation drawer and plan drawer with dedicated close buttons. +- Confirm opening one drawer closes the other. +- Confirm background page does not scroll while a mobile drawer or modal is open. + +## Reduced Motion + +- Enable system-level reduced motion and verify pulsing/loading animations are suppressed. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 416a215..876daaf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,256 +1,818 @@ -import type { CSSProperties } from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { - abortTask, - connectProgressWs, - createTask, - getDag, - getReport, - getTask, - listConflicts, - listEvidence, - pauseTask, - resumeTask, - startTask, - voteConflict + createConversation, + deleteAllConversations, + deleteConversation, + downloadConversationReport, + getConversation, + listConversations, + renameConversation, + reviseConversationPlan, + runConversation, + updateConversationPlan } from "./api"; -import type { ConflictRecord, Evidence, ProgressEvent, TaskResponse } from "./types"; +import { ChatTimeline } from "./components/ChatTimeline"; +import { Composer } from "./components/Composer"; +import { ConversationSidebar } from "./components/ConversationSidebar"; +import { Dialog } from "./components/Dialog"; +import { PlanEditorPane } from "./components/PlanEditorPane"; +import type { ConversationDetail, ConversationMessage, ConversationStatus, ConversationSummary } from "./types"; -const initialForm = { - title: "大语言模型幻觉问题研究", - description: "调研幻觉成因、检测方法、缓解策略与评测基准。", +const FIRST_MESSAGE_LIMIT = 500; +const REFRESH_INTERVAL_MS = Number(import.meta.env.VITE_CONVERSATION_REFRESH_MS ?? "2500"); +const LEFT_SIDEBAR_KEY = "dr:left-sidebar-visible"; +const RIGHT_SIDEBAR_KEY = "dr:right-sidebar-visible"; + +const DEFAULT_CONFIG = { maxDepth: 2, maxNodes: 8, - sources: "arXiv,Semantic Scholar", + searchSources: ["arXiv", "Semantic Scholar"], priority: 4 }; +const STATUS_LABEL: Record = { + DRAFTING_PLAN: "草稿生成中", + PLAN_READY: "方案可执行", + RUNNING: "处理中", + COMPLETED: "研究完成", + FAILED: "执行失败" +}; + +function toErrorText(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +function readStoredFlag(key: string, fallback: boolean): boolean { + if (typeof window === "undefined") return fallback; + const raw = window.localStorage.getItem(key); + if (raw === "1") return true; + if (raw === "0") return false; + return fallback; +} + +interface PendingAssistantBubble { + conversationId: string | null; + content: string; +} + +type ConfirmDialogState = + | { + kind: "deleteConversation"; + conversationId: string; + topic: string; + } + | { + kind: "deleteAll"; + total: number; + }; + +interface RenameDialogState { + conversationId: string; + value: string; +} + export function App() { - const [form, setForm] = useState(initialForm); - const [task, setTask] = useState(null); - const [dag, setDag] = useState(null); - const [evidence, setEvidence] = useState([]); - const [conflicts, setConflicts] = useState([]); - const [progress, setProgress] = useState(0); - const [report, setReport] = useState(""); - const [events, setEvents] = useState([]); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - const canControl = Boolean(task?.taskId); - const stateLabel = task?.status ?? "READY"; - const openConflicts = useMemo(() => conflicts.filter((c) => c.resolutionStatus === "OPEN"), [conflicts]); - const progressStyle = { "--value": `${progress}%` } as CSSProperties; + const [summaries, setSummaries] = useState([]); + const [activeConversationId, setActiveConversationId] = useState(null); + const [activeDetail, setActiveDetail] = useState(null); + const [draftMode, setDraftMode] = useState(false); + const [draftMessages, setDraftMessages] = useState([]); + const [pendingAssistantBubble, setPendingAssistantBubble] = useState(null); + + const [composerText, setComposerText] = useState(""); + const [planDraft, setPlanDraft] = useState(""); + const [planVersion, setPlanVersion] = useState(0); + const [draftDirty, setDraftDirty] = useState(false); + const [editorMode, setEditorMode] = useState<"edit" | "preview">("edit"); + + const [sending, setSending] = useState(false); + const [saving, setSaving] = useState(false); + const [starting, setStarting] = useState(false); + const [downloading, setDownloading] = useState(false); + const [deletingConversationId, setDeletingConversationId] = useState(null); + const [renamingConversationId, setRenamingConversationId] = useState(null); + const [deletingAll, setDeletingAll] = useState(false); + const [refreshingList, setRefreshingList] = useState(false); + const [error, setError] = useState(""); + const [confirmDialog, setConfirmDialog] = useState(null); + const [renameDialog, setRenameDialog] = useState(null); + + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [mobileEditorOpen, setMobileEditorOpen] = useState(false); + const [leftSidebarVisible, setLeftSidebarVisible] = useState(() => readStoredFlag(LEFT_SIDEBAR_KEY, true)); + const [rightSidebarVisible, setRightSidebarVisible] = useState(() => readStoredFlag(RIGHT_SIDEBAR_KEY, false)); + + const composerRef = useRef(null); + + const activeStatus = activeDetail?.status ?? null; + const statusLabel = draftMode ? "等待输入研究主题" : activeStatus ? STATUS_LABEL[activeStatus] : "未选择会话"; + const composerDisabled = activeStatus === "RUNNING" || activeStatus === "DRAFTING_PLAN" || (!activeConversationId && !draftMode); + + const activeSummary = useMemo( + () => summaries.find((item) => item.conversationId === activeConversationId) ?? null, + [summaries, activeConversationId] + ); + const timelineMessages = useMemo(() => { + if (activeDetail) return activeDetail.messages; + if (draftMode && !activeConversationId) return draftMessages; + return []; + }, [activeDetail, draftMode, activeConversationId, draftMessages]); + const pendingAssistantText = useMemo(() => { + if (!pendingAssistantBubble) return null; + if (pendingAssistantBubble.conversationId === null) { + return draftMode && !activeConversationId ? pendingAssistantBubble.content : null; + } + return pendingAssistantBubble.conversationId === activeConversationId ? pendingAssistantBubble.content : null; + }, [pendingAssistantBubble, draftMode, activeConversationId]); + + useEffect(() => { + void refreshConversations({ autoSelectFirst: true }); + }, []); + + useEffect(() => { + if (!activeConversationId) return; + void refreshConversationDetail(activeConversationId, { syncDraft: true }); + }, [activeConversationId]); + + useEffect(() => { + if (!activeConversationId || (activeStatus !== "RUNNING" && activeStatus !== "DRAFTING_PLAN")) return; + const timer = window.setInterval(() => { + void refreshConversationDetail(activeConversationId, { syncDraft: false }); + void refreshConversations(); + }, REFRESH_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [activeConversationId, activeStatus]); useEffect(() => { - if (!task?.taskId) return; - const ws = connectProgressWs(task.taskId, (msg) => { - const payload = JSON.parse(msg.data) as ProgressEvent; - setEvents((prev) => [`${payload.timestamp} ${payload.event}`, ...prev].slice(0, 30)); - if (payload.event === "TASK_PROGRESS" && typeof payload.data.progress === "number") { - setProgress(payload.data.progress); + window.localStorage.setItem(LEFT_SIDEBAR_KEY, leftSidebarVisible ? "1" : "0"); + }, [leftSidebarVisible]); + + useEffect(() => { + window.localStorage.setItem(RIGHT_SIDEBAR_KEY, rightSidebarVisible ? "1" : "0"); + }, [rightSidebarVisible]); + + useEffect(() => { + const drawerOpen = mobileSidebarOpen || mobileEditorOpen; + if (!drawerOpen) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [mobileSidebarOpen, mobileEditorOpen]); + + async function refreshConversations(options?: { autoSelectFirst?: boolean }) { + setRefreshingList(true); + try { + const items = await listConversations(); + setSummaries(items); + if (options?.autoSelectFirst && !activeConversationId && !draftMode && items.length > 0) { + setActiveConversationId(items[0].conversationId); } - if (payload.event === "TASK_COMPLETED") { - refreshAll(task.taskId); + if (activeConversationId && !items.some((item) => item.conversationId === activeConversationId)) { + setActiveConversationId(null); + setActiveDetail(null); + setPendingAssistantBubble((prev) => (prev?.conversationId === activeConversationId ? null : prev)); } - }); - return () => ws.close(); - }, [task?.taskId]); - - async function refreshAll(taskId: string) { - const [nextTask, nextDag, nextEvidence, nextConflicts] = await Promise.all([ - getTask(taskId), - getDag(taskId), - listEvidence(taskId), - listConflicts(taskId) - ]); - setTask(nextTask); - setDag(nextDag ?? null); - setEvidence(nextEvidence); - setConflicts(nextConflicts); - if (nextTask.reportPath) { - try { - setReport(await getReport(taskId)); - } catch { - setReport(""); + } catch (err) { + setError(toErrorText(err)); + } finally { + setRefreshingList(false); + } + } + + async function refreshConversationDetail( + conversationId: string, + options?: { syncDraft?: boolean; forceDraft?: boolean } + ) { + try { + const detail = await getConversation(conversationId); + setActiveDetail(detail); + setPendingAssistantBubble((prev) => { + if (detail.status === "DRAFTING_PLAN") { + if (!prev || prev.conversationId === conversationId || prev.conversationId === null) { + return { + conversationId, + content: prev?.content ?? "正在规划中,请等待,方案生成后会自动显示。" + }; + } + return prev; + } + if (!prev) return prev; + if (prev.conversationId === conversationId || prev.conversationId === null) { + return null; + } + return prev; + }); + const currentPlan = detail.currentPlan; + if (!currentPlan) return; + const shouldSync = + Boolean(options?.forceDraft) || + Boolean(options?.syncDraft) || + !draftDirty || + currentPlan.version !== planVersion; + if (shouldSync) { + setPlanDraft(currentPlan.markdown); + setPlanVersion(currentPlan.version); + setDraftDirty(false); } + } catch (err) { + setError(toErrorText(err)); } } - async function onCreateTask() { - setBusy(true); + async function recoverDraftConversation( + topic: string, + previousConversationIds: Set + ): Promise { + try { + const items = await listConversations(); + setSummaries(items); + const recovered = + items.find((item) => !previousConversationIds.has(item.conversationId)) ?? + items.find((item) => item.topic.trim() === topic); + if (!recovered) return null; + setDraftMode(false); + setActiveConversationId(recovered.conversationId); + const detail = await getConversation(recovered.conversationId); + setActiveDetail(detail); + setPlanDraft(detail.currentPlan?.markdown ?? ""); + setPlanVersion(detail.currentPlan?.version ?? 0); + setDraftDirty(false); + return detail; + } catch { + return null; + } + } + + function onCreateDraftConversation() { + setDraftMode(true); + setActiveConversationId(null); + setActiveDetail(null); + setComposerText(""); + setPlanDraft(""); + setPlanVersion(0); + setDraftDirty(false); + setDraftMessages([]); + setPendingAssistantBubble(null); + setRightSidebarVisible(false); + setMobileSidebarOpen(false); + setMobileEditorOpen(false); + setError(""); + } + + async function onSendInstruction() { + const text = composerText.trim(); + if (!text) return; + const submittingDraftTopic = !activeConversationId && draftMode; + if (submittingDraftTopic && text.length > FIRST_MESSAGE_LIMIT) { + setError(`首条研究主题最多 ${FIRST_MESSAGE_LIMIT} 字,请精简后再发送。`); + return; + } + const previousConversationIds = submittingDraftTopic + ? new Set(summaries.map((item) => item.conversationId)) + : undefined; + + let optimisticMessage: ConversationMessage | null = null; + setSending(true); + setComposerText(""); + if (submittingDraftTopic) { + optimisticMessage = { + messageId: `temp-user-${Date.now()}`, + conversationId: "__draft__", + role: "user", + kind: "USER_TEXT", + content: text, + metadata: { optimistic: true, draft: true }, + collapsed: false, + createdAt: new Date().toISOString() + }; + setDraftMessages([optimisticMessage]); + setPendingAssistantBubble({ + conversationId: null, + content: "正在规划中,请等待,方案生成后会自动显示。" + }); + } else if (activeConversationId) { + optimisticMessage = { + messageId: `temp-user-${Date.now()}`, + conversationId: activeConversationId, + role: "user", + kind: "USER_TEXT", + content: text, + metadata: { optimistic: true }, + collapsed: false, + createdAt: new Date().toISOString() + }; + const pendingMessage = optimisticMessage; + setActiveDetail((prev) => { + if (!prev || prev.conversationId !== activeConversationId) return prev; + return { ...prev, messages: [...prev.messages, pendingMessage] }; + }); + setPendingAssistantBubble({ + conversationId: activeConversationId, + content: activeStatus === "COMPLETED" ? "正在修改中,请等待。" : "正在生成中,请等待。" + }); + } setError(""); try { - const created = await createTask({ - title: form.title, - description: form.description, - config: { - maxDepth: Number(form.maxDepth), - maxNodes: Number(form.maxNodes), - searchSources: form.sources.split(",").map((s) => s.trim()).filter(Boolean), - priority: Number(form.priority) + if (submittingDraftTopic) { + const detail = await createConversation({ + topic: text, + config: DEFAULT_CONFIG + }); + setSummaries((prev) => [detail, ...prev.filter((item) => item.conversationId !== detail.conversationId)]); + setDraftMode(false); + setActiveConversationId(detail.conversationId); + setActiveDetail(detail); + setPlanDraft(detail.currentPlan?.markdown ?? ""); + setPlanVersion(detail.currentPlan?.version ?? 0); + setDraftDirty(false); + setDraftMessages([]); + setPendingAssistantBubble(null); + await refreshConversations(); + return; + } + + if (!activeConversationId) return; + await reviseConversationPlan(activeConversationId, text); + setPendingAssistantBubble(null); + await refreshConversationDetail(activeConversationId, { syncDraft: true, forceDraft: true }); + await refreshConversations(); + } catch (err) { + const errorText = toErrorText(err); + if (submittingDraftTopic && previousConversationIds && errorText.includes("请求超时")) { + const recoveredDetail = await recoverDraftConversation(text, previousConversationIds); + if (recoveredDetail) { + setDraftMessages([]); + if (recoveredDetail.status === "DRAFTING_PLAN") { + setPendingAssistantBubble({ + conversationId: recoveredDetail.conversationId, + content: "正在规划中,请等待,方案生成后会自动显示。" + }); + } else { + setPendingAssistantBubble(null); + } + await refreshConversations(); + return; } - }); - setTask(created); - await refreshAll(created.taskId); + } + if (submittingDraftTopic) { + setComposerText(text); + setDraftMessages([]); + setPendingAssistantBubble(null); + } else { + setComposerText(text); + setPendingAssistantBubble(null); + if (optimisticMessage) { + setActiveDetail((prev) => { + if (!prev || prev.conversationId !== optimisticMessage.conversationId) return prev; + return { + ...prev, + messages: prev.messages.filter((message) => message.messageId !== optimisticMessage?.messageId) + }; + }); + } + } + setError(errorText); + } finally { + setSending(false); + } + } + + async function onSavePlan() { + if (!activeConversationId || !planDraft.trim()) return; + setSaving(true); + setError(""); + try { + await updateConversationPlan(activeConversationId, planDraft); + setDraftDirty(false); + await refreshConversationDetail(activeConversationId, { syncDraft: true }); + await refreshConversations(); } catch (err) { - setError(String(err)); + setError(toErrorText(err)); } finally { - setBusy(false); + setSaving(false); } } - async function onAction(action: "start" | "pause" | "resume" | "abort") { - if (!task?.taskId) return; - setBusy(true); + async function onStartResearch() { + if (!activeConversationId) return; + setStarting(true); setError(""); try { - if (action === "start") await startTask(task.taskId); - if (action === "pause") await pauseTask(task.taskId); - if (action === "resume") await resumeTask(task.taskId); - if (action === "abort") await abortTask(task.taskId); - await refreshAll(task.taskId); + await runConversation(activeConversationId); + await refreshConversationDetail(activeConversationId, { syncDraft: false }); + await refreshConversations(); } catch (err) { - setError(String(err)); + setError(toErrorText(err)); } finally { - setBusy(false); + setStarting(false); } } - async function onResolveConflict(conflict: ConflictRecord) { - const selected = conflict.disputedValues[0]?.evidenceId; - if (!selected) return; - await voteConflict({ - evidenceId: selected, - conflictId: conflict.conflictId, - selectedEvidenceId: selected, - reason: "Single-user default decision." + async function onDownloadReport() { + if (!activeConversationId) return; + setDownloading(true); + setError(""); + try { + await downloadConversationReport(activeConversationId); + } catch (err) { + setError(toErrorText(err)); + } finally { + setDownloading(false); + } + } + + function onOpenPlanDrawer() { + setRightSidebarVisible(true); + if (window.matchMedia("(max-width: 1120px)").matches) { + setMobileEditorOpen(true); + } + } + + function onApplyPlan(markdown: string) { + setPlanDraft(markdown); + setDraftDirty(true); + setEditorMode("edit"); + onOpenPlanDrawer(); + } + + function onFocusComposer() { + composerRef.current?.focus(); + } + + function onRequestDeleteConversation(conversationId: string) { + const summary = summaries.find((item) => item.conversationId === conversationId); + setConfirmDialog({ + kind: "deleteConversation", + conversationId, + topic: summary?.topic ?? "未命名会话" }); - await refreshAll(task!.taskId); } + async function onConfirmDeleteConversation(conversationId: string) { + setDeletingConversationId(conversationId); + setError(""); + try { + const deletingActive = activeConversationId === conversationId; + await deleteConversation(conversationId); + setConfirmDialog(null); + if (deletingActive) { + setActiveConversationId(null); + setActiveDetail(null); + setPlanDraft(""); + setPlanVersion(0); + setDraftDirty(false); + setDraftMessages([]); + setPendingAssistantBubble((prev) => (prev?.conversationId === conversationId ? null : prev)); + setRightSidebarVisible(false); + } + await refreshConversations({ autoSelectFirst: deletingActive }); + } catch (err) { + setError(toErrorText(err)); + } finally { + setDeletingConversationId(null); + } + } + + function onRequestRenameConversation(conversationId: string) { + const current = summaries.find((item) => item.conversationId === conversationId); + setRenameDialog({ + conversationId, + value: current?.topic ?? "" + }); + } + + async function onConfirmRenameConversation() { + if (!renameDialog) return; + const topic = renameDialog.value.trim(); + if (!topic) { + setError("会话名称不能为空。"); + return; + } + if (topic.length > FIRST_MESSAGE_LIMIT) { + setError(`会话名称最多 ${FIRST_MESSAGE_LIMIT} 字。`); + return; + } + + const { conversationId } = renameDialog; + setRenamingConversationId(conversationId); + setError(""); + try { + const detail = await renameConversation(conversationId, { topic, syncCurrentPlan: true }); + setSummaries((prev) => + prev.map((item) => + item.conversationId === conversationId + ? { + conversationId: detail.conversationId, + topic: detail.topic, + status: detail.status, + taskId: detail.taskId, + createdAt: detail.createdAt, + updatedAt: detail.updatedAt + } + : item + ) + ); + if (activeConversationId === conversationId) { + setActiveDetail(detail); + setPlanDraft(detail.currentPlan?.markdown ?? ""); + setPlanVersion(detail.currentPlan?.version ?? 0); + setDraftDirty(false); + } + setRenameDialog(null); + await refreshConversations(); + } catch (err) { + setError(toErrorText(err)); + } finally { + setRenamingConversationId(null); + } + } + + function onRequestDeleteAllConversations() { + if (summaries.length === 0) return; + setConfirmDialog({ + kind: "deleteAll", + total: summaries.length + }); + } + + async function onConfirmDeleteAllConversations() { + setDeletingAll(true); + setError(""); + try { + await deleteAllConversations(); + setSummaries([]); + setActiveConversationId(null); + setActiveDetail(null); + setDraftMode(false); + setComposerText(""); + setPlanDraft(""); + setPlanVersion(0); + setDraftDirty(false); + setDraftMessages([]); + setPendingAssistantBubble(null); + setRightSidebarVisible(false); + setMobileSidebarOpen(false); + setMobileEditorOpen(false); + setConfirmDialog(null); + await refreshConversations(); + } catch (err) { + setError(toErrorText(err)); + } finally { + setDeletingAll(false); + } + } + + const composerPlaceholder = + activeStatus === "RUNNING" + ? "正在处理中,完成后可继续补充修改意见。" + : activeStatus === "DRAFTING_PLAN" + ? "正在生成研究方案,请等待当前规划完成。" + : draftMode + ? "先输入研究主题(最多 500 字),Agent 会先给出第一版研究方案。" + : activeConversationId + ? "输入需求,例如:改成演讲稿;补充最新证据并自动重跑。" + : "请选择会话,或点击“新建研究”。"; + const sendLabel = draftMode && !activeConversationId ? "开始规划" : "发送"; + const confirmDeleteConversationPending = + confirmDialog?.kind === "deleteConversation" && deletingConversationId === confirmDialog.conversationId; + const confirmDeleteAllPending = confirmDialog?.kind === "deleteAll" && deletingAll; + const renamePending = Boolean(renameDialog) && renamingConversationId === renameDialog.conversationId; + return ( -
-
-

Deep Research Local Console

-
State: {stateLabel} | Task: {task?.taskId ?? "未创建"}
-
+
+
+ +
+
+ +
-
-
-

1. 创建任务

- - setForm((f) => ({ ...f, title: e.target.value }))} /> - -