代码合并

This commit is contained in:
董宏凯 committed 2026-09-08 09:40:31 +08:00
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:
@@ -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 []:
@@ -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"]
@@ -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,
)
@@ -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
@@ -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()