代码合并
This commit is contained in:
1 parent
b0970de677
commit
8859c13cfb
26 files changed
+1707
-334
No files matched your search
@@ -0,0 +1,186 @@
|
||||
# 报告协作工作台 — 交互重设计方案(v2)
|
||||
|
||||
> 日期:2026-09-07
|
||||
> 状态:**待确认**(确认后按 §7 分期实施)
|
||||
> 取代:现行「规则澄清 + 三张模板方案卡 + 画布为主」的交互
|
||||
> 原则:回到最初设计 —— **协调者智能体全程与用户对话的主线**
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计目标与总体形态
|
||||
|
||||
用户视角只有一条主线:**和一个规划师智能体对话 → 它摸清意图并规划工作流 → 确认后执行、过程在流程图上可见 → 随时插话干预、指定智能体重跑 → 最终成稿、继续对话式修改**。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 阶段1 计划模式 阶段2/3 执行+干预 阶段4 成稿 │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌──────────┐ │
|
||||
│ │ 规划师对话 │ 确认 │ 聊天时间线 │ 审稿过 │ 对话改稿 │ │
|
||||
│ │ 需求摘要卡 │ ─────→ │ +成员Tab │ ─────→ │ 版本/diff│ │
|
||||
│ │ 工作流卡 │ 冻结 │ +流程图动画│ 报告卡 │ 上游重跑 │ │
|
||||
│ └───────────┘ └───────────┘ └──────────┘ │
|
||||
│ ↑ 回到计划模式(replan)随时可触发 ↑ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
与现行的核心差异:
|
||||
|
||||
| 维度 | 现行(做偏的) | v2 |
|
||||
|------|----------------|-----|
|
||||
| 需求澄清 | 规则模板问句秒回 | 规划师智能体(LLM)领域化提问 |
|
||||
| 工作流来源 | 3 张确定性模板 | LLM 按意图生成自定义 DAG,对话迭代修改 |
|
||||
| 执行展示 | 画布为主、聊天为辅 | **聊天时间线为主**(成员 Tab 切换保留),**执行状态在流程图上动画呈现** |
|
||||
| 干预 | 命令确认卡体系 | 输入框常开 → 协调者智能体分类;支持**指定智能体重跑** |
|
||||
| 报告修改 | 改写候选(纯文字层) | 意图分流:润色走改写;**事实性问题触发上游节点重跑** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 阶段 1:计划模式 — 需求梳理与工作流规划
|
||||
|
||||
### 2.1 计划模式状态
|
||||
|
||||
- 用户发出第一条需求消息起进入**计划模式**,Header 显示徽章「📋 计划模式」,输入框提示语切换为「梳理需求中,你可以随时补充或纠正…」。
|
||||
- 计划模式在**工作流确认冻结**时结束;执行期大改可通过 replan 回到计划模式(见 §5.4)。
|
||||
|
||||
### 2.2 规划师对话(LLM,替换现行规则路由)
|
||||
|
||||
- 模型:`report_collaboration.planner_model`(回退 `coordinator_model` → 默认模型,复用现有 model_resolver 冻结机制)。
|
||||
- 提问是领域化的:写市场分析就问「关注整车还是供应链 / 要不要竞品对比 / 给谁看 / 篇幅」,而非固定模板问句。
|
||||
- 澄清轮次上限沿用 `max_clarification_rounds`;用户随时可以说「直接开始」按默认假设推进。
|
||||
|
||||
### 2.3 需求摘要卡
|
||||
|
||||
信息足够后,规划师在对话中输出一张**结构化需求摘要卡**(业务卡插槽渲染):主题 / 目的 / 读者 / 角度清单 / 篇幅与重点 / 默认假设。用户逐项看,可在对话里继续改(「读者改成投资人」→ 卡片更新出新版)。
|
||||
|
||||
### 2.4 工作流生成与迭代
|
||||
|
||||
- 用户确认摘要后,规划师生成**一份**工作流方案(不是三选一):以工作流卡(成员构成、各节点使命、预计时长/成本)+ 右侧流程图预览呈现。
|
||||
- 生成约束(保证可执行):**节点类型受词表约束** — `research / verify / analyze / write / review`;自由的是结构(几条线、并行还是串行、核验深度)与每个节点的使命/角度/工具。
|
||||
- 对话式修改:「把价格策略单独拆一条线」「加一个政策风险分析」→ 规划师修订后出新版工作流卡(带变更说明),再确认。
|
||||
- 「开始执行」→ 冻结需求 + 工作流(含模型/预算快照,沿用现有 freeze 机制)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 阶段 2:执行 — 过程在流程图上可见
|
||||
|
||||
### 3.1 流程图动画规格(执行状态的可视化主载体)
|
||||
|
||||
节点状态 → 样式(`ReportFlowCanvas` / `ReportFlowNode`,状态源 `selectNodeStatusMap`,事件源 `node.status.changed` SSE):
|
||||
|
||||
| 状态 | 节点样式 | 连入边 |
|
||||
|------|----------|--------|
|
||||
| planned / queued | 灰色常态 | 静态 |
|
||||
| **running** | 主色描边 + **呼吸脉冲动画**(ring)+ 角标 spinner | **流动动画**(@xyflow 内置 `animated: true` 的 dash 流动) |
|
||||
| **completed** | 绿色 + ✓ 角标,常驻 | 恢复静态实线 |
|
||||
| failed | 红色 + 错误角标 | 红色虚线 |
|
||||
| awaiting_input | 橙色 + ?角标 | 橙色静态 |
|
||||
| interrupted / superseded | 半透明降级 | 灰 |
|
||||
|
||||
- 语义:**边流动 = 数据正在流入该节点**(该节点执行中);节点完成即停动画换完成样式。
|
||||
- 实现成本:@xyflow 原生支持 animated 边与自定义节点 className,纯 CSS 补呼吸动画即可,无需新依赖。
|
||||
|
||||
### 3.2 聊天区(保留现有 Tab 切换)
|
||||
|
||||
- 并行成员沿用 **AgentStageSelector Tab 切换**(维持现状,不改交错时间线)。
|
||||
- 每个 Tab 内是该成员的完整流式输出:思考、工具调用卡、检索过程、正文 —— 与普通智能体问答同观感(message-adapter 已具备映射能力)。
|
||||
- 协调者播报(「研究线 A 完成,核心发现 X,开始交叉核验」)进入总时间线 Tab。
|
||||
|
||||
---
|
||||
|
||||
## 4. 阶段 3:干预 — 对话即干预 + 指定重跑
|
||||
|
||||
### 4.1 输入框常开,协调者智能体分流
|
||||
|
||||
执行期输入框永远可用,所有输入先到协调者(LLM 分类,规则高精度直判兜底——复用 RC-BE-014 IntentRouter 骨架,把「规则优先」反转为「LLM 优先 + 规则快路径」):
|
||||
|
||||
| 用户意图 | 协调者行为 | 生效时机 |
|
||||
|----------|-----------|----------|
|
||||
| 提问(「查到什么了?」) | 直接回答 | 立即,不打断 |
|
||||
| 修正方向(「重点看下沉市场」) | 小改:在安全点注入 overlay,并回复说明 | 下个安全点 |
|
||||
| **指定重跑**(见 §4.2) | 出确认卡 → 重开节点 attempt | 确认后 |
|
||||
| 大改(砍线/加线) | 提议 replan → 回计划模式 | 用户确认 |
|
||||
| 停止 | 直接取消 | 立即 |
|
||||
|
||||
### 4.2 指定智能体重跑(新增核心交互)
|
||||
|
||||
**场景**:用户发现某节点传给下游的信息不对(如研究线 B 的结论有误)。
|
||||
|
||||
两个入口,同一后端动作:
|
||||
|
||||
1. **对话**:「研究线 B 的结果不对,重新查,重点看价格战」→ 协调者解析出 `node_id` + 新指令;
|
||||
2. **画布**:点节点 → 抽屉(NodeInspectorDrawer)加「↻ 重新执行」按钮。
|
||||
|
||||
**级联规则**(复用并补全 ledger 的 targeted rework):
|
||||
|
||||
```
|
||||
重跑节点 N(attempt+1,带用户新指令作为 mission overlay)
|
||||
→ N 完成 → 所有直接/间接依赖 N 产物的下游节点标记 superseded
|
||||
→ 按 DAG 依赖序逐波重跑(边动画重新流动,用户全程可见)
|
||||
→ 受影响章节重新生成
|
||||
```
|
||||
|
||||
- 确认卡必须展示**影响面**:「将级联重跑 核验B / 分析 / 写作 共 3 个节点,预计 +8 分钟」。
|
||||
- 成本保护:级联重跑计入 run 预算(现有 budget 机制),预算不足时明确报因。
|
||||
|
||||
---
|
||||
|
||||
## 5. 阶段 4:报告成稿与对话式修改
|
||||
|
||||
### 5.1 成稿
|
||||
|
||||
审稿通过 → 报告卡出现在时间线(点开 ReportPreviewDialog 全文),协调者播报总结(各线核心发现 → 结论如何形成)。
|
||||
|
||||
### 5.2 修改意图分流(协调者 LLM 判定)
|
||||
|
||||
| 用户说 | 判定 | 动作 |
|
||||
|--------|------|------|
|
||||
| 「把第二章结论写得更笃定」 | 文字润色 | 改写候选(现有 rewrite API)→ 对比预览 → apply → 新版本 |
|
||||
| 「这个数据不对 / 重新收集一下」 | **事实性问题** | 定位来源节点 → **触发该节点重跑(可带新角度)→ 级联 §4.2 → 受影响章节重生成 → 新版本** |
|
||||
| 「再从供应链角度补一节」 | 结构性新增 | replan:回计划模式改工作流(只增不删已完成部分) |
|
||||
| 「恢复到上一版」 | 版本回退 | 现有 restore API |
|
||||
|
||||
- 版本链:每次 apply / restore / 级联重跑产出均产生新 head,历史完整保留(现有机制)。
|
||||
- 修改对话里协调者必须先**复述理解**(「你是想让研究线 B 换个角度重查,然后重写第三章,对吗?」)再执行,避免误触发昂贵的级联。
|
||||
|
||||
---
|
||||
|
||||
## 6. 工程改造清单
|
||||
|
||||
### 6.1 保留(本次联调已验证)
|
||||
|
||||
- 持久化 13 张表 + durable SSE + `after_seq` 断线续传;幂等键 / 乐观锁 / 预算框架。
|
||||
- MessageList 复用链路(message-adapter → LangGraph Message → 业务卡插槽)。
|
||||
- AgentStageSelector Tab、commands 安全点骨架(RC-BE-014)、报告版本三件套(RC-BE-015)、前端 hooks/reducer/selectors 主体。
|
||||
- 「清空历史」「生成候选方案」等本次新增入口。
|
||||
|
||||
### 6.2 后端重做 / 新建
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 规划师智能体 | LLM 多轮澄清(替换 `requirement/intent_router.py` 规则路由)+ 需求摘要结构化 + **工作流 LLM 生成**(受 §2.4 词表约束,替换 `planning/controller.py` 三模板)+ 对话式工作流修订 |
|
||||
| 协调者智能体升级 | 干预分类 LLM 优先;修改意图分流(§5.2);执行前复述确认 |
|
||||
| 指定重跑 + 级联 | ledger targeted rework 补全级联 superseded/重跑波次;确认卡带影响面计算 |
|
||||
| AgentScope 适配器修复 | `count_tokens` 协议缺失(上下文压缩必崩);attempt 耗尽终态化(防僵尸 run) |
|
||||
| 事件补齐 | 边流动所需的节点状态事件已有;补「级联影响预告」事件 |
|
||||
|
||||
### 6.3 前端重做 / 新建
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 计划模式 UI | Header 徽章、composer 提示切换、需求摘要卡、工作流卡(替换现三张模板卡) |
|
||||
| 流程图动画 | running 节点呼吸动画 + 入边 `animated`、completed ✓ 样式、级联重跑时边动画重流(§3.1) |
|
||||
| 节点抽屉 | 加「重新执行」按钮 → 确认卡 |
|
||||
| 干预交互 | 执行期输入框常开;意图确认卡(含影响面);修改对话复述确认 |
|
||||
| 布局 | 聊天为主、画布为辅的默认比例微调(不动整体两栏结构) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施分期
|
||||
|
||||
| 期 | 内容 | 验收 |
|
||||
|----|------|------|
|
||||
| **P0 地基** | AgentScope `count_tokens` 修复;attempt 耗尽终态化;级联重跑的 ledger 补全 | 执行阶段不崩、无僵尸 run、单节点重跑级联正确(后端测试) |
|
||||
| **P1 计划模式 + 执行可视化** | 规划师 LLM 对话、需求摘要卡、工作流 LLM 生成与迭代、计划模式 UI、流程图动画 | 从新会话到确认工作流全程对话完成;执行时动画正确随状态流转 |
|
||||
| **P2 干预 + 成稿修改** | 协调者 LLM 分类、指定重跑(两入口 + 影响面确认卡)、报告修改意图分流、上游级联重跑 | 四类干预各自走通;「数据不对→重跑→新章节→新版本」全链路 |
|
||||
|
||||
每期结束跑 RC 全量测试(当前基线 233 通过)+ 前端 26 单测,不引入对其他模块的影响。
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, FileText, MessageSquarePlus, Square } from "lucide-react";
|
||||
import { ArrowLeft, ClipboardList, FileText, MessageSquarePlus, Square } from "lucide-react";
|
||||
|
||||
import type { PagePhase } from "@/report-collaboration/state/selectors";
|
||||
|
||||
/** 计划模式覆盖的阶段:第一条消息起到工作流确认执行前。 */
|
||||
const PLAN_MODE_PHASES = new Set<PagePhase>(["clarifying", "planning", "proposal_ready"]);
|
||||
|
||||
const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = {
|
||||
empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" },
|
||||
clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
|
||||
@@ -51,6 +54,16 @@ export function ReportCollaborationHeader({
|
||||
<span className="rc-header-title" title={title}>
|
||||
报告协作 / {title}
|
||||
</span>
|
||||
{PLAN_MODE_PHASES.has(phase) ? (
|
||||
<span
|
||||
className="rc-chip"
|
||||
title="规划师正在梳理需求并规划工作流,确认后开始执行"
|
||||
style={{ background: "rgba(37, 99, 235, 0.1)", color: "var(--rc-accent)" }}
|
||||
>
|
||||
<ClipboardList className="size-3" aria-hidden />
|
||||
计划模式
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rc-chip" style={{ background: badge.bg, color: badge.color }}>
|
||||
{badge.label}
|
||||
</span>
|
||||
|
||||
@@ -156,8 +156,23 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
if (!plan) return [];
|
||||
return plan.edges.map((edge, index) => {
|
||||
const targetState = nodeStates[edge.target];
|
||||
const active = targetState?.status === "running" || targetState?.status === "validating";
|
||||
// 边语义 = 数据正在流入 target 节点:target 执行中 → 流动动画;
|
||||
// 失败红虚线 / 等待橙 / 已被替代降灰,其余静态。
|
||||
const targetStatus = nodeStates[edge.target]?.status;
|
||||
let className = "rc-edge";
|
||||
let stroke = "var(--rc-flow-edge)";
|
||||
if (targetStatus === "running" || targetStatus === "validating") {
|
||||
className = "rc-edge-active";
|
||||
stroke = "var(--rc-running)";
|
||||
} else if (targetStatus === "failed") {
|
||||
className = "rc-edge-failed";
|
||||
stroke = "var(--rc-danger)";
|
||||
} else if (targetStatus === "awaiting_input") {
|
||||
className = "rc-edge-waiting";
|
||||
stroke = "var(--rc-waiting)";
|
||||
} else if (targetStatus === "superseded" || targetStatus === "interrupted" || targetStatus === "cancelled") {
|
||||
className = "rc-edge-dimmed";
|
||||
}
|
||||
return {
|
||||
id: `${edge.source}->${edge.target}-${index}`,
|
||||
source: edge.source,
|
||||
@@ -165,11 +180,8 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
|
||||
label: edge.label ?? undefined,
|
||||
type: "default",
|
||||
animated: false,
|
||||
className: active ? "rc-edge-active" : "rc-edge",
|
||||
style: {
|
||||
stroke: active ? "var(--rc-running)" : "var(--rc-flow-edge)",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
className,
|
||||
style: { stroke, strokeWidth: 2 },
|
||||
};
|
||||
});
|
||||
}, [plan, nodeStates]);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
|
||||
import type { ReportRequirementSnapshot } from "@/report-collaboration/api/types";
|
||||
|
||||
interface RequirementSummaryCardProps {
|
||||
requirement: ReportRequirementSnapshot;
|
||||
/** 需求仍可修改的提示(生成方案前);方案已出后为 false。 */
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex gap-2 text-[12px] leading-5">
|
||||
<span className="w-16 shrink-0 text-[var(--rc-text-secondary)]">{label}</span>
|
||||
<span className="min-w-0 flex-1 break-words">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 计划模式 · 需求摘要卡:结构化展示规划师确认的需求快照。 */
|
||||
export function RequirementSummaryCard({ requirement, editable = true }: RequirementSummaryCardProps) {
|
||||
const angles = (requirement.required_angles ?? []).filter(Boolean).join("、");
|
||||
return (
|
||||
<div className="rc-card mx-auto w-full max-w-[560px] rounded-xl border border-[var(--rc-border)] bg-[var(--rc-bg-panel)] p-3.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<ClipboardCheck className="size-4" style={{ color: "var(--rc-accent)" }} aria-hidden />
|
||||
<span className="text-[13px] font-semibold">需求摘要 · v{requirement.revision}</span>
|
||||
<span className="rc-chip ml-auto" style={{ background: "rgba(37,99,235,.1)", color: "var(--rc-accent)" }}>
|
||||
计划模式
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Field label="主题" value={requirement.topic} />
|
||||
<Field label="目的" value={requirement.objective} />
|
||||
<Field label="读者" value={requirement.audience} />
|
||||
<Field label="角度" value={angles || null} />
|
||||
<Field label="时间范围" value={requirement.time_range} />
|
||||
<Field label="地域" value={requirement.geography} />
|
||||
<Field label="篇幅" value={requirement.length_target} />
|
||||
<Field label="语气" value={requirement.tone} />
|
||||
</div>
|
||||
{editable ? (
|
||||
<p className="mt-2.5 border-t border-[var(--rc-border)] pt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
|
||||
我正在基于这份需求为你规划工作流,稍候即可在对话中查看方案;想调整可直接在下方补充。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { toast } from "sonner";
|
||||
import { FileText, Loader2, Sparkles } from "lucide-react";
|
||||
import { FileText, Sparkles } from "lucide-react";
|
||||
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/report-collaboration/components/ReportPreviewDialog";
|
||||
import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay";
|
||||
import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell";
|
||||
import { RequirementSummaryCard } from "@/report-collaboration/components/RequirementSummaryCard";
|
||||
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
|
||||
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
|
||||
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
buildCommandConfirmCard,
|
||||
buildPlanGroupCard,
|
||||
buildReportAvailableCard,
|
||||
buildRequirementReadyCard,
|
||||
readReportCollaborationCard,
|
||||
} from "@/report-collaboration/state/message-adapter";
|
||||
import { resolveReportSpeaker } from "@/report-collaboration/state/speaker";
|
||||
@@ -247,11 +249,23 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
if (next) setFocusedAgentRunId(next);
|
||||
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
|
||||
|
||||
// 虚拟业务卡:方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
|
||||
// 虚拟业务卡:需求摘要(计划模式)/ 方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
|
||||
// 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。
|
||||
const runStarted = Boolean(state.run && state.run.plan_id);
|
||||
const extraCards = useMemo<Message[]>(() => {
|
||||
const cards: Message[] = [];
|
||||
if (
|
||||
state.requirement &&
|
||||
state.plans.length === 0 &&
|
||||
!runStarted &&
|
||||
Object.values(state.messagesById).some(
|
||||
(message) =>
|
||||
message.role === "ai" &&
|
||||
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
|
||||
)
|
||||
) {
|
||||
cards.push(buildRequirementReadyCard(state.requirement));
|
||||
}
|
||||
if (
|
||||
state.plans.length > 0 &&
|
||||
!runStarted &&
|
||||
@@ -341,39 +355,16 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
[sessionId, session],
|
||||
);
|
||||
|
||||
// 澄清完成(协调者已写入需求版本)但还没生成过方案:给出显式「发起规划」入口。
|
||||
const requirementReady = useMemo(
|
||||
() =>
|
||||
Object.values(state.messagesById).some(
|
||||
(message) =>
|
||||
message.role === "ai" &&
|
||||
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
|
||||
),
|
||||
[state.messagesById],
|
||||
);
|
||||
const showPlanRequestEntry =
|
||||
state.session?.status === "clarifying" && state.plans.length === 0 && requirementReady;
|
||||
const [planRequestBusy, setPlanRequestBusy] = useState(false);
|
||||
const handleRequestPlans = useCallback(async () => {
|
||||
setPlanRequestBusy(true);
|
||||
try {
|
||||
await getReportCollaborationDriver().requestPlans(
|
||||
sessionId,
|
||||
state.session?.requirement_revision ?? null,
|
||||
);
|
||||
await session.refreshSnapshot();
|
||||
toast.success("已生成候选方案,可在对话中选择");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPlanRequestBusy(false);
|
||||
}
|
||||
}, [sessionId, session, state.session?.requirement_revision]);
|
||||
// 方案生成由后端在规划师判定 ready 后自动衔接(同一次请求内规划),
|
||||
// 前端无手动「生成候选方案」按钮;此处仅保留摘要卡的注入判定。
|
||||
|
||||
const businessCardSlot = useCallback(
|
||||
(message: Message) => {
|
||||
const card = readReportCollaborationCard(message);
|
||||
if (!card) return null;
|
||||
if (card.kind === "requirement_ready") {
|
||||
return <RequirementSummaryCard requirement={card.requirement} editable={!runStarted} />;
|
||||
}
|
||||
if (card.kind === "plan_group") {
|
||||
return (
|
||||
<ReportPlanCards
|
||||
@@ -414,7 +405,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[previewPlan, previewPlanId, session],
|
||||
[previewPlan, previewPlanId, runStarted, session],
|
||||
);
|
||||
|
||||
const speakerShell = useCallback(
|
||||
@@ -565,43 +556,19 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
: undefined
|
||||
}
|
||||
composer={
|
||||
<div className="flex flex-col">
|
||||
{showPlanRequestEntry ? (
|
||||
<div className="mb-1.5 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn"
|
||||
style={{
|
||||
background: "var(--rc-accent)",
|
||||
color: "#fff",
|
||||
borderColor: "var(--rc-accent)",
|
||||
}}
|
||||
disabled={planRequestBusy}
|
||||
onClick={() => void handleRequestPlans()}
|
||||
>
|
||||
{planRequestBusy ? (
|
||||
<Loader2 className="rc-spin size-3.5" aria-hidden />
|
||||
) : (
|
||||
<Sparkles className="size-3.5" aria-hidden />
|
||||
)}
|
||||
{planRequestBusy ? "正在生成候选方案…" : "需求已就绪,生成候选方案"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<ReportComposer
|
||||
phase={phase}
|
||||
busy={session.sendBusy}
|
||||
target={composerTarget}
|
||||
onClearTarget={() => setComposerTarget(null)}
|
||||
onSend={(text) =>
|
||||
void session.send(text, {
|
||||
nodeId: composerTarget?.nodeId ?? null,
|
||||
<ReportComposer
|
||||
phase={phase}
|
||||
busy={session.sendBusy}
|
||||
target={composerTarget}
|
||||
onClearTarget={() => setComposerTarget(null)}
|
||||
onSend={(text) =>
|
||||
void session.send(text, {
|
||||
nodeId: composerTarget?.nodeId ?? null,
|
||||
agentRunId: composerTarget?.agentRunId ?? null,
|
||||
})
|
||||
}
|
||||
focusSignal={focusSignal}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
CollaborationCommand,
|
||||
CollaborationMessage,
|
||||
ReportPlanCandidate,
|
||||
ReportRequirementSnapshot,
|
||||
ReportVersion,
|
||||
} from "@/report-collaboration/api/types";
|
||||
|
||||
@@ -113,6 +114,11 @@ export type ReportCollaborationCardPayload =
|
||||
selectedPlanId: string | null;
|
||||
requirementRevision: number;
|
||||
}
|
||||
| {
|
||||
kind: "requirement_ready";
|
||||
requirement: ReportRequirementSnapshot;
|
||||
revision: number;
|
||||
}
|
||||
| {
|
||||
kind: "command_confirm";
|
||||
command: CollaborationCommand;
|
||||
@@ -163,6 +169,17 @@ export function buildPlanGroupCard(
|
||||
});
|
||||
}
|
||||
|
||||
/** 需求摘要卡(计划模式:需求已就绪、尚未生成方案)。 */
|
||||
export function buildRequirementReadyCard(
|
||||
requirement: ReportRequirementSnapshot,
|
||||
): Message {
|
||||
return buildCardMessage(`rc-card-requirement-r${requirement.revision}`, {
|
||||
kind: "requirement_ready",
|
||||
requirement,
|
||||
revision: requirement.revision,
|
||||
});
|
||||
}
|
||||
|
||||
/** 命令确认卡(command.confirmation.required 之后)。 */
|
||||
export function buildCommandConfirmCard(command: CollaborationCommand): Message {
|
||||
return buildCardMessage(`rc-card-confirm-${command.command_id}`, {
|
||||
|
||||
@@ -850,6 +850,21 @@
|
||||
animation: rc-edge-flow 0.5s linear infinite;
|
||||
}
|
||||
|
||||
/* P1.4:失败红虚线 / 等待用户橙色 / 已替代·中断降灰 */
|
||||
.rc-edge-failed .react-flow__edge-path {
|
||||
stroke: var(--rc-danger);
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
.rc-edge-waiting .react-flow__edge-path {
|
||||
stroke: var(--rc-waiting);
|
||||
}
|
||||
|
||||
.rc-edge-dimmed .react-flow__edge-path {
|
||||
stroke: var(--rc-flow-edge);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@keyframes rc-edge-flow {
|
||||
from {
|
||||
stroke-dashoffset: 10;
|
||||
|
||||
@@ -302,7 +302,29 @@ async def send_message(
|
||||
agent_run_id=body.agent_run_id,
|
||||
node_run_id=body.target_node_id,
|
||||
)
|
||||
await RequirementResolver(store).handle_user_message(session_id, user_message=created, idempotency_key=key)
|
||||
decision = await RequirementResolver(
|
||||
store,
|
||||
config=get_app_config().report_collaboration,
|
||||
planner_llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
|
||||
).handle_user_message(session_id, user_message=created, idempotency_key=key)
|
||||
# 确认完意图直接规划工作流(设计 v2 衔接):ready 且尚无方案时,
|
||||
# 同一次请求内自动发起规划,用户不用再手动点「生成候选方案」。
|
||||
if decision is not None and decision.action == "ready":
|
||||
record = await store.get_session_record(session_id)
|
||||
plans = await store.list_plans(session_id)
|
||||
if not plans and record and not record.get("selected_plan_id"):
|
||||
command = await store.enqueue_plan_request(
|
||||
session_id,
|
||||
idempotency_key=f"{key}:autoplan",
|
||||
expected_revision=decision.requirement.revision,
|
||||
)
|
||||
if command.get("status") == "pending":
|
||||
await PlanProposalService(
|
||||
store,
|
||||
agent_store=getattr(request.app.state, "agent_store", None),
|
||||
config=get_app_config().report_collaboration,
|
||||
llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
|
||||
).fulfill(session_id, user_id=user_id, command_id=command["command_id"], idempotency_key=f"{key}:autoplan")
|
||||
return created
|
||||
except Exception as exc:
|
||||
return _map_store_error(exc)
|
||||
@@ -333,6 +355,8 @@ async def request_plans(
|
||||
await PlanProposalService(
|
||||
store,
|
||||
agent_store=getattr(request.app.state, "agent_store", None),
|
||||
config=get_app_config().report_collaboration,
|
||||
llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
|
||||
).fulfill(session_id, user_id=user_id, command_id=command["command_id"], idempotency_key=key)
|
||||
return Response(status_code=204)
|
||||
except Exception as exc:
|
||||
|
||||
+86
@@ -91,6 +91,62 @@ class AttemptUsage:
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
# AgentScope 的 Agent.compress_context 会读 model.context_size 并调 count_tokens。
|
||||
_DEFAULT_CONTEXT_SIZE = 128_000
|
||||
_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]")
|
||||
|
||||
|
||||
def estimate_tokens_for_text(text: str) -> int:
|
||||
"""CJK-aware local token estimate: ~1 token per CJK char, ~4 bytes otherwise."""
|
||||
|
||||
if not text:
|
||||
return 0
|
||||
cjk = len(_CJK_RE.findall(text))
|
||||
other = len(text) - cjk
|
||||
return cjk + other // 4 + (1 if other % 4 else 0)
|
||||
|
||||
|
||||
def _collect_countable_text(messages: list[Any]) -> str:
|
||||
parts: list[str] = []
|
||||
for msg in messages or []:
|
||||
blocks: Any = None
|
||||
getter = getattr(msg, "get_content_blocks", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
blocks = getter()
|
||||
except Exception:
|
||||
blocks = None
|
||||
if blocks is not None:
|
||||
for block in blocks:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict):
|
||||
for key in ("text", "thinking", "hint"):
|
||||
value = block.get(key)
|
||||
if isinstance(value, str):
|
||||
parts.append(value)
|
||||
elif isinstance(value, list):
|
||||
parts.extend(str(item) for item in value)
|
||||
else:
|
||||
text = getattr(block, "text", None) or getattr(block, "thinking", None)
|
||||
if text:
|
||||
parts.append(str(text))
|
||||
continue
|
||||
if isinstance(msg, dict):
|
||||
parts.append(str(msg.get("content") or ""))
|
||||
else:
|
||||
raw = getattr(msg, "content", "")
|
||||
if isinstance(raw, list):
|
||||
for block in raw:
|
||||
if isinstance(block, dict):
|
||||
parts.append(str(block.get("text") or ""))
|
||||
else:
|
||||
parts.append(str(getattr(block, "text", block) if not isinstance(block, str) else block))
|
||||
else:
|
||||
parts.append(str(raw or ""))
|
||||
return "\n".join(parts)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return redact_mapping(
|
||||
{
|
||||
@@ -244,6 +300,21 @@ def to_lc_messages(messages: list[Any]) -> list[Any]:
|
||||
return converted
|
||||
|
||||
|
||||
def _resolve_context_size(app_config: AppConfig | None, config_name: str) -> int:
|
||||
"""Best-effort context window from model config extras; sane default otherwise."""
|
||||
|
||||
try:
|
||||
cfg = (app_config or get_app_config()).get_model_config(config_name)
|
||||
extra = cfg.model_dump() if cfg is not None else {}
|
||||
for key in ("context_size", "context_length", "context_window", "max_context_tokens"):
|
||||
value = extra.get(key)
|
||||
if isinstance(value, (int, float)) and int(value) > 0:
|
||||
return int(value)
|
||||
except Exception:
|
||||
pass
|
||||
return _DEFAULT_CONTEXT_SIZE
|
||||
|
||||
|
||||
class DeerFlowChatModelAdapter:
|
||||
"""Frozen DeerFlow-backed chat model. Construct once per node attempt from a snapshot."""
|
||||
|
||||
@@ -266,6 +337,21 @@ class DeerFlowChatModelAdapter:
|
||||
self._llm_factory = llm_factory
|
||||
self._node_run_id = node_run_id
|
||||
self.last_usage = AttemptUsage(model_name=snapshot.config_name, node_run_id=node_run_id)
|
||||
# AgentScope compress_context 契约:读 context_size 判断是否压缩。
|
||||
self.context_size = _resolve_context_size(app_config, snapshot.config_name)
|
||||
|
||||
async def count_tokens(self, messages: list[Any], tools: list[dict[str, Any]] | None = None) -> int:
|
||||
"""AgentScope 契约方法(compress_context 调用)。本地估算,不打 API。"""
|
||||
|
||||
import json as _json
|
||||
|
||||
total = estimate_tokens_for_text(_collect_countable_text(messages))
|
||||
if tools:
|
||||
try:
|
||||
total += estimate_tokens_for_text(_json.dumps(tools, ensure_ascii=False, default=str))
|
||||
except Exception:
|
||||
total += sum(estimate_tokens_for_text(str(item)) for item in tools)
|
||||
return total
|
||||
|
||||
def _config(self) -> AppConfig:
|
||||
return self._app_config or get_app_config()
|
||||
|
||||
@@ -47,7 +47,18 @@ class ReportCollaborationExecutor:
|
||||
existing = self._tasks.get(params.run_id)
|
||||
if existing is not None and not existing.done():
|
||||
return False
|
||||
self._tasks[params.run_id] = asyncio.create_task(self.execute(params), name=f"rc-run-{params.run_id}")
|
||||
task = asyncio.create_task(self.execute(params), name=f"rc-run-{params.run_id}")
|
||||
|
||||
def _log_crash(finished: asyncio.Task[str]) -> None:
|
||||
if not finished.cancelled() and finished.exception() is not None:
|
||||
logger.error(
|
||||
"rc executor task crashed for run %s",
|
||||
params.run_id,
|
||||
exc_info=finished.exception(),
|
||||
)
|
||||
|
||||
task.add_done_callback(_log_crash)
|
||||
self._tasks[params.run_id] = task
|
||||
return True
|
||||
|
||||
async def drain(self) -> None:
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.report_collaboration.execution.board import ArtifactBoard
|
||||
from app.report_collaboration.execution.completion import late_output_may_write_completed, require_can_complete_node
|
||||
from app.report_collaboration.execution.quality_gate import QualityGate, QualityGateContext, requirement_from_session_record
|
||||
from deerflow.persistence.report_collaboration.base import ReportCollaborationStore
|
||||
from deerflow.persistence.report_collaboration.codec import iso_now, make_node_run_id
|
||||
from deerflow.persistence.report_collaboration.codec import _TERMINAL_RUN_STATUSES, iso_now, make_node_run_id
|
||||
|
||||
TaskResultKind = Literal["artifact_draft", "clarification_request", "repair_request", "failure", "advice"]
|
||||
|
||||
@@ -314,6 +314,7 @@ class TaskLedger:
|
||||
command_id: str,
|
||||
instruction: str,
|
||||
node_ids: list[str],
|
||||
cascade_downstream: bool = False,
|
||||
) -> list[str]:
|
||||
"""Supersede affected attempts and persist an immutable command overlay.
|
||||
|
||||
@@ -321,6 +322,10 @@ class TaskLedger:
|
||||
``superseded`` and is therefore stored only as superseded evidence.
|
||||
User-directed rework is not counted against automatic quality-repair
|
||||
limits; it creates the next monotonic attempt number.
|
||||
|
||||
``cascade_downstream=True`` additionally supersedes every transitive
|
||||
dependent of ``node_ids`` and re-plans it, so the wave loop re-runs
|
||||
the whole affected sub-DAG in dependency order (设计 §4.2 指定重跑).
|
||||
"""
|
||||
|
||||
plan, run, latest, artifacts = await self._load(run_id)
|
||||
@@ -407,8 +412,112 @@ class TaskLedger:
|
||||
active_dedupe_key=str(run["session_id"]),
|
||||
session_status="running",
|
||||
)
|
||||
if cascade_downstream and applied:
|
||||
applied.extend(
|
||||
await self._cascade_downstream(
|
||||
run,
|
||||
plan,
|
||||
latest,
|
||||
artifacts,
|
||||
roots=[node_id for node_id in node_ids if node_id in applied],
|
||||
command_id=command_id,
|
||||
)
|
||||
)
|
||||
return applied
|
||||
|
||||
async def _cascade_downstream(
|
||||
self,
|
||||
run: dict[str, Any],
|
||||
plan: dict[str, Any],
|
||||
latest: dict[str, dict[str, Any]],
|
||||
artifacts: list[dict[str, Any]],
|
||||
*,
|
||||
roots: list[str],
|
||||
command_id: str,
|
||||
) -> list[str]:
|
||||
"""Supersede transitive dependents of ``roots`` and re-plan them.
|
||||
|
||||
Planned/reopened dependents are left alone — they have produced
|
||||
nothing yet and pick up the fresh upstream artifacts when queued.
|
||||
Like the rework target itself, user-directed cascade re-planning is
|
||||
not budget-limited (monotonic attempt number).
|
||||
"""
|
||||
|
||||
deps = _dependency_map(plan)
|
||||
specs = _plan_nodes(plan)
|
||||
dependents: set[str] = set()
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for node_id, parents in deps.items():
|
||||
if node_id in dependents or node_id in roots:
|
||||
continue
|
||||
if any(parent in dependents or parent in roots for parent in parents):
|
||||
dependents.add(node_id)
|
||||
changed = True
|
||||
cascaded: list[str] = []
|
||||
for node_id in sorted(dependents):
|
||||
node = latest.get(node_id)
|
||||
spec = specs.get(node_id)
|
||||
if node is None or spec is None:
|
||||
continue
|
||||
status = str(node.get("status") or "")
|
||||
if status in {"planned", "reopened", "superseded", "cancelled", "cancel_requested", ""}:
|
||||
continue
|
||||
require_transition(status, "superseded")
|
||||
node["status"] = "superseded"
|
||||
node["ended_at"] = iso_now()
|
||||
node["awaiting_input"] = None
|
||||
await self._store.save_node_run(node)
|
||||
for artifact in artifacts:
|
||||
if (
|
||||
artifact.get("node_id") == node_id
|
||||
and int(artifact.get("attempt") or 0) == int(node.get("attempt") or 0)
|
||||
and not artifact.get("superseded_by")
|
||||
):
|
||||
await self._board.mark(artifact, validation_status="superseded", superseded_by=f"command:{command_id}")
|
||||
await self._store.append_event(
|
||||
session_id=str(run["session_id"]),
|
||||
run_id=str(run["id"]),
|
||||
event_type="node.status.changed",
|
||||
data={"node_id": node_id, "node_run_id": node["node_run_id"], "status": "superseded", "reason": "downstream_cascade"},
|
||||
)
|
||||
nxt = int(node.get("attempt") or 0) + 1
|
||||
await self._store.save_node_run(
|
||||
{
|
||||
"node_run_id": make_node_run_id(str(run["id"]), node_id, nxt),
|
||||
"session_id": run["session_id"],
|
||||
"run_id": run["id"],
|
||||
"node_id": node_id,
|
||||
"attempt": nxt,
|
||||
"status": "planned",
|
||||
"label": node.get("label") or spec.get("label") or node_id,
|
||||
"role_display_name": node.get("role_display_name") or spec.get("role_display_name"),
|
||||
"angle": node.get("angle") or spec.get("angle"),
|
||||
"contract_json": node.get("contract_json"),
|
||||
"input_artifact_ids": [],
|
||||
"command_overlay_revision": int(node.get("command_overlay_revision") or 0),
|
||||
"agent_snapshot": dict(node.get("agent_snapshot") or {}),
|
||||
"error": None,
|
||||
"started_at": None,
|
||||
"ended_at": None,
|
||||
}
|
||||
)
|
||||
await self._store.append_event(
|
||||
session_id=str(run["session_id"]),
|
||||
run_id=str(run["id"]),
|
||||
event_type="node.attempt.created",
|
||||
data={
|
||||
"node_id": node_id,
|
||||
"node_run_id": make_node_run_id(str(run["id"]), node_id, nxt),
|
||||
"attempt": nxt,
|
||||
"reason": "downstream_cascade",
|
||||
"command_id": command_id,
|
||||
},
|
||||
)
|
||||
cascaded.append(node_id)
|
||||
return cascaded
|
||||
|
||||
@staticmethod
|
||||
def _add_command_overlay(node: dict[str, Any], *, command_id: str, instruction: str) -> None:
|
||||
snapshot = dict(node.get("agent_snapshot") or {})
|
||||
@@ -622,8 +731,18 @@ class TaskLedger:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def refresh_run_terminal(self, run_id: str) -> str:
|
||||
"""Public terminal-state evaluation; safe to call repeatedly."""
|
||||
|
||||
return await self._refresh_run_terminal(run_id)
|
||||
|
||||
async def _refresh_run_terminal(self, run_id: str) -> str:
|
||||
_plan, run, latest, _artifacts = await self._load(run_id)
|
||||
plan, run, latest, _artifacts = await self._load(run_id)
|
||||
run_status = str(run.get("status") or "running")
|
||||
if run_status in _TERMINAL_RUN_STATUSES:
|
||||
# Idempotency guard: never re-finalize (the finalizer would run
|
||||
# twice when the executor's wave loop re-checks after completion).
|
||||
return run_status
|
||||
statuses = [item["status"] for item in latest.values()]
|
||||
if statuses and all(status == "completed" for status in statuses):
|
||||
from app.report_collaboration.reporting.finalizer import ReportFinalizationError, ReportFinalizer
|
||||
@@ -636,11 +755,32 @@ class TaskLedger:
|
||||
await self._store.patch_run(run_id, status="completed", active_dedupe_key=None, session_status="completed")
|
||||
return "completed"
|
||||
if any(status in _BLOCKING or status == "queued" for status in statuses):
|
||||
return str(run.get("status") or "running")
|
||||
return run_status
|
||||
if any(status == "failed" for status in statuses):
|
||||
await self._store.patch_run(run_id, status="failed", active_dedupe_key=None, session_status="failed")
|
||||
await self._fail_run(run_id, run, reason="node_failed")
|
||||
return "failed"
|
||||
return str(run.get("status") or "running")
|
||||
# Zombie guard: an ``interrupted`` node whose attempt budget is
|
||||
# exhausted can never run again — the ledger only queues
|
||||
# planned/reopened attempts and recovery only opens a new attempt
|
||||
# while attempt < max_attempts. Without this, the run would stay
|
||||
# "running" forever while the dispatcher keeps re-claiming it.
|
||||
specs = _plan_nodes(plan)
|
||||
for node_id, item in latest.items():
|
||||
if str(item.get("status")) != "interrupted":
|
||||
continue
|
||||
if int(item.get("attempt") or 1) >= _node_max_attempts(specs.get(node_id), item):
|
||||
await self._fail_run(run_id, run, reason="node_attempts_exhausted", node_id=node_id)
|
||||
return "failed"
|
||||
return run_status
|
||||
|
||||
async def _fail_run(self, run_id: str, run: dict[str, Any], *, reason: str, node_id: str | None = None) -> None:
|
||||
await self._store.patch_run(run_id, status="failed", active_dedupe_key=None, session_status="failed")
|
||||
await self._store.append_event(
|
||||
session_id=str(run.get("session_id") or ""),
|
||||
run_id=run_id,
|
||||
event_type="run.status.changed",
|
||||
data={"status": "failed", "reason": reason, **({"node_id": node_id} if node_id else {})},
|
||||
)
|
||||
|
||||
async def _load(self, run_id: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, dict[str, Any]], list[dict[str, Any]]]:
|
||||
run = await self._store.get_run(run_id)
|
||||
@@ -666,6 +806,20 @@ def _plan_nodes(plan: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
return {str(item["id"]): item for item in (plan.get("nodes") or []) if item.get("id")}
|
||||
|
||||
|
||||
def _node_max_attempts(spec: dict[str, Any] | None, node_record: dict[str, Any]) -> int:
|
||||
"""Same precedence as ``_queue_node``: node contract, then plan spec, then 3."""
|
||||
|
||||
contract = node_record.get("contract_json") or {}
|
||||
if isinstance(contract, str):
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
contract = _json.loads(contract)
|
||||
except Exception:
|
||||
contract = {}
|
||||
return int(contract.get("max_attempts") or (spec or {}).get("max_attempts") or 3)
|
||||
|
||||
|
||||
def _dependency_map(plan: dict[str, Any]) -> dict[str, list[str]]:
|
||||
deps: dict[str, list[str]] = {node_id: list(spec.get("depends_on") or []) for node_id, spec in _plan_nodes(plan).items()}
|
||||
for edge in plan.get("edges") or []:
|
||||
|
||||
+5
-2
@@ -195,8 +195,11 @@ class WaveExecutor:
|
||||
await self._command_processor.process_safe_point(run_id)
|
||||
wave = await self._ledger.ready_assignments(run_id)
|
||||
if not wave:
|
||||
run = await self._store.get_run(run_id)
|
||||
return str((run or {}).get("status") or "queued")
|
||||
# Nothing runnable. Evaluate terminal state: all-completed
|
||||
# finalizes, exhausted-interrupted nodes fail the run (zombie
|
||||
# guard). Idempotent when the run already reached a terminal
|
||||
# status via apply_result.
|
||||
return await self._ledger.refresh_run_terminal(run_id)
|
||||
for assignment in wave:
|
||||
self.dispatched.append(assignment.node_id)
|
||||
result = await self._runner.run(assignment)
|
||||
|
||||
@@ -386,6 +386,7 @@ class RuntimeCommandService:
|
||||
command_id=command["command_id"],
|
||||
instruction=instruction,
|
||||
node_ids=impact_nodes,
|
||||
cascade_downstream=True,
|
||||
)
|
||||
if not changed:
|
||||
raise ReportCollaborationValidationError("COMMAND_NO_EFFECT", "目标节点已过期或命令已经应用")
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
"""规划师智能体(PlannerAgent)——真智能体,不是规则路由 + 模板文案。
|
||||
|
||||
设计 v2 的需求对话与工作流生成主路径:
|
||||
- ``converse``:与用户多轮自由对话确认需求。回复文案(含开场)完全由
|
||||
智能体生成并直接落库展示 —— 「收到您的报告需求。为了给出…」这类
|
||||
死模板文案已从该路径移除。
|
||||
- ``design_workflow``:带着**完整对话上下文**(规划师问了什么、用户
|
||||
答了什么)生成受词表约束的自定义工作流(``StrategyRoleSet``),
|
||||
与确定性模板走同一条 装配 → 校验 → 绑定 链路。
|
||||
|
||||
规则路由与模板只作为模型彻底不可用时的最后防线,不出现在用户面前。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from app.report_collaboration.agentscope_runtime.model_resolver import ROLE_MODEL_FIELD, RoleModelSnapshot, resolve_role_model
|
||||
from app.report_collaboration.contracts.plans import RoleDemand
|
||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||
from app.report_collaboration.planning.controller import RoleSlot, StrategyRoleSet
|
||||
from app.report_collaboration.requirement.intent_router import _merge_snapshot, apply_default_assumptions
|
||||
from deerflow.config.app_config import AppConfig, get_app_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 词表(结构化输出的安全边界,LLM 不受约束的自由只在文案层) ─────────────
|
||||
|
||||
MEMBER_ROLES: tuple[str, ...] = tuple(key for key in ROLE_MODEL_FIELD if key != "coordinator")
|
||||
|
||||
ROLE_ARTIFACTS: dict[str, tuple[str, ...]] = {
|
||||
"researcher": ("EvidenceBundle",),
|
||||
"verifier": ("VerificationResult",),
|
||||
"analyst": ("AngleAnalysis",),
|
||||
"synthesizer": ("SynthesisOutline",),
|
||||
"writer": ("ReportSectionDraft",),
|
||||
"reviewer": ("ReviewDecision",),
|
||||
"reviser": ("ReportSectionDraft",),
|
||||
}
|
||||
|
||||
QUALITY_GATES: tuple[str, ...] = ("cite_required", "dual_verification", "independent_review", "coverage_matrix")
|
||||
|
||||
# ── 智能体输出契约 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PlannerTurn(BaseModel):
|
||||
"""规划师一轮对话的完整产出:自由文案 + 结构化状态。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
action: str = "clarify"
|
||||
reply: str = ""
|
||||
questions: list[str] = Field(default_factory=list)
|
||||
impact: list[str] = Field(default_factory=list)
|
||||
assumptions: list[str] = Field(default_factory=list)
|
||||
requirement: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class StrategyNodeDraft(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
slot_id: str
|
||||
role_key: str
|
||||
display_name: str
|
||||
angle: str | None = None
|
||||
mission: str
|
||||
depends_on: list[str] = Field(default_factory=list)
|
||||
output_artifact_type: str
|
||||
suggested_tools: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StrategyDraft(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
reply: str = ""
|
||||
strategy: str = "custom"
|
||||
title: str
|
||||
summary: str
|
||||
rationale: str
|
||||
estimated_duration_seconds: int = 900
|
||||
estimated_cost_level: str = "medium"
|
||||
quality_gate_ids: list[str] = Field(default_factory=lambda: ["cite_required"])
|
||||
nodes: list[StrategyNodeDraft] = Field(default_factory=list)
|
||||
|
||||
|
||||
LlmFactory = Callable[[], Any]
|
||||
|
||||
|
||||
def planner_model_snapshot(app_config: AppConfig | None = None) -> RoleModelSnapshot:
|
||||
"""规划师身份沿用角色解析链:coordinator_model → planner_model → 默认模型。"""
|
||||
|
||||
return resolve_role_model("coordinator", app_config=app_config or get_app_config())
|
||||
|
||||
|
||||
def build_planner_llm(app_config: AppConfig | None = None) -> Any:
|
||||
from deerflow.models.factory import create_chat_model
|
||||
|
||||
snapshot = planner_model_snapshot(app_config)
|
||||
return create_chat_model(snapshot.config_name, app_config=app_config or get_app_config())
|
||||
|
||||
|
||||
_THINK_BLOCK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json(text: str) -> dict[str, Any] | None:
|
||||
if not text:
|
||||
return None
|
||||
# 思考模型(MiniMax-M2.7 等)回复可能带 <think>…</think>;JSON 在思考块之后。
|
||||
# 先剥思考块提取;剥完没有再回退用全文(极端情况 JSON 混在思考内)。
|
||||
for candidate in (_THINK_BLOCK_RE.sub("", text).strip(), text.strip()):
|
||||
if not candidate:
|
||||
continue
|
||||
cleaned = candidate
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
start = cleaned.find("{")
|
||||
end = cleaned.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(cleaned[start : end + 1])
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
async def _invoke_json(llm: Any, messages: list[Any]) -> tuple[dict[str, Any] | None, str]:
|
||||
"""调用模型并提取 JSON。返回 (解析结果, 原始文本);解析失败结果为 None。"""
|
||||
|
||||
try:
|
||||
response = await llm.ainvoke(messages)
|
||||
raw = getattr(response, "content", response)
|
||||
if isinstance(raw, list):
|
||||
raw = "".join(str(getattr(block, "text", block) if not isinstance(block, str) else block) for block in raw)
|
||||
raw = str(raw)
|
||||
return _extract_json(raw), raw
|
||||
except Exception as exc: # noqa: BLE001 — 智能体故障必须显式失败
|
||||
logger.warning("rc planner agent llm call failed: %s: %s", exc.__class__.__name__, exc)
|
||||
return None, ""
|
||||
|
||||
|
||||
async def _invoke_json_with_retry(llm: Any, system: str, user_prompt: str) -> dict[str, Any] | None:
|
||||
"""带一次格式重试:模型输出不含 JSON 时,附原始输出要求只出 JSON 再问一次。"""
|
||||
|
||||
parsed, raw = await _invoke_json(llm, [("system", system), ("human", user_prompt)])
|
||||
if parsed is None:
|
||||
logger.warning("rc planner reply without JSON (raw head: %.200s)", raw[:200])
|
||||
retry_prompt = (
|
||||
user_prompt
|
||||
+ "\n\n【格式提醒】你上一轮的输出没有包含合法 JSON。这次必须只输出一个 JSON 对象,"
|
||||
+ f"不要在 JSON 之外输出任何文字。你上一轮的输出是:{raw[:500]}"
|
||||
)
|
||||
parsed, _ = await _invoke_json(llm, [("system", system), ("human", retry_prompt)])
|
||||
return parsed
|
||||
|
||||
|
||||
# ── 规划师智能体 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PlannerAgent:
|
||||
"""需求对话与工作流设计的唯一主路径(真智能体)。"""
|
||||
|
||||
def __init__(self, llm: Any, *, app_config: AppConfig | None = None) -> None:
|
||||
self._llm = llm
|
||||
self._app_config = app_config
|
||||
|
||||
# ── 需求对话 ───────────────────────────────────────────────────────────
|
||||
|
||||
async def converse(
|
||||
self,
|
||||
*,
|
||||
user_text: str,
|
||||
history: list[dict[str, str]],
|
||||
previous_snapshot: dict[str, Any] | None,
|
||||
previous_revision: int,
|
||||
clarification_round: int,
|
||||
max_rounds: int,
|
||||
) -> PlannerTurn | None:
|
||||
"""一轮规划对话。返回 None 表示智能体不可用(调用方走最后防线)。"""
|
||||
|
||||
forced = clarification_round >= max_rounds
|
||||
system = _CONVERSE_SYSTEM.format(
|
||||
roles=", ".join(MEMBER_ROLES),
|
||||
forced_note=(
|
||||
"\n\n【重要】澄清轮次已达上限,本轮你必须判定需求就绪(action=ready):"
|
||||
"自然地告诉用户你将按当前理解开始规划,把未能确认的点写进 assumptions。"
|
||||
if forced
|
||||
else ""
|
||||
),
|
||||
)
|
||||
known = {k: v for k, v in (previous_snapshot or {}).items() if v not in (None, "", [])}
|
||||
known.pop("revision", None)
|
||||
history_text = "\n".join(
|
||||
f"{'用户' if item.get('role') == 'human' else '规划师'}: {str(item.get('content') or '')[:400]}"
|
||||
for item in history[-10:]
|
||||
)
|
||||
user_prompt = (
|
||||
f"当前已确认的需求快照(JSON):{json.dumps(known, ensure_ascii=False)}\n\n"
|
||||
f"最近的对话:\n{history_text or '(无)'}\n\n"
|
||||
f"用户最新消息:{user_text}"
|
||||
)
|
||||
parsed = await _invoke_json_with_retry(self._llm, system, user_prompt)
|
||||
if parsed is None:
|
||||
return None
|
||||
try:
|
||||
turn = PlannerTurn.model_validate(parsed)
|
||||
except ValidationError:
|
||||
logger.warning("rc planner turn invalid: %s", str(parsed)[:200])
|
||||
return None
|
||||
if not (turn.reply or "").strip():
|
||||
return None
|
||||
if forced and turn.action != "ready":
|
||||
turn.action = "ready"
|
||||
if turn.action == "ready" and not turn.assumptions:
|
||||
turn.assumptions = ["按对话中已确认的信息直接规划"]
|
||||
return turn
|
||||
|
||||
def merge_requirement(
|
||||
self,
|
||||
turn: PlannerTurn,
|
||||
*,
|
||||
previous_snapshot: dict[str, Any] | None,
|
||||
previous_revision: int,
|
||||
) -> ReportRequirementSnapshot:
|
||||
"""把智能体本轮带回的需求字段合并进既有快照(revision 语义同规则路由)。"""
|
||||
|
||||
known = {k: v for k, v in (previous_snapshot or {}).items() if k != "revision"}
|
||||
# LLM 契约是「只填能确认的字段」;再过滤一次空值,避免空串覆盖已确认信息。
|
||||
patch = {
|
||||
k: v
|
||||
for k, v in (turn.requirement or {}).items()
|
||||
if k != "revision" and v not in (None, "", [])
|
||||
}
|
||||
revision = (
|
||||
max(1, int(previous_revision) + 1)
|
||||
if (previous_snapshot or patch)
|
||||
else max(1, int(previous_revision) or 1)
|
||||
)
|
||||
if not previous_snapshot:
|
||||
revision = 1
|
||||
merged = _merge_snapshot(known, patch, revision=revision)
|
||||
if turn.action == "ready":
|
||||
merged = apply_default_assumptions(merged)[0]
|
||||
return merged
|
||||
|
||||
# ── 工作流设计 ─────────────────────────────────────────────────────────
|
||||
|
||||
async def design_workflow(
|
||||
self,
|
||||
*,
|
||||
requirement: ReportRequirementSnapshot,
|
||||
history: list[dict[str, str]],
|
||||
max_team_members: int = 8,
|
||||
) -> tuple[StrategyRoleSet, str] | None:
|
||||
"""带着完整对话上下文设计工作流;返回 (策略, 展示文案) 或 None。"""
|
||||
|
||||
fields = {k: v for k, v in requirement.model_dump().items() if v not in (None, "", [])}
|
||||
fields.pop("revision", None)
|
||||
history_text = "\n".join(
|
||||
f"{'用户' if item.get('role') == 'human' else '规划师'}: {str(item.get('content') or '')[:300]}"
|
||||
for item in history[-12:]
|
||||
)
|
||||
system = _DESIGN_SYSTEM.format(
|
||||
roles=", ".join(MEMBER_ROLES),
|
||||
artifacts="\n".join(f" - {role}: {' / '.join(arts)}" for role, arts in ROLE_ARTIFACTS.items()),
|
||||
gates=", ".join(QUALITY_GATES),
|
||||
max_nodes=min(max_team_members, 8),
|
||||
)
|
||||
user_prompt = (
|
||||
f"需求快照(JSON):{json.dumps(fields, ensure_ascii=False)}\n\n"
|
||||
f"需求确认阶段的对话(你与用户):\n{history_text or '(无)'}\n\n请设计工作流。"
|
||||
)
|
||||
parsed = await _invoke_json_with_retry(self._llm, system, user_prompt)
|
||||
if parsed is None:
|
||||
return None
|
||||
try:
|
||||
draft = StrategyDraft.model_validate(parsed)
|
||||
except ValidationError:
|
||||
logger.warning("rc planner strategy draft invalid: %s", str(parsed)[:200])
|
||||
return None
|
||||
strategy = _validate_strategy_dag(draft)
|
||||
if strategy is None:
|
||||
logger.warning("rc planner strategy failed vocabulary/DAG validation: %s", str(parsed)[:200])
|
||||
return None
|
||||
reply = (draft.reply or "").strip() or f"已按需求设计好协作工作流「{draft.title}」。"
|
||||
return strategy, reply
|
||||
|
||||
def workflow_reply_fallback(self, strategy: StrategyRoleSet) -> str:
|
||||
return (
|
||||
f"我按确认的需求设计了「{strategy.title}」:{strategy.summary}\n"
|
||||
"可以直接采用开始执行;想调整就告诉我(例如「加一条竞品对比线」)。"
|
||||
)
|
||||
|
||||
|
||||
# ── 提示词 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_CONVERSE_SYSTEM = """你是「报告协作工作台」的规划师智能体。用户想让你组织一个多智能体团队协作产出一份报告,你的任务是先通过自然对话把需求弄清楚,然后(由你自己)设计协作工作流。
|
||||
|
||||
你的对话风格:
|
||||
- 像一位资深顾问:先回应用户刚说的话,再追问。回复里**不要**出现「收到您的报告需求」这类机械开场——用你自己的话自然表达。
|
||||
- 每轮最多追问 2~3 个真正影响方案设计的问题:关注对象与范围、给谁看、必须覆盖的角度、篇幅与深度、时间范围、特殊约束。用户已说清的绝不重复问。
|
||||
- 用户回复后先简短确认你的理解(一两句),再决定是否继续问。
|
||||
- 信息足够时,自然地总结你对需求的把握并判定就绪(action=ready),告诉用户下一步你会为他们设计工作流。
|
||||
|
||||
可用的团队角色(供你构思,不必对用户罗列):{roles}{forced_note}
|
||||
|
||||
每轮严格输出一个 JSON 对象(不要输出 JSON 以外的文字):
|
||||
{{
|
||||
"reply": "给用户看的完整回复(自然对话,直接展示)",
|
||||
"action": "clarify" 或 "ready",
|
||||
"questions": ["追问的问题(与 reply 一致,供系统记录)"],
|
||||
"impact": ["为什么需要问(与 questions 对应)"],
|
||||
"assumptions": ["ready 时:你采用的默认假设"],
|
||||
"requirement": {{"topic": "...", "objective": "...", "audience": "...", "required_angles": ["..."], "time_range": "...", "length_target": "...", "tone": "..."}}
|
||||
}}
|
||||
|
||||
requirement 只填你从对话中能确认的字段(含此前轮次已确认的),其余省略。reply 用中文。"""
|
||||
|
||||
_DESIGN_SYSTEM = """你是「报告协作工作台」的规划师智能体。需求已经与你确认完毕,现在请设计多智能体协作工作流。
|
||||
|
||||
词表约束(结构化部分必须严格遵守):
|
||||
- role_key 只能取:{roles}
|
||||
- 各角色的 output_artifact_type 只能取:
|
||||
{artifacts}
|
||||
- quality_gate_ids 只能取:{gates}
|
||||
|
||||
结构要求:
|
||||
- 2~{max_nodes} 个节点;至少 1 个 writer;正式报告建议加 reviewer。
|
||||
- 研究线(researcher)按角度并行;research → verify → analyze → write → review 是常见主干,可按需求裁剪。
|
||||
- depends_on 引用其他节点的 slot_id;禁止环;至少一个节点 depends_on 为空(起点)。
|
||||
- slot_id 用简短英文(如 research-market / write-main)。mission 写清楚该节点做什么、产出什么。
|
||||
|
||||
严格输出 JSON(不要输出 JSON 以外的文字):
|
||||
{{
|
||||
"reply": "给用户看的介绍:说明你为什么这样设计(对应需求的哪些要求),一两句即可",
|
||||
"strategy": "custom",
|
||||
"title": "方案标题",
|
||||
"summary": "一句话概述团队构成与链路",
|
||||
"rationale": "设计理由",
|
||||
"estimated_duration_seconds": 1500,
|
||||
"estimated_cost_level": "low|medium|high",
|
||||
"quality_gate_ids": ["cite_required"],
|
||||
"nodes": [
|
||||
{{"slot_id": "research-market", "role_key": "researcher", "display_name": "研究员", "angle": "市场份额", "mission": "...", "depends_on": [], "output_artifact_type": "EvidenceBundle", "suggested_tools": ["web_search"]}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
|
||||
# ── 工作流草稿校验(词表 + DAG) ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_strategy_dag(draft: StrategyDraft) -> StrategyRoleSet | None:
|
||||
nodes = draft.nodes
|
||||
if not 2 <= len(nodes) <= 8:
|
||||
return None
|
||||
slot_ids = {node.slot_id for node in nodes}
|
||||
if len(slot_ids) != len(nodes):
|
||||
return None
|
||||
if not any(node.role_key == "writer" for node in nodes):
|
||||
return None
|
||||
for node in nodes:
|
||||
if node.role_key not in MEMBER_ROLES:
|
||||
return None
|
||||
if node.output_artifact_type not in ROLE_ARTIFACTS.get(node.role_key, ()):
|
||||
return None
|
||||
for dep in node.depends_on:
|
||||
if dep not in slot_ids or dep == node.slot_id:
|
||||
return None
|
||||
indegree = {node.slot_id: len(node.depends_on) for node in nodes}
|
||||
children: dict[str, list[str]] = {node.slot_id: [] for node in nodes}
|
||||
for node in nodes:
|
||||
for dep in node.depends_on:
|
||||
children[dep].append(node.slot_id)
|
||||
queue = [slot for slot, degree in indegree.items() if degree == 0]
|
||||
seen = 0
|
||||
while queue:
|
||||
current = queue.pop()
|
||||
seen += 1
|
||||
for child in children[current]:
|
||||
indegree[child] -= 1
|
||||
if indegree[child] == 0:
|
||||
queue.append(child)
|
||||
if seen != len(nodes):
|
||||
return None
|
||||
|
||||
gates = [gate for gate in draft.quality_gate_ids if gate in QUALITY_GATES] or ["cite_required"]
|
||||
return StrategyRoleSet(
|
||||
strategy="custom" if draft.strategy == "custom" else f"custom_{draft.strategy[:24]}",
|
||||
title=draft.title[:80],
|
||||
summary=draft.summary[:200],
|
||||
rationale=draft.rationale[:300],
|
||||
estimated_duration_seconds=max(300, min(int(draft.estimated_duration_seconds or 1500), 7200)),
|
||||
estimated_cost_level=draft.estimated_cost_level if draft.estimated_cost_level in {"low", "medium", "high"} else "medium",
|
||||
recommended=True,
|
||||
slots=[
|
||||
RoleSlot(
|
||||
slot_id=node.slot_id,
|
||||
demand=RoleDemand(
|
||||
role_key=node.role_key,
|
||||
display_name=node.display_name[:32] or node.role_key,
|
||||
angle=(node.angle or None),
|
||||
mission=node.mission[:400],
|
||||
depends_on_roles=[],
|
||||
output_artifact_type=node.output_artifact_type,
|
||||
suggested_tools=[tool for tool in node.suggested_tools if tool][:4],
|
||||
),
|
||||
depends_on=list(node.depends_on),
|
||||
)
|
||||
for node in nodes
|
||||
],
|
||||
quality_gate_ids=gates,
|
||||
)
|
||||
@@ -1,17 +1,18 @@
|
||||
"""Fulfill a durable plan_request: catalog → select → assemble → validate → persist.
|
||||
|
||||
Does not start a run. Rule-based until the BE-7 model adapter is wired.
|
||||
Does not start a run. P1.2 起优先由规划师 LLM 生成定制策略,模板为回退。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||
from app.report_collaboration.planning.assembler import PlanAssembler
|
||||
from app.report_collaboration.planning.catalog import project_agent_catalog
|
||||
from app.report_collaboration.planning.controller import PlanController
|
||||
from app.report_collaboration.planning.planner_agent import LlmFactory
|
||||
from app.report_collaboration.planning.validator import apply_validation, validate_assembled_plan
|
||||
from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE
|
||||
from deerflow.config.app_config import get_app_config
|
||||
@@ -22,6 +23,8 @@ from deerflow.persistence.report_collaboration import (
|
||||
ReportCollaborationValidationError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BUSY = frozenset({"running", "awaiting_input", "reviewing", "completed", "failed", "cancelled"})
|
||||
|
||||
|
||||
@@ -51,12 +54,15 @@ class PlanProposalService:
|
||||
*,
|
||||
agent_store: Any | None = None,
|
||||
config: ReportCollaborationConfig | None = None,
|
||||
llm_factory: LlmFactory | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._agent_store = agent_store
|
||||
self._config = config
|
||||
self._controller = PlanController()
|
||||
self._assembler = PlanAssembler()
|
||||
self._llm_factory = llm_factory
|
||||
self._llm: Any | None = None
|
||||
self._llm_tried = False
|
||||
|
||||
def _cfg(self) -> ReportCollaborationConfig:
|
||||
return self._config or get_app_config().report_collaboration
|
||||
@@ -110,12 +116,29 @@ class PlanProposalService:
|
||||
requirement = requirement_from_session(record)
|
||||
cfg = self._cfg()
|
||||
catalog = await project_agent_catalog(user_id=user_id, config=cfg, agent_store=self._agent_store)
|
||||
strategies = self._controller.propose(
|
||||
requirement,
|
||||
max_parallel_tasks=cfg.max_parallel_tasks,
|
||||
group_id = f"rpg_{uuid4().hex[:12]}"
|
||||
|
||||
# P1-R:规划师智能体是唯一路径 —— 带着完整对话上下文设计工作流。
|
||||
# 模型不可用即无法继续(无模板兜底)。
|
||||
from app.report_collaboration.planning.planner_agent import PlannerAgent
|
||||
|
||||
llm = self._planner_llm()
|
||||
history = [
|
||||
{"role": "human" if item.get("role") == "human" else "ai", "content": str(item.get("content") or "")}
|
||||
for item in await self._store.list_messages(session_id)
|
||||
]
|
||||
designed = await PlannerAgent(llm).design_workflow(
|
||||
requirement=requirement,
|
||||
history=history,
|
||||
max_team_members=cfg.max_team_members,
|
||||
)
|
||||
group_id = f"rpg_{uuid4().hex[:12]}"
|
||||
if designed is None:
|
||||
raise ReportCollaborationValidationError(
|
||||
"PLANNER_UNAVAILABLE", "规划师智能体未能生成有效工作流,请重试或换个说法再试"
|
||||
)
|
||||
strategies: list[Any] = [designed[0]]
|
||||
planner_reply: str | None = designed[1]
|
||||
|
||||
assembled_list = [
|
||||
self._assembler.assemble(
|
||||
strategy,
|
||||
@@ -140,13 +163,44 @@ class PlanProposalService:
|
||||
for candidate in persisted:
|
||||
saved.append(await self._store.insert_plan(session_id, candidate.model_dump()))
|
||||
await self._store.update_command_status(command_id, status="completed")
|
||||
content = planner_reply or "工作流已生成,可开始执行。"
|
||||
await self._store.create_message(
|
||||
session_id,
|
||||
role="ai",
|
||||
content="已根据当前需求生成 3 个候选协作方案。请选择其中一个后再开始执行;选择方案不会自动开跑。",
|
||||
content=content,
|
||||
idempotency_key=f"{idempotency_key}:plans",
|
||||
name=COORDINATOR_NAME,
|
||||
metadata={"plan_request_command_id": command_id, "proposal_group_id": group_id},
|
||||
metadata={"plan_request_command_id": command_id, "proposal_group_id": group_id, "plan_source": "planner_agent"},
|
||||
completed=True,
|
||||
)
|
||||
return saved
|
||||
|
||||
def _planner_llm(self) -> Any:
|
||||
"""规划师模型:注入优先(测试);否则按 planner_llm_enabled 懒构建。不可用即抛错。"""
|
||||
|
||||
if self._llm_factory is not None:
|
||||
llm = self._llm_factory()
|
||||
if llm is None:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||
return llm
|
||||
try:
|
||||
if not (self._config or get_app_config().report_collaboration).planner_llm_enabled:
|
||||
raise ReportCollaborationValidationError(
|
||||
"PLANNER_UNAVAILABLE", "规划师智能体未启用(report_collaboration.planner_llm_enabled)"
|
||||
)
|
||||
except ReportCollaborationValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", f"规划师智能体配置不可读:{exc}") from None
|
||||
if not self._llm_tried:
|
||||
self._llm_tried = True
|
||||
try:
|
||||
from app.report_collaboration.planning.planner_agent import build_planner_llm
|
||||
|
||||
self._llm = build_planner_llm()
|
||||
except Exception as exc:
|
||||
logger.warning("rc planner agent llm build failed: %s", exc)
|
||||
self._llm = None
|
||||
if self._llm is None:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||
return self._llm
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Pre-plan requirement resolution package."""
|
||||
|
||||
from app.report_collaboration.requirement.intent_router import PrePlanDecision, route_pre_plan_turn
|
||||
from app.report_collaboration.requirement.intent_router import PrePlanDecision
|
||||
from app.report_collaboration.requirement.resolver import RequirementResolver
|
||||
|
||||
__all__ = ["PrePlanDecision", "RequirementResolver", "route_pre_plan_turn"]
|
||||
__all__ = ["PrePlanDecision", "RequirementResolver"]
|
||||
+11
-79
@@ -1,4 +1,9 @@
|
||||
"""Pre-plan IntentRouterAgent: understand the user turn, never dispatch work."""
|
||||
"""Pre-plan 数据结构与快照合并工具。
|
||||
|
||||
P1-R 之后需求确认完全由规划师智能体(``planning/planner_agent.py``)驱动;
|
||||
本模块只保留被复用的数据结构与合并工具。原规则路由 ``route_pre_plan_turn``
|
||||
已按设计 v2 移除 —— 模型不可用即报错,不再有规则兜底。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,12 +12,11 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||
from app.report_collaboration.requirement.defaults import DEFAULT_ANGLES, DEFAULT_AUDIENCE, DEFAULT_LANGUAGE, DEFAULT_SOURCE_CONSTRAINTS, DEFAULT_TONE, PLAN_AFFECTING_GAPS
|
||||
from app.report_collaboration.requirement.extractor import extract_requirement_patch, wants_direct_start
|
||||
from app.report_collaboration.requirement.defaults import DEFAULT_ANGLES, DEFAULT_AUDIENCE, DEFAULT_LANGUAGE, DEFAULT_SOURCE_CONSTRAINTS, DEFAULT_TONE
|
||||
|
||||
|
||||
class PrePlanDecision(BaseModel):
|
||||
"""Structured output of the pre-plan router. It never builds a graph or starts a run."""
|
||||
"""规划师智能体一轮决策。It never builds a graph or starts a run."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -24,11 +28,13 @@ class PrePlanDecision(BaseModel):
|
||||
used_defaults: bool = False
|
||||
reason: str
|
||||
confidence: float = Field(ge=0, le=1)
|
||||
#: 规划师智能体产出的完整对话文案,直接落库展示。
|
||||
reply: str | None = None
|
||||
|
||||
|
||||
def _merge_snapshot(previous: dict[str, Any] | None, patch: dict[str, Any], *, revision: int) -> ReportRequirementSnapshot:
|
||||
base = dict(previous or {})
|
||||
base.update(patch)
|
||||
base.update(patch or {})
|
||||
topic = str(base.get("topic") or "").strip()
|
||||
return ReportRequirementSnapshot(
|
||||
topic=topic or "未指定主题",
|
||||
@@ -49,21 +55,6 @@ def _merge_snapshot(previous: dict[str, Any] | None, patch: dict[str, Any], *, r
|
||||
)
|
||||
|
||||
|
||||
def _gaps(requirement: ReportRequirementSnapshot) -> list[tuple[str, str, str]]:
|
||||
missing: list[tuple[str, str, str]] = []
|
||||
if not requirement.topic or requirement.topic == "未指定主题":
|
||||
missing.append(("topic", "这份报告要写什么主题?请用一两句话说明对象、时间和目的。", "没有主题无法选择方案"))
|
||||
for field, question, impact in PLAN_AFFECTING_GAPS:
|
||||
value = getattr(requirement, field, None)
|
||||
if field == "required_angles":
|
||||
empty = not value
|
||||
else:
|
||||
empty = not (value or "").strip()
|
||||
if empty:
|
||||
missing.append((field, question, impact))
|
||||
return missing
|
||||
|
||||
|
||||
def apply_default_assumptions(requirement: ReportRequirementSnapshot) -> tuple[ReportRequirementSnapshot, list[str]]:
|
||||
assumptions: list[str] = []
|
||||
data = requirement.model_dump()
|
||||
@@ -82,62 +73,3 @@ def apply_default_assumptions(requirement: ReportRequirementSnapshot) -> tuple[R
|
||||
data["tone"] = DEFAULT_TONE
|
||||
assumptions.append(DEFAULT_TONE)
|
||||
return ReportRequirementSnapshot.model_validate(data), assumptions
|
||||
|
||||
|
||||
def route_pre_plan_turn(
|
||||
text: str,
|
||||
*,
|
||||
previous_snapshot: dict[str, Any] | None,
|
||||
previous_revision: int,
|
||||
clarification_round: int,
|
||||
max_clarification_rounds: int,
|
||||
) -> PrePlanDecision:
|
||||
"""Rule-based pre-plan routing. Does not call tools or create plans."""
|
||||
|
||||
patch = extract_requirement_patch(text, previous=previous_snapshot)
|
||||
revision = max(1, int(previous_revision) + 1) if previous_snapshot or patch else max(1, int(previous_revision) or 1)
|
||||
if not previous_snapshot:
|
||||
revision = 1
|
||||
requirement = _merge_snapshot(previous_snapshot, patch, revision=revision)
|
||||
skip = wants_direct_start(text)
|
||||
hit_cap = clarification_round >= max_clarification_rounds
|
||||
gaps = _gaps(requirement)
|
||||
|
||||
if requirement.topic == "未指定主题" and skip:
|
||||
return PrePlanDecision(
|
||||
action="clarify",
|
||||
requirement=requirement,
|
||||
questions=["这份报告要写什么主题?请用一两句话说明对象、时间和目的。"],
|
||||
impact=["没有主题无法选择方案"],
|
||||
reason="用户要求直接开始,但还没有主题",
|
||||
confidence=0.9,
|
||||
)
|
||||
|
||||
if skip or (hit_cap and gaps):
|
||||
filled, assumptions = apply_default_assumptions(requirement)
|
||||
return PrePlanDecision(
|
||||
action="ready",
|
||||
requirement=filled,
|
||||
assumptions=assumptions,
|
||||
used_defaults=True,
|
||||
reason="用户要求按默认假设继续" if skip else "已达澄清上限,使用明确假设继续",
|
||||
confidence=0.86 if skip else 0.7,
|
||||
)
|
||||
|
||||
if gaps:
|
||||
return PrePlanDecision(
|
||||
action="clarify",
|
||||
requirement=requirement,
|
||||
questions=[item[1] for item in gaps],
|
||||
impact=[item[2] for item in gaps],
|
||||
reason="缺少真正影响方案选择的信息",
|
||||
confidence=0.8,
|
||||
)
|
||||
|
||||
return PrePlanDecision(
|
||||
action="ready",
|
||||
requirement=requirement,
|
||||
reason="必要信息已齐,可以进入规划",
|
||||
confidence=0.84,
|
||||
used_defaults=False,
|
||||
)
|
||||
+142
-67
@@ -1,18 +1,39 @@
|
||||
"""Validate and persist requirement revisions. Never starts planning or a run."""
|
||||
"""Requirement resolver: the planner agent is the only path.
|
||||
|
||||
模型不可用即无法继续 —— 直接抛 ``PLANNER_UNAVAILABLE``,没有任何规则/
|
||||
模板兜底(设计 v2:需求确认必须由真智能体完成)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.report_collaboration.contracts.clarifications import ClarificationRecord
|
||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||
from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE
|
||||
from app.report_collaboration.requirement.intent_router import PrePlanDecision, route_pre_plan_turn
|
||||
from app.report_collaboration.requirement.intent_router import PrePlanDecision
|
||||
from deerflow.config.app_config import get_app_config
|
||||
from deerflow.persistence.report_collaboration import ReportCollaborationStore
|
||||
from deerflow.persistence.report_collaboration import (
|
||||
ReportCollaborationStore,
|
||||
ReportCollaborationValidationError,
|
||||
)
|
||||
from deerflow.persistence.report_collaboration.codec import iso_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 仅供类型注解;运行时惰性导入避免 requirement ↔ planning 循环依赖。
|
||||
LlmFactory = Any
|
||||
|
||||
|
||||
def _title_from_topic(topic: str, current: str | None) -> str | None:
|
||||
if current and current != DEFAULT_TITLE:
|
||||
return None
|
||||
cleaned = (topic or "").strip()
|
||||
if not cleaned or cleaned == "未指定主题":
|
||||
return None
|
||||
return cleaned[:40]
|
||||
|
||||
|
||||
def _state_from_session(record: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = record.get("requirement_json")
|
||||
@@ -35,58 +56,56 @@ def _state_from_session(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"snapshot": None, "clarifications": [], "assumptions": [], "clarification_round": 0, "ready_to_plan": False}
|
||||
|
||||
|
||||
def _dump_state(
|
||||
*,
|
||||
snapshot: ReportRequirementSnapshot,
|
||||
clarifications: list[dict[str, Any]],
|
||||
assumptions: list[str],
|
||||
clarification_round: int,
|
||||
ready_to_plan: bool,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"snapshot": snapshot.model_dump(),
|
||||
"clarifications": clarifications,
|
||||
"assumptions": assumptions,
|
||||
"clarification_round": clarification_round,
|
||||
"ready_to_plan": ready_to_plan,
|
||||
}
|
||||
|
||||
|
||||
def format_clarification_message(questions: list[str], *, allow_skip: bool = True) -> str:
|
||||
numbered = "\n".join(f"{index}. {question}" for index, question in enumerate(questions, start=1))
|
||||
suffix = "\n\n也可以直接回复「直接开始」,我会按默认假设记录需求。澄清完成不会自动生成方案。" if allow_skip else ""
|
||||
return f"收到您的报告需求。为了给出最合适的协作方案,请先确认:\n\n{numbered}{suffix}"
|
||||
|
||||
|
||||
def format_ready_message(decision: PrePlanDecision) -> str:
|
||||
req = decision.requirement
|
||||
angles = "、".join(req.required_angles) if req.required_angles else "(未指定)"
|
||||
lines = [
|
||||
f"已写入需求版本 v{req.revision}。",
|
||||
f"主题:{req.topic}",
|
||||
f"读者:{req.audience or '未指定'}",
|
||||
f"角度:{angles}",
|
||||
]
|
||||
if decision.assumptions:
|
||||
lines.append("默认假设:" + ";".join(decision.assumptions) + "。")
|
||||
lines.append("澄清完成不等于已经生成候选方案,需要显式发起规划后才会出现方案卡。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _title_from_topic(topic: str, current: str | None) -> str | None:
|
||||
if current and current != DEFAULT_TITLE:
|
||||
return None
|
||||
cleaned = (topic or "").strip()
|
||||
if not cleaned or cleaned == "未指定主题":
|
||||
return None
|
||||
return cleaned[:40]
|
||||
|
||||
|
||||
class RequirementResolver:
|
||||
"""Writes requirement revisions and clarification records. Does not enqueue plans."""
|
||||
|
||||
def __init__(self, store: ReportCollaborationStore) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
store: ReportCollaborationStore,
|
||||
*,
|
||||
planner_llm_factory: LlmFactory | None = None,
|
||||
config: Any | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._planner_llm_factory = planner_llm_factory
|
||||
self._config = config
|
||||
self._default_llm: Any = None
|
||||
self._default_llm_tried = False
|
||||
|
||||
def _planner_llm(self) -> Any:
|
||||
"""规划师模型:注入优先(测试);否则按 planner_llm_enabled 懒构建。不可用即抛错。"""
|
||||
|
||||
if self._planner_llm_factory is not None:
|
||||
llm = self._planner_llm_factory()
|
||||
if llm is None:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||
return llm
|
||||
try:
|
||||
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
|
||||
|
||||
cfg: Any = self._config
|
||||
if cfg is None:
|
||||
cfg = get_app_config().report_collaboration
|
||||
if not ReportCollaborationConfig.model_validate(cfg).planner_llm_enabled:
|
||||
raise ReportCollaborationValidationError(
|
||||
"PLANNER_UNAVAILABLE", "规划师智能体未启用(report_collaboration.planner_llm_enabled)"
|
||||
)
|
||||
except ReportCollaborationValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", f"规划师智能体配置不可读:{exc}") from None
|
||||
if not self._default_llm_tried:
|
||||
self._default_llm_tried = True
|
||||
try:
|
||||
from app.report_collaboration.planning.planner_agent import build_planner_llm
|
||||
|
||||
self._default_llm = build_planner_llm()
|
||||
except Exception as exc:
|
||||
logger.warning("rc planner agent llm build failed: %s", exc)
|
||||
self._default_llm = None
|
||||
if self._default_llm is None:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||
return self._default_llm
|
||||
|
||||
async def handle_user_message(
|
||||
self,
|
||||
@@ -111,16 +130,67 @@ class RequirementResolver:
|
||||
cfg_rounds = max_clarification_rounds
|
||||
if cfg_rounds is None:
|
||||
cfg_rounds = int(get_app_config().report_collaboration.max_clarification_rounds)
|
||||
decision = route_pre_plan_turn(
|
||||
str(user_message.get("content") or ""),
|
||||
previous_snapshot=state["snapshot"],
|
||||
|
||||
text = str(user_message.get("content") or "")
|
||||
history = [
|
||||
{"role": "human" if item.get("role") == "human" else "ai", "content": str(item.get("content") or "")}
|
||||
for item in messages
|
||||
if item.get("message_id") != user_message["message_id"]
|
||||
]
|
||||
|
||||
decision = await self._agent_decision(
|
||||
text,
|
||||
history=history,
|
||||
state=state,
|
||||
previous_revision=int(record.get("requirement_revision") or 0),
|
||||
clarification_round=int(state["clarification_round"] or 0),
|
||||
max_clarification_rounds=cfg_rounds,
|
||||
max_rounds=cfg_rounds,
|
||||
)
|
||||
if decision is None:
|
||||
# 模型调用失败/输出不合法 —— 无法继续,直接报错(无兜底)。
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体本轮未返回有效决策,请重试")
|
||||
await self._apply(session_id, record, state, decision, user_message=user_message, idempotency_key=idempotency_key)
|
||||
return decision
|
||||
|
||||
async def _agent_decision(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
history: list[dict[str, str]],
|
||||
state: dict[str, Any],
|
||||
previous_revision: int,
|
||||
max_rounds: int,
|
||||
) -> PrePlanDecision | None:
|
||||
llm = self._planner_llm()
|
||||
from app.report_collaboration.planning.planner_agent import PlannerAgent
|
||||
|
||||
agent = PlannerAgent(llm)
|
||||
turn = await agent.converse(
|
||||
user_text=text,
|
||||
history=history,
|
||||
previous_snapshot=state["snapshot"],
|
||||
previous_revision=previous_revision,
|
||||
clarification_round=int(state["clarification_round"] or 0),
|
||||
max_rounds=max_rounds,
|
||||
)
|
||||
if turn is None:
|
||||
return None
|
||||
requirement = agent.merge_requirement(
|
||||
turn,
|
||||
previous_snapshot=state["snapshot"],
|
||||
previous_revision=previous_revision,
|
||||
)
|
||||
return PrePlanDecision(
|
||||
action="ready" if turn.action == "ready" else "clarify",
|
||||
requirement=requirement,
|
||||
questions=[item for item in turn.questions if item and item.strip()][:3],
|
||||
impact=[item for item in turn.impact if item][:3],
|
||||
assumptions=[item for item in turn.assumptions if item],
|
||||
used_defaults=bool(turn.assumptions),
|
||||
reason="planner_agent",
|
||||
confidence=0.9,
|
||||
reply=turn.reply.strip(),
|
||||
)
|
||||
|
||||
async def _apply(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -146,7 +216,7 @@ class RequirementResolver:
|
||||
created_at=iso_now(),
|
||||
)
|
||||
clarifications.append(clarification.model_dump())
|
||||
content = format_clarification_message(decision.questions)
|
||||
content = decision.reply or ""
|
||||
metadata = {
|
||||
"preplan_for": user_message["message_id"],
|
||||
"clarification": clarification.model_dump(),
|
||||
@@ -155,17 +225,22 @@ class RequirementResolver:
|
||||
else:
|
||||
for item in clarifications:
|
||||
item["resolved"] = True
|
||||
content = format_ready_message(decision)
|
||||
metadata = {"preplan_for": user_message["message_id"], "ready_to_plan": True}
|
||||
content = decision.reply or ""
|
||||
metadata = {
|
||||
"preplan_for": user_message["message_id"],
|
||||
"ready_to_plan": True,
|
||||
# 结构化需求快照:前端据此渲染「需求摘要卡」(计划模式 UI)。
|
||||
"requirement": decision.requirement.model_dump(),
|
||||
}
|
||||
ready = True
|
||||
|
||||
payload = _dump_state(
|
||||
snapshot=decision.requirement,
|
||||
clarifications=clarifications,
|
||||
assumptions=list(decision.assumptions),
|
||||
clarification_round=round_count,
|
||||
ready_to_plan=ready,
|
||||
)
|
||||
payload = {
|
||||
"snapshot": decision.requirement.model_dump(),
|
||||
"clarifications": clarifications,
|
||||
"assumptions": list(decision.assumptions),
|
||||
"clarification_round": round_count,
|
||||
"ready_to_plan": ready,
|
||||
}
|
||||
await self._store.save_requirement_state(
|
||||
session_id,
|
||||
requirement_json=payload,
|
||||
|
||||
@@ -748,6 +748,7 @@ workflows:
|
||||
report_collaboration:
|
||||
enabled: true
|
||||
worker_enabled: true
|
||||
planner_llm_enabled: true
|
||||
planner_model: null
|
||||
coordinator_model: null
|
||||
research_model: null
|
||||
|
||||
+4
@@ -18,6 +18,10 @@ class ReportCollaborationConfig(BaseModel):
|
||||
default=False,
|
||||
description="When true, a dispatcher/worker may claim run leases. Gateway-only processes keep this false.",
|
||||
)
|
||||
planner_llm_enabled: bool = Field(
|
||||
default=False,
|
||||
description="P1: LLM planner conversation (clarify + custom strategy). Off keeps the deterministic rule router and template plans.",
|
||||
)
|
||||
planner_model: str | None = Field(default=None, description="Model name from AppConfig.models for planning")
|
||||
coordinator_model: str | None = Field(default=None, description="Model name from AppConfig.models for the constrained coordinator")
|
||||
research_model: str | None = Field(default=None, description="Model name from AppConfig.models for research members")
|
||||
|
||||
@@ -25,6 +25,40 @@ class _DisabledCfg:
|
||||
report_collaboration = ReportCollaborationConfig(enabled=False)
|
||||
|
||||
|
||||
class _PlannerFakeLLM:
|
||||
"""规划师假模型:需求确认回「读者」追问;工作流设计回单份定制方案。"""
|
||||
|
||||
_converse = (
|
||||
'{"reply": "好的。先确认:这份报告的主要读者是谁?", "action": "clarify",'
|
||||
'"questions": ["这份报告的主要读者是谁?"], "impact": ["读者影响研究线划分"],'
|
||||
'"requirement": {"topic": "新能源汽车趋势报告"}}'
|
||||
)
|
||||
_ready = (
|
||||
'{"reply": "明白了,读者为企业战略决策人员。我来设计工作流。", "action": "ready",'
|
||||
'"assumptions": [], "requirement": {"topic": "新能源汽车趋势报告", "audience": "企业战略决策人员"}}'
|
||||
)
|
||||
_design = (
|
||||
'{"reply": "已按需求设计定制工作流。", "strategy": "custom", "title": "定制方案",'
|
||||
'"summary": "研究后写作", "rationale": "贴合需求", "estimated_duration_seconds": 900,'
|
||||
'"estimated_cost_level": "low", "quality_gate_ids": ["cite_required"],'
|
||||
'"nodes": ['
|
||||
'{"slot_id": "research-1", "role_key": "researcher", "display_name": "研究员", "mission": "检索证据", "depends_on": [], "output_artifact_type": "EvidenceBundle"},'
|
||||
'{"slot_id": "write-1", "role_key": "writer", "display_name": "撰写员", "mission": "成稿", "depends_on": ["research-1"], "output_artifact_type": "ReportSectionDraft"}]}'
|
||||
)
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
from types import SimpleNamespace
|
||||
|
||||
prompt = str(messages[-1][1]) if messages else ""
|
||||
if "请设计工作流" in prompt:
|
||||
payload = self._design
|
||||
elif "企业战略决策人员" in prompt or "读者" in prompt and "用户最新消息" in prompt:
|
||||
payload = self._ready
|
||||
else:
|
||||
payload = self._converse
|
||||
return SimpleNamespace(content=payload)
|
||||
|
||||
|
||||
def _client(monkeypatch: pytest.MonkeyPatch, *, enabled: bool = True, user: str | None = "user_a") -> tuple[TestClient, MemoryReportCollaborationStore]:
|
||||
monkeypatch.setattr(report_collaboration, "get_app_config", lambda: _Cfg() if enabled else _DisabledCfg())
|
||||
app = FastAPI()
|
||||
@@ -32,6 +66,7 @@ def _client(monkeypatch: pytest.MonkeyPatch, *, enabled: bool = True, user: str
|
||||
inner = MemoryReportCollaborationStore()
|
||||
store = PublishingReportCollaborationStore(inner, hub)
|
||||
app.state.report_collaboration_store = store
|
||||
app.state.report_collaboration_planner_llm = _PlannerFakeLLM
|
||||
app.state.report_collaboration_live_hub = hub
|
||||
app.state.report_collaboration_sse_settings = SseStreamSettings(
|
||||
deadline_seconds=0.6,
|
||||
@@ -126,14 +161,14 @@ def test_session_message_plan_run_http(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
proposed_body = proposed.json()
|
||||
assert proposed_body["session"]["status"] == "proposal_ready"
|
||||
plans = proposed_body["plans"]
|
||||
assert {item["strategy"] for item in plans} == {"balanced_review", "parallel_depth", "focused_fast"}
|
||||
assert {item["strategy"] for item in plans} == {"custom"}
|
||||
plan = next(item for item in plans if item.get("recommended"))
|
||||
assert plan["validation"]["ok"] is True
|
||||
assert all(node.get("output_artifact_type") and node.get("acceptance_criteria") for node in plan["nodes"])
|
||||
|
||||
listed = client.get(f"/api/report-collaboration/sessions/{session_id}/plans")
|
||||
assert listed.status_code == 200
|
||||
assert len(listed.json()["plans"]) == 3
|
||||
assert len(listed.json()["plans"]) == 1
|
||||
|
||||
selected = client.post(
|
||||
f"/api/report-collaboration/sessions/{session_id}/plans/{plan['id']}/select",
|
||||
|
||||
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
from app.report_collaboration.agentscope_runtime.event_adapter import ConversationReplica, MemberStreamContext, SeqAllocator, make_emitter
|
||||
from app.report_collaboration.agentscope_runtime.model_adapter import DeerFlowChatModelAdapter, ModelAdapterError, record_attempt_usage, redact_secrets
|
||||
from app.report_collaboration.agentscope_runtime.model_adapter import DeerFlowChatModelAdapter, ModelAdapterError, estimate_tokens_for_text, record_attempt_usage, redact_secrets
|
||||
from app.report_collaboration.agentscope_runtime.model_resolver import freeze_plan_models, resolve_role_model
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.model_config import ModelConfig
|
||||
@@ -128,6 +128,33 @@ async def test_adapter_streams_text_tool_and_usage() -> None:
|
||||
assert frozen.value.code == "MODEL_FROZEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_count_tokens_and_context_size_contract() -> None:
|
||||
"""AgentScope compress_context 依赖 model.context_size 与 count_tokens(messages, tools)。"""
|
||||
|
||||
snapshot = resolve_role_model("researcher", app_config=_app())
|
||||
adapter = DeerFlowChatModelAdapter(snapshot, app_config=_app())
|
||||
assert isinstance(adapter.context_size, int) and adapter.context_size > 0
|
||||
|
||||
class _Msg:
|
||||
def __init__(self, text: str) -> None:
|
||||
self.content = text
|
||||
|
||||
def get_content_blocks(self):
|
||||
return [{"type": "text", "text": self.content}]
|
||||
|
||||
agentscope_style = [_Msg("市场分析" * 20)]
|
||||
tokens_msg = await adapter.count_tokens(agentscope_style, None)
|
||||
dict_style = [{"role": "user", "content": "hello world"}]
|
||||
tokens_dict = await adapter.count_tokens(dict_style, None)
|
||||
tools = [{"name": "web_search", "parameters": {"type": "object"}}]
|
||||
tokens_tools = await adapter.count_tokens(dict_style, tools)
|
||||
assert tokens_msg > 0
|
||||
assert tokens_dict == estimate_tokens_for_text("hello world")
|
||||
assert tokens_tools > tokens_dict
|
||||
assert await adapter.count_tokens([], None) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_redacts_provider_errors_and_records_metrics() -> None:
|
||||
snapshot = resolve_role_model("writer", app_config=_app())
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""P1-R 规划师智能体:真对话(文案由智能体产出直接落库)+ 工作流设计 + 降级路径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||
from app.report_collaboration.planning.planner_agent import PlannerAgent
|
||||
from app.report_collaboration.requirement.resolver import RequirementResolver
|
||||
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore
|
||||
|
||||
|
||||
class _FakeLLM:
|
||||
def __init__(self, payload: str) -> None:
|
||||
self._payload = payload
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
return SimpleNamespace(content=self._payload)
|
||||
|
||||
|
||||
_TURN_CLARIFY = """```json
|
||||
{"reply": "好的,智能家居是个不错的方向。为了让团队分工更准,我想先确认两点:这份报告主要给谁看?大概需要多长的篇幅?",
|
||||
"action": "clarify",
|
||||
"questions": ["这份报告主要给谁看?", "大概需要多长的篇幅?"],
|
||||
"impact": ["读者决定研究线划分", "篇幅决定成员数量"],
|
||||
"requirement": {"topic": "智能家居设备2026年市场分析", "required_angles": ["市场规模", "竞争格局", "技术趋势"]}}
|
||||
```"""
|
||||
|
||||
_TURN_READY = """{"reply": "明白了:给公司管理层的战略规划参考,5000字左右,覆盖三个角度。我现在就可以为你们设计工作流了。",
|
||||
"action": "ready", "assumptions": ["地域默认中国大陆"],
|
||||
"requirement": {"topic": "智能家居设备2026年市场分析", "audience": "公司管理层", "required_angles": ["市场规模", "竞争格局", "技术趋势"], "length_target": "5000字"}}"""
|
||||
|
||||
_DESIGN_JSON = """{"reply": "我按三个角度设计了两条并行研究线汇写再审的链路,正好对齐管理层决策的阅读习惯。",
|
||||
"strategy": "custom", "title": "三角度并行研究工作流", "summary": "三线并行 + 汇写 + 独立审稿",
|
||||
"rationale": "需求明确三个角度,管理层需要可信结论", "estimated_duration_seconds": 1800,
|
||||
"estimated_cost_level": "medium", "quality_gate_ids": ["cite_required", "independent_review"],
|
||||
"nodes": [
|
||||
{"slot_id": "research-scale", "role_key": "researcher", "display_name": "研究员", "angle": "市场规模",
|
||||
"mission": "检索市场规模证据", "depends_on": [], "output_artifact_type": "EvidenceBundle", "suggested_tools": ["web_search"]},
|
||||
{"slot_id": "research-comp", "role_key": "researcher", "display_name": "研究员", "angle": "竞争格局",
|
||||
"mission": "检索竞争格局证据", "depends_on": [], "output_artifact_type": "EvidenceBundle"},
|
||||
{"slot_id": "research-tech", "role_key": "researcher", "display_name": "研究员", "angle": "技术趋势",
|
||||
"mission": "检索技术趋势证据", "depends_on": [], "output_artifact_type": "EvidenceBundle"},
|
||||
{"slot_id": "write-main", "role_key": "writer", "display_name": "撰写员",
|
||||
"mission": "汇总三线证据成稿", "depends_on": ["research-scale", "research-comp", "research-tech"], "output_artifact_type": "ReportSectionDraft"},
|
||||
{"slot_id": "review-1", "role_key": "reviewer", "display_name": "审稿员",
|
||||
"mission": "独立审稿", "depends_on": ["write-main"], "output_artifact_type": "ReviewDecision"}
|
||||
]}"""
|
||||
|
||||
|
||||
# ── converse:智能体对话 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_converse_returns_agent_reply_and_questions() -> None:
|
||||
agent = PlannerAgent(_FakeLLM(_TURN_CLARIFY))
|
||||
turn = await agent.converse(
|
||||
user_text="帮我写一份智能家居设备2026年市场分析报告",
|
||||
history=[],
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_rounds=3,
|
||||
)
|
||||
assert turn is not None and turn.action == "clarify"
|
||||
assert "为了让团队分工更准" in turn.reply
|
||||
assert len(turn.questions) == 2
|
||||
merged = agent.merge_requirement(turn, previous_snapshot=None, previous_revision=0)
|
||||
assert merged.topic == "智能家居设备2026年市场分析"
|
||||
assert merged.revision == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_converse_forced_ready_at_round_cap() -> None:
|
||||
agent = PlannerAgent(_FakeLLM(_TURN_CLARIFY))
|
||||
turn = await agent.converse(
|
||||
user_text="再说说",
|
||||
history=[],
|
||||
previous_snapshot={"topic": "智能家居市场", "revision": 2},
|
||||
previous_revision=2,
|
||||
clarification_round=3,
|
||||
max_rounds=3,
|
||||
)
|
||||
assert turn is not None and turn.action == "ready"
|
||||
assert turn.assumptions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_converse_invalid_json_returns_none() -> None:
|
||||
agent = PlannerAgent(_FakeLLM("我拒绝输出 JSON"))
|
||||
assert (
|
||||
await agent.converse(
|
||||
user_text="写报告",
|
||||
history=[],
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_rounds=3,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_persists_agent_reply_without_template_wrapper() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="新会话", idempotency_key="ik1")
|
||||
resolver = RequirementResolver(store, planner_llm_factory=lambda: _FakeLLM(_TURN_CLARIFY))
|
||||
decision = await resolver.handle_user_message(
|
||||
session["id"],
|
||||
user_message={"message_id": "m1", "role": "human", "content": "帮我写一份智能家居设备2026年市场分析报告"},
|
||||
idempotency_key="ik-m1",
|
||||
)
|
||||
assert decision is not None and decision.reply and "为了让团队分工更准" in decision.reply
|
||||
messages = await store.list_messages(session["id"])
|
||||
ai_messages = [item for item in messages if item["role"] == "ai"]
|
||||
assert ai_messages, "agent reply message missing"
|
||||
assert "为了让团队分工更准" in ai_messages[-1]["content"]
|
||||
assert "收到您的报告需求" not in ai_messages[-1]["content"] # 模板文案不得出现
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_raises_when_agent_output_invalid() -> None:
|
||||
"""模型输出不合法 → 直接报错(无规则/模板兜底)。"""
|
||||
|
||||
from deerflow.persistence.report_collaboration import ReportCollaborationValidationError
|
||||
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="新会话", idempotency_key="ik1")
|
||||
resolver = RequirementResolver(store, planner_llm_factory=lambda: _FakeLLM("not json"))
|
||||
with pytest.raises(ReportCollaborationValidationError) as exc:
|
||||
await resolver.handle_user_message(
|
||||
session["id"],
|
||||
user_message={"message_id": "m1", "role": "human", "content": "帮我写一份智能家居设备2026年市场分析报告"},
|
||||
idempotency_key="ik-m1",
|
||||
)
|
||||
assert exc.value.code == "PLANNER_UNAVAILABLE"
|
||||
|
||||
|
||||
# ── design_workflow:工作流设计 ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_design_workflow_returns_strategy_with_context_reply() -> None:
|
||||
requirement = ReportRequirementSnapshot(
|
||||
topic="智能家居设备2026年市场分析",
|
||||
required_angles=["市场规模", "竞争格局", "技术趋势"],
|
||||
revision=2,
|
||||
)
|
||||
history = [{"role": "human", "content": "给管理层看,覆盖三个角度"}]
|
||||
designed = await PlannerAgent(_FakeLLM(_DESIGN_JSON)).design_workflow(
|
||||
requirement=requirement, history=history
|
||||
)
|
||||
assert designed is not None
|
||||
strategy, reply = designed
|
||||
assert strategy.strategy == "custom"
|
||||
assert {slot.demand.role_key for slot in strategy.slots} == {"researcher", "writer", "reviewer"}
|
||||
assert "管理层" in reply or "设计" in reply
|
||||
write = next(slot for slot in strategy.slots if slot.slot_id == "write-main")
|
||||
assert set(write.depends_on) == {"research-scale", "research-comp", "research-tech"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_design_workflow_rejects_bad_vocabulary_and_cycles() -> None:
|
||||
requirement = ReportRequirementSnapshot(topic="测试", revision=1)
|
||||
agent = PlannerAgent(_FakeLLM(_DESIGN_JSON))
|
||||
|
||||
bad_role = _DESIGN_JSON.replace('"role_key": "writer"', '"role_key": "project_manager"')
|
||||
assert await PlannerAgent(_FakeLLM(bad_role)).design_workflow(requirement=requirement, history=[]) is None
|
||||
|
||||
cyclic = _DESIGN_JSON.replace(
|
||||
'"depends_on": [], "output_artifact_type": "EvidenceBundle", "suggested_tools"',
|
||||
'"depends_on": ["review-1"], "output_artifact_type": "EvidenceBundle", "suggested_tools"',
|
||||
)
|
||||
assert await PlannerAgent(_FakeLLM(cyclic)).design_workflow(requirement=requirement, history=[]) is None
|
||||
|
||||
bad_artifact = _DESIGN_JSON.replace('"output_artifact_type": "ReportSectionDraft"', '"output_artifact_type": "PDF"')
|
||||
assert await PlannerAgent(_FakeLLM(bad_artifact)).design_workflow(requirement=requirement, history=[]) is None
|
||||
_ = agent # 保持引用清晰
|
||||
@@ -12,7 +12,7 @@ from app.report_collaboration.planning.selector import AgentSelector
|
||||
from app.report_collaboration.planning.service import PlanProposalService
|
||||
from app.report_collaboration.planning.validator import validate_assembled_plan
|
||||
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
|
||||
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore
|
||||
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore, ReportCollaborationValidationError
|
||||
|
||||
|
||||
class _AgentStore:
|
||||
@@ -135,6 +135,26 @@ def test_validator_rejects_unknown_agent_id() -> None:
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
class _DesignFakeLLM:
|
||||
"""规划师工作流设计假模型:返回单份合法定制方案。"""
|
||||
|
||||
_payload = (
|
||||
'{"reply": "我按需求设计了两条研究线汇写再审的定制工作流。",'
|
||||
'"strategy": "custom", "title": "定制方案", "summary": "两线并行汇写再审",'
|
||||
'"rationale": "贴合需求", "estimated_duration_seconds": 1200,'
|
||||
'"estimated_cost_level": "medium", "quality_gate_ids": ["cite_required"],'
|
||||
'"nodes": ['
|
||||
'{"slot_id": "research-a", "role_key": "researcher", "display_name": "研究员", "angle": "市场", "mission": "检索市场证据", "depends_on": [], "output_artifact_type": "EvidenceBundle"},'
|
||||
'{"slot_id": "write-main", "role_key": "writer", "display_name": "撰写员", "mission": "成稿", "depends_on": ["research-a"], "output_artifact_type": "ReportSectionDraft"}'
|
||||
']}'
|
||||
)
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(content=self._payload)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_writes_plans_without_starting_a_run() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
@@ -146,15 +166,20 @@ async def test_service_writes_plans_without_starting_a_run() -> None:
|
||||
status="clarifying",
|
||||
)
|
||||
command = await store.enqueue_plan_request(session["id"], idempotency_key="ik-plan", expected_revision=1)
|
||||
saved = await PlanProposalService(store).fulfill(session["id"], user_id="u1", command_id=command["command_id"], idempotency_key="ik-plan")
|
||||
assert len(saved) == 3
|
||||
saved = await PlanProposalService(store, llm_factory=lambda: _DesignFakeLLM()).fulfill(
|
||||
session["id"], user_id="u1", command_id=command["command_id"], idempotency_key="ik-plan"
|
||||
)
|
||||
assert len(saved) == 1 # 智能体生成单份定制方案
|
||||
snapshot = await store.get_snapshot(session["id"])
|
||||
assert snapshot["session"]["status"] == "proposal_ready"
|
||||
assert snapshot["session"]["selected_plan_id"] is None
|
||||
assert snapshot["run"] is None
|
||||
assert {item["strategy"] for item in snapshot["plans"]} == {"balanced_review", "parallel_depth", "focused_fast"}
|
||||
assert snapshot["plans"][0]["strategy"] == "custom"
|
||||
assert all(item["requirement_revision"] == 1 for item in snapshot["plans"])
|
||||
assert snapshot["session"]["active_run_id"] is None
|
||||
ai = [item for item in snapshot["messages"] if item["role"] == "ai"][-1]
|
||||
assert ai["metadata"]["plan_source"] == "planner_agent"
|
||||
assert "定制工作流" in ai["content"]
|
||||
|
||||
selected = await store.select_plan(session["id"], saved[0]["id"], idempotency_key="ik-select", expected_revision=1)
|
||||
after_select = await store.get_snapshot(session["id"])
|
||||
@@ -163,6 +188,24 @@ async def test_service_writes_plans_without_starting_a_run() -> None:
|
||||
assert after_select["session"]["active_run_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_raises_when_planner_unavailable() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="", idempotency_key="s1b")
|
||||
await store.save_requirement_state(
|
||||
session["id"],
|
||||
requirement_json={"snapshot": _requirement().model_dump(), "clarifications": [], "assumptions": [], "clarification_round": 1, "ready_to_plan": True},
|
||||
requirement_revision=1,
|
||||
status="clarifying",
|
||||
)
|
||||
command = await store.enqueue_plan_request(session["id"], idempotency_key="ik-plan2", expected_revision=1)
|
||||
with pytest.raises(ReportCollaborationValidationError) as exc:
|
||||
await PlanProposalService(store, llm_factory=lambda: None).fulfill(
|
||||
session["id"], user_id="u1", command_id=command["command_id"], idempotency_key="ik-plan2"
|
||||
)
|
||||
assert exc.value.code == "PLANNER_UNAVAILABLE"
|
||||
|
||||
|
||||
def test_cycle_is_rejected() -> None:
|
||||
cfg = ReportCollaborationConfig()
|
||||
candidate_nodes_cycle = AssembledPlan.model_validate(
|
||||
|
||||
@@ -1,76 +1,35 @@
|
||||
"""RC-BE-005: pre-plan intent router and requirement resolver."""
|
||||
"""P1-R 需求确认:规划师智能体是唯一路径(无规则/模板兜底)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.report_collaboration.requirement.extractor import extract_topic, wants_direct_start
|
||||
from app.report_collaboration.requirement.intent_router import route_pre_plan_turn
|
||||
from app.report_collaboration.requirement.resolver import RequirementResolver
|
||||
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore
|
||||
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore, ReportCollaborationValidationError
|
||||
|
||||
|
||||
def test_topic_only_asks_plan_affecting_gaps() -> None:
|
||||
decision = route_pre_plan_turn(
|
||||
"写一份新能源汽车主要品牌市场趋势分析报告",
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_clarification_rounds=3,
|
||||
)
|
||||
assert decision.action == "clarify"
|
||||
assert "新能源" in decision.requirement.topic
|
||||
assert decision.requirement.revision == 1
|
||||
assert len(decision.questions) >= 2
|
||||
assert any("读者" in item for item in decision.questions)
|
||||
assert any("角度" in item for item in decision.questions)
|
||||
class _ScriptedLLM:
|
||||
"""按顺序吐出预置 JSON 决策(模拟规划师智能体多轮)。"""
|
||||
|
||||
def __init__(self, payloads: list[str]) -> None:
|
||||
self._payloads = list(payloads)
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
payload = self._payloads.pop(0) if self._payloads else self._payloads[0]
|
||||
return SimpleNamespace(content=payload)
|
||||
|
||||
|
||||
def test_direct_start_records_default_assumptions() -> None:
|
||||
first = route_pre_plan_turn(
|
||||
"撰写新能源汽车趋势报告,直接开始",
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_clarification_rounds=3,
|
||||
)
|
||||
assert first.action == "ready"
|
||||
assert first.used_defaults is True
|
||||
assert first.requirement.audience == "企业战略决策人员"
|
||||
assert "市场份额" in first.requirement.required_angles
|
||||
assert first.assumptions
|
||||
_CLARIFY = """{"reply": "新能源汽车趋势是个好题目。先确认两个关键点:这份报告主要给谁看?希望覆盖哪些角度?",
|
||||
"action": "clarify", "questions": ["这份报告主要给谁看?", "希望覆盖哪些角度?"],
|
||||
"impact": ["读者影响研究线", "角度影响成员分工"],
|
||||
"requirement": {"topic": "新能源汽车趋势报告"}}"""
|
||||
|
||||
|
||||
def test_direct_start_without_topic_still_asks_topic() -> None:
|
||||
decision = route_pre_plan_turn(
|
||||
"直接开始",
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_clarification_rounds=3,
|
||||
)
|
||||
assert decision.action == "clarify"
|
||||
assert any("主题" in item for item in decision.questions)
|
||||
|
||||
|
||||
def test_clarification_cap_uses_explicit_assumptions() -> None:
|
||||
first = route_pre_plan_turn(
|
||||
"写一份行业报告",
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_clarification_rounds=1,
|
||||
)
|
||||
assert first.action == "clarify"
|
||||
capped = route_pre_plan_turn(
|
||||
"先这样吧",
|
||||
previous_snapshot=first.requirement.model_dump(),
|
||||
previous_revision=first.requirement.revision,
|
||||
clarification_round=1,
|
||||
max_clarification_rounds=1,
|
||||
)
|
||||
assert capped.action == "ready"
|
||||
assert capped.used_defaults is True
|
||||
_READY = """{"reply": "清楚了:给投资机构,覆盖市场份额、价格策略和政策环境。我来为你们设计工作流。",
|
||||
"action": "ready", "assumptions": ["时间范围默认近两年"],
|
||||
"requirement": {"topic": "新能源汽车趋势报告", "audience": "投资机构", "required_angles": ["市场份额", "价格策略", "政策环境"]}}"""
|
||||
|
||||
|
||||
def test_extract_helpers() -> None:
|
||||
@@ -79,14 +38,15 @@ def test_extract_helpers() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_writes_clarification_without_planning() -> None:
|
||||
async def test_resolver_clarify_round_persists_agent_reply_and_record() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="", idempotency_key="s1")
|
||||
user = await store.create_message(session["id"], role="human", content="写一份新能源汽车趋势报告", idempotency_key="m1")
|
||||
resolver = RequirementResolver(store)
|
||||
llm = _ScriptedLLM([_CLARIFY])
|
||||
resolver = RequirementResolver(store, planner_llm_factory=lambda: llm)
|
||||
decision = await resolver.handle_user_message(session["id"], user_message=user, idempotency_key="m1", max_clarification_rounds=3)
|
||||
assert decision is not None
|
||||
assert decision.action == "clarify"
|
||||
assert decision is not None and decision.action == "clarify"
|
||||
assert "先确认两个关键点" in (decision.reply or "")
|
||||
|
||||
snapshot = await store.get_snapshot(session["id"])
|
||||
assert snapshot["session"]["status"] == "clarifying"
|
||||
@@ -96,10 +56,14 @@ async def test_resolver_writes_clarification_without_planning() -> None:
|
||||
assert len(ai) == 1
|
||||
assert ai[0]["completed_at"]
|
||||
assert ai[0]["name"] == "协调者"
|
||||
# 智能体文案直接落库,模板开场不得出现
|
||||
assert "先确认两个关键点" in ai[0]["content"]
|
||||
assert "收到您的报告需求" not in ai[0]["content"]
|
||||
record = await store.get_session_record(session["id"])
|
||||
assert record["requirement_json"]["ready_to_plan"] is False
|
||||
assert record["requirement_json"]["clarifications"][0]["resolved"] is False
|
||||
|
||||
# 幂等:同一消息重放不产生第二条 AI 回复
|
||||
replay = await resolver.handle_user_message(session["id"], user_message=user, idempotency_key="m1", max_clarification_rounds=3)
|
||||
assert replay is None
|
||||
snapshot2 = await store.get_snapshot(session["id"])
|
||||
@@ -107,52 +71,63 @@ async def test_resolver_writes_clarification_without_planning() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_ready_is_not_plan_ready() -> None:
|
||||
async def test_resolver_ready_writes_requirement_metadata_for_summary_card() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="", idempotency_key="s2")
|
||||
user = await store.create_message(
|
||||
session["id"],
|
||||
role="human",
|
||||
content="写一份新能源汽车趋势报告,面向战略决策层,必须覆盖市场、政策、海外和风险,直接开始",
|
||||
idempotency_key="m2",
|
||||
session["id"], role="human", content="写一份新能源汽车趋势报告,给投资机构看", idempotency_key="m2"
|
||||
)
|
||||
resolver = RequirementResolver(store)
|
||||
llm = _ScriptedLLM([_READY])
|
||||
resolver = RequirementResolver(store, planner_llm_factory=lambda: llm)
|
||||
decision = await resolver.handle_user_message(session["id"], user_message=user, idempotency_key="m2")
|
||||
assert decision is not None
|
||||
assert decision.action == "ready"
|
||||
assert decision is not None and decision.action == "ready"
|
||||
|
||||
snapshot = await store.get_snapshot(session["id"])
|
||||
assert snapshot["session"]["status"] == "clarifying"
|
||||
assert snapshot["plans"] == []
|
||||
assert snapshot["plans"] == [] # ready ≠ 已生成方案
|
||||
ai = [item for item in snapshot["messages"] if item["role"] == "ai"][-1]
|
||||
assert ai["metadata"]["ready_to_plan"] is True
|
||||
assert ai["metadata"]["requirement"]["audience"] == "投资机构"
|
||||
assert "设计工作流" in ai["content"]
|
||||
record = await store.get_session_record(session["id"])
|
||||
assert record["requirement_json"]["ready_to_plan"] is True
|
||||
assert all(item["resolved"] for item in record["requirement_json"]["clarifications"])
|
||||
assert "不等于已经生成" in snapshot["messages"][-1]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_merges_and_resolves_clarification() -> None:
|
||||
async def test_resolver_answer_merges_fields_across_rounds() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="", idempotency_key="s3")
|
||||
first = await store.create_message(session["id"], role="human", content="写一份新能源汽车趋势报告", idempotency_key="m3a")
|
||||
resolver = RequirementResolver(store)
|
||||
llm = _ScriptedLLM([_CLARIFY, _READY])
|
||||
resolver = RequirementResolver(store, planner_llm_factory=lambda: llm)
|
||||
await resolver.handle_user_message(session["id"], user_message=first, idempotency_key="m3a")
|
||||
|
||||
second = await store.create_message(
|
||||
session["id"],
|
||||
role="human",
|
||||
content="读者是投资机构,必须覆盖市场份额、价格策略和政策环境",
|
||||
idempotency_key="m3b",
|
||||
session["id"], role="human", content="读者是投资机构,必须覆盖市场份额、价格策略和政策环境", idempotency_key="m3b"
|
||||
)
|
||||
decision = await resolver.handle_user_message(session["id"], user_message=second, idempotency_key="m3b")
|
||||
assert decision is not None
|
||||
assert decision.action == "ready"
|
||||
assert decision.used_defaults is False
|
||||
assert decision.requirement.audience == "投资机构"
|
||||
assert decision is not None and decision.action == "ready"
|
||||
assert decision.requirement.revision == 2
|
||||
assert decision.requirement.audience == "投资机构"
|
||||
assert set(decision.requirement.required_angles) == {"市场份额", "价格策略", "政策环境"}
|
||||
record = await store.get_session_record(session["id"])
|
||||
assert record["requirement_revision"] == 2
|
||||
assert all(item["resolved"] for item in record["requirement_json"]["clarifications"])
|
||||
snapshot = await store.get_snapshot(session["id"])
|
||||
assert snapshot["plans"] == []
|
||||
assert snapshot["session"]["status"] == "clarifying"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_raises_planner_unavailable_without_fallback() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="", idempotency_key="s4")
|
||||
user = await store.create_message(session["id"], role="human", content="写报告", idempotency_key="m4")
|
||||
|
||||
# 工厂显式禁用(模型不可用)→ 直接报错,无规则兜底
|
||||
disabled = RequirementResolver(store, planner_llm_factory=lambda: None)
|
||||
with pytest.raises(ReportCollaborationValidationError) as exc:
|
||||
await disabled.handle_user_message(session["id"], user_message=user, idempotency_key="m4")
|
||||
assert exc.value.code == "PLANNER_UNAVAILABLE"
|
||||
|
||||
# 模型输出不合法 → 同样报错
|
||||
broken = RequirementResolver(store, planner_llm_factory=lambda: _ScriptedLLM(["我说人话不行吗"]))
|
||||
with pytest.raises(ReportCollaborationValidationError) as exc2:
|
||||
await broken.handle_user_message(session["id"], user_message=user, idempotency_key="m4")
|
||||
assert exc2.value.code == "PLANNER_UNAVAILABLE"
|
||||
@@ -155,6 +155,89 @@ async def test_waves_researcher_before_writer_and_text_does_not_complete() -> No
|
||||
assert phases[f"{run_id}-research-attempt1"] != phases[f"{run_id}-write-attempt1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_rework_cascades_downstream_and_reruns_in_order() -> None:
|
||||
"""P0.3 指定重跑级联:重跑 research 后 write 被级联 superseded 并按依赖序重跑。"""
|
||||
|
||||
store = MemoryReportCollaborationStore()
|
||||
run_id = await _boot(store)
|
||||
kernel = ScriptedMemberKernel()
|
||||
kernel.enqueue("research", TaskResult(kind="artifact_draft", artifact_type="EvidenceBundle", artifact_draft=_evidence()))
|
||||
kernel.enqueue("write", TaskResult(kind="artifact_draft", artifact_type="ReportSectionDraft", artifact_draft=_draft()))
|
||||
executor = WaveExecutor(store, kernel)
|
||||
assert await executor.run_until_idle(run_id) == "completed"
|
||||
|
||||
ledger = TaskLedger(store)
|
||||
changed = await ledger.apply_command_rework(
|
||||
run_id,
|
||||
command_id="cmd-cascade-1",
|
||||
instruction="重新查,重点看价格战",
|
||||
node_ids=["research"],
|
||||
cascade_downstream=True,
|
||||
)
|
||||
assert set(changed) == {"research", "write"}
|
||||
|
||||
nodes = await store.list_node_runs(run_id)
|
||||
by_node: dict[str, list[dict]] = {}
|
||||
for item in nodes:
|
||||
by_node.setdefault(item["node_id"], []).append(item)
|
||||
research_attempts = {item["attempt"]: item["status"] for item in by_node["research"]}
|
||||
write_attempts = {item["attempt"]: item["status"] for item in by_node["write"]}
|
||||
assert research_attempts[1] == "superseded" and research_attempts[2] == "planned"
|
||||
assert write_attempts[1] == "superseded" and write_attempts[2] == "planned"
|
||||
events = [item for item in await store.list_events(run_id) if item["type"] == "node.status.changed"]
|
||||
cascade_events = [item for item in events if (item.get("data") or {}).get("reason") == "downstream_cascade"]
|
||||
assert cascade_events and cascade_events[0]["data"]["node_id"] == "write"
|
||||
|
||||
kernel2 = ScriptedMemberKernel()
|
||||
kernel2.enqueue("research", TaskResult(kind="artifact_draft", artifact_type="EvidenceBundle", artifact_draft=_evidence("价格战证据")))
|
||||
kernel2.enqueue("write", TaskResult(kind="artifact_draft", artifact_type="ReportSectionDraft", artifact_draft=_draft()))
|
||||
executor2 = WaveExecutor(store, kernel2)
|
||||
assert await executor2.run_until_idle(run_id) == "completed"
|
||||
assert executor2.dispatched == ["research", "write"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exhausted_interrupted_node_fails_run_instead_of_zombie() -> None:
|
||||
"""P0.2 僵尸 run 守卫:attempt 耗尽的 interrupted 节点应让 run 进入 failed。"""
|
||||
|
||||
store = MemoryReportCollaborationStore()
|
||||
run_id = await _boot(store, _plan(max_attempts=2))
|
||||
# 模拟两次崩溃被 recovery 打断后 attempt 耗尽:research attempt2 interrupted。
|
||||
nodes = await store.list_node_runs(run_id)
|
||||
research = next(item for item in nodes if item["node_id"] == "research")
|
||||
research["attempt"] = 2
|
||||
research["status"] = "interrupted"
|
||||
research["error"] = "lease_reclaimed"
|
||||
await store.save_node_run(research)
|
||||
write = next(item for item in nodes if item["node_id"] == "write")
|
||||
write["status"] = "planned"
|
||||
|
||||
executor = WaveExecutor(store, ScriptedMemberKernel())
|
||||
status = await executor.run_until_idle(run_id)
|
||||
assert status == "failed"
|
||||
run = await store.get_run(run_id)
|
||||
assert run["status"] == "failed"
|
||||
events = [item for item in await store.list_events(run_id) if item["type"] == "run.status.changed"]
|
||||
assert events and events[-1]["data"]["reason"] == "node_attempts_exhausted"
|
||||
assert events[-1]["data"]["node_id"] == "research"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupted_with_budget_left_keeps_run_alive() -> None:
|
||||
"""未耗尽的 interrupted 节点仍留给 recovery 补开 attempt,不能提前 fail。"""
|
||||
|
||||
store = MemoryReportCollaborationStore()
|
||||
run_id = await _boot(store, _plan(max_attempts=3))
|
||||
nodes = await store.list_node_runs(run_id)
|
||||
research = next(item for item in nodes if item["node_id"] == "research")
|
||||
research["attempt"] = 1
|
||||
research["status"] = "interrupted"
|
||||
await store.save_node_run(research)
|
||||
ledger = TaskLedger(store)
|
||||
assert await ledger.refresh_run_terminal(run_id) != "failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_researchers_share_phase() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
|
||||
Reference in new issue
Block a user