diff --git a/frontend-web/.env.development b/frontend-web/.env.development index cca4033a..3837c075 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -10,6 +10,9 @@ VITE_MOCK_COP_TASK_DETAIL=true # 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。 VITE_MOCK_SQ_REPORT=true +# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。 +VITE_MOCK_COP_TASK_DETAIL=true + # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api diff --git a/frontend-web/.env.network b/frontend-web/.env.network index a8f304b8..ae615eb8 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -10,6 +10,9 @@ VITE_MOCK_COP_TASK_DETAIL=false # 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。 VITE_MOCK_SQ_REPORT=false +# 深链任务详情(cop-task-detail)数据源:默认真实接口;真实接口未就绪可改 true 走本地假数据。 +VITE_MOCK_COP_TASK_DETAIL=false + # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=http://47.88.25.99:7001 VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit index f94d33ed..31dda6ed 100644 --- a/frontend-web/.env.sit +++ b/frontend-web/.env.sit @@ -10,6 +10,9 @@ VITE_MOCK_COP_TASK_DETAIL=false # 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。 VITE_MOCK_SQ_REPORT=false +# 深链任务详情(cop-task-detail)数据源:默认真实接口;真实接口未就绪可改 true 走本地假数据。 +VITE_MOCK_COP_TASK_DETAIL=false + # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index cfd7e8c2..c28771b0 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -229,6 +229,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar ); const [editPublished, setEditPublished] = useState(agent.published); const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private); + // 会商席位「技能识别强化」开关(弱模型补偿)。默认关。 + const [editSeatSkillDirective, setEditSeatSkillDirective] = useState( + agent.seat_skill_directive ?? false, + ); const [editSquareId, setEditSquareId] = useState(agent.square_id ?? ""); const [publishPickerOpen, setPublishPickerOpen] = useState(false); const { data: publishSquares } = usePublishSquares(); @@ -274,6 +278,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar setEditSquareId(agent.square_id ?? ""); setSelectedSkills(agent.skills ?? []); setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions)); + setEditSeatSkillDirective(agent.seat_skill_directive ?? false); }, [ agent.common_questions, agent.description, @@ -282,6 +287,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar agent.published, agent.square_id, agent.skills, + agent.seat_skill_directive, editOpen, ]); @@ -295,6 +301,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar if (agentDetail) { setEditSoul(agentDetail.soul ?? ""); setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions)); + // 列表响应不带 config 额外字段(seat_skill_directive 为 null),详情才有真值。 + setEditSeatSkillDirective(agentDetail.seat_skill_directive ?? false); } else { setEditSoul(agent.soul ?? ""); } @@ -386,6 +394,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar request.skills = selectedSkills; if (JSON.stringify(nextQuestions) !== JSON.stringify(baselineQuestions)) request.common_questions = nextQuestions; + if (editSeatSkillDirective !== ((agentDetail?.seat_skill_directive ?? agent.seat_skill_directive) ?? false)) + request.seat_skill_directive = editSeatSkillDirective; if (Object.keys(request).length === 0) { setEditOpen(false); @@ -960,6 +970,19 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar onChange={setEditCommonQuestions} /> +
会商技能识别强化
++ 作为会商席位时,把已配置技能(含 SKILL.md 路径与「先 read_file + 再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在业务链条配置里打开(任一即生效)。 +
+公开发布
diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index 9a40afc3..0d6bc0b1 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -76,6 +76,8 @@ interface AgentDraft { published: boolean; featured: boolean; templateName: string; + /** 会商席位「技能识别强化」开关。 */ + seatSkillDirective: boolean; } function draftFromAgent(agent: Agent): AgentDraft { @@ -88,6 +90,7 @@ function draftFromAgent(agent: Agent): AgentDraft { published: agent.published, featured: agent.featured_order != null, templateName: agent.template_name ?? "", + seatSkillDirective: agent.seat_skill_directive ?? false, }; } @@ -295,6 +298,8 @@ export function AgentDetailPanel({ if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul; if (draft.templateName.trim() !== (agent.template_name ?? "")) request.template_name = draft.templateName.trim(); + if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false)) + request.seat_skill_directive = draft.seatSkillDirective; if (nextPublished !== agent.published) request.published = nextPublished; if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? [])) request.skills = draft.skills; @@ -561,6 +566,21 @@ export function AgentDetailPanel({ 配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。会商技能识别强化
++ 作为会商席位时,把已配置技能(含 SKILL.md 路径与「先 read_file + 再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在业务链条配置中打开(任一即生效)。 +
+公开发布
diff --git a/frontend-web/src/core/agents/types.ts b/frontend-web/src/core/agents/types.ts index 0af55b85..283993f8 100644 --- a/frontend-web/src/core/agents/types.ts +++ b/frontend-web/src/core/agents/types.ts @@ -58,6 +58,10 @@ export interface Agent { /** 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的 * Markdown 产物右上角出现"发布入库"按钮。空/缺省 = 不支持入库。 */ template_name?: string | null; + /** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能 + * (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。 + * 与业务链条同名开关取 OR。null/false = 关 (默认)。 */ + seat_skill_directive?: boolean | null; tags?: Tag[]; featured_order?: number | null; /** Whether the current user has favorited (收藏) this agent. */ @@ -91,6 +95,8 @@ export interface CreateAgentRequest { tool_groups?: string[] | null; common_questions?: AgentQuestion[] | null; template_name?: string | null; + /** 会商席位「技能识别强化」开关;缺省/false = 关。 */ + seat_skill_directive?: boolean | null; } export interface UpdateAgentRequest { @@ -106,4 +112,6 @@ export interface UpdateAgentRequest { common_questions?: AgentQuestion[] | null; /** 入库模板名;传空字符串表示清除。 */ template_name?: string | null; + /** 会商席位「技能识别强化」开关;缺省/false = 关。 */ + seat_skill_directive?: boolean | null; } diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index c8a2322c..60076bd6 100644 --- a/frontend-web/src/core/auth/task-deeplink.ts +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -35,6 +35,9 @@ export interface CopTaskDetail { /** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */ export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail"; +/** 行动详情整包暂存的 sessionStorage key(xdfx 行动分析:会商/单智能体读取行动名称/叙事)。 */ +export const ACTION_DETAIL_STORAGE_KEY = "login.actionDetail"; + /** goPath 基础码 → 业务码(配置管理 businessCode)。 */ const GOPATH_BUSINESS_CODE: Record+ 开启后,选入研讨时会**先并行跑一轮「只取数」**:各席位同时用技能把相关信息收集回来(不研讨), + 产出作为共享资料注入后续研讨轮。把最慢的技能检索前置、并行做一次,给(尤其按链条顺序串行的) + 研讨段大幅提速;研讨阶段席位仍可按需再调技能。 +
+会商技能识别强化
++ 开启后,该席位作为会商子智能体执行时,会把已配置的技能(含 SKILL.md + 路径与「先 read_file 再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在该智能体编辑里打开(任一即生效)。 +
+行动名称
++ {actionDetail?.actionName?.trim() || "(未获取到行动名称)"} +
+任务名称
++ {taskDetail?.taskName?.trim() || "(未获取到任务名称)"} +
+任务描述
++ {taskDetail?.taskContent?.trim() || "(未获取到任务描述)"} +
+`. Missing/revoked/non-`view` codes all return 404. |
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
@@ -620,6 +620,19 @@ scheduler runs once per deployment. Per run:
- Config values starting with `$` resolved as environment variables
- Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`)
+### Roundtable Model Fault Tolerance (会商模型自动容错 / 换下一个模型)
+
+会商第二步(leader/seat)+ 第三步(summary/report/dashboard + 结构化抽取)单轮执行时,**某模型出问题就自动切到下一个模型继续**,并告知用户「『X』模型异常 → 已切换『Y』」。前台实时(`multi_agent.py`)与后台挂起(`engine.py` + `InProcessRoundtableGateway`)两条路径都覆盖。
+
+- **可检测的模型失败**:`LLMErrorHandlingMiddleware` 在同一模型重试 3 次后**不抛异常**,而是返回兜底 `AIMessage` —— 现在给它打 `additional_kwargs[LLM_ERROR_MARKER="deerflow_llm_error"]=reason`(model_dump 保留,经 checkpointer 序列化仍在)。`is_llm_error_message(msg)` 据标记(回退按已知兜底文案正文)识别,返回 reason。
+- **候选模型链**:`app/gateway/roundtable_model_fallback.py::fallback_model_chain(current)` 按 `config.yaml models[]` 顺序给出 `[当前模型, …其余]`(去重保序)。`reason_text(reason)` 翻译成简短中文。总开关 env `ROUNDTABLE_MODEL_FALLBACK`(默认开,`0/false/off` 关 → 单模型旧行为);`ROUNDTABLE_MODEL_FALLBACK_MAX`(0=试完全部)。
+- **触发换模型的三种情形**:① 单轮 run 抛异常(start_run 失败 / idle 卡死 → `timeout`,其余 `exception`);② 兜底错误文案(`is_llm_error_message`);③ 交付为空(仅 seat/leader;leader 还含「既无正文又无派活」)。Step3 单例(report/summary/dashboard/ingest/结构化)的产物是落盘文件,**不**把「正文为空」当失败,只对真模型错误换模型。
+- **后台**:`InProcessRoundtableGateway._run_turn_with_fallback(...)` 包住 `_run_agent_turn`,逐候选模型试;换模型落 `model_switch` 诊断、全失败落 `model_fallback_exhausted`。`run_leader/run_seat/run_report/run_summary/run_dashboard_data` 全走它(report/summary/dashboard 的产物自愈重试保留,并记住已切到的可用模型用于后续续写轮)。各 Turn/Result 带回 `switches: list[ModelSwitch]`,引擎 `_emit_switches` → `JobProgressWriter.model_switch(...)` 往研讨时间线插一条 `role="system"` 系统提示气泡。
+- **前台**:`_special_run`/`_leader_run` stream 收尾后检测失败 → yield `{status:"model_switch", role, agent_name, failed_model, next_model, reason}` 帧并用下一个模型重新流式;终态帧带 `model_used`。结构化抽取 `flow_extract.py` 的 round1 `model.astream` 也包了同款换模型循环(emit `event:"model_switch"` 帧)。
+- **Step2 单席位隔离**:`engine._run_recommend/_run_chain/_run_dag` 的每个席位执行包 try/except —— 一个席位彻底失败(落 `seat_failed` 诊断 + 置空交付)不拖垮整批/整链/整个作业。
+- **前端**:`multi-agent.ts` 解析 `model_switch` 帧 → `onModelSwitch` 回调(清空失败尝试的半截气泡 + 累计的 tool_call delta,phase 退回 connected);`useStep2Orchestration` 弹 toast + 时间线插「系统提示」气泡;`useStep3Summary/Report/Dashboard` + `flow-extract.ts` 重置对应气泡并在步骤卡留可见提示;后台路径经 `job-live.ts::toStep2Dialogue` 把 `role="system"` 渲染成 amber「系统提示」气泡。
+- Tests: `tests/test_roundtable_model_fallback.py`。
+
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints
diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py
index 95511930..d8dd17e6 100644
--- a/offline-backend-20260512/backend/app/gateway/deps.py
+++ b/offline-backend-20260512/backend/app/gateway/deps.py
@@ -339,6 +339,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
seat_thinking_enabled=p.seat_thinking_enabled,
seat_reasoning_effort=p.seat_reasoning_effort,
seat_subagent_enabled=p.seat_subagent_enabled,
+ seat_skill_directives=p.seat_skill_directives,
+ # 每席位推理深度覆盖(业务链条里各席位单独配):run_seat 命中即覆盖作业级 seatMode。
+ seat_modes=p.seat_modes,
# 诊断日志上下文:网关每轮 leader/seat/report/summary/dashboard 报错均带这些归属。
job_id=p.job_id,
draft_id=p.draft_id,
diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py
index 3521a652..fadc3093 100644
--- a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py
+++ b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py
@@ -18,12 +18,18 @@ import asyncio
import logging
import os
import uuid
+from collections.abc import Callable
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from langgraph.checkpoint.base import empty_checkpoint
+from app.gateway.roundtable_model_fallback import (
+ fallback_model_chain,
+ is_llm_error_message,
+ reason_text,
+)
from app.gateway.roundtable_run_policy import roundtable_run_policy
from app.gateway.roundtable_seat_skills import append_seat_skill_directive
from app.gateway.routers._roundtable_seed import (
@@ -36,6 +42,7 @@ from app.gateway.routers._roundtable_seed import (
from deerflow.agents.roundtable_orchestrator.report_json import extract_report_json
from deerflow.agents.roundtable_orchestrator.types import (
LeaderTurn,
+ ModelSwitch,
ReportResult,
SeatRef,
SeatTurn,
@@ -509,6 +516,43 @@ def _build_seat_intro(agents: list[SeatRef]) -> str:
)
+# 席位执行模式 → (thinking_enabled, reasoning_effort, subagent_enabled) 派生参数。
+# 与前端 `lib/seat-mode.ts::seatModeToRunParams` **逐字段对齐**(务必同步修改),供每席位
+# 推理深度覆盖(self._seat_modes)在 run_seat 里把 SeatMode 解析成具体运行参数。未知/缺省档
+# 落 None(→ 跟随作业级 seat_* / policy 默认)。
+_SEAT_MODE_PARAMS: dict[str, tuple[bool | None, str | None, bool]] = {
+ "flash": (False, "minimal", False),
+ "thinking": (True, "low", False),
+ "pro": (True, "medium", False),
+ "ultra": (True, "high", True),
+}
+
+
+def _exception_reason(exc: BaseException) -> str:
+ """把单轮 run 抛出的异常归类成模型容错原因码(用于换模型提示)。
+
+ idle 守护判定的卡死(``_await_run_with_idle_guard`` 抛 ``... stalled ...``)→ ``timeout``;
+ 其余(start_run 失败 / DB 抖动 / 其它)→ ``exception``。
+ """
+ text = str(exc).lower()
+ if "stall" in text or "idle" in text or "timeout" in text or "timed out" in text:
+ return "timeout"
+ return "exception"
+
+
+def _seat_empty_reason(messages: list[dict[str, Any]]) -> str | None:
+ """席位/取数轮校验:最后一条有正文的 AI 消息为空 → ``empty``(触发换模型)。"""
+ return "empty" if not _latest_ai_text(messages).strip() else None
+
+
+def _leader_failed_reason(messages: list[dict[str, Any]]) -> str | None:
+ """总控轮校验:既无正文、也无任何工具调用(派活/澄清)→ ``empty``(视为模型没干活)。"""
+ ai = _latest_ai_message(messages)
+ has_text = bool(_latest_ai_text(messages).strip())
+ has_tools = bool(_normalize_tool_calls((ai or {}).get("tool_calls")))
+ return None if (has_text or has_tools) else "empty"
+
+
# ── 网关实现 ─────────────────────────────────────────────────────────────────
@@ -525,6 +569,8 @@ class InProcessRoundtableGateway:
seat_thinking_enabled: bool | None = None,
seat_reasoning_effort: str | None = None,
seat_subagent_enabled: bool = False,
+ seat_skill_directives: dict[str, bool] | None = None,
+ seat_modes: dict[str, str] | None = None,
job_id: str | None = None,
draft_id: str | None = None,
task_id: str | None = None,
@@ -547,6 +593,13 @@ class InProcessRoundtableGateway:
self._seat_thinking_enabled = seat_thinking_enabled
self._seat_reasoning_effort = seat_reasoning_effort
self._seat_subagent_enabled = seat_subagent_enabled
+ # 每席位「技能识别强化」开关映射 {agent_id: bool}(沿用 Step2 所选业务链条里每个席位
+ # 的配置)。run_seat 按 agent_id 查表,与各席位 agent 自身的 config.yaml 开关取 OR,
+ # 传给 append_seat_skill_directive。默认空 dict(仅各 agent 自身开关生效)。
+ self._seat_skill_directives = dict(seat_skill_directives or {})
+ # 每席位「推理深度覆盖」映射 {agent_id: SeatMode}。run_seat 命中即用该档(_SEAT_MODE_PARAMS)
+ # 派生的 thinking/reasoning/subagent 覆盖作业级 seat_*;未命中则跟随作业级。默认空 dict。
+ self._seat_modes = dict(seat_modes or {})
self._seat_intro = ""
self._first_leader = True
@@ -564,25 +617,116 @@ class InProcessRoundtableGateway:
self._seat_intro = _build_seat_intro(self._agents)
return thread_ids, COORDINATOR_AGENT_ID
+ async def _run_turn_with_fallback(
+ self,
+ *,
+ thread_id: str,
+ agent_name: str,
+ message: str,
+ requested_model: str | None,
+ excluded_tools: list[str],
+ skill_stop_names: list[str],
+ thinking_enabled: bool,
+ reasoning_effort: str,
+ subagent_enabled: bool = False,
+ stage: str,
+ cycle: int | None = None,
+ validate: Callable[[list[dict[str, Any]]], str | None] | None = None,
+ ) -> tuple[list[dict[str, Any]], list[ModelSwitch], str | None]:
+ """跑一轮 agent,并在「模型出问题」时自动切到下一个模型重试。
+
+ 触发换模型的三种情形(与用户选择一致):
+ ① ``_run_agent_turn`` 抛异常(start_run 失败 / idle 卡死超时);
+ ② 返回了中间件兜底错误文案(``is_llm_error_message`` 命中);
+ ③ 调用方 ``validate`` 判定无效(如席位交付为空 / 总控既无正文又无派活)。
+
+ 候选模型链按 ``config.yaml models[]`` 顺序(``fallback_model_chain``),首个为
+ ``requested_model``(空则作业级 ``self._model``,再空则配置默认)。返回
+ ``(最终 messages, 发生的换模型记录, 实际所用模型)``。全部模型都失败时返回最后一次的
+ messages(让既有的「空交付 / 产物缺失」逻辑兜底,绝不抛异常拖垮整个作业)。
+ """
+ base = requested_model if (requested_model not in (None, "")) else self._model
+ chain = fallback_model_chain(base) or [base]
+ switches: list[ModelSwitch] = []
+ messages: list[dict[str, Any]] = []
+ prev_model: str | None = None
+ prev_reason: str | None = None
+ used_model: str | None = chain[0]
+ for idx, candidate in enumerate(chain):
+ if idx > 0:
+ label = reason_text(prev_reason)
+ switches.append(ModelSwitch(failed_model=prev_model or "", next_model=candidate or "", reason_label=label))
+ logger.warning(
+ "roundtable %s '%s' switching model %s -> %s (%s)",
+ stage, agent_name, prev_model, candidate, prev_reason,
+ )
+ await self._diag.record(
+ stage=stage, level="warning", event="model_switch",
+ message=f"「{agent_name}」模型「{prev_model}」{label},已自动切换到「{candidate}」重试",
+ detail={"failed_model": prev_model, "next_model": candidate, "reason": prev_reason, "attempt": idx + 1},
+ agent_name=agent_name, cycle=cycle,
+ )
+ used_model = candidate
+ try:
+ messages = await _run_agent_turn(
+ self._app,
+ user_id=self._user_id,
+ thread_id=thread_id,
+ agent_name=agent_name,
+ message=message,
+ model=candidate,
+ excluded_tools=excluded_tools,
+ skill_stop_names=skill_stop_names,
+ thinking_enabled=thinking_enabled,
+ reasoning_effort=reasoning_effort,
+ subagent_enabled=subagent_enabled,
+ diag=self._diag,
+ stage=stage,
+ cycle=cycle,
+ )
+ except Exception as exc: # noqa: BLE001 — 模型故障:归类后尝试下一个模型
+ prev_model, prev_reason, messages = candidate, _exception_reason(exc), []
+ if idx + 1 < len(chain):
+ continue
+ await self._diag.record(
+ stage=stage, level="error", event="model_fallback_exhausted",
+ message=f"「{agent_name}」所有备选模型均失败(最后 {candidate}:{prev_reason})",
+ detail={"reason": prev_reason, "tried": chain}, agent_name=agent_name, cycle=cycle,
+ )
+ return messages, switches, used_model
+ # 跑通了:再看是否「兜底错误文案」或 validate 判定无效。
+ reason = is_llm_error_message(_latest_ai_message(messages))
+ if reason is None and validate is not None:
+ reason = validate(messages)
+ if reason is None:
+ return messages, switches, used_model
+ prev_model, prev_reason = candidate, reason
+ if idx + 1 >= len(chain):
+ await self._diag.record(
+ stage=stage, level="error", event="model_fallback_exhausted",
+ message=f"「{agent_name}」所有备选模型均未产出有效结果(最后 {candidate}:{reason})",
+ detail={"reason": reason, "tried": chain}, agent_name=agent_name, cycle=cycle,
+ )
+ return messages, switches, used_model
+ return messages, switches, used_model
+
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
msg = message
if self._first_leader and self._seat_intro:
msg = self._seat_intro + "\n\n" + message
self._first_leader = False
policy = roundtable_run_policy("leader")
- messages = await _run_agent_turn(
- self._app,
- user_id=self._user_id,
+ messages, switches, _used = await self._run_turn_with_fallback(
thread_id=thread_ids[coordinator_name],
agent_name=coordinator_name,
message=msg,
- model=model or self._model,
+ requested_model=model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
- diag=self._diag,
stage="step2_leader",
+ validate=_leader_failed_reason,
)
ai = _latest_ai_message(messages)
# tool_calls 取最后一条 AI 消息(派活就在这条);正文取最后一条有文字的 AI 消息。
@@ -596,7 +740,7 @@ class InProcessRoundtableGateway:
if clar is not None:
cargs = clar.get("args") or {}
question = str(cargs.get("question") or "").strip() or "总控需要补充信息,请补充后继续。"
- return LeaderTurn(final_text=final_text, clarification=question, tool_calls=turn_tool_calls)
+ return LeaderTurn(final_text=final_text, clarification=question, tool_calls=turn_tool_calls, switches=switches)
# 派活解析(复用 multi_agent 纯函数)
from app.gateway.routers.multi_agent import _extract_dispatch
@@ -609,39 +753,52 @@ class InProcessRoundtableGateway:
dispatched.append((name, task))
except Exception: # noqa: BLE001
logger.warning("dispatch parse failed for tool_call", exc_info=True)
- return LeaderTurn(final_text=final_text, dispatched=dispatched, tool_calls=turn_tool_calls)
+ return LeaderTurn(final_text=final_text, dispatched=dispatched, tool_calls=turn_tool_calls, switches=switches)
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
policy = roundtable_run_policy("seat")
# 弱模型补偿:把该席位配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其
# 流程取信息的硬指令)显式拼进本轮任务,避免弱模型无视系统提示里的技能块(见
- # roundtable_seat_skills 模块说明)。无配置技能时原样返回 task。
- seat_task = append_seat_skill_directive(agent_id, task)
- # 席位模式覆盖 policy 的 thinking/reasoning(None → policy 默认),与前端 _special_run 一致。
- messages = await _run_agent_turn(
- self._app,
- user_id=self._user_id,
+ # roundtable_seat_skills 模块说明)。默认关:是否注入 = 该席位 agent 的 config.yaml
+ # 开关 OR 该席位在所选业务链条里的开关(按 agent_id 查表)。无配置技能 / 两开关都关时
+ # 原样返回 task。
+ seat_task = append_seat_skill_directive(
+ agent_id, task, chain_enabled=bool(self._seat_skill_directives.get(agent_id, False))
+ )
+ # 每席位「推理深度覆盖」:业务链条里给该席位单独配了 seatMode → 用该档派生的
+ # thinking/reasoning/subagent **覆盖作业级**(self._seat_*);未配则跟随作业级。
+ # 作业级仍为 None 时落 policy 默认。与前端 useStep2Orchestration「覆盖优先、否则跟随
+ # selectedMode」逐席位语义一致。
+ seat_thinking, seat_reasoning, seat_subagent = (
+ self._seat_thinking_enabled,
+ self._seat_reasoning_effort,
+ self._seat_subagent_enabled,
+ )
+ override = _SEAT_MODE_PARAMS.get(self._seat_modes.get(agent_id, ""))
+ if override is not None:
+ seat_thinking, seat_reasoning, seat_subagent = override
+ messages, switches, _used = await self._run_turn_with_fallback(
thread_id=thread_ids[agent_id],
agent_name=agent_id,
message=seat_task,
- model=model or self._model,
+ requested_model=model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=(
- policy.thinking_enabled
- if self._seat_thinking_enabled is None
- else self._seat_thinking_enabled
+ policy.thinking_enabled if seat_thinking is None else seat_thinking
),
reasoning_effort=(
- policy.reasoning_effort
- if self._seat_reasoning_effort is None
- else self._seat_reasoning_effort
+ policy.reasoning_effort if seat_reasoning is None else seat_reasoning
),
- subagent_enabled=self._seat_subagent_enabled,
- diag=self._diag,
+ subagent_enabled=seat_subagent,
stage="step2_seat",
+ validate=_seat_empty_reason,
+ )
+ return SeatTurn(
+ final_text=_latest_ai_text(messages),
+ tool_calls=_tool_calls_since_last_human(messages),
+ switches=switches,
)
- return SeatTurn(final_text=_latest_ai_text(messages), tool_calls=_tool_calls_since_last_human(messages))
async def create_report_thread(self) -> str:
ensure_roundtable_functional_agents()
@@ -654,21 +811,22 @@ class InProcessRoundtableGateway:
summary = ""
html: str | None = None
message = prompt
+ switches: list[ModelSwitch] = []
+ current_model = model # 跨产物自愈轮记住实际所用模型:一旦换到可用模型,后续续写轮就从它起。
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
- messages = await _run_agent_turn(
- self._app,
- user_id=self._user_id,
+ messages, sw, used = await self._run_turn_with_fallback(
thread_id=report_thread_id,
agent_name=REPORT_AGENT_ID,
message=message,
- model=model or self._model,
+ requested_model=current_model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
- diag=self._diag,
stage="step3_report",
)
+ switches.extend(sw)
+ current_model = used or current_model
summary = _latest_ai_text(messages) or summary
html = _read_report_html(self._app, report_thread_id, self._user_id)
if _html_complete(html):
@@ -695,7 +853,7 @@ class InProcessRoundtableGateway:
)
# 截断的 HTML 也比纯文字强(浏览器容忍未闭合标签),仅在完全没有文件时才回退正文。
final_html = html if (html and html.strip()) else summary
- return ReportResult(html=final_html, summary=summary, model=model or self._model)
+ return ReportResult(html=final_html, summary=summary, model=current_model or self._model, switches=switches)
async def create_summary_thread(self) -> str:
ensure_roundtable_functional_agents()
@@ -711,21 +869,22 @@ class InProcessRoundtableGateway:
closing = ""
md: str | None = None
message = prompt
+ switches: list[ModelSwitch] = []
+ current_model = model
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
- messages = await _run_agent_turn(
- self._app,
- user_id=self._user_id,
+ messages, sw, used = await self._run_turn_with_fallback(
thread_id=summary_thread_id,
agent_name=SUMMARY_AGENT_ID,
message=message,
- model=model or self._model,
+ requested_model=current_model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
- diag=self._diag,
stage="step3_summary",
)
+ switches.extend(sw)
+ current_model = used or current_model
closing = _latest_ai_text(messages) or closing
md = _read_summary_md(self._app, summary_thread_id, self._user_id)
if md and md.strip():
@@ -751,7 +910,7 @@ class InProcessRoundtableGateway:
agent_name=SUMMARY_AGENT_ID,
)
final_md = md if (md and md.strip()) else closing
- return SummaryResult(md=final_md, closing=closing, model=model or self._model, thread_id=summary_thread_id)
+ return SummaryResult(md=final_md, closing=closing, model=current_model or self._model, thread_id=summary_thread_id, switches=switches)
async def run_dashboard_data(self, *, prompt, model) -> str | None:
"""大屏多页的结构化数据:进程内驱动**专职**「大屏多页数据智能体」
@@ -767,21 +926,20 @@ class InProcessRoundtableGateway:
thread_id = await _create_thread(self._app)
policy = roundtable_run_policy("dashboard")
message = prompt
+ current_model = model
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
- messages = await _run_agent_turn(
- self._app,
- user_id=self._user_id,
+ messages, _sw, used = await self._run_turn_with_fallback(
thread_id=thread_id,
agent_name=DASHBOARD_AGENT_ID,
message=message,
- model=model or self._model,
+ requested_model=current_model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
- diag=self._diag,
stage="step3_dashboard",
)
+ current_model = used or current_model
body = _extract_report_json(_latest_ai_text(messages))
if body:
return body
diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py
index bd23f41e..7f3b11cd 100644
--- a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py
+++ b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py
@@ -17,7 +17,7 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
@@ -189,6 +189,20 @@ class StartParams:
seat_thinking_enabled: bool | None = None
seat_reasoning_effort: str | None = None
seat_subagent_enabled: bool = False
+ # 每席位「技能识别强化」开关映射 {agent_id: bool}(沿用 Step2 所选业务链条里每个席位的
+ # 配置)。透传给进程内网关,run_seat 按 agent_id 查表,与各席位 agent 自身 config.yaml 的
+ # 同名开关取 OR。默认空 dict。
+ seat_skill_directives: dict[str, bool] = field(default_factory=dict)
+ # 每席位「推理深度覆盖」映射 {agent_id: SeatMode}(值 flash/thinking/pro/ultra,沿用 Step2 所选
+ # 业务链条里每个席位单独配的推理深度)。透传给进程内网关,run_seat 按 agent_id 查表:命中 → 用该
+ # 档派生的 thinking/reasoning/subagent 覆盖作业级 seatMode;未命中 → 跟随作业级。默认空 dict。
+ seat_modes: dict[str, str] = field(default_factory=dict)
+ # 是否在研讨前先跑一轮**全席位并行『取数』**(业务链条逐条 opt-in):各席位并行只调技能取数、
+ # 不研讨,产出作为共享资料注入后续研讨轮。透传给 run_orchestration 的 gather_first。默认 False。
+ gather_first: bool = False
+ # 深链接业务码(rwfx→6BF 等)。透传给 run_orchestration → build_summary_prompt:写实业务额外注入
+ # 「业务链完整性核对」,让后台总结报告核对产出相对业务链完不完整。普通会商为空 → 不注入。
+ business_code: str | None = None
# 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。
@@ -276,6 +290,8 @@ class RoundtableJobExecutor:
intent_text=params.intent_text,
enable_report=params.enable_report,
plan=params.orchestration_plan,
+ gather_first=params.gather_first,
+ business_code=params.business_code,
)
# 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。
await self._write_run_to_draft(
diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_model_fallback.py b/offline-backend-20260512/backend/app/gateway/roundtable_model_fallback.py
new file mode 100644
index 00000000..755e93c5
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/roundtable_model_fallback.py
@@ -0,0 +1,104 @@
+"""圆桌会商「模型自动容错(换下一个模型)」共享助手。
+
+会商的第二步(leader / seat)与第三步(summary / report / dashboard / structure)单轮执行时,
+若所用模型出现问题(抛异常 / idle 卡死 / 中间件兜底错误文案 / 交付为空),应自动切到
+**下一个可用模型**继续,并把「『X』模型异常 → 已切换『Y』模型」告知用户。
+
+本模块只放**与执行路径无关**的纯逻辑,供前台(``multi_agent.py``)与后台
+(``roundtable_inprocess_gateway.py``)两条路径共用:
+
+ - ``model_fallback_enabled()``:总开关(env ``ROUNDTABLE_MODEL_FALLBACK``,默认开);
+ - ``fallback_model_chain(current)``:按 ``config.yaml`` ``models[]`` 顺序给出候选模型链
+ ``[当前模型, …其余]``(去重保序);
+ - ``reason_text(reason)``:把失败原因码翻译成简短中文(用于用户提示);
+ - 复用 harness 的 ``is_llm_error_message`` 识别中间件兜底文案。
+
+放在 app 层(依赖 ``deerflow.config`` + harness 的中间件常量,皆为 app→harness 合法方向)。
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+from deerflow.agents.middlewares.llm_error_handling_middleware import (
+ is_llm_error_message as is_llm_error_message, # re-export for callers
+)
+from deerflow.config import get_app_config
+
+logger = logging.getLogger(__name__)
+
+# 失败原因码 → 用户可见简短中文(用于「『X』模型<原因>,已切换…」提示)。
+_REASON_TEXT: dict[str, str] = {
+ "quota": "额度不足/账单异常",
+ "auth": "鉴权失败/无访问权限",
+ "busy": "服务繁忙",
+ "transient": "网络/连接异常",
+ "upstream": "上游响应异常",
+ "circuit": "连续失败被熔断",
+ "generic": "调用失败",
+ "exception": "调用异常",
+ "timeout": "长时间无响应(卡死)",
+ "empty": "未产出有效内容",
+}
+
+
+def reason_text(reason: str | None) -> str:
+ """把失败原因码翻译成简短中文(未知码 → 「调用失败」)。"""
+ if not reason:
+ return "调用失败"
+ return _REASON_TEXT.get(reason, "调用失败")
+
+
+def model_fallback_enabled() -> bool:
+ """模型自动容错总开关(默认开;设 ``ROUNDTABLE_MODEL_FALLBACK=0/false/off`` 关闭)。
+
+ 关闭时 ``fallback_model_chain`` 只返回单个候选(原模型),即恢复旧行为:模型失败就失败,
+ 不再尝试其它模型。
+ """
+ raw = (os.getenv("ROUNDTABLE_MODEL_FALLBACK", "1") or "1").strip().lower()
+ return raw not in {"0", "false", "no", "off"}
+
+
+def _max_attempts() -> int:
+ """最多尝试的模型个数(含首个)。``ROUNDTABLE_MODEL_FALLBACK_MAX``,0/未设 = 不限(试完全部)。"""
+ try:
+ return max(0, int(os.getenv("ROUNDTABLE_MODEL_FALLBACK_MAX", "0") or "0"))
+ except (TypeError, ValueError):
+ return 0
+
+
+def _config_model_names() -> list[str]:
+ try:
+ cfg = get_app_config()
+ return [m.name for m in (cfg.models or []) if getattr(m, "name", None)]
+ except Exception: # noqa: BLE001 — 拿不到配置(内存后端/测试桩)时退化为空
+ logger.debug("fallback_model_chain: get_app_config().models unavailable", exc_info=True)
+ return []
+
+
+def fallback_model_chain(current: str | None) -> list[str]:
+ """按 ``config.yaml`` ``models[]`` 顺序构造候选模型链:``[当前模型, …其余]``,去重保序。
+
+ - ``current`` 为空/None → 从 ``models[0]`` 起(即后端默认模型),后接其余;
+ - ``current`` 在配置内 → 它排第一,其余按配置顺序补齐;
+ - ``current`` 不在配置内(自定义/未知名)→ 仍排第一,再补齐全部配置模型;
+ - 总开关关闭 → 只返回单个候选(``current`` 或默认模型),等同旧行为;
+ - 拿不到任何配置模型 → 返回 ``[current]``(current 为空则空列表,调用方自行退化为不换模型)。
+ """
+ all_names = _config_model_names()
+ cur = (current or "").strip()
+ ordered: list[str] = []
+ if cur:
+ ordered.append(cur)
+ for n in all_names:
+ if n and n not in ordered:
+ ordered.append(n)
+ if not ordered:
+ return [cur] if cur else []
+ if not model_fallback_enabled():
+ return ordered[:1]
+ cap = _max_attempts()
+ if cap > 0:
+ ordered = ordered[:cap]
+ return ordered
diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_seat_skills.py b/offline-backend-20260512/backend/app/gateway/roundtable_seat_skills.py
index 0634d983..f5cad7a6 100644
--- a/offline-backend-20260512/backend/app/gateway/roundtable_seat_skills.py
+++ b/offline-backend-20260512/backend/app/gateway/roundtable_seat_skills.py
@@ -11,12 +11,20 @@
强得多——这与本仓已有的弱模型补偿手法(``_SEAT_NO_ASK``、总控派活强化、AI 写作 SKILL.md
注入,见 ``project-ai-writing-skill-agent``)一脉相承。
+**默认关闭、按需开启(2026-06-20 改造)**:不再全域生效。是否对某席位注入由两个开关
+取 **OR** 决定——
+ 1. **智能体级**:该席位 agent 的 ``config.yaml → seat_skill_directive``(智能体编辑页
+ 可开关);
+ 2. **业务链条级**:本次会商所选业务链条的同名开关,由调用方读出后以 ``chain_enabled``
+ 传入(前端手动与后台挂起两条路径都按席位透传)。
+任一为真即注入;两者都假 → 返回空串、原样使用任务文本(零副作用)。
+
两条席位执行路径共用本 helper:
- 前端手动:``multi_agent._special_run``(seat / seat_parallel 角色);
- 后台挂起:``roundtable_inprocess_gateway.InProcessRoundtableGateway.run_seat``。
-无配置技能的席位 → 返回空串(完全无副作用)。可用 ``ROUNDTABLE_SEAT_SKILL_DIRECTIVE=0``
-全局关闭(默认开)。
+``ROUNDTABLE_SEAT_SKILL_DIRECTIVE`` 仅作**总开关(kill-switch)**:默认放行,显式设为
+0/false 时彻底关闭本功能(不论两个开关如何),用于线上一键熔断。它**不再** force-on。
"""
from __future__ import annotations
@@ -27,25 +35,36 @@ import os
logger = logging.getLogger(__name__)
_TRUTHY = frozenset({"1", "true", "yes", "on", "y"})
+_FALSY = frozenset({"0", "false", "no", "off", "n"})
-def _directive_enabled() -> bool:
- """读 ``ROUNDTABLE_SEAT_SKILL_DIRECTIVE``(默认开)。空 / 未设 = 开。"""
- return (os.getenv("ROUNDTABLE_SEAT_SKILL_DIRECTIVE") or "1").strip().lower() in _TRUTHY
+def _master_enabled() -> bool:
+ """``ROUNDTABLE_SEAT_SKILL_DIRECTIVE`` 总开关:默认放行(kill-switch)。
+
+ 空 / 未设 / 非法 = 放行;仅显式 0/false/no/off/n 时整体关闭。注意它**只熔断不强开**,
+ 具体是否注入仍由 agent / chain 两个开关取 OR 决定。
+ """
+ return (os.getenv("ROUNDTABLE_SEAT_SKILL_DIRECTIVE") or "").strip().lower() not in _FALSY
-def build_seat_skill_directive(agent_id: str | None) -> str:
+def build_seat_skill_directive(agent_id: str | None, *, chain_enabled: bool = False) -> str:
"""为某席位 agent 构建「必须先读 SKILL.md 再用技能」的本轮任务追加指令。
- 返回 ``""`` 表示无需追加(开关关闭 / 无 agent_id / 该席位没有配置技能 / 解析失败),
- 调用方原样使用任务文本,零副作用。
+ 是否注入由 **agent 开关(config.yaml)OR chain 开关(``chain_enabled``)** 决定,
+ 默认两者皆关 → 不注入。返回 ``""`` 表示无需追加(总开关熔断 / 两开关都关 / 无 agent_id /
+ 该席位没有配置技能 / 解析失败),调用方原样使用任务文本,零副作用。
"""
- if not _directive_enabled() or not agent_id:
+ if not _master_enabled() or not agent_id:
return ""
try:
from deerflow.config.agents_config import load_agent_config
cfg = load_agent_config(agent_id)
+ # agent 级开关 OR chain 级开关(默认关)。任一打开才继续,否则零副作用返回。
+ agent_enabled = bool(cfg and cfg.seat_skill_directive)
+ if not (agent_enabled or chain_enabled):
+ return ""
+
configured = [name for name in (cfg.skills or []) if name] if cfg else []
if not configured:
return ""
@@ -87,9 +106,12 @@ def build_seat_skill_directive(agent_id: str | None) -> str:
return ""
-def append_seat_skill_directive(agent_id: str | None, task: str) -> str:
- """把技能识别指令追加到席位本轮任务文本后(无指令时原样返回)。"""
- directive = build_seat_skill_directive(agent_id)
+def append_seat_skill_directive(agent_id: str | None, task: str, *, chain_enabled: bool = False) -> str:
+ """把技能识别指令追加到席位本轮任务文本后(无指令时原样返回)。
+
+ ``chain_enabled``:本次会商所选业务链条是否打开「技能识别强化」。与 agent 级开关取 OR。
+ """
+ directive = build_seat_skill_directive(agent_id, chain_enabled=chain_enabled)
if not directive:
return task
return f"{task}\n\n---\n\n{directive}"
diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md
index 40ec5ca6..426d2b7c 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md
+++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md
@@ -44,7 +44,7 @@
{"id":"n2","label":"精准追踪管理公众情绪曲线","type":"task","group":"目的","detail":"…"} ← 少了 "from"
```
-**硬性要求**:除根节点(任务,n1)外,**每个节点都必须有 `from`**(连到它在上一层级里对应的那个节点);目的i 连 任务,行为体i 连 目的i,预期行为i 连 行为体i,叙事i 连 预期行为i……一组一条自上而下的链,不要让任何节点悬空。
+**硬性要求**:除根节点(任务,n1)外,**每个节点都必须有 `from`**——连到它在上一层级里**与之对应**的那个节点(第 k 层第 i 条 → 第 k−1 层对应的那条),一组一条自上而下的链,不要让任何节点悬空。(具体有哪些层级、层间是一对一还是一对多,以本轮给出的「业务链抽取规范」为准。)
## 二、画法(严格遵守)
@@ -57,17 +57,22 @@
④ **连线**:你要做对三件事 ——
(a) 每个节点的 `group` 标对层级;
(b) **各层内的条目按"相同的对应顺序"排列**——报告里相互对应的一组(目的1 ↔ 它对应的行为体 ↔ 对应的预期行为 ↔ 对应的叙事)在各自层里**都排同一位次**(第 1 组都排第 1 位、第 2 组都排第 2 位…);
- (c) **每个非根节点都填上 `from`**,连到它在上一层级里对应的那个节点(目的i 连 任务、行为体i 连 目的i、预期行为i 连 行为体i、叙事i 连 预期行为i……一组一条自上而下的链)——**最常见的错误就是漏填 from 导致节点平铺,务必每个都填**。
+ (c) **每个非根节点都填上 `from`**,连到它在上一层级里**与之对应**的那个节点(第 k 层第 i 条 → 第 k−1 层对应的那条,一组一条自上而下的链)——**最常见的错误就是漏填 from 导致节点平铺,务必每个都填**。
系统还会按 group 层级关系**校正 / 补全连线**作为兜底,但你必须先把 from 填全、填对;
⑤ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**(典型规模约 13–16 个节点);
⑥ **只画**这些层级实体节点;**不要**把「综合分析 / 核心洞察 / 执行路线 / 建议 / 结论」以及「阶段①②③④」等章节名 / 阶段名 / 汇总段落画成节点;节点 `label` 也**绝不能**是「阶段1 / 阶段2 / 第N阶段」这类阶段编号。
-## 三、层级顺序
+## 三、层级顺序(重要:不要写死任何一套层级)
-- 若数据来源里给了「业务链总控编排提示」并写明了层级链(如 任务 → 目的 → 行为体 → 预期行为 → 驱动因素 → 叙事),**优先按它的层级**组织;
-- 否则按给出的「默认层级」;都没有时,以「任务」为根、按报告 / 交付的逻辑结构合理分层。
+**每个业务链的层级各不相同**,绝不要默认套用某一套固定层级(如「目的→行为体」)。本轮该用哪套层级,按下面的优先级,**完全以本轮给出的信息为准**:
+
+1. 若本轮用户消息里给了「业务链抽取规范」(写明完整层级链、层间一对一/一对多关系等)——**这是最高优先级,严格逐层照它抽全、不得缺层、不得停在中间层**;
+2. 否则若给了「业务链总控编排提示」并写明了层级链——按它的层级;
+3. 都没有时,以「任务」为根、按报告 / 交付里**真实出现的层级**逐层展开。
+
+> 示例(仅示意「层级链」这个概念,**不是固定模板、不要照搬**):某业务可能是 任务 → 目的 → 行为体 → 预期行为 → 驱动因素 → 叙事;另一业务可能完全不同。实际层级一律以上面第 1/2/3 条给出的为准。
## 四、修改 / 校验时
diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-summary/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-summary/SOUL.md
index fdaa5e19..e72394c0 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-summary/SOUL.md
+++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-summary/SOUL.md
@@ -22,6 +22,20 @@
请通读全部原料:提炼每个席位的核心观点 / 交付,串成一份完整的方案总结。
+## 动笔前:意图对齐与缺口核对(质量关键,每轮首次生成报告都要做)
+各席位可能由能力较弱的模型产出,交付未必充分、未必都扣住了用户意图。你是这份报告的**质量兜底**——不能只是把各席位说的拼接起来,而要**对齐用户意图、核对覆盖、补强缺口**。动笔前先在脑内完成(这张核对表不必输出):
+
+1. **拆解意图**:把「任务意图」(目标 / 约束 / 关键假设)拆成若干条**用户真正想要的诉求**;业务链总控编排提示里对最终产出的要求也并入诉求清单。
+2. **逐条核对覆盖**:对每条诉求,检查各席位交付 + 总控共识**是否覆盖、覆盖得够不够、有没有跑题或自相矛盾**。
+3. **据此组织报告**,并按下面的「缺口处理护栏」补强——让最终报告明显**比"各席位交付的简单拼接"更扣用户意图**:意图里要的、席位没说清的,你补强或标缺口;意图里没要的、席位跑题的,不要喧宾夺主。
+
+### 缺口处理护栏(报告文件要完整干净、把不足说在对话里)
+**核心原则:写进 `.md` 报告文件的,必须是一份完整、干净、可直接使用、无需用户再修改的报告——报告文件里不要出现「待补充 / 不足 / ⚠️ / 尚需完善」之类的提示或占位。** 着急的用户拿到文件就能用。
+- **能从已有原料重组 / 推理补上的** → 直接补全、讲透,正常写进报告。这是你最该做的增值:把零散、跑题、含糊的席位产物,按用户意图重新梳理成扣题、完整的方案。
+- **超出席位原料、属于你自己的合理推断 / 建议** → 可以写进报告,让报告完整连贯;不必在报告里逐处打标签(保持可读),绝不编造与原料矛盾的内容。
+- **缺真实信息、靠现有原料补不了的**(需要最新数据 / 外部事实,而席位没给)→ **绝不凭空编**,但也**不要在报告文件里写「待补充」之类的警告**。报告文件就按现有信息给出尽量完整的版本;这些「不足 / 尚可补充之处」**只在对话回复正文里、报告之后单独说明**(见下「B」)。
+- 始终:报告文件 = 一份完整可用的报告(无缺口提示);不足只在对话文字里点出;绝不编造无依据的"事实"。
+
## 产出要求(最重要)
### ⓪ 先判断模式:用户是否要求了结构化输出 / 流程图
@@ -40,9 +54,10 @@
- **严禁**写 Python / shell / 任何脚本去"生成"文件,也**严禁**用 bash——你必须自己把 Markdown 文本直接写进 `write_file`;
- `write_file` **支持很长的内容,不会截断**——请放心一次性把整篇报告写进去。
-### B. 在对话回复里给出报告正文
+### B. 在对话回复里给出报告正文(不足只写在这里)
- 写完文件后,**把这份报告的完整正文也输出到对话回复里**(直接给用户读),不要只回一句「已写入文件」。
-- 用户既能在对话里直接读到报告,也能在右侧预览 / 下载这个 `.md` 文件——两边内容应当一致。
+- 报告正文部分与 `.md` 文件**应当一致**(都是那份完整干净的报告)。
+- **在对话回复里、报告正文之后,必须追加一段简短「核对说明」**(这段**只在对话里**、**绝不写进 `.md` 报告文件**):对照用户意图(及业务链各层级,如有)**逐条说明产出是否覆盖、哪些充分 / 哪些不足、有哪些可补充完善之处**,并提示可在对话里让你补全(如「---\n**核对说明**:① 已覆盖:…;② 资料不足 / 尚可补充:…(可直接告诉我补全)」)。**即便核对下来各诉求 / 层级都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求 / 层级均已覆盖,无明显缺口」——这段核对说明任何时候都不要省略。** 报告 `.md` 文件本身始终是一份完整可用、无需修改、不含任何缺口提示的版本。
## 报告结构(叙事型,文字为主线)
**下面是默认结构——仅当「业务链总控编排提示」没有指定输出结构时使用。** 用户指定了结构(章节、表格、清单、固定模板等)就完全按用户的来;两者只部分重叠时,以用户要求为骨架、默认章节做补充。按下面的章节顺序组织,缺料的章节可省略或合并:
@@ -122,5 +137,8 @@
- ❌ 修订流程图时只输出改动的节点(必须输出全量 flow-json,前端整体重绘)
- ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的内容
+- ❌ 只把各席位交付简单拼接、不做「意图对齐与缺口核对」(席位可能弱,你要兜底扣住用户意图)
+- ❌ 缺真实信息时凭空编造来"填满"报告(按现有信息给出完整版即可;不足只在对话正文点出,不写进 .md 文件)
+- ❌ 在 `.md` 报告文件里写「待补充 / 不足 / ⚠️ / 尚需完善」等缺口提示或占位(报告文件要完整干净、无需修改;不足只写在对话正文里)
- ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高)
- ❌ 编排提示已指定输出结构 / 重点席位,却仍按默认章节平均用力(结构要完全跟用户走,重点席位要明显加权)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py
index 40e9764a..7c6ec6e1 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/agents.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py
@@ -64,6 +64,11 @@ class AgentResponse(BaseModel):
description="入库模板名 (external knowledge-ingest templateName). When set, markdown artifacts produced in this agent's chats can be published to the external knowledge-ingest API.",
)
+ seat_skill_directive: bool | None = Field(
+ default=None,
+ description="会商席位「技能识别强化」开关 (config.yaml). True = 本智能体作为会商席位时强化技能识别 (与业务链条同名开关取 OR). None/False = 关 (默认).",
+ )
+
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
featured_order: int | None = Field(
@@ -115,6 +120,7 @@ class AgentCreateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.")
+ seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
class AgentUpdateRequest(BaseModel):
@@ -131,6 +137,7 @@ class AgentUpdateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.")
+ seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
class AgentSelectorResponse(BaseModel):
@@ -236,6 +243,7 @@ def _write_agent_config(
tool_groups: list[str] | None = None,
common_questions: list[CommonQuestion] | None = None,
template_name: str | None = None,
+ seat_skill_directive: bool | None = None,
) -> None:
agent_dir = get_paths().agent_dir(agent_id)
agent_dir.mkdir(parents=True, exist_ok=True)
@@ -264,13 +272,16 @@ def _write_agent_config(
]
if template_name is not None and template_name.strip():
config_data["template_name"] = template_name.strip()
+ # 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。
+ if seat_skill_directive is not None:
+ config_data["seat_skill_directive"] = bool(seat_skill_directive)
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
def _load_config_extras(agent_id: str) -> dict[str, Any]:
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
- empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None}
+ empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
try:
cfg = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
@@ -283,6 +294,7 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
"tool_groups": cfg.tool_groups,
"common_questions": cfg.common_questions,
"template_name": cfg.template_name,
+ "seat_skill_directive": cfg.seat_skill_directive,
}
@@ -443,7 +455,7 @@ def _record_to_response(
can happen lazily when the agent is actually opened.
"""
soul: str | None = None
- extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None}
+ extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
@@ -467,6 +479,7 @@ def _record_to_response(
skills=extras["skills"],
common_questions=extras.get("common_questions"),
template_name=extras.get("template_name"),
+ seat_skill_directive=extras.get("seat_skill_directive"),
featured_order=record.get("featured_order"),
)
@@ -793,6 +806,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
tool_groups=request.tool_groups,
common_questions=request.common_questions,
template_name=request.template_name,
+ seat_skill_directive=request.seat_skill_directive,
)
_write_agent_soul(agent_id, request.soul)
@@ -873,8 +887,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
+ next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
- config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name"))
+ config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive"))
if config_dirty:
_write_agent_config(
agent_id,
@@ -885,6 +900,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
tool_groups=next_tool_groups,
common_questions=next_common_questions,
template_name=next_template_name,
+ seat_skill_directive=next_seat_skill_directive,
)
# Keep the listing cache in lock-step with config.yaml, but only
# when the cached fields were actually touched — a partial update
diff --git a/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py
index e448ea66..c9fa9caa 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py
@@ -31,10 +31,16 @@ from fastapi.responses import StreamingResponse
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
+from app.gateway.roundtable_model_fallback import fallback_model_chain, reason_text
from app.gateway.routers._roundtable_seed import (
STRUCTURE_AGENT_ID,
ensure_roundtable_functional_agents,
)
+from deerflow.agents.roundtable_orchestrator.extraction_spec import (
+ build_structure_directive,
+ chain_level_rank,
+ is_single_parent_chain,
+)
from deerflow.config import get_app_config
from deerflow.config.agents_config import load_agent_config, load_agent_soul
from deerflow.models.factory import create_chat_model
@@ -59,6 +65,11 @@ class FlowExtractRequest(BaseModel):
# 层级骨架:优先 coordinator_prompt 里写明的层级,否则用 chain_levels。
chain_levels: list[str] = Field(default_factory=list)
coordinator_prompt: str | None = Field(default=None)
+ # 深链接业务码(rwfx→6BF 等)。非空 → 按该业务的「业务链抽取规范」**强制**注入完整层级
+ # (覆盖默认/示例层级假设),根治"无论什么业务都按目的→行为体抽"。普通会商为空 → 不注入。
+ business_code: str | None = Field(default=None)
+ # 根节点 label 强制值(xdfx:本次「行动名称」)。非空 → 根节点 n1 的 label 必须用它,不要用任务名当根。
+ root_label: str | None = Field(default=None)
model: str | None = Field(default=None)
# Phase 2 问答修改:带上当前图 + 用户指令 → 进 edit 模式(按指令改图,其余不变)。
current_graph: dict[str, Any] | None = Field(default=None)
@@ -71,11 +82,14 @@ class FlowIntentRequest(BaseModel):
message: str = ""
has_report: bool = False
has_flow: bool = False
+ # 当前是否可入库(结构化抽取智能体已判定有适配本场景的入库技能、入库按钮已出现且未入库)。
+ # 为真时才允许识别出 ingest 意图——否则「帮我入库」回退为 qa/unclear。
+ can_ingest: bool = False
model: str | None = Field(default=None)
-# 意图枚举(与前端路由一一对应)。
-_INTENTS = {"regenerate_report", "modify_flow", "regenerate_flow", "qa", "unclear"}
+# 意图枚举(与前端路由一一对应)。ingest 仅在 can_ingest=true 时可用。
+_INTENTS = {"regenerate_report", "modify_flow", "regenerate_flow", "ingest", "qa", "unclear"}
# ── flow-json 容错解析(Python 版,对齐前端 lib/flow-graph.ts)──────────────
@@ -396,6 +410,64 @@ def _derive_layered_edges(nodes: list[dict[str, Any]]) -> list[dict[str, Any]] |
return out
+def _collapse_to_single_parent(graph: dict[str, Any], business_code: str | None) -> dict[str, Any]:
+ """**纯树**业务链(rwfx)确定性兜底:每个非根节点最多保留一个父节点(一条入边)。
+
+ rwfx 全链是一棵树(除 1:1 的预期行为↔驱动因素,其余皆 1:N),任何节点都只有一个父节点、
+ 绝无多对一/汇聚。但弱模型常给某节点(典型是「叙事」)连多个上层父节点 → 画出汇聚/乱线。
+ 这里按「最近上一层」原则把每个节点的入边收敛到一条:在该节点的多个父节点里,优先保留 group
+ 处于它**紧邻上一层**的那个(按业务链层级序 ``chain_level_rank``);同层多个则保留模型给出的
+ 第一个;都判断不了就保留第一个。根节点(无入边)不变。**只删多余入边、不新增**,不影响节点。
+ """
+ nodes = graph.get("nodes") or []
+ edges = graph.get("edges") or []
+ rank = chain_level_rank(business_code) # 层级名 → 自上而下序号
+ group_by_id: dict[str, str] = {
+ n["id"]: _as_str(n.get("group")) for n in nodes if isinstance(n, dict) and n.get("id")
+ }
+ appear: dict[str, int] = {}
+ for n in nodes:
+ g = _as_str(n.get("group")) if isinstance(n, dict) else ""
+ if g and g not in appear:
+ appear[g] = len(appear)
+
+ def grank(nid: str) -> int:
+ g = group_by_id.get(nid, "")
+ if g in rank:
+ return rank[g]
+ return 1000 + appear.get(g, 0) # 未在业务层级里的 group 排到最后,不抢「上一层」判定
+
+ by_to: dict[str, list[dict[str, Any]]] = {}
+ order: list[str] = []
+ for e in edges:
+ t = e.get("to")
+ if t not in by_to:
+ by_to[t] = []
+ order.append(t)
+ by_to[t].append(e)
+
+ kept: list[dict[str, Any]] = []
+ collapsed = 0
+ for t in order:
+ ins = by_to[t]
+ if len(ins) <= 1:
+ kept.extend(ins)
+ continue
+ tr = grank(t)
+ prev_layer = [e for e in ins if grank(_as_str(e.get("from"))) == tr - 1]
+ if prev_layer:
+ best = prev_layer[0]
+ else:
+ upper = [e for e in ins if grank(_as_str(e.get("from"))) < tr]
+ best = max(upper, key=lambda e: grank(_as_str(e.get("from")))) if upper else ins[0]
+ kept.append(best)
+ collapsed += len(ins) - 1
+ if collapsed:
+ logger.info("flow-extract: collapsed %d extra parent edge(s) to single-parent (tree chain %s)", collapsed, business_code)
+ graph["edges"] = kept
+ return graph
+
+
# ── prompts ────────────────────────────────────────────────────────────────
_SHAPE = (
@@ -419,25 +491,66 @@ _STRUCTURE = (
"② 从第二层起按层级逐层展开:每层在报告/交付里通常列了多条具体条目(如 目的层有 目的1/2/3、行为体层有各自对应行为体,后面还有 预期行为·驱动因素、叙事…),"
"把每一条具体条目各画成一个节点,不要把整层合并成一个笼统节点;"
"③ 每个节点 group 填它所在层级名称(取自报告里该层级真实名称);"
- "④ **连线**:(a)group 标对层级;(b)各层内条目按**相同对应顺序**排列(相互对应的一组在各层都排同一位次);"
- "(c)**每个非根节点都必须填 from**,连到上一层级里对应的那个节点(目的i连任务、行为体i连目的i、预期行为i连行为体i、叙事i连预期行为i…)——**最常见错误就是漏 from 导致平铺,务必每个都填**;系统还会按层级校正/补全连线兜底;"
+ "④ **连线**:(a)group 标对层级;(b)**每个非根节点都必须填 from**,连到它在上一层级里**所属/对应**的那个节点;"
+ "层间关系(哪层一对一、哪层一对多)**以本轮给出的业务链规范为准**:**一对一**的相邻层按位次配对(下层第i条连上层第i条);"
+ "**一对多**的相邻层里,上层一条可对应下层多条——下层每条连到它**所属**的那一条上层节点,**绝不能把多条上层节点汇聚连到同一个下层节点**(那是把一对多画反成多对一);"
+ "(c)最常见错误是漏 from 导致平铺,或把一对多画成多对一,务必每个都填对;系统还会按层级校正/补全连线兜底;"
"⑤ 务必把每层每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停(典型约13-16个节点);"
"⑥ 只画这些层级实体节点,不要把『综合分析/核心洞察/执行路线/建议/结论』及『阶段①②③④』等汇总段落/阶段名画成节点。"
)
def _material(req: FlowExtractRequest) -> str:
+ """抽取的数据来源:**严格以《方案总结报告》及其文件内容为准**;各席位原始交付仅作兜底。
+
+ 报告是各席位交付经总控综合提炼、**已做取舍**后的最终结论(如从 10 个候选目的里锁定 3 个)。
+ 因此抽取**只按报告最终呈现的条目**逐条抽全——既不要漏(报告列了 3 条别只抽 1 条),也不要多
+ (别去席位产物里把报告已淘汰的候选也抽进来)。各席位原始交付**仅在报告某层整层缺失 / 报告明确
+ 说有 N 条却没逐条列出时**才用来补全那一层;与报告冲突时一律以报告为准。
+ """
+ # xdfx:把本次「行动名称」作为材料抬头喂进去(根节点就是它),让抽取贴着这条行动展开。
+ action = (req.root_label or "").strip()
+ prefix = f"【本次行动】{action}\n\n" if action else ""
report = (req.report_md or "").strip()
- if report:
- return "===== 方案总结报告全文(数据来源,逐条抽取,勿改写)=====\n" + report + "\n===== 报告结束 ====="
seats = [s for s in req.seat_deliveries if (s.content or "").strip()]
+ parts: list[str] = []
+ if report:
+ parts.append(
+ "===== 方案总结报告全文(**主数据来源,以它为准**:按它最终呈现的条目逐条抽取、勿改写、勿增删)=====\n"
+ + report
+ + "\n===== 报告结束 ====="
+ )
if seats:
body = "\n\n".join(f"## 席位{i + 1}:{s.sender or '(未具名席位)'}\n{s.content.strip()}" for i, s in enumerate(seats))
- return "===== 各席位交付产物(数据来源,逐条抽取,勿改写)=====\n" + body + "\n===== 交付结束 ====="
- return ""
+ if report:
+ parts.append(
+ "===== 各席位原始交付产物(**兜底参考,仅报告不足时才用**)=====\n"
+ "用途:**只有当报告某个层级整层缺失、或报告明确说有 N 条却没逐条列出时**,才回到这里把那一层补全。\n"
+ "⚠️ **绝不要用这里去扩充报告已经取舍过的层级**——报告里被淘汰 / 未采用的候选项(典型:席位列了 10 个候选目的,"
+ "但报告最终只锁定 3 个),**一律以报告最终采用的为准,不要把被淘汰的候选也抽进来**。与报告冲突时以报告为准。\n"
+ + body
+ + "\n===== 交付结束 ====="
+ )
+ else:
+ # 没有报告时,席位产物才是主来源(此时按席位最终采用的结论抽,候选池同样别全抽)。
+ parts.append("===== 各席位交付产物(数据来源,逐条抽取、勿改写;按其最终采用的结论抽,不要把被淘汰的候选都抽进来)=====\n" + body + "\n===== 交付结束 =====")
+ if not parts:
+ return ""
+ return prefix + "\n\n".join(parts)
def _hierarchy_hint(req: FlowExtractRequest) -> str:
+ # 深链接业务规范**最高优先**:按 business_code 取写实/通用规范,覆盖任何默认层级假设。
+ spec = build_structure_directive(req.business_code)
+ if spec:
+ cp = (req.coordinator_prompt or "").strip()
+ if cp:
+ spec += (
+ "\n(若「业务链总控编排提示」对层级另有更细要求,可在**不违背上述完整层级**的前提下采纳:\n"
+ + cp
+ + ")"
+ )
+ return spec
if (req.coordinator_prompt or "").strip():
return "层级顺序优先采用下面「业务链总控编排提示」里写明的层级链(如它写的 任务→目的→行为体→…):\n" + req.coordinator_prompt.strip()
levels = [s.strip() for s in req.chain_levels if s.strip()]
@@ -446,6 +559,14 @@ def _hierarchy_hint(req: FlowExtractRequest) -> str:
return "层级顺序:以「任务」为根,按报告/交付的逻辑结构合理分层展开。"
+def _root_label_hint(req: FlowExtractRequest) -> str:
+ """根节点 label 强制提示:xdfx 用「行动名称」当根节点(而非任务名)。无 root_label → 空。"""
+ label = (req.root_label or "").strip()
+ if not label:
+ return ""
+ return f"\n【根节点强制】整张图的根节点 n1 的 label **必须**用本次行动名称:「{label}」——不要用任务名/其它当根节点。"
+
+
# 兜底系统提示:当 roundtable-structure 的 SOUL 缺失时用它(与 SOUL 同款契约)。
_DEFAULT_SYSTEM = (
"你是「结构化抽取智能体」。职责:读懂《方案总结报告》/ 各席位交付内容,分析其层级结构,"
@@ -483,6 +604,14 @@ def _extract_messages(req: FlowExtractRequest) -> list[Any]:
_material(req)
+ "\n\n"
+ _hierarchy_hint(req)
+ + _root_label_hint(req)
+ + "\n\n【按报告逐条抽全,不漏也不多(最重要)】**严格以《方案总结报告》最终呈现的条目为准**:"
+ + "报告某层级用表格 / 列表 / ①②③ 逐条列了几条,就出几个节点、**一条都不能漏**(不要只抽被标「确定 / 第1名」的那条而漏掉同列的其余);"
+ + "某上层节点名下列了几条下层条目就出几个节点,**不许把多条合并 / 概括成一条**;"
+ + "⚠️ 但**不要无中生有**:报告已明确取舍、最终只采用 N 条的层级(如「从 10 个候选目的中锁定 3 个」「最终选定方案」),"
+ + "**只抽报告最终采用的那 N 条**,**绝不要去席位产物或报告的「候选池 / 被淘汰项」里把没被采用的也抽出来凑数**;"
+ + "报告把某层多条目压缩成一句话、没逐条列出时(如只说「12 条预期行为」却没列),才回《各席位原始交付产物》把那一层补全。"
+ + "不要只照搬报告结尾「方案总纲 / 最终框架」里被精简的主干,要回到正文(如「各席位观点与交付」)把报告列出的每层条目抽全。"
+ "\n\n请分析以上数据来源,按你的职责设定输出一个完整的 ```flow-json 代码块。"
)
return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)]
@@ -504,20 +633,35 @@ def _edit_messages(req: FlowExtractRequest) -> list[Any]:
def _validate_messages(req: FlowExtractRequest, extracted: str) -> list[Any]:
+ # 深链接业务规范:校验阶段也带上完整层级 + 根节点强制,确保按规范补齐缺层、根节点用行动名。
+ spec = build_structure_directive(req.business_code)
+ spec_block = (spec + _root_label_hint(req) + "\n\n") if (spec or req.root_label) else ""
user = (
- _material(req)
+ spec_block
+ + _material(req)
+ "\n\n===== 已抽取的 flow-json(待校验修复)=====\n"
+ extracted
+ "\n===== 待校验结束 =====\n\n请对照数据来源,**逐个节点检查上面这份 flow-json 是不是缺字段**、并修复它:"
+ "\n**校验要点(务必遵守)**:"
+ "① **连接关系 `from`(最重要,必查必补)**:**逐个**检查上面每个节点——除根任务外,"
- + "**只要某个节点缺了 `from`(或 `from` 为空),就必须给它补上正确的 `from`**:连到它在上一层级里对应的那个节点"
- + "(目的i 连 任务、行为体i 连 目的i、预期行为i 连 行为体i、叙事i 连 预期行为i…)。"
- + "缺 `from` 是这份 JSON 最常见、最致命的错漏(会导致流程图平铺),**绝不能让任何非根节点悬空**;"
- + "② **每层每条都在、没漏层没漏条目**,节点数只能 ≥ 原图、不能变少;"
- + "③ 每个节点的 `group`(层级名)标对;各层内条目按相同对应顺序排列(相互对应的一组在各层排同一位次);"
- + "④ 每个节点的 `detail` **原样保留、不要简化或精简理由**——理由要完整,不要删减内容;"
- + "⑤ 没有汇总/阶段编号节点;只在确有错漏时才动,没问题就原样规范化输出。"
+ + "**只要某个节点缺了 `from`(或 `from` 为空),就必须给它补上正确的 `from`**:连到它在上一层级里**所属/对应**的那个节点。"
+ + "层间关系(哪层一对一、哪层一对多)**以上面给出的业务链规范为准**:一对一层级按位次配对(下层第i条连上层第i条);"
+ + "**一对多**层级里下层每条连到它**所属**的那一条上层节点,**绝不能把多条上层节点汇聚到同一个下层节点**(那是把一对多画反成多对一,是本次要重点纠正的错)。"
+ + "缺 `from` / 把一对多画成多对一 是最常见错漏,**绝不能让任何非根节点悬空、也不要张冠李戴**;"
+ + "② **一对一(1:1)层级必须严格 1:1、绝不多对一(按业务链规范,如预期行为↔驱动因素;本次重点纠错)**:"
+ + "这两层节点数**必须完全相等**、按位次一一配对;**每个下层节点的 `from` 只能写一条上层节点**,"
+ + "**绝不允许多条上层节点共用 / 汇聚到同一个下层节点**(多对一),也不允许同一条上层节点被多个下层节点指向"
+ + "(即 1:1 两层里任何节点都不能冒出 / 汇入多条连线)。若发现下层节点偏少(被合并)导致只能多对一,"
+ + "**必须按上层逐条拆分 / 补出等量的下层节点**(每条上层各配一个,内容综合提炼),使两层等量后再逐一配对;"
+ + "③ **逐层条目数对齐《方案总结报告》(不漏也不多,必查)**:以报告为准——报告某层列了几条就该有几个节点。"
+ + "漏抽的补齐(报告列了 3 条却只抽了 1 条 → 补到 3;某行为体名下多条预期行为被并成一句 → 拆全);"
+ + "**但绝不要新增报告未采用的候选**——报告已取舍、最终只采用 N 条的层级(如从 10 个候选目的里锁定 3 个),"
+ + "**只保留报告最终采用的那 N 条;若图里混进了报告未采用 / 被淘汰的候选(从席位候选池里抽出来的),把它们删掉**。"
+ + "仅当报告把某层压缩成一句话、没逐条列时,才回《各席位原始交付产物》补全那一层;"
+ + "④ **不要漏报告列出的层 / 条目**;一对多的下层(某上层对应多条下层)不要被错误合并成一条;"
+ + "⑤ 每个节点的 `group`(层级名)标对;一对一层级内条目按相同位次对齐,一对多层级下层每条紧挨其所属上层条目分组;"
+ + "⑥ 每个节点的 `detail` **原样保留、不要简化或精简理由**——理由要完整,不要删减内容;"
+ + "⑦ 没有汇总/阶段编号节点;只在确有错漏时才动,没问题就原样规范化输出。"
+ "\n输出修复后的完整 ```flow-json 代码块(含全部节点 + 每个非根节点的 from)。"
)
return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)]
@@ -559,28 +703,60 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo
return
# ① 抽取 / 编辑(正文 + 思考(reasoning) 拼成 `… 正文` 增量逐帧吐,前端实时显示)
+ # 模型自动容错:结构化抽取非常重要——某模型 astream 抛错 / 产出空时,按 config.yaml
+ # models[] 顺序切到下一个模型重抽,并发 model_switch 帧让前端把抽取面板已累计的半截内容重置。
round1_messages = _edit_messages(payload) if is_edit else _extract_messages(payload)
- model = create_chat_model(name=resolved_model, thinking_enabled=True)
+ chain = fallback_model_chain(resolved_model) or [resolved_model]
extracted = ""
- think_open = False
- async for chunk in model.astream(round1_messages):
- c, r = _chunk_parts(chunk)
- out = ""
- if r:
- if not think_open:
- out += ""
- think_open = True
- out += r
- if c:
+ used_model = chain[0]
+ prev_model: str | None = None
+ prev_reason: str | None = None
+ for idx, candidate in enumerate(chain):
+ if idx > 0:
+ yield _frame(
+ {
+ "event": "model_switch",
+ "agent_name": "结构化抽取",
+ "failed_model": prev_model or "",
+ "next_model": candidate or "",
+ "reason": reason_text(prev_reason),
+ }
+ )
+ used_model = candidate
+ extracted = ""
+ think_open = False
+ try:
+ model = create_chat_model(name=candidate, thinking_enabled=True)
+ async for chunk in model.astream(round1_messages):
+ c, r = _chunk_parts(chunk)
+ out = ""
+ if r:
+ if not think_open:
+ out += ""
+ think_open = True
+ out += r
+ if c:
+ if think_open:
+ out += " "
+ think_open = False
+ out += c
+ extracted += c
+ if out:
+ yield _frame({"event": "extract_delta", "content": out})
if think_open:
- out += " "
- think_open = False
- out += c
- extracted += c
- if out:
- yield _frame({"event": "extract_delta", "content": out})
- if think_open:
- yield _frame({"event": "extract_delta", "content": ""})
+ yield _frame({"event": "extract_delta", "content": ""})
+ except Exception as exc: # noqa: BLE001 — 该模型抽取失败:换下一个模型重抽
+ prev_model, prev_reason = candidate, "exception"
+ logger.warning("flow-extract round1 model %s failed: %s", candidate, exc)
+ if idx + 1 < len(chain):
+ continue
+ raise
+ if extracted.strip():
+ break
+ # 产出为空:换下一个模型(已是最后一个则继续走后续解析兜底)。
+ prev_model, prev_reason = candidate, "empty"
+ if idx + 1 >= len(chain):
+ break
yield _frame({"event": "extract_done"})
# ② 校验 / 修复(同样流式 + 思考实时显示)
@@ -588,7 +764,7 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo
validated = ""
graph: dict[str, Any] | None = None
try:
- vmodel = create_chat_model(name=resolved_model, thinking_enabled=True)
+ vmodel = create_chat_model(name=used_model, thinking_enabled=True)
think_open = False
async for chunk in vmodel.astream(_validate_messages(payload, extracted)):
c, r = _chunk_parts(chunk)
@@ -633,6 +809,10 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo
if graph is None:
yield _frame({"event": "error", "detail": "未能从模型输出解析出流程图节点"})
return
+ # 纯树业务链(rwfx)确定性兜底:把任何节点的多父入边收敛到单一父节点,杜绝弱模型
+ # 违反 1:N 画出的汇聚/乱线(典型是叙事连了一片驱动因素)。非纯树业务(xdfx 有汇聚)跳过。
+ if is_single_parent_chain(payload.business_code):
+ graph = _collapse_to_single_parent(graph, payload.business_code)
yield _frame({"event": "final", "graph": graph})
except Exception as exc: # noqa: BLE001
logger.exception("flow-extract stream failed")
@@ -672,23 +852,33 @@ def _parse_intent_json(text: str) -> dict[str, Any] | None:
_INTENT_SYS = (
- "你是圆桌「方案总结」第三步输入框的**意图识别器**。第三步有两类产物:方案总结报告(Markdown) 和 结构化流程图。"
- "用户在输入框发了一条消息,请判断他的**真实意图**,只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n"
+ "你是圆桌「方案总结」第三步对话的**意图识别器**,也是个能帮用户的助手。第三步已产出:方案总结报告(Markdown)、"
+ "结构化流程图,可能还可把成果入库到知识库。用户在对话框发了一条消息,请**按语义理解他真正想干什么**"
+ "(不要只做关键词匹配),只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n"
'{"intent":"<枚举>","reason":"<一句话依据>"}\n'
- "intent 只能取其一:\n"
- "- regenerate_report:想重新分析 / 重写【方案总结报告】(如「重新生成报告」「报告再写一版」「重新分析一下」);\n"
- "- modify_flow:想修改**当前流程图**的某些节点/连线(如「把目的1改成X」「删掉第3个行为体」「加个验收节点」「流程图这里调整下」);\n"
+ "intent 取值:\n"
+ "- regenerate_report:想**重做/重写整份**方案总结报告(如「重新生成报告」「报告再写一版」「重新分析一下」);\n"
+ "- modify_flow:想**修改当前流程图**的节点/连线(如「把目的1改成X」「删掉第3个行为体」「加个验收节点」「这一层调整下」);\n"
"- regenerate_flow:想**重新生成整张**流程图(如「重新画流程图」「流程图重来」「重新结构化输出」);\n"
- "- qa:只是提问 / 咨询,不需要改任何产物(如「为什么选 OTA 平台」「这个目的什么意思」);\n"
- "- unclear:意图不明确、无法判断要改报告还是改流程图还是提问(如只说「改一下」「不对」「重来」而没指明对象)。\n"
- "判断要点:结合下面给出的上下文(是否已有报告 / 是否已有流程图);当前已有流程图时,含「节点/连线/这一层/这个目的/行为体…改/删/加」之类多半是 modify_flow;"
- "明确提到「报告」才是 regenerate_report;只有完全无法判断对象时才用 unclear。\n"
+ "- ingest:想把本次成果**入库/沉淀/存进知识库**(如「帮我入库」「入库吧」「把方案存进知识库」「沉淀一下」);\n"
+ "- qa:**提问 / 要求解释、介绍、概述、答疑**,只需用文字回答、不改任何产物(如「介绍一下这报告的内容」"
+ "「这方案主要讲了啥」「为什么选 OTA 平台」「风险最高的是哪条」「帮我看看有没有遗漏」「总结成几句话」);\n"
+ "- unclear:**实在**判断不出他想做哪件事(如只说「改一下」「不对」「重来」「再说」且无任何指向)。\n"
+ "判断要点:\n"
+ "1. **优先识别出明确意图,不要动不动就 unclear**——只要能合理判断就给具体意图;尤其『介绍/讲讲/解释/说明/"
+ "为什么/是什么/有没有/帮我看/概述/总结一下』这类是**对内容的提问** → 一律 qa(用文字答),不要误判成重做报告。\n"
+ "2. 含『节点/连线/这一层/这个目的/行为体/流程图…的 改/删/加/换』 → modify_flow(前提是已有流程图)。\n"
+ "3. 明确说『报告』要重写/重做才是 regenerate_report;只是问报告内容是 qa。\n"
+ "4. **入库类意图只有在下文上下文标注「可入库=是」时才允许返回 ingest**;若标注不可入库,用户提入库就归 qa"
+ "(用文字说明当前不可入库/无适配技能)。\n"
"示例:\n"
- '「再帮我生成一次报告」→ {"intent":"regenerate_report","reason":"明确要求重新生成报告"}\n'
- '「把目的1的名称改短点」→ {"intent":"modify_flow","reason":"要求修改流程图某节点"}\n'
- '「流程图重新画一张」→ {"intent":"regenerate_flow","reason":"要求重画整张流程图"}\n'
- '「OTA 平台为什么是直接行为体」→ {"intent":"qa","reason":"纯提问不改产物"}\n'
- '「改一下」→ {"intent":"unclear","reason":"没说改报告还是流程图"}'
+ '「帮我介绍一下这报告中的内容」→ {"intent":"qa","reason":"要求介绍报告内容,属提问答疑"}\n'
+ '「这方案为什么这么定」→ {"intent":"qa","reason":"询问方案理由"}\n'
+ '「帮我入库」(可入库=是) → {"intent":"ingest","reason":"明确要求入库且当前可入库"}\n'
+ '「再帮我生成一次报告」→ {"intent":"regenerate_report","reason":"明确要求重做报告"}\n'
+ '「把目的1的名称改短点」→ {"intent":"modify_flow","reason":"修改流程图某节点"}\n'
+ '「流程图重新画一张」→ {"intent":"regenerate_flow","reason":"重画整张流程图"}\n'
+ '「改一下」→ {"intent":"unclear","reason":"没说改什么"}'
)
@@ -698,7 +888,11 @@ async def classify_flow_intent(request: Request, payload: FlowIntentRequest = Bo
msg = (payload.message or "").strip()
if not msg:
return {"intent": "unclear", "reason": "空消息"}
- ctx = f"当前上下文:{'已生成报告' if payload.has_report else '尚无报告'};{'已生成流程图' if payload.has_flow else '尚无流程图'}。"
+ ctx = (
+ f"当前上下文:{'已生成报告' if payload.has_report else '尚无报告'};"
+ f"{'已生成流程图' if payload.has_flow else '尚无流程图'};"
+ f"可入库={'是' if payload.can_ingest else '否'}。"
+ )
try:
model = create_chat_model(name=_resolve_model(payload.model), thinking_enabled=False)
res = await model.ainvoke(
@@ -709,6 +903,9 @@ async def classify_flow_intent(request: Request, payload: FlowIntentRequest = Bo
intent = _as_str(obj.get("intent")).lower()
if intent not in _INTENTS:
intent = "unclear"
+ # 不可入库时模型若误判 ingest → 降级为 qa(用文字说明当前不可入库),不要触发入库。
+ if intent == "ingest" and not payload.can_ingest:
+ intent = "qa"
return {"intent": intent, "reason": _as_str(obj.get("reason"))}
except Exception: # noqa: BLE001 — 分类失败不致命,回退澄清让用户自己定
logger.warning("flow intent classify failed", exc_info=True)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
index 16d99fce..83046e24 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
@@ -63,7 +63,10 @@ from fastapi.responses import StreamingResponse
from langgraph.checkpoint.base import empty_checkpoint
from app.gateway.roundtable_diag import record_foreground_diag
+from app.gateway.roundtable_model_fallback import fallback_model_chain, is_llm_error_message, reason_text
from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy
+from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
+
from app.gateway.roundtable_seat_skills import append_seat_skill_directive
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
@@ -1545,21 +1548,31 @@ async def _leader_run(
if files:
_read_tools = {"read_file", "ls", "grep", "view_image"}
leader_excluded = [t for t in leader_excluded if t not in _read_tools]
- body = _run_payload(
- agent_name=agent_name,
- model_name=model_name,
- thread_id=thread_id,
- # 注入了席位清单的输入(leader_input);seat_roster 为空时等同 new_message。
- new_message=leader_input,
- excluded_tools=leader_excluded,
- skill_stop_names=effective_stop_names,
- thinking_enabled=policy.thinking_enabled,
- reasoning_effort=policy.reasoning_effort,
- # 人工干预/恢复会重发 leader run 到同一总控 thread;抢占掉没结束的旧 run,避免 409。
- multitask_strategy="rollback",
- # 用户随干预上传的参考文件 → 总控可用 read_file 等工具读取后再派活。
- files=files,
- )
+
+ # 模型自动容错:按 config.yaml models[] 顺序的候选链。当前模型出问题(兜底错误文案 /
+ # 既无正文又无派活)时切到下一个模型重跑(见循环内检测)。``_mk_body`` 用候选模型重建 body。
+ model_chain = fallback_model_chain(model_name) or [model_name]
+ model_idx = 0
+ current_model = model_chain[0]
+
+ def _mk_body(m: str) -> Any:
+ return _run_payload(
+ agent_name=agent_name,
+ model_name=m,
+ thread_id=thread_id,
+ # 注入了席位清单的输入(leader_input);seat_roster 为空时等同 new_message。
+ new_message=leader_input,
+ excluded_tools=leader_excluded,
+ skill_stop_names=effective_stop_names,
+ thinking_enabled=policy.thinking_enabled,
+ reasoning_effort=policy.reasoning_effort,
+ # 人工干预/恢复会重发 leader run 到同一总控 thread;抢占掉没结束的旧 run,避免 409。
+ multitask_strategy="rollback",
+ # 用户随干预上传的参考文件 → 总控可用 read_file 等工具读取后再派活。
+ files=files,
+ )
+
+ body = _mk_body(current_model)
t_payload_built = time.perf_counter()
logger.info(
"[leader-timing] %s payload_built=%.3fs (broadcast running in background)",
@@ -1643,6 +1656,28 @@ async def _leader_run(
leader_text = _flatten_content(dispatch_msg.get("content", ""))
tool_calls = dispatch_msg.get("tool_calls") or []
+ # 模型自动容错:本轮总控产出是「中间件兜底错误文案」或「既无正文又无派活」(模型没干活)
+ # → 若还有备选模型,切到下一个重跑(不消耗派活纠正预算)。先发 model_switch 帧让前端把
+ # 这条失败气泡重置,再用新模型重新流式。最后一个模型仍失败则照常往下走(错误文案当共识展示)。
+ fail_reason = is_llm_error_message(dispatch_msg)
+ if fail_reason is None and not (leader_text or "").strip() and not tool_calls:
+ fail_reason = "empty"
+ if fail_reason and model_idx + 1 < len(model_chain):
+ model_idx += 1
+ next_model = model_chain[model_idx]
+ logger.warning("[leader-fallback] %s 模型 %s 失败(%s),切换到 %s", agent_name, current_model, fail_reason, next_model)
+ yield {
+ "status": "model_switch",
+ "role": "leader",
+ "agent_name": agent_name,
+ "failed_model": current_model,
+ "next_model": next_model,
+ "reason": reason_text(fail_reason),
+ }
+ current_model = next_model
+ body = _mk_body(current_model)
+ continue
+
# 澄清分支:与 intent.py 相同字段;status 为字符串 "clarification"(非数组),
# 前端据此暂停 runOrchestration 循环,展示 Step 2 澄清条。
clarification_call = next(
@@ -1663,12 +1698,13 @@ async def _leader_run(
"allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
"agent_name": agent_name,
+ "model_used": current_model,
}
return
if not tool_calls:
# 空 status 数组 = 总控不再派活,前端视为共识(hasConsensus)。
- yield {"status": [], "content": leader_text, "agent_name": agent_name}
+ yield {"status": [], "content": leader_text, "agent_name": agent_name, "model_used": current_model}
return
# 派活前按真实 roster 校验:合法保留、非法丢弃(只丢非法,不殃及同轮合法席位)。
@@ -1704,7 +1740,7 @@ async def _leader_run(
# 关键路径优先:**先 yield 派活帧**,让前端立刻启动席位 special run;派活广播(告知
# 其它席位"总控给 X 派了什么活")改成 detached 后台任务,不再 await 阻塞 —— 否则广播
# 读写各席位巨大 thread 状态会卡几十秒,白白拖慢席位启动(见 _spawn_detached_broadcast)。
- yield {"status": dispatched, "content": leader_text, "agent_name": agent_name}
+ yield {"status": dispatched, "content": leader_text, "agent_name": agent_name, "model_used": current_model}
# DAG 派活轮(suppress_pre_broadcast)同样**不**把「总控给 X 派活」广播给兄弟席位 ——
# 否则弱模型从历史里看到派活语句又会模仿派活(席位任务由前端 pickDagSeatTask 干净下发,
# 跨席位承上靠各 special run 的"完成交付"后广播,不需要这条派活广播)。recommend/chain 照旧。
@@ -1737,6 +1773,8 @@ async def _special_run(
thinking_enabled: bool | None = None,
reasoning_effort: str | None = None,
subagent_enabled: bool = False,
+ seat_skill_directive: bool = False,
+ business_code: str = "",
) -> AsyncIterator[str | dict[str, Any]]:
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
@@ -1789,99 +1827,140 @@ async def _special_run(
# 弱模型补偿:仅对真正的研讨席位(seat / seat_parallel;report/summary/dashboard/ingest
# 等 Step3 单例不在此列)把其配置技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其
# 流程取信息的硬指令)显式拼进本轮任务,避免弱模型无视系统提示里的技能块(见
- # roundtable_seat_skills 模块说明)。无配置技能时原样返回。
+ # roundtable_seat_skills 模块说明)。默认关闭:是否注入 = 该席位 agent 的开关
+ # (config.yaml)OR 本次会商所选业务链条的开关(seat_skill_directive 透传)。无配置
+ # 技能 / 两开关都关时原样返回。
seat_message = new_message
if role in ("seat", "seat_parallel"):
- seat_message = append_seat_skill_directive(agent_name, new_message)
- body = _run_payload(
- agent_name=agent_name,
- model_name=model_name,
- thread_id=thread_id,
- new_message=seat_message,
- excluded_tools=seat_excluded,
- skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names),
- # 席位执行模式覆盖 policy 的 thinking/reasoning(None → 回退 policy 默认);
- # subagent 仅 ultra 档置真。工具裁剪(excluded_tools)仍完全由 policy 决定。
- thinking_enabled=(policy.thinking_enabled if thinking_enabled is None else thinking_enabled),
- reasoning_effort=(policy.reasoning_effort if reasoning_effort is None else reasoning_effort),
- subagent_enabled=subagent_enabled,
- # ultra 席位才会派子智能体 → 才需要 custom 流回传子任务实时进度(task_running 等)。
- stream_custom=subagent_enabled,
- # 干预/重新派活会把同一席位 thread 再跑一次;抢占掉没结束的旧 run,避免 409。
- multitask_strategy="rollback",
- # 前序席位完整交付 → run context,供 read_peer_delivery 工具按需取全文(§12.9)。
- extra_context=(
- {"roundtable_peer_deliveries": peer_deliveries} if peer_deliveries else None
- ),
- # 用户随干预上传的参考文件 → 席位可用 read_file 等工具读取。
- files=files,
- )
+ seat_message = append_seat_skill_directive(agent_name, new_message, chain_enabled=seat_skill_directive)
+ # 深链接业务规范(仅 summary 单例):把该业务的「业务链完整性核对」指令拼进本轮任务,强制总结报告
+ # 核对产出相对业务链完不完整。business_code 为空(普通会商)/ 非写实业务 → directive 为 "",原样不变。
+ elif role == "summary" and business_code:
+ directive = build_summary_directive(business_code)
+ if directive:
+ seat_message = f"{new_message}\n\n---\n\n{directive}"
+ # 模型自动容错:按 config.yaml models[] 顺序的候选链。某模型出问题(兜底错误文案;
+ # 真正的研讨席位还含「交付为空」)→ 发 model_switch 帧让前端重置该席位气泡,再用下一个
+ # 模型重跑。Step3 单例(report/summary/dashboard/ingest)的产物是落盘文件,正文可能很短,
+ # 故**不**把「正文为空」当失败(与后台 run_report/summary 的 validate=None 一致),只在真正
+ # 的模型错误时换模型。
+ model_chain = fallback_model_chain(model_name) or [model_name]
+ empty_is_failure = role in ("seat", "seat_parallel")
+
+ def _mk_body(m: str) -> Any:
+ return _run_payload(
+ agent_name=agent_name,
+ model_name=m,
+ thread_id=thread_id,
+ new_message=seat_message,
+ excluded_tools=seat_excluded,
+ skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names),
+ # 席位执行模式覆盖 policy 的 thinking/reasoning(None → 回退 policy 默认);
+ # subagent 仅 ultra 档置真。工具裁剪(excluded_tools)仍完全由 policy 决定。
+ thinking_enabled=(policy.thinking_enabled if thinking_enabled is None else thinking_enabled),
+ reasoning_effort=(policy.reasoning_effort if reasoning_effort is None else reasoning_effort),
+ subagent_enabled=subagent_enabled,
+ # ultra 席位才会派子智能体 → 才需要 custom 流回传子任务实时进度(task_running 等)。
+ stream_custom=subagent_enabled,
+ # 干预/重新派活会把同一席位 thread 再跑一次;抢占掉没结束的旧 run,避免 409。
+ multitask_strategy="rollback",
+ # 前序席位完整交付 → run context,供 read_peer_delivery 工具按需取全文(§12.9)。
+ extra_context=(
+ {"roundtable_peer_deliveries": peer_deliveries} if peer_deliveries else None
+ ),
+ # 用户随干预上传的参考文件 → 席位可用 read_file 等工具读取。
+ files=files,
+ )
+
t_payload_built = time.perf_counter()
logger.info(
"[special-timing] %s payload_built=%.3fs",
agent_name,
t_payload_built - t0,
)
-
- captured: list[str] = []
- first_line_ts: float | None = None
- line_count = 0
- async for line, done in _stream_upstream(client, base, headers, thread_id, body):
- if line is not None:
- if first_line_ts is None:
- first_line_ts = time.perf_counter()
- logger.info(
- "[special-timing] %s first_upstream_line=%.3fs since_entry=%.3fs",
- agent_name,
- first_line_ts - t_payload_built,
- first_line_ts - t0,
- )
- line_count += 1
- yield line
- elif done is not None:
- captured = done
- t_stream_done = time.perf_counter()
- logger.info(
- "[special-timing] %s upstream_stream_complete=%.3fs lines=%d since_entry=%.3fs",
- agent_name,
- t_stream_done - (first_line_ts or t_payload_built),
- line_count,
- t_stream_done - t0,
- )
-
- messages = _parse_last_messages(captured)
- if messages is None:
- # 与 _leader_run 同理:席位 run 被抢占/断开导致流截断,不抛 502,静默收尾。
- # WARNING 级:正常是 rollback 抢占;否则查上游 run 的服务端报错(如 DB 连接断)。
- logger.warning(
- "[special-timing] %s upstream truncated (no values frame) — ending quietly "
- "(expected on rollback preemption; otherwise check the upstream run's server-side error)",
- agent_name,
- )
- return
- # Sub-agent's final deliverable is the latest AIMessage's text content.
- last_msg: dict[str, Any] = {}
- for msg in reversed(messages):
- if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
- last_msg = msg
- break
- content = _flatten_content(last_msg.get("content", ""))
# 优先用 display_name(中文友好,例如"情报收集")提示 leader,id 放括号备查;
# display_name 缺失时退化为仅 agent_name —— 与旧行为一致。
speaker_label = f"{display_name}({agent_name})" if display_name else agent_name
- _prefix = f"子智能体{speaker_label}完成{new_message}工作,交付内容为:"
- # 广播给**所有**席位(含总控)的都只是**截断摘要** —— 总控 thread 不再囤积各席位全文,
- # 每轮 leader 的上下文因此保持小、响应快。总控做最终综合需要全文时,由 _leader_run 在
- # 「综合轮」按需现读各席位 thread 注入(见 _build_full_deliverables_block),不靠历史囤积。
- brief_msg = _truncate_broadcast_content(_prefix, content)
- # 先 yield 交付结果让前端立即收口本席位;后广播改 detached,不再 await 阻塞 ——
- # 否则它读写各席位大状态会让 SSE 多挂十几秒,把下一轮 leader 的启动也一起拖慢。
- yield {
- "status": f"子智能体{speaker_label}完成{new_message}工作",
- "content": content,
- "agent_name": agent_name,
- }
- _spawn_detached_broadcast(base, headers, brief_msg, agent_threads, [agent_name])
+
+ for idx, candidate in enumerate(model_chain):
+ body = _mk_body(candidate)
+ captured: list[str] = []
+ first_line_ts: float | None = None
+ line_count = 0
+ async for line, done in _stream_upstream(client, base, headers, thread_id, body):
+ if line is not None:
+ if first_line_ts is None:
+ first_line_ts = time.perf_counter()
+ logger.info(
+ "[special-timing] %s first_upstream_line=%.3fs since_entry=%.3fs",
+ agent_name,
+ first_line_ts - t_payload_built,
+ first_line_ts - t0,
+ )
+ line_count += 1
+ yield line
+ elif done is not None:
+ captured = done
+ t_stream_done = time.perf_counter()
+ logger.info(
+ "[special-timing] %s upstream_stream_complete=%.3fs lines=%d since_entry=%.3fs",
+ agent_name,
+ t_stream_done - (first_line_ts or t_payload_built),
+ line_count,
+ t_stream_done - t0,
+ )
+
+ messages = _parse_last_messages(captured)
+ if messages is None:
+ # 与 _leader_run 同理:席位 run 被抢占/断开导致流截断,不抛 502,静默收尾。
+ # 这不是模型错误(多半 rollback 抢占),不触发换模型。
+ logger.warning(
+ "[special-timing] %s upstream truncated (no values frame) — ending quietly "
+ "(expected on rollback preemption; otherwise check the upstream run's server-side error)",
+ agent_name,
+ )
+ return
+ # Sub-agent's final deliverable is the latest AIMessage's text content.
+ last_msg: dict[str, Any] = {}
+ for msg in reversed(messages):
+ if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
+ last_msg = msg
+ break
+ content = _flatten_content(last_msg.get("content", ""))
+
+ # 模型失败检测:兜底错误文案;研讨席位再加「交付为空」。还有备选模型则换。
+ fail_reason = is_llm_error_message(last_msg)
+ if fail_reason is None and empty_is_failure and not content.strip():
+ fail_reason = "empty"
+ if fail_reason and idx + 1 < len(model_chain):
+ next_model = model_chain[idx + 1]
+ logger.warning("[special-fallback] %s 模型 %s 失败(%s),切换到 %s", agent_name, candidate, fail_reason, next_model)
+ yield {
+ "status": "model_switch",
+ "role": role,
+ "agent_name": agent_name,
+ "failed_model": candidate,
+ "next_model": next_model,
+ "reason": reason_text(fail_reason),
+ }
+ continue
+
+ # 成功(或已用尽备选):发交付终态帧 + detached 后广播。
+ _prefix = f"子智能体{speaker_label}完成{new_message}工作,交付内容为:"
+ # 广播给**所有**席位(含总控)的都只是**截断摘要** —— 总控 thread 不再囤积各席位全文,
+ # 每轮 leader 的上下文因此保持小、响应快。总控做最终综合需要全文时,由 _leader_run 在
+ # 「综合轮」按需现读各席位 thread 注入(见 _build_full_deliverables_block),不靠历史囤积。
+ brief_msg = _truncate_broadcast_content(_prefix, content)
+ # 先 yield 交付结果让前端立即收口本席位;后广播改 detached,不再 await 阻塞 ——
+ # 否则它读写各席位大状态会让 SSE 多挂十几秒,把下一轮 leader 的启动也一起拖慢。
+ yield {
+ "status": f"子智能体{speaker_label}完成{new_message}工作",
+ "content": content,
+ "agent_name": agent_name,
+ "model_used": candidate,
+ }
+ _spawn_detached_broadcast(base, headers, brief_msg, agent_threads, [agent_name])
+ return
# ---------------------------------------------------------------------------
@@ -1957,6 +2036,12 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
_re_raw = payload.get("reasoning_effort")
seat_reasoning_effort: str | None = _re_raw if isinstance(_re_raw, str) and _re_raw else None
seat_subagent_enabled: bool = bool(payload.get("subagent_enabled") or False)
+ # 业务链条级「技能识别强化」开关(仅 special 席位消费):前端读所选业务链条的同名配置
+ # 后逐席位透传。与该席位 agent 自身的 config.yaml 开关取 OR;缺省 False(默认关)。
+ seat_skill_directive: bool = bool(payload.get("seat_skill_directive") or False)
+ # 深链接业务码(rwfx→6BF 等)。仅 summary 单例消费:非空 → 按业务链抽取规范强制注入「完整性核对」
+ # 指令,让总结报告核对产出相对业务链完不完整。普通会商为空 → 不注入。
+ business_code: str = str(payload.get("business_code") or "")
if not agent_name or agent_name not in agent_threads:
raise HTTPException(
@@ -2007,6 +2092,8 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
thinking_enabled=seat_thinking_enabled,
reasoning_effort=seat_reasoning_effort,
subagent_enabled=seat_subagent_enabled,
+ seat_skill_directive=seat_skill_directive,
+ business_code=business_code,
)
async for chunk in gen:
if isinstance(chunk, dict):
diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
index a3ca7133..b06926ee 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
@@ -48,6 +48,16 @@ class ChainSeatSchema(BaseModel):
# 数组以 JSON 透传持久化(见 persistence/roundtable_chains),故新增此字段无需迁移;
# 仅需在此 schema 显式声明,否则 model_dump() 会把未知字段剥掉。
model: str | None = Field(default=None, max_length=128)
+ # 该席位「会商技能识别强化」开关(弱模型补偿)。True = 该席位作为会商子智能体执行时,
+ # 把其配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 的硬指令)拼进本轮任务。
+ # 与该席位 agent 自身 config.yaml 的同名开关取 OR。默认 False(关)。整条 seats 数组以
+ # JSON 透传持久化,故新增此字段无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。
+ seat_skill_directive: bool = False
+ # 该席位「推理深度覆盖」(flash/thinking/pro/ultra):覆盖链条级 seat_mode,仅本席位生效。
+ # None = 跟随链条级默认。「卡顿提速」用:把次要席位降到 flash。整条 seats 数组以 JSON 透传
+ # 持久化,故无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。非法值在运行期
+ # 被 _SEAT_MODE_PARAMS.get() 安全忽略(回退作业级),故此处只约束长度、不强校验取值。
+ seat_mode: str | None = Field(default=None, max_length=16)
class ChainResponse(BaseModel):
@@ -64,6 +74,8 @@ class ChainResponse(BaseModel):
coordinator_prompt: str | None = None
# 默认席位执行模式(flash/thinking/pro/ultra);None = 未设,选入研讨时回退默认(pro)。
seat_mode: str | None = None
+ # 研讨前并行取数开关(逐条 opt-in);True = 选入研讨时研讨前先并行取数。缺省 False。
+ gather_first: bool = False
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
@@ -91,6 +103,7 @@ class ChainCreateRequest(BaseModel):
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16)
+ gather_first: bool = False
class ChainUpdateRequest(BaseModel):
@@ -102,6 +115,8 @@ class ChainUpdateRequest(BaseModel):
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16)
+ # 研讨前并行取数开关;send 显式 true/false 改,omit 不改。
+ gather_first: bool | None = None
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None
@@ -284,6 +299,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"stage_goals": body.stage_goals,
"coordinator_prompt": body.coordinator_prompt,
"seat_mode": body.seat_mode,
+ "gather_first": body.gather_first,
},
)
await _enrich_owner_names([row])
diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py
index b6569fd7..5116d0b8 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py
@@ -77,6 +77,18 @@ class StartJobRequest(BaseModel):
thinking_enabled: bool | None = None
reasoning_effort: str | None = None
subagent_enabled: bool = False
+ # 每席位「技能识别强化」开关映射 {agent_id: bool}(前端读所选业务链条里**每个席位**的
+ # 同名配置后下发)。后台席位 run 据此(与各席位 agent 自身 config.yaml 的开关取 OR)决定
+ # 是否注入。缺省空 dict(全部按 False/关,仅各 agent 自身开关生效)。
+ seat_skill_directives: dict[str, bool] = Field(default_factory=dict)
+ # 每席位「推理深度覆盖」映射 {agent_id: SeatMode}(前端读所选业务链条里**每个席位**单独配的
+ # 推理深度后下发;值为 flash/thinking/pro/ultra)。后台席位 run 按 agent_id 查表:命中则用该档
+ # 派生的 thinking/reasoning/subagent 覆盖作业级 seatMode;未命中 → 跟随作业级。缺省空 dict。
+ seat_modes: dict[str, str] = Field(default_factory=dict)
+ # 是否研讨前先并行『取数』(业务链条逐条 opt-in;前端读所选链条的开关后下发)。默认 False。
+ gather_first: bool = False
+ # 深链接业务码(rwfx→6BF 等)。透传到后台总结报告的「业务链完整性核对」。普通会商为空。
+ business_code: str | None = None
class ResumeJobRequest(BaseModel):
@@ -235,6 +247,10 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
seat_thinking_enabled=body.thinking_enabled,
seat_reasoning_effort=body.reasoning_effort,
seat_subagent_enabled=body.subagent_enabled,
+ seat_skill_directives=dict(body.seat_skill_directives or {}),
+ seat_modes=dict(body.seat_modes or {}),
+ gather_first=bool(body.gather_first),
+ business_code=(body.business_code or None),
)
)
return _to_response(row)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py
index 7d641553..2dcc8200 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py
@@ -24,6 +24,63 @@ from deerflow.config.app_config import AppConfig
logger = logging.getLogger(__name__)
+# 兜底/熔断的 AIMessage 会带上 ``additional_kwargs[LLM_ERROR_MARKER] = reason``,
+# 让下游(圆桌会商的单轮执行器等)能**可靠识别**「这条 AI 回复其实是模型调用失败的
+# 兜底文案」而不是真实交付——据此触发自动换模型。``reason`` ∈ {quota, auth, busy,
+# transient, upstream, generic, circuit}。注意:model_dump() 会保留 additional_kwargs,
+# 故经 checkpointer 序列化后仍在;万一被剥离,``is_llm_error_message`` 还会回退按已知
+# 兜底文案精确匹配。
+LLM_ERROR_MARKER = "deerflow_llm_error"
+
+# 各 reason 对应的用户可见兜底文案(单一数据源:构造与识别都用它)。
+_USER_MESSAGES: dict[str, str] = {
+ "quota": "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。",
+ "auth": "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。",
+ "busy": "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。",
+ "transient": "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。",
+ "upstream": "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。",
+ "generic": "大模型本次调用未能成功完成,请稍后再试或更换模型。",
+}
+_CIRCUIT_MESSAGE = "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
+# 全部兜底文案集合,供 ``is_llm_error_message`` 在 additional_kwargs 被剥离时按正文回退识别。
+_ALL_ERROR_MESSAGES: frozenset[str] = frozenset({*_USER_MESSAGES.values(), _CIRCUIT_MESSAGE})
+
+
+def is_llm_error_message(message: Any) -> str | None:
+ """判断一条 AI 消息是否为「模型调用失败的兜底文案」,是则返回 reason,否则 None。
+
+ 入参可为 LangChain ``BaseMessage``,或 checkpointer 序列化后的 dict(两种都支持)。
+ 优先看 ``additional_kwargs[LLM_ERROR_MARKER]``(最可靠);万一被序列化剥离,再回退
+ 按正文是否精确等于已知兜底文案识别(容忍前后空白)。
+ """
+ if message is None:
+ return None
+ # additional_kwargs(dict 或对象属性两种形态)。
+ extra: Any = None
+ if isinstance(message, dict):
+ extra = message.get("additional_kwargs")
+ else:
+ extra = getattr(message, "additional_kwargs", None)
+ if isinstance(extra, dict):
+ reason = extra.get(LLM_ERROR_MARKER)
+ if reason:
+ return str(reason)
+ # 正文回退匹配。
+ if isinstance(message, dict):
+ content = message.get("content")
+ else:
+ content = getattr(message, "content", None)
+ text = ""
+ if isinstance(content, str):
+ text = content
+ elif isinstance(content, list):
+ text = "\n".join(p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text")
+ text = text.strip()
+ if text and text in _ALL_ERROR_MESSAGES:
+ return "generic"
+ return None
+
+
_RETRIABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504}
_BUSY_PATTERNS = (
"server busy",
@@ -201,17 +258,9 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
def _build_circuit_breaker_message(self) -> str:
- return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
+ return _CIRCUIT_MESSAGE
def _build_user_message(self, reason: str) -> str:
- if reason == "quota":
- return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
- if reason == "auth":
- return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
- if reason in {"busy", "transient"}:
- return "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
- if reason == "upstream":
- return "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。"
# NOTE: We intentionally do NOT embed the raw exception detail into the
# user-facing message. Raw `Error code: 400 - {...}` strings — especially
# Python repr-style dict literals — get persisted as AI messages and then
@@ -219,7 +268,11 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
# braces can corrupt the upstream JSON payload and cause deterministic
# JSONDecodeError loops. Keep the user message clean; full detail is
# already in the backend log via `logger.warning(..., exc_info=exc)`.
- return "大模型本次调用未能成功完成,请稍后再试或更换模型。"
+ return _USER_MESSAGES.get(reason, _USER_MESSAGES["generic"])
+
+ def _error_message(self, content: str, reason: str) -> AIMessage:
+ """构造带 ``LLM_ERROR_MARKER`` 标记的兜底 AIMessage(供下游识别+换模型)。"""
+ return AIMessage(content=content, additional_kwargs={LLM_ERROR_MARKER: reason})
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
try:
@@ -246,7 +299,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
if self._check_circuit():
- return AIMessage(content=self._build_circuit_breaker_message())
+ return self._error_message(self._build_circuit_breaker_message(), "circuit")
attempt = 1
while True:
@@ -283,7 +336,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
)
if retriable:
self._record_failure()
- return AIMessage(content=self._build_user_message(reason))
+ return self._error_message(self._build_user_message(reason), reason)
@override
async def awrap_model_call(
@@ -292,7 +345,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
if self._check_circuit():
- return AIMessage(content=self._build_circuit_breaker_message())
+ return self._error_message(self._build_circuit_breaker_message(), "circuit")
attempt = 1
while True:
@@ -329,7 +382,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
)
if retriable:
self._record_failure()
- return AIMessage(content=self._build_user_message(reason))
+ return self._error_message(self._build_user_message(reason), reason)
def _matches_any(detail: str, patterns: tuple[str, ...]) -> bool:
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py
index cdbcca68..81b3f6a4 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py
@@ -23,6 +23,7 @@ import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
+from .extraction_spec import build_summary_directive
from .progress import JobProgressWriter
from .types import (
CHAIN_CLOSEOUT_PROMPT,
@@ -80,6 +81,8 @@ async def run_orchestration(
intent_text: str = "",
enable_report: bool = True,
plan: dict | None = None,
+ gather_first: bool = False,
+ business_code: str | None = None,
) -> OrchestrationResult:
"""运行一次完整编排(循环 → 共识 → 报告绘制)。
@@ -97,23 +100,32 @@ async def run_orchestration(
可解析出非空 stages 时走 ``_run_dag``;否则(dag 但 plan 缺失/为空)回退 recommend。
enable_report: 是否在收口后执行 Step3(方案总结报告 + 结果绘制)。False 时只跑完
Step2 研讨即收尾(step3=None)。两段产物各自故障隔离,详见 ``_run_step3``。
+ gather_first: 是否在研讨前先跑一轮**全席位并行『取数』**(见 ``_run_gather_phase``),结果作为
+ 共享资料注入各研讨轮席位任务。业务链条逐条 opt-in(默认 False)。三种模式通用——尤其让
+ chain(串行)的技能延迟前置并行、研讨段提速。
"""
intent = (intent_text or seed_message or "").strip()
try:
await progress.begin()
await progress.diag(
stage="step2_orchestration", level="info", event="begin",
- message=f"会商编排启动(mode={mode},{len(agents)} 个席位)",
- detail={"mode": mode, "agents": [a.agent_id for a in agents]},
+ message=f"会商编排启动(mode={mode},{len(agents)} 个席位{',研讨前并行取数' if gather_first else ''})",
+ detail={"mode": mode, "agents": [a.agent_id for a in agents], "gather_first": gather_first},
+ )
+ # 研讨前并行取数(opt-in):全席位并行只取数,产出作为共享资料注入后续研讨。失败/为空安全降级。
+ gathered = (
+ await _run_gather_phase(gateway, progress, agents, thread_ids, model, intent)
+ if gather_first
+ else {}
)
dag_plan = parse_dag_plan(plan) if mode == "dag" else None
if mode == "dag" and dag_plan is not None:
- outcome = await _run_dag(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent, dag_plan)
+ outcome = await _run_dag(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent, dag_plan, gathered=gathered)
elif mode == "chain":
- outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent)
+ outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent, gathered=gathered)
else:
# recommend,或 dag 但 plan 缺失/为空 → 回退总控自由派活(绝不静默跑错语义)。
- outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent)
+ outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent, gathered=gathered)
if outcome.kind == "clarification":
question = outcome.question or "总控需要补充信息,请补充后继续。"
@@ -152,6 +164,7 @@ async def run_orchestration(
consensus_text=outcome.last_leader_content,
deliveries=outcome.deliveries,
coordinator_prompt=(dag_plan.coordinator_prompt if dag_plan is not None else None),
+ business_code=business_code,
)
await progress.done()
return OrchestrationResult(
@@ -189,6 +202,26 @@ _SEAT_NO_ASK = (
)
+async def _emit_switches(
+ progress: JobProgressWriter,
+ switches: list,
+ *,
+ agent_name: str,
+ cycle: int = 0,
+ agent_id: str | None = None,
+) -> None:
+ """把单轮执行器带回的模型容错记录逐条插进研讨时间线(系统提示气泡)。"""
+ for sw in switches or []:
+ await progress.model_switch(
+ agent_name=agent_name,
+ failed_model=getattr(sw, "failed_model", ""),
+ next_model=getattr(sw, "next_model", ""),
+ reason_label=getattr(sw, "reason_label", "调用失败"),
+ cycle=cycle,
+ agent_id=agent_id,
+ )
+
+
def _deliveries_block(deliveries: dict[str, str], name_by_id: dict[str, str]) -> str:
parts: list[str] = []
for aid, text in deliveries.items():
@@ -199,11 +232,30 @@ def _deliveries_block(deliveries: dict[str, str], name_by_id: dict[str, str]) ->
return "\n\n".join(parts)
-def _seat_message(intent_text: str, deliveries: dict[str, str], name_by_id: dict[str, str], task: str) -> str:
- """把任务背景 + 前序交付 + 防提问约束拼进席位任务(替代 thread 广播)。"""
+def _seat_message(
+ intent_text: str,
+ deliveries: dict[str, str],
+ name_by_id: dict[str, str],
+ task: str,
+ *,
+ gathered: dict[str, str] | None = None,
+) -> str:
+ """把任务背景 + (可选)研讨前并行收集的资料 + 前序交付 + 防提问约束拼进席位任务(替代 thread 广播)。
+
+ ``gathered``(可选)= 研讨前「并行取数阶段」各席位收集回来的**原始资料/事实**(见
+ ``_run_gather_phase``),与 ``deliveries``(研讨阶段的结论性交付)分块、各自标题不同:
+ 资料供席位直接引用以**减少重复检索**,结论供席位承接。两者都为空则都不拼。
+ """
parts: list[str] = []
if intent_text.strip():
parts.append(f"【本次研讨任务背景】\n{intent_text.strip()}")
+ if gathered:
+ gblock = _deliveries_block(gathered, name_by_id)
+ if gblock:
+ parts.append(
+ "【研讨前各席位已并行收集的资料/事实,供你直接引用——相关内容已在此,"
+ "尽量避免重复检索;确有缺口可再补充检索】\n" + gblock
+ )
block = _deliveries_block(deliveries, name_by_id)
if block:
parts.append("【前序各席位已完成的交付,请在此基础上承接、补充、不要重复或推翻】\n" + block)
@@ -212,6 +264,73 @@ def _seat_message(intent_text: str, deliveries: dict[str, str], name_by_id: dict
return "\n\n---\n\n".join(parts)
+# ── 研讨前「并行取数」阶段(可选,业务链条逐条 opt-in)──────────────────────────
+#
+# 各席位**只调技能/凭专业把相关信息收集回来、不研讨**,全席位 ``asyncio.gather`` 并行跑一轮,
+# 结果作为「共享资料」注入后续研讨轮(``_seat_message(gathered=...)``)。这样最慢的技能延迟只在
+# 这一次、并行发生;后续研讨段(哪怕 chain 串行)每席位基本变成一次纯推理调用,大幅缩短第二步
+# 总耗时。研讨阶段是否还再调技能不作限制(按用户设定)。
+
+_GATHER_TASK = (
+ "【资料收集阶段——只取数、不研讨】\n"
+ "现在是正式研讨**之前**的并行资料收集阶段。请你**只负责把与本次任务、与你这个席位职责相关的"
+ "真实信息/数据收集、整理出来**(优先用你配置的技能去检索/获取真实信息,没有技能可凭专业知识梳理),"
+ "为后续正式研讨打底。\n"
+ "**这一步不要做分析评估、不要给方案、不要下结论**——把判断与方案留到研讨阶段。"
+ "请把你收集到的关键信息整理成条理清晰的事实/要点清单输出。"
+)
+
+
+async def _run_gather_phase(
+ gateway: RoundtableGateway,
+ progress: JobProgressWriter,
+ agents: list[SeatRef],
+ thread_ids: dict[str, str],
+ model: str | None,
+ intent_text: str,
+) -> dict[str, str]:
+ """研讨前并行『取数』:所有席位并行只取数(不研讨),返回 {agent_id: 收集到的资料}。
+
+ 复用 ``_run_dag`` 同款 ``asyncio.gather`` + ``gateway.run_seat``;各席位独立 thread,进程内并行
+ 安全。某席位取数失败/为空不阻塞其它(其 gathered 项缺省即不注入)。结果由调用方传给后续研讨轮的
+ ``_seat_message(gathered=...)``。
+ """
+ name_by_id = {a.agent_id: a.name for a in agents}
+ gathered: dict[str, str] = {}
+ await progress.diag(
+ stage="step2_orchestration", level="info", event="gather_begin",
+ message=f"研讨前并行资料收集阶段启动({len(agents)} 个席位并行取数)",
+ detail={"agents": [a.agent_id for a in agents]},
+ )
+
+ async def _gather_one(seat: SeatRef) -> None:
+ await progress.seat_active(seat.agent_id)
+ seat_msg = _seat_message(intent_text, {}, name_by_id, _GATHER_TASK)
+ try:
+ seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
+ except Exception: # noqa: BLE001 — 单席位取数失败不拖垮整阶段;其 gathered 缺省即可
+ logger.warning("gather phase seat %s failed", seat.agent_id, exc_info=True)
+ await progress.seat_done(seat.agent_id, 0)
+ return
+ await _emit_switches(progress, seat_turn.switches, agent_name=name_by_id.get(seat.agent_id, seat.agent_id), agent_id=seat.agent_id)
+ text = (seat_turn.final_text or "").strip()
+ if text:
+ gathered[seat.agent_id] = text
+ await progress.add_seat_dialogue(
+ seat.agent_id, name_by_id.get(seat.agent_id, seat.agent_id), seat_turn.final_text, 0,
+ tool_calls=seat_turn.tool_calls, thread_id=thread_ids.get(seat.agent_id),
+ )
+ await progress.seat_done(seat.agent_id, 0)
+
+ await progress.dispatching(0)
+ await asyncio.gather(*(_gather_one(seat) for seat in agents))
+ await progress.diag(
+ stage="step2_orchestration", level="info", event="gather_done",
+ message=f"资料收集阶段完成({len(gathered)}/{len(agents)} 个席位有产出)",
+ )
+ return gathered
+
+
async def _run_recommend(
gateway: RoundtableGateway,
progress: JobProgressWriter,
@@ -221,6 +340,8 @@ async def _run_recommend(
model: str | None,
seed_message: str,
intent_text: str,
+ *,
+ gathered: dict[str, str] | None = None,
) -> _LoopOutcome:
deliveries: dict[str, str] = {}
last_leader = ""
@@ -235,6 +356,7 @@ async def _run_recommend(
message=message,
model=model,
)
+ await _emit_switches(progress, turn.switches, agent_name="总控", cycle=cycle)
if turn.final_text:
last_leader = turn.final_text
await progress.add_leader_dialogue(
@@ -246,24 +368,49 @@ async def _run_recommend(
if turn.is_consensus:
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
- # **顺序**派活 —— 不要并行;每个席位带上「背景 + 前序交付」上下文
+ # 批内**并行**派活(与前端 runOrchestration「总控一轮派多席位=判定相互独立→并行」一致,
+ # 2026-06-20 由串行改并行):总控一轮派出的多个席位彼此独立,并发跑可大幅缩短本轮耗时。
+ # 每个席位带上「背景 +(可选)研讨前并行取数资料 + 前序交付」上下文。各席位独立 thread,进程内
+ # gather 安全;后端用户级信号量(_BACKGROUND_SLOTS_PER_USER)仍限制实际并发,保护 sqlite 锁。
+ # 批内交付在本轮结束后统一并入 deliveries(同前端:批内不互相看,下一轮总控才看到全部)。
await progress.dispatching(cycle)
- for agent_id, task in turn.dispatched:
+ # 派活前对照快照(批内各席位看到的「前序交付」一致,不受批内执行顺序影响 → 并行无副作用)。
+ prior_snapshot = dict(deliveries)
+ batch: dict[str, str] = {}
+
+ async def _run_one(agent_id: str, task: str, cyc: int = cycle) -> None:
+ name = name_by_id.get(agent_id, agent_id)
await progress.seat_active(agent_id)
- seat_msg = _seat_message(intent_text, deliveries, name_by_id, task)
- seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=seat_msg, model=model)
- deliveries[agent_id] = seat.final_text
+ seat_msg = _seat_message(intent_text, prior_snapshot, name_by_id, task, gathered=gathered)
+ try:
+ seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=seat_msg, model=model)
+ except Exception: # noqa: BLE001 — 单席位彻底失败不拖垮整批;置空交付 + 记诊断
+ logger.warning("recommend seat %s failed", agent_id, exc_info=True)
+ await progress.diag(
+ stage="step2_seat", level="error", event="seat_failed",
+ message=f"席位「{name}」本轮执行异常(已跳过,不影响其它席位)",
+ agent_id=agent_id, agent_name=name, cycle=cyc,
+ )
+ batch[agent_id] = ""
+ await progress.add_seat_dialogue(agent_id, name, "", cyc, thread_id=thread_ids.get(agent_id))
+ await progress.seat_done(agent_id, cyc)
+ return
+ await _emit_switches(progress, seat.switches, agent_name=name, cycle=cyc, agent_id=agent_id)
+ batch[agent_id] = seat.final_text
if not (seat.final_text or "").strip():
await progress.diag(
stage="step2_seat", level="warning", event="seat_empty_delivery",
- message=f"席位「{name_by_id.get(agent_id, agent_id)}」本轮交付为空(可能模型未产出或被截断)",
- agent_id=agent_id, agent_name=name_by_id.get(agent_id, agent_id), cycle=cycle,
+ message=f"席位「{name}」本轮交付为空(可能模型未产出或被截断)",
+ agent_id=agent_id, agent_name=name, cycle=cyc,
)
await progress.add_seat_dialogue(
- agent_id, name_by_id.get(agent_id, agent_id), seat.final_text, cycle,
+ agent_id, name, seat.final_text, cyc,
tool_calls=seat.tool_calls, thread_id=thread_ids.get(agent_id),
)
- await progress.seat_done(agent_id, cycle)
+ await progress.seat_done(agent_id, cyc)
+
+ await asyncio.gather(*(_run_one(agent_id, task) for agent_id, task in turn.dispatched))
+ deliveries.update(batch)
await progress.bump_consensus()
# 续轮:把各席位本轮交付喂给总控,让它据实判断(总控 SOUL 依赖看到交付)
@@ -287,6 +434,8 @@ async def _run_chain(
model: str | None,
seed_message: str,
intent_text: str,
+ *,
+ gathered: dict[str, str] | None = None,
) -> _LoopOutcome:
deliveries: dict[str, str] = {}
last_leader = ""
@@ -313,6 +462,7 @@ async def _run_chain(
message=leader_task,
model=model,
)
+ await _emit_switches(progress, turn.switches, agent_name="总控", cycle=i + 1)
if turn.final_text:
last_leader = turn.final_text
await progress.add_leader_dialogue(
@@ -323,10 +473,26 @@ async def _run_chain(
task = pick_chain_task(turn.dispatched, turn.final_text, seat.agent_id, seat.name)
- # (b) 当前席位交付(顺序,不并行);带上「背景 + 前序交付 + 别提问」上下文
+ # (b) 当前席位交付(顺序,不并行);带上「背景 +(可选)研讨前并行取数资料 + 前序交付 + 别提问」上下文。
+ # chain 严格串行(seat B 看 seat A 交付),但有 gathered 时各席位已带共享资料,不必各自从零检索 → 提速。
await progress.seat_active(seat.agent_id)
- seat_msg = _seat_message(intent_text, deliveries, name_by_id, task)
- seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
+ seat_msg = _seat_message(intent_text, deliveries, name_by_id, task, gathered=gathered)
+ try:
+ seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
+ except Exception: # noqa: BLE001 — 单席位失败不中断整链,置空交付后继续后续席位
+ logger.warning("chain seat %s failed", seat.agent_id, exc_info=True)
+ await progress.diag(
+ stage="step2_seat", level="error", event="seat_failed",
+ message=f"席位「{seat.name}」本轮执行异常(已跳过,链条继续)",
+ agent_id=seat.agent_id, agent_name=seat.name, cycle=i + 1,
+ )
+ deliveries[seat.agent_id] = ""
+ await progress.add_seat_dialogue(seat.agent_id, seat.name, "", i + 1, thread_id=thread_ids.get(seat.agent_id))
+ await progress.seat_done(seat.agent_id, i + 1)
+ if total > 0:
+ await progress.set_consensus_pct(round((i + 1) / total * 90))
+ continue
+ await _emit_switches(progress, seat_turn.switches, agent_name=seat.name, cycle=i + 1, agent_id=seat.agent_id)
deliveries[seat.agent_id] = seat_turn.final_text
if not (seat_turn.final_text or "").strip():
await progress.diag(
@@ -354,6 +520,7 @@ async def _run_chain(
message=closeout_msg,
model=model,
)
+ await _emit_switches(progress, closeout.switches, agent_name="总控", cycle=total + 1)
if closeout.final_text:
last_leader = closeout.final_text
await progress.add_leader_dialogue(
@@ -376,6 +543,8 @@ async def _run_dag(
seed_message: str,
intent_text: str,
plan: DagPlan,
+ *,
+ gathered: dict[str, str] | None = None,
) -> _LoopOutcome:
"""分层 DAG 编排:每个 stage 一轮总控派活(钉死本批席位)→ 批内席位**并行**执行
(``asyncio.gather``)→ 下一 stage 总控再派;全部 stage 完成后由总控收口综合。
@@ -416,6 +585,7 @@ async def _run_dag(
message=leader_task,
model=model,
)
+ await _emit_switches(progress, turn.switches, agent_name="总控", cycle=i + 1)
if turn.final_text:
last_leader = turn.final_text
await progress.add_leader_dialogue(
@@ -429,22 +599,35 @@ async def _run_dag(
for seat in stage_seats:
await progress.seat_active(seat.agent_id)
- async def _run_one(seat: SeatRef) -> None:
+ async def _run_one(seat: SeatRef, _gathered: dict[str, str] | None = gathered, _cyc: int = i + 1) -> None:
task = pick_dag_seat_task(turn.dispatched, seat.agent_id, seat.name, goal=stage.goal)
- seat_msg = _seat_message(intent_text, deliveries, name_by_id, task)
- seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
+ seat_msg = _seat_message(intent_text, deliveries, name_by_id, task, gathered=_gathered)
+ try:
+ seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
+ except Exception: # noqa: BLE001 — 单席位失败不拖垮整个 stage 并行批
+ logger.warning("dag seat %s failed", seat.agent_id, exc_info=True)
+ await progress.diag(
+ stage="step2_seat", level="error", event="seat_failed",
+ message=f"席位「{seat.name}」本轮执行异常(已跳过,不影响同批其它席位)",
+ agent_id=seat.agent_id, agent_name=seat.name, cycle=_cyc,
+ )
+ deliveries[seat.agent_id] = ""
+ await progress.add_seat_dialogue(seat.agent_id, seat.name, "", _cyc, thread_id=thread_ids.get(seat.agent_id))
+ await progress.seat_done(seat.agent_id, _cyc)
+ return
+ await _emit_switches(progress, seat_turn.switches, agent_name=seat.name, cycle=_cyc, agent_id=seat.agent_id)
deliveries[seat.agent_id] = seat_turn.final_text
if not (seat_turn.final_text or "").strip():
await progress.diag(
stage="step2_seat", level="warning", event="seat_empty_delivery",
message=f"席位「{seat.name}」本轮交付为空(可能模型未产出或被截断)",
- agent_id=seat.agent_id, agent_name=seat.name, cycle=i + 1,
+ agent_id=seat.agent_id, agent_name=seat.name, cycle=_cyc,
)
await progress.add_seat_dialogue(
- seat.agent_id, seat.name, seat_turn.final_text, i + 1,
+ seat.agent_id, seat.name, seat_turn.final_text, _cyc,
tool_calls=seat_turn.tool_calls, thread_id=thread_ids.get(seat.agent_id),
)
- await progress.seat_done(seat.agent_id, i + 1)
+ await progress.seat_done(seat.agent_id, _cyc)
await asyncio.gather(*(_run_one(seat) for seat in stage_seats))
@@ -462,6 +645,7 @@ async def _run_dag(
message=closeout_msg,
model=model,
)
+ await _emit_switches(progress, closeout.switches, agent_name="总控", cycle=total + 1)
if closeout.final_text:
last_leader = closeout.final_text
await progress.add_leader_dialogue(
@@ -484,6 +668,7 @@ async def _run_step3(
consensus_text: str,
deliveries: dict[str, str],
coordinator_prompt: str | None = None,
+ business_code: str | None = None,
) -> dict | None:
"""Step3 双产物:「方案总结报告」(roundtable-summary 写 md)与「结果绘制」
(roundtable-report 画 HTML)**并行执行**(两段无依赖,2026-06-13 由串行改并行,
@@ -504,11 +689,13 @@ async def _run_step3(
await progress.summary_writing()
summary_thread_id = await gateway.create_summary_thread()
summary_prompt = build_summary_prompt(
- intent_text, agents, deliveries, consensus_text, coordinator_prompt=coordinator_prompt
+ intent_text, agents, deliveries, consensus_text,
+ coordinator_prompt=coordinator_prompt, business_code=business_code,
)
summary: SummaryResult = await gateway.run_summary(
summary_thread_id=summary_thread_id, prompt=summary_prompt, model=model
)
+ await _emit_switches(progress, getattr(summary, "switches", None), agent_name="方案总结报告")
if (summary.md or "").strip() or (summary.closing or "").strip():
step3["summaryReport"] = {
"md": summary.md or "",
@@ -592,6 +779,7 @@ async def _run_report(
report_thread_id = await gateway.create_report_thread()
prompt = build_report_prompt(intent_text, agents, deliveries, consensus_text)
report: ReportResult = await gateway.run_report(report_thread_id=report_thread_id, prompt=prompt, model=model)
+ await _emit_switches(progress, getattr(report, "switches", None), agent_name="结果绘制")
return {
"html": report.html,
"generatedAt": report.generated_at or datetime.now(UTC).isoformat(),
@@ -659,6 +847,7 @@ def build_summary_prompt(
consensus_text: str,
*,
coordinator_prompt: str | None = None,
+ business_code: str | None = None,
) -> str:
"""拼装 Step3「方案总结报告」prompt —— 对齐前端 ``useStep3Summary.buildSummaryPrompt``
(非流程图分支)+ ``buildMaterials`` 的原料结构,使后台产出与手动调用一致。"""
@@ -693,6 +882,22 @@ def build_summary_prompt(
lines.append("# 三、总控最终共识结论")
lines.append((consensus_text or "").strip() or "(未提供)")
lines.append("")
+ # 意图对齐 + 缺口核对 + 防幻觉护栏(弱席位场景的质量兜底)。即便部署里的 SOUL 未重新 seed,
+ # 这段也保证后台总结会主动扣用户意图、标注推断/缺口,而非简单拼接各席位交付。
+ lines.append(
+ "【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。"
+ "你是这份报告的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)"
+ "拆成用户的核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、"
+ "有无跑题或矛盾;再据此组织:① 能从已有原料重组/推理补上的→直接补全讲透;② 缺真实信息补不了的→**绝不凭空编**。"
+ "**写进 .md 报告文件的必须是一份完整、干净、可直接使用、无需用户修改的报告——不要在报告文件里写「待补充/不足/⚠️」之类提示或占位**。"
+ "**但在对话回复正文、报告之后,必须追加一段简短「核对说明」**(只在对话里、**不写进 .md 文件**):逐条说明产出是否覆盖了用户意图、哪些充分 / 哪些不足 / 有哪些可补充(可在对话里让我补全);"
+ "**即便核对下来都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求均已覆盖,无明显缺口」,这段核对说明任何时候都不要省略。**"
+ "最终报告要明显比「各席位交付的简单拼接」更扣用户意图。"
+ )
+ # 深链接业务规范:写实业务(如 rwfx)额外注入「业务链完整性核对」,强制核对产出覆盖了每一层级。
+ biz_directive = build_summary_directive(business_code)
+ if biz_directive:
+ lines.append(biz_directive)
lines.append(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。"
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);"
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/extraction_spec.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/extraction_spec.py
new file mode 100644
index 00000000..dcd738c7
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/extraction_spec.py
@@ -0,0 +1,228 @@
+"""按 businessCode 的「业务链抽取规范」——深链接会商模式下强制注入 summary / 结构化抽取。
+
+背景:结构化抽取(flow_extract,roundtable-structure 智能体)和方案总结报告(roundtable-summary)
+的层级该怎么抽/怎么核对,是**每个业务链各不相同**的领域知识;但智能体 SOUL 只有一份、全局共用,
+写死某一套层级就会对所有业务套同一套(典型 bug:无论什么业务都按「目的→行为体」抽、停在行为体)。
+
+解法:把每个业务的**完整层级 + 层间关系 + 节点命名 + 取数优先**做成这里的一份规范,**单份领域知识、
+两个角色用法**:
+ - ``build_structure_directive``:给结构化抽取——按完整层级抽全、层间 1:1/1:N、节点命名、取数优先;
+ - ``build_summary_directive``:给方案总结报告——核对产出相对业务链完不完整。
+
+**仅深链接模式(business_code 非空)注入**;普通手动会商 business_code 为空 → 返回 ""(零副作用)。
+rwfx(6BF) 先写实,其余业务(3Q/7BF/8BF)暂用通用规范(仍强约束完整性 / 命名 / 取数,等真实层级再补)。
+
+放在 harness 层(``deerflow.*``):app 层(flow_extract / multi_agent)与 harness 层(engine
+后台 build_summary_prompt)都要用它,故置于此(app 可 import harness,harness 不可 import app)。
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class ChainSpec:
+ """一个业务链的抽取规范。"""
+
+ code: str # 规范的规范化业务码(如 6BF)
+ label: str # 人类可读业务名(如 rwfx)
+ levels: list[str] # 自上而下的层级名(线性链=逐层;分叉/汇聚的复杂链=各类节点自上而下的粗略顺序)
+ relations_note: str # 层间关系说明(1:1 / 1:N / 多对一 / 分叉汇聚,复杂结构全写在这里)
+ completeness_note: str = "" # 「完整性」要求行;空 → 用通用「以上 N 层都要出节点」
+ extra_note: str = "" # 该业务额外补充(可空)
+ # 是否为**纯树**业务链:每个非根节点恰有一个父节点(全链 1:N + 1:1,绝无多对一/多对多/汇聚)。
+ # 为真时后端结构化抽取会**确定性兜底**把每个节点收敛到单一父节点(见 flow_extract._collapse_to_single_parent),
+ # 杜绝弱模型违反 1:N 画出乱线/汇聚。rwfx=True;xdfx 有「脆弱点→策略」多对一汇聚,为 False。
+ single_parent: bool = False
+
+
+# 节点命名 / 取数优先:各业务通用,单独抽出避免重复。
+_NAMING_NOTE = (
+ "节点 label 必须用该层级下的**具体实体 / 概念短名**(≤12 字),"
+ "**绝不能含「步骤 / 阶段 / 环节 / 第N步 / 阶段①②③」等流程或编号词**,也不要拿层级名本身当 label。"
+)
+_SOURCE_NOTE = (
+ "**严格以《方案总结报告》及其文件内容为准**逐条抽取、勿改写、勿精简理由(报告是各席位交付经总控综合提炼、"
+ "**已做取舍**后的最终结论)。**《各席位原始交付产物》仅作兜底**:只有当报告某层级**整层缺失、或明确说有 N 条却没逐条列出**时,"
+ "才回席位产物把那一层补全;**绝不要用席位产物去扩充报告已取舍过的层级**——报告淘汰 / 未采用的候选一律不抽。与报告冲突时以报告为准。"
+)
+# 「按报告逐条抽全、不漏也不多」—— 本次最关键的准确性约束(治「报告列 3 条却只抽 1 条」与「报告只采用 3 条却抽出一堆候选」)。
+# 业务无关措辞(不出现任何具体业务的层级名),通用规范与写实规范都复用它。
+_COMPLETENESS_NOTE = (
+ "**按报告逐条抽全、不漏也不多(最重要,必须做到)**:\n"
+ " · 报告某层级用表格 / 列表 / 「①②③」逐条列出的条目,**每条都要各自成一个独立节点、一条都不能漏**"
+ "(不要只抽被标「确定 / 第1名 / 选用」的那一条而漏掉报告同列展示的其余条目);\n"
+ " · 某个上层节点名下若列了多条下层条目(同一上层名下罗列了好几条),"
+ "**报告里有几条就出几个节点,绝对不许合并 / 概括 / 只取代表性的一条**;\n"
+ " · ⚠️ **但绝不要无中生有 / 多抽**:报告**已取舍、最终只采用其中几条**的层级(如报告从若干候选里只锁定了最终几个目的、"
+ "或只选定了最终方案),**只抽报告最终采用的那几条**——**不要去席位产物、也不要去报告的「候选池 / 被淘汰项」"
+ "里把没被采用的候选也抽出来凑数**(这是要重点纠正的「抽出一堆目的」错);\n"
+ " · **不要只照搬报告结尾「方案总纲 / 最终框架 / 共识方案」里被精简的主干链**(那里通常只画被选中的一条主线)——"
+ "要回到正文把报告该层级列出的**全部条目**抽全。"
+)
+
+
+# ── rwfx(6BF):六步法完整链(线性) ─────────────────────────────────────────
+_RWFX = ChainSpec(
+ code="6BF",
+ label="rwfx",
+ levels=["任务", "目的", "行为体", "预期行为", "驱动因素", "叙事"],
+ relations_note=(
+ "层间关系(务必分清一对一 / 一对多,连线别画反):\n"
+ "① **「预期行为」与「驱动因素」严格一对一(1:1,本次必须重点纠正的错)**:\n"
+ " 为**每一条**预期行为,归纳出驱动它的**那一个**核心驱动因素,两两配成一对——"
+ "两层节点数**必须完全相等**(有 N 条预期行为就出 N 个驱动因素),按相同位次一一对应;\n"
+ " 每个驱动因素的 from **只能写它对应的那一条预期行为**;**严禁多对一**:"
+ "不许多条预期行为汇聚连到同一个驱动因素,也不许一条预期行为被多个驱动因素指向——"
+ "**这两层里任何一个节点都不能冒出 / 汇入多条连线**(每条预期行为只伸出一条线连到它唯一的驱动因素);\n"
+ " ⚠️ 数据来源常把某行为体的多个驱动因素**合并写在一处**(一个表格单元格里用「+」「、」连接、或「表层触发→深层」一整段、"
+ "或只在第一条预期行为那行写驱动因素、其余行留空)——**这种情况绝不能抽成「一个驱动因素节点连到多条预期行为」**,"
+ "而要**按预期行为把驱动因素拆开 / 重新归纳**:给每条预期行为各配一个驱动因素节点(其内容综合提炼该条预期行为背后最核心的那个原因),"
+ "拆分 / 提炼后务必让两层等量、严格一一对应;\n"
+ "② **「驱动因素」→「叙事」是一对多**(一个驱动因素下可以挂多条叙事,一条叙事只属于一个驱动因素)。"
+ "**⚠️ 叙事条数严格按报告 / 叙事席位实际写出的来——写了几条就抽几条,绝不要为「让每个驱动因素都配一条」而凭空造、复制、拆分出更多叙事**"
+ "(这是「叙事凭空多出来一堆」的错);每条叙事的 from **只连它所属的那一个驱动因素**(叙事的上一层是驱动因素);"
+ "**绝不能把多个驱动因素汇聚连到同一条叙事**(多对一,rwfx 链不存在),**一条叙事只能有一个父节点**;也不要把全部叙事合并成一个节点;\n"
+ "③ 其余相邻层级(任务→目的、目的→行为体、行为体→预期行为)均为**一对多**(上层一条可带下层多条,下层每条连其所属上层条目);\n"
+ "④ **全链是一棵树:除根「任务」外,每个节点有且仅有一个父节点(一个 from)**——任何节点都不会从多个上层节点汇入(无多对一 / 多对多 / 汇聚)。"
+ ),
+ completeness_note=(
+ "以上 6 个层级都要出节点,且**每层条数完全由报告实际呈现决定、严格抽全——不漏也不多**"
+ "(各席位交付条数本就不固定,绝不要假设任何固定数量、也不要按上一层数量去硬凑下一层):\n"
+ " · **目的**:抽报告**最终采用 / 锁定的那几个目的**(报告锁定几个就抽几个)。报告若说「从若干候选里只锁定最终几个」就"
+ "**只抽报告最终采用的、绝不要把被淘汰的候选也抽进来**(这是「抽出一堆目的」的错);报告把目的并列成表展示,就把表里列出的都抽全、别只抽第 1 个;\n"
+ " · **行为体 / 预期行为**:每个行为体名下报告列了几条预期行为就出几个预期行为节点(有几条出几条),**不许把多条合并成一条、也不要凭空多造**;\n"
+ " · **驱动因素(1:1 特例,按上面①处理)**:驱动因素层节点数 **= 预期行为层节点数**——每条预期行为各配一个核心驱动因素,"
+ "**绝不能因为报告把多个原因挤在一处 / 留空,就只抽 1 个驱动因素去连多条预期行为(多对一,本次最严重的错)**,也不要抽得比预期行为多;务必两层等量、位次一一配对;\n"
+ " · **叙事**:抽报告 / 叙事席位**实际写出的那几条叙事**(写了几条就抽几条),全部抽、但**一条都不要多造**——"
+ "**绝不要为「每个驱动因素都配一条叙事」而把实际的少数几条叙事扩成一堆**(「叙事凭空多出来一堆」的错);"
+ "每条叙事**单 from** 连它所属的那一个驱动因素,绝不要连多个驱动因素、也不要让多个驱动因素指向同一叙事。"
+ ),
+ single_parent=True, # rwfx 是纯树:每个非根节点恰一个父节点(后端确定性兜底收敛)。
+)
+
+
+# ── xdfx(7BF):分叉汇聚 DAG(非线性,根为「行动」) ──────────────────────────
+_XDFX = ChainSpec(
+ code="7BF",
+ label="xdfx",
+ levels=["行动", "受众目标 / 关键传播节点", "脆弱点", "策略"],
+ relations_note=(
+ "这是一张**分叉汇聚**的图(不是单链),层间关系如下:\n"
+ "① **根节点 = 用户传入的「行动」**(不是「任务」);\n"
+ "② 「行动」下分**两类并列分支**——「受众目标」与「关键传播节点」(行动→受众目标 一对多、"
+ "行动→关键传播节点 一对多);\n"
+ "③ 「受众目标」和「关键传播节点」**各自都挂多个「脆弱点」**(受众目标→脆弱点 一对多、"
+ "关键传播节点→脆弱点 一对多);\n"
+ "④ 「策略」由「脆弱点」**汇聚而来、是多对一**:一个策略可对应「某个受众目标的全部或部分脆弱点」"
+ "「多个受众目标各自的全部或部分脆弱点」(关键传播节点侧同理)——即多个脆弱点(**可跨多个受众目标"
+ "或多个关键传播节点**)汇聚到一个策略;策略节点的 from 要连到它实际汇聚的那些脆弱点(可能多个、可跨分支)。"
+ ),
+ completeness_note=(
+ "行动 / 受众目标 / 关键传播节点 / 脆弱点 / 策略 这几类节点都要出全;每个脆弱点都要 from 连到它所属的"
+ "那个受众目标或关键传播节点;策略按上面的多对一关系,from 连到它汇聚的全部脆弱点。"
+ ),
+)
+
+
+# 规范化业务码 → 规范。同时收录人类可读别名(rwfx/xdfx)便于前端任意传。
+_SPECS: dict[str, ChainSpec] = {
+ "6BF": _RWFX,
+ "RWFX": _RWFX,
+ "7BF": _XDFX,
+ "XDFX": _XDFX,
+}
+
+
+def _normalize(code: str | None) -> str:
+ """规范化业务码:大写、去首尾空白、去掉多智能体 ``-A`` 后缀(rwfx-A → RWFX)。"""
+ c = (code or "").strip().upper()
+ if c.endswith("-A"):
+ c = c[:-2]
+ return c
+
+
+def get_chain_spec(business_code: str | None) -> ChainSpec | None:
+ """取某业务的写实规范;无 business_code 或未写实 → None。"""
+ norm = _normalize(business_code)
+ if not norm:
+ return None
+ return _SPECS.get(norm)
+
+
+def is_single_parent_chain(business_code: str | None) -> bool:
+ """该业务链是否为**纯树**(每个非根节点恰一个父节点,无多对一/多对多/汇聚)。
+
+ 为真时结构化抽取会确定性兜底把每个节点收敛到单一父节点。rwfx=True;xdfx(有汇聚)/
+ 未写实业务=False(保守,不强行收敛)。
+ """
+ spec = get_chain_spec(business_code)
+ return bool(spec and spec.single_parent)
+
+
+def chain_level_rank(business_code: str | None) -> dict[str, int]:
+ """该业务链的「层级名 → 自上而下序号」映射(用于确定性兜底判断「上一层」)。无写实规范 → 空。"""
+ spec = get_chain_spec(business_code)
+ if spec is None:
+ return {}
+ return {name: i for i, name in enumerate(spec.levels)}
+
+
+def build_structure_directive(business_code: str | None) -> str:
+ """结构化抽取(flow-json)用的强制层级规范。
+
+ - 无 business_code(非深链接 / 普通会商)→ ""(不注入,保持现状);
+ - 有 business_code 但未写实(3Q/7BF/8BF…)→ 通用规范:强约束完整性 + 命名 + 取数,且明确**不要套用任何
+ 固定的「目的→行为体」式模板**(counter 掉 SOUL 里可能残留的写死层级);
+ - 写实业务(rwfx)→ 完整层级 + 层间关系 + 命名 + 取数 + 完整性硬约束。
+ """
+ norm = _normalize(business_code)
+ if not norm:
+ return ""
+ spec = _SPECS.get(norm)
+ if spec is None:
+ return (
+ "【本业务链抽取规范(强制遵守,优先级高于系统提示里的任何默认/示例层级)】\n"
+ "- 本业务**没有固定层级模板**:请严格按《方案总结报告》/ 各席位交付里**真实出现的层级**,"
+ "自「任务」为根逐层完整展开,每层每一条都出成节点,不要只抽开头一两层就停;\n"
+ "- **不要套用「目的→行为体」之类的固定示例层级**——层级名一律取自本次报告 / 交付里的真实层级;\n"
+ f"- {_COMPLETENESS_NOTE}\n- {_NAMING_NOTE}\n- {_SOURCE_NOTE}"
+ )
+ arrows = " → ".join(spec.levels)
+ completeness = spec.completeness_note or f"以上 {len(spec.levels)} 个层级都要出节点,不得缺层。"
+ lines = [
+ f"【{spec.label} 业务链抽取规范(强制遵守,优先级高于系统提示里的任何默认/示例层级)】",
+ f"- **完整层级**(自上而下,必须逐层抽全、不得缺层、不得停在中间层):{arrows};",
+ f"- {spec.relations_note}",
+ f"- {_COMPLETENESS_NOTE}",
+ f"- {_NAMING_NOTE}",
+ f"- {_SOURCE_NOTE}",
+ f"- **完整性**:{completeness}",
+ ]
+ if spec.extra_note:
+ lines.append(f"- {spec.extra_note}")
+ return "\n".join(lines)
+
+
+def build_summary_directive(business_code: str | None) -> str:
+ """方案总结报告用的「业务链完整性核对」规范。
+
+ 无 business_code 或未写实 → ""(普通会商 / 未知业务不强加完整性核对)。写实业务 → 注入完整层级,
+ 要求 summary 核对产出是否覆盖了每一层级、缺层按「待补充」护栏标出。
+ """
+ spec = get_chain_spec(business_code)
+ if spec is None:
+ return ""
+ arrows = " → ".join(spec.levels)
+ return (
+ f"【{spec.label} 业务链完整性核对(强制)】本次为 {spec.label} 业务场景,其完整业务链层级为:{arrows}。\n"
+ f"{spec.relations_note}\n"
+ "撰写报告时请**额外核对各席位交付 + 共识是否完整覆盖了上述每一个层级**:"
+ "报告文件本身按现有信息给出尽量完整的版本(**不要在 .md 文件里写「待补充」之类提示**);\n"
+ "⚠️ **呈现「一对一(1:1)」相邻层级时(如预期行为↔驱动因素)务必逐条配对**:每一条上层条目单独成一行、"
+ "紧跟它**唯一对应的那一条**下层条目(一行一对、数量相等);**绝对不要把下层的多个条目用「+」「、」合并塞进一个单元格、"
+ "也不要只在第一行写、其余行留空**——那会让后续结构化抽取把它误画成「多条上层汇聚到一个下层」(多对一)。每条上层条目都要有它自己专属的那一条下层条目。\n"
+ "并在**对话回复正文、报告之后的「核对说明」里逐层说明各层级是否覆盖、缺了哪层 / 哪层条目不足**"
+ "(即便各层级都已覆盖,也要写一句「各层级均已覆盖」),可在对话里让我补全;这段核对只在对话里、不写进 .md 文件,不要跳层。"
+ )
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/progress.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/progress.py
index 023c41df..b2977fde 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/progress.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/progress.py
@@ -270,3 +270,27 @@ class JobProgressWriter:
self._add("seat", agent_id, name, content or "", cycle, tool_calls=tool_calls, thread_id=thread_id)
await self._persist()
await self._flush_dialogue()
+
+ async def model_switch(
+ self,
+ *,
+ agent_name: str,
+ failed_model: str,
+ next_model: str,
+ reason_label: str,
+ cycle: int = 0,
+ agent_id: str | None = None,
+ ) -> None:
+ """模型自动容错通知:当前模型出问题、已自动切到下一个模型时调用。
+
+ 往研讨时间线插一条 ``role="system"`` 系统提示气泡(前端 ``toStep2Dialogue`` 据此渲染
+ 为「系统提示」)。对应的 ``model_switch`` **诊断**由网关
+ (``InProcessRoundtableGateway._run_turn_with_fallback``)在换模型当下落库(含完整
+ detail),此处只负责时间线展示,避免重复落诊断。``reason_label`` 由调用方(app 层)
+ 预先翻译好(harness 不依赖 app 的文案表)。"""
+ fm = (failed_model or "").strip() or "当前模型"
+ nm = (next_model or "").strip() or "下一个模型"
+ content = f"⚠️ 模型容错:「{agent_name}」所用模型「{fm}」{reason_label},已自动切换到「{nm}」继续。"
+ self._add("system", agent_id, "系统提示", content, cycle)
+ await self._persist()
+ await self._flush_dialogue()
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/types.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/types.py
index 47ac8cf9..e52d1182 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/types.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/types.py
@@ -43,6 +43,20 @@ class SeatRef:
name: str
+@dataclass
+class ModelSwitch:
+ """一次「模型自动容错」记录:当前模型出问题、已切到下一个模型继续。
+
+ 由网关(单轮执行器)在换模型时产出,挂在各 Turn/Result 上带回引擎;引擎据此调
+ ``JobProgressWriter.model_switch(...)`` 往研讨时间线插系统提示并落诊断。``reason_label``
+ 已由 app 层翻译成简短中文(harness 不依赖 app 文案表)。
+ """
+
+ failed_model: str
+ next_model: str
+ reason_label: str
+
+
@dataclass
class LeaderTurn:
"""总控一轮执行的归一化结果(由 gateway.run_leader 产出)。"""
@@ -55,6 +69,8 @@ class LeaderTurn:
# 本轮总控调用的工具(按出现顺序,每项 {name, args, id})——通常是 agent_orchestration
# 派活 / ask_clarification。用于在草稿 run 里重建「工具步骤卡」,与前端手动跑一致。
tool_calls: list[dict] = field(default_factory=list)
+ # 本轮发生的模型自动容错记录(按发生顺序);引擎据此向用户提示「已切换模型」。
+ switches: list[ModelSwitch] = field(default_factory=list)
@property
def is_clarification(self) -> bool:
@@ -73,6 +89,8 @@ class SeatTurn:
# 本轮席位调用的工具(按出现顺序,每项 {name, args, id})——用于在草稿 run 里
# 重建「工具步骤卡」+ write_file stub(点开产物),与前端手动跑一致。
tool_calls: list[dict] = field(default_factory=list)
+ # 本轮发生的模型自动容错记录(按发生顺序)。
+ switches: list[ModelSwitch] = field(default_factory=list)
@dataclass
@@ -83,6 +101,7 @@ class ReportResult:
summary: str | None = None
model: str | None = None
generated_at: str | None = None
+ switches: list[ModelSwitch] = field(default_factory=list)
@dataclass
@@ -99,6 +118,7 @@ class SummaryResult:
model: str | None = None
thread_id: str | None = None
generated_at: str | None = None
+ switches: list[ModelSwitch] = field(default_factory=list)
# ── 网关协议 ────────────────────────────────────────────────────────────────
@@ -328,6 +348,13 @@ def build_dag_stage_leader_task(
lines += [
f"【本阶段目标】{goal_text}",
"请据此目标为每个席位拆解任务,并要求他们显式承接上一阶段的产出来完成本阶段目标。",
+ "⚠️ 派活时**严格忠实于本阶段目标,不要自行新增目标里没有的「数量上限 / 只取最有代表性的 N 组 / 精简归纳成几条」等限制**,"
+ "也不要照搬其它阶段的数量(如上一阶段筛了 3 组,不代表本阶段也只要 3 组);"
+ "目标没要求筛选 / 限定数量时,就要求席位**按其职责把每一条都完整输出**——"
+ "若上一阶段有 N 条产出需逐条处理,就让席位**逐条、全部**产出(有多少条就出多少条),绝不替席位归纳 / 合并 / 只取代表性的几条。",
+ "⚠️ 也**不要替席位新增目标 / 席位职责之外的「输出体例 / 风格」要求**(如『写成连贯故事 / 要有场景感 / "
+ "举具体城市景区为例 / 虚构时间地点情节』等)——派活只需转达本阶段目标 + 上一阶段产物 + 承接关系,"
+ "**让席位严格按它自己的职责设定(SOUL)输出**,且**忠实基于上一阶段的真实产物做分析,不要诱导席位脱离输入去编造/ 虚构事例**。",
]
lines += [
f"请你作为总控,通过 agent_orchestration 技能**分别**向上述{'这些席位' if is_parallel else '席位'}派活,"
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py
index 4579bf29..27721109 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py
@@ -103,6 +103,12 @@ class AgentConfig(BaseModel):
# 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的
# Markdown 产物可一键发布到外部入库接口;None/空 = 不支持入库。
template_name: str | None = None
+ # 会商席位「技能识别强化」开关(弱模型补偿)。作为会商席位运行时,是否把本智能体
+ # 配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其流程取信息的硬指令)显式
+ # 拼进本轮任务,避免弱模型无视系统提示里的技能块。None/False = 关(默认);True = 开。
+ # 与「业务链条配置」里的同名开关取 OR:任一打开即对该席位生效。见
+ # ``app/gateway/roundtable_seat_skills.py``。
+ seat_skill_directive: bool | None = None
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260620_02_chain_gather_first.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260620_02_chain_gather_first.py
new file mode 100644
index 00000000..bb035e45
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260620_02_chain_gather_first.py
@@ -0,0 +1,54 @@
+"""Add gather_first column to roundtable_chains.
+
+Per-chain「研讨前并行取数」opt-in switch: when true, selecting the chain into a roundtable
+discussion first runs one **parallel data-gathering round** over all seats (each seat only
+calls its skills to fetch facts, no deliberation); the collected material is injected as
+shared context into the discussion rounds, front-loading the slow skill latency in parallel
+so the (possibly serial) discussion phase runs faster. Default false → existing behavior
+unchanged. BOOLEAN NOT NULL DEFAULT 0, matching ``RoundtableChainRow.gather_first``.
+
+``create_all`` adds the column for brand-new DBs; the startup ``_ensure_orm_columns_sync``
+sweep adds it to already-existing tables (sqlite/mysql/postgres). This revision keeps the
+alembic graph consistent with the rest of the chain columns and documents the change.
+
+Revision ID: 20260620_02
+Revises: 20260619_01
+Create Date: 2026-06-20
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy import inspect
+
+
+revision = "20260620_02"
+down_revision = "20260619_01"
+branch_labels = None
+depends_on = None
+
+
+def _has_table(table_name: str) -> bool:
+ inspector = inspect(op.get_bind())
+ return table_name in inspector.get_table_names()
+
+
+def _has_column(table_name: str, column_name: str) -> bool:
+ if not _has_table(table_name):
+ return False
+ inspector = inspect(op.get_bind())
+ return any(col["name"] == column_name for col in inspector.get_columns(table_name))
+
+
+def upgrade() -> None:
+ if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "gather_first"):
+ op.add_column(
+ "roundtable_chains",
+ sa.Column("gather_first", sa.Boolean(), nullable=False, server_default="0"),
+ )
+
+
+def downgrade() -> None:
+ if _has_column("roundtable_chains", "gather_first"):
+ op.drop_column("roundtable_chains", "gather_first")
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py
index eb2d5baf..bba96994 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py
@@ -52,6 +52,12 @@ class RoundtableChainRow(Base):
# 可选「默认席位执行模式」:flash/thinking/pro/ultra(见前端 lib/seat-mode.ts)。选入研讨时
# 弹窗默认用它(仍可临时改)。NULL = 未设,调用方回退默认(pro)。短串,VARCHAR(16)。
seat_mode: Mapped[str | None] = mapped_column(String(16), nullable=True)
+ # 可选「研讨前并行取数」开关:True = 选入研讨时,研讨前先跑一轮全席位并行『取数』(各席位只调
+ # 技能取数、不研讨),产出作为共享资料注入后续研讨轮,把最慢的技能延迟前置并行、给串行研讨段提速。
+ # 默认 False(不变更现有行为)。逐条 opt-in。BOOLEAN,server_default "0"。
+ gather_first: Mapped[bool] = mapped_column(
+ Boolean, nullable=False, default=False, server_default="0"
+ )
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
is_public: Mapped[bool] = mapped_column(
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py
index f3506589..99c3ef2e 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py
@@ -58,6 +58,8 @@ class RoundtableChainRepository:
"coordinator_prompt": getattr(row, "coordinator_prompt", None),
# 默认席位执行模式(flash/thinking/pro/ultra);None = 未设。
"seat_mode": getattr(row, "seat_mode", None),
+ # 研讨前并行取数开关(逐条 opt-in);缺省 False。
+ "gather_first": bool(getattr(row, "gather_first", False)),
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
@@ -113,6 +115,7 @@ class RoundtableChainRepository:
stage_goals=_dumps(data.get("stage_goals")),
coordinator_prompt=(data.get("coordinator_prompt") or None),
seat_mode=(data.get("seat_mode") or None),
+ gather_first=bool(data.get("gather_first") or False),
is_public=bool(data.get("is_public") or False),
created_at=now,
updated_at=now,
@@ -135,6 +138,7 @@ class RoundtableChainRepository:
stage_goals=_UNSET,
coordinator_prompt=_UNSET,
seat_mode=_UNSET,
+ gather_first=_UNSET,
is_public=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
@@ -155,6 +159,8 @@ class RoundtableChainRepository:
row.coordinator_prompt = coordinator_prompt or None
if seat_mode is not _UNSET:
row.seat_mode = seat_mode or None
+ if gather_first is not _UNSET and gather_first is not None:
+ row.gather_first = bool(gather_first)
if is_public is not _UNSET and is_public is not None:
row.is_public = bool(is_public)
row.updated_at = datetime.now(UTC)
diff --git a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql
index 852d3d78..b240ad09 100644
--- a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql
+++ b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql
@@ -89,6 +89,11 @@ CALL _add_column_if_missing('roundtable_jobs', 'orchestration_plan', 'LONGTEXT N
-- 选入研讨时弹窗默认用它(仍可临时改)。ORM: RoundtableChainRow.seat_mode = String(16), nullable。
CALL _add_column_if_missing('roundtable_chains', 'seat_mode', 'VARCHAR(16) NULL DEFAULT NULL');
+-- gather_first: 业务链条「研讨前并行取数」开关,对应 migration 20260620_02。True=选入研讨时研讨前先
+-- 跑一轮全席位并行取数(只调技能、不研讨),产出作为共享资料注入研讨轮。逐条 opt-in。
+-- ORM: RoundtableChainRow.gather_first = Boolean, NOT NULL, server_default '0'。
+CALL _add_column_if_missing('roundtable_chains', 'gather_first', 'TINYINT(1) NOT NULL DEFAULT 0');
+
-- ---- 岗位 (positions) 订阅系统,对应 migration 20260616_01 -------------------
-- positions / position_tag_bindings 是【新表】,由 create_all 自动建,无需补列。
-- 以下是给【已存在的表】补的列:
diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py
index 0d55badf..57345641 100644
--- a/offline-backend-20260512/backend/tests/test_agents_backend.py
+++ b/offline-backend-20260512/backend/tests/test_agents_backend.py
@@ -177,12 +177,12 @@ def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> No
)
extras = _load_config_extras(agent_id)
- assert extras == {"skills": ["s1"], "model": None, "tool_groups": None, "common_questions": None}
+ assert extras == {"skills": ["s1"], "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None:
extras = _load_config_extras("does-not-exist")
- assert extras == {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
+ assert extras == {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
diff --git a/offline-backend-20260512/backend/tests/test_flow_extract_single_parent.py b/offline-backend-20260512/backend/tests/test_flow_extract_single_parent.py
new file mode 100644
index 00000000..b3757f56
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_flow_extract_single_parent.py
@@ -0,0 +1,78 @@
+"""rwfx 结构化抽取「纯树·单父节点」确定性兜底测试。
+
+rwfx 全链是一棵树(除预期行为↔驱动因素 1:1,其余皆 1:N),任何节点只有一个父节点、绝无
+多对一/汇聚。弱模型常给「叙事」连多个驱动因素 → 乱线。``_collapse_to_single_parent`` 兜底把
+每个节点的多父入边收敛到紧邻上一层的单一父节点。
+"""
+
+from __future__ import annotations
+
+from app.gateway.routers.flow_extract import _collapse_to_single_parent
+from deerflow.agents.roundtable_orchestrator.extraction_spec import (
+ chain_level_rank,
+ is_single_parent_chain,
+)
+
+
+def test_is_single_parent_chain() -> None:
+ assert is_single_parent_chain("6BF") is True
+ assert is_single_parent_chain("rwfx") is True
+ assert is_single_parent_chain("rwfx-A") is True # 多智能体 -A 后缀也认
+ assert is_single_parent_chain("7BF") is False # xdfx 有「脆弱点→策略」汇聚,非纯树
+ assert is_single_parent_chain("3Q") is False # 未写实业务保守不收敛
+ assert is_single_parent_chain(None) is False
+
+
+def test_chain_level_rank_rwfx() -> None:
+ rank = chain_level_rank("6BF")
+ assert rank == {"任务": 0, "目的": 1, "行为体": 2, "预期行为": 3, "驱动因素": 4, "叙事": 5}
+
+
+def _rwfx_graph_with_bad_narrative() -> dict:
+ # 叙事 s1 错误地连了 2 个驱动因素(上一层) + 1 个行为体(更上层),应被收敛成单父。
+ return {
+ "title": "t",
+ "nodes": [
+ {"id": "n1", "group": "任务"},
+ {"id": "m1", "group": "目的"},
+ {"id": "a1", "group": "行为体"},
+ {"id": "b1", "group": "预期行为"},
+ {"id": "b2", "group": "预期行为"},
+ {"id": "d1", "group": "驱动因素"},
+ {"id": "d2", "group": "驱动因素"},
+ {"id": "s1", "group": "叙事"},
+ ],
+ "edges": [
+ {"from": "n1", "to": "m1"},
+ {"from": "m1", "to": "a1"},
+ {"from": "a1", "to": "b1"},
+ {"from": "a1", "to": "b2"},
+ {"from": "b1", "to": "d1"},
+ {"from": "b2", "to": "d2"},
+ {"from": "d1", "to": "s1"}, # 叙事多父:驱动因素 d1
+ {"from": "d2", "to": "s1"}, # 叙事多父:驱动因素 d2
+ {"from": "a1", "to": "s1"}, # 叙事多父:行为体 a1(跨层)
+ ],
+ }
+
+
+def test_collapse_narrative_multi_parent_to_single() -> None:
+ out = _collapse_to_single_parent(_rwfx_graph_with_bad_narrative(), "6BF")
+ s1_parents = [e["from"] for e in out["edges"] if e["to"] == "s1"]
+ # 叙事只剩一个父;应保留紧邻上一层(驱动因素),不保留更上层的行为体;同层保留第一个。
+ assert s1_parents == ["d1"]
+ # 其余每个非根节点仍恰一个父(1:1 / 1:N 本就单父,不受影响)。
+ for nid in ("m1", "a1", "b1", "b2", "d1", "d2"):
+ assert len([e for e in out["edges"] if e["to"] == nid]) == 1
+ # 根节点无入边。
+ assert [e for e in out["edges"] if e["to"] == "n1"] == []
+
+
+def test_collapse_noop_when_already_tree() -> None:
+ # 每个节点本就单父 → 边数不变、内容不变。
+ graph = {
+ "nodes": [{"id": "n1", "group": "任务"}, {"id": "m1", "group": "目的"}, {"id": "a1", "group": "行为体"}],
+ "edges": [{"from": "n1", "to": "m1"}, {"from": "m1", "to": "a1"}],
+ }
+ out = _collapse_to_single_parent(graph, "6BF")
+ assert out["edges"] == [{"from": "n1", "to": "m1"}, {"from": "m1", "to": "a1"}]
diff --git a/offline-backend-20260512/backend/tests/test_roundtable_extraction_spec.py b/offline-backend-20260512/backend/tests/test_roundtable_extraction_spec.py
new file mode 100644
index 00000000..6557e56a
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_roundtable_extraction_spec.py
@@ -0,0 +1,107 @@
+"""按 businessCode 的「业务链抽取规范」+ 其在 flow_extract / summary 的注入测试。
+
+深链接会商模式下:结构化抽取(flow_extract)按业务完整层级抽全(根治"无论什么业务都按目的→行为体抽")、
+方案总结报告按业务链完整性核对。规范单份、两角色用法;仅 business_code 非空时注入。
+"""
+
+from __future__ import annotations
+
+from deerflow.agents.roundtable_orchestrator.extraction_spec import (
+ build_structure_directive,
+ build_summary_directive,
+ get_chain_spec,
+)
+
+
+# ── 规范本身 ──────────────────────────────────────────────────────────────────
+
+
+def test_rwfx_structure_directive_has_full_six_levels_and_relations():
+ out = build_structure_directive("6BF")
+ for level in ("任务", "目的", "行为体", "预期行为", "驱动因素", "叙事"):
+ assert level in out
+ assert "一一对应" in out # 预期行为↔驱动因素 1:1
+ assert "一对多" in out
+ # 不含步骤/阶段名 的命名约束
+ assert "步骤" in out and "阶段" in out
+ assert "优先" in out and "方案总结报告" in out # 取数优先
+
+
+def test_xdfx_structure_directive_is_branching_dag_with_many_to_one():
+ out = build_structure_directive("7BF")
+ for node in ("行动", "受众目标", "关键传播节点", "脆弱点", "策略"):
+ assert node in out, node
+ assert "分叉汇聚" in out
+ assert "多对一" in out
+ # 根是「行动」而非「任务」
+ assert "根节点 = 用户传入的「行动」" in out
+ # 7BF / xdfx / xdfx-A 都命中同一规范
+ for code in ("7BF", "xdfx", "XDFX-A"):
+ assert get_chain_spec(code) is not None and get_chain_spec(code).label == "xdfx", code
+
+
+def test_business_code_normalization_handles_suffix_and_case():
+ # rwfx-A / 小写 / 含 -A 后缀都应命中 rwfx 规范
+ for code in ("rwfx", "RWFX", "rwfx-A", "6bf", "6BF"):
+ spec = get_chain_spec(code)
+ assert spec is not None and spec.label == "rwfx", code
+
+
+def test_unknown_business_gets_generic_directive_not_rwfx_template():
+ out = build_structure_directive("3Q")
+ assert out # 非空(深链接业务仍注入通用规范)
+ assert "不要套用" in out # 明确不要套固定模板
+ # 通用规范不应写死 rwfx 的「预期行为/驱动因素」层级
+ assert "预期行为" not in out
+
+
+def test_no_business_code_returns_empty():
+ assert build_structure_directive("") == ""
+ assert build_structure_directive(None) == ""
+ assert build_summary_directive("") == ""
+ assert build_summary_directive(None) == ""
+
+
+def test_rwfx_summary_directive_checks_completeness():
+ out = build_summary_directive("6BF")
+ assert "完整性核对" in out
+ assert "叙事" in out and "驱动因素" in out
+ assert "待补充" in out # 缺层走护栏标注
+
+
+# ── flow_extract 层级提示注入(结构化抽取) ───────────────────────────────────
+
+
+def test_flow_extract_hierarchy_hint_uses_spec_when_business_code():
+ from app.gateway.routers.flow_extract import FlowExtractRequest, _hierarchy_hint
+
+ req = FlowExtractRequest(report_md="x", business_code="6BF")
+ hint = _hierarchy_hint(req)
+ # 业务规范权威注入:含完整层级 + 覆盖默认假设的措辞
+ assert "预期行为" in hint and "驱动因素" in hint and "叙事" in hint
+ assert "强制遵守" in hint
+
+ # 无 business_code → 回退原行为(不含业务规范块)
+ req2 = FlowExtractRequest(report_md="x", chain_levels=["目的", "行为体"])
+ hint2 = _hierarchy_hint(req2)
+ assert "强制遵守" not in hint2
+ assert "目的" in hint2 # 仍用 chain_levels
+
+
+# ── build_summary_prompt 业务完整性注入(方案总结报告) ────────────────────────
+
+
+def test_build_summary_prompt_injects_business_completeness():
+ from deerflow.agents.roundtable_orchestrator.engine import build_summary_prompt
+ from deerflow.agents.roundtable_orchestrator.types import SeatRef
+
+ agents = [SeatRef("seat-a", "情报")]
+ out = build_summary_prompt(
+ "目标X", agents, {"seat-a": "交付"}, "共识", business_code="6BF"
+ )
+ assert "完整性核对" in out
+ assert "预期行为" in out and "驱动因素" in out
+ # 无 business_code → 不注入业务完整性块(但意图对齐块仍在)
+ out2 = build_summary_prompt("目标X", agents, {"seat-a": "交付"}, "共识")
+ assert "完整性核对" not in out2
+ assert "意图对齐与缺口核对" in out2
diff --git a/offline-backend-20260512/backend/tests/test_roundtable_gather_phase.py b/offline-backend-20260512/backend/tests/test_roundtable_gather_phase.py
new file mode 100644
index 00000000..fa8de8ff
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_roundtable_gather_phase.py
@@ -0,0 +1,140 @@
+"""会商 Step2 性能改造引擎测试:研讨前并行『取数』阶段(gather_first)+ recommend 批内并行。
+
+覆盖:
+ - gather_first=True:研讨前先对**全席位并行**跑一轮「只取数」,产出作为共享资料注入后续研讨轮席位任务;
+ - gather_first=False(默认):不跑取数阶段,行为不变;
+ - recommend 一轮派多席位 → 批内**并行**执行(_run_recommend 由串行改 asyncio.gather,峰值并发>1)。
+
+引擎纯逻辑,用 JobProgressWriter(store=None) 干跑 + 录制网关,不需要 DB / 真模型。
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from deerflow.agents.roundtable_orchestrator import (
+ JobProgressWriter,
+ SeatRef,
+ run_orchestration,
+)
+from deerflow.agents.roundtable_orchestrator.types import LeaderTurn, SeatTurn
+
+_AGENTS = [SeatRef("seat-a", "情报"), SeatRef("seat-b", "环境"), SeatRef("seat-c", "方案")]
+# _GATHER_TASK 里的稳定标记(取数阶段)/ _seat_message 里注入资料块的稳定标记(研讨阶段)。
+_GATHER_MARK = "资料收集阶段"
+_GATHERED_BLOCK_MARK = "研讨前各席位已并行收集"
+
+
+def _writer() -> JobProgressWriter:
+ return JobProgressWriter(None, job_id="job-x", agents=_AGENTS)
+
+
+def _thread_ids() -> dict[str, str]:
+ return {a.agent_id: f"tid-{a.agent_id}" for a in _AGENTS} | {"roundtable-coordinator": "tid-co"}
+
+
+class _RecGateway:
+ """录制网关:run_leader 按预设各轮派活;run_seat 记录 (agent_id, task) 并追踪峰值并发。"""
+
+ def __init__(self, dispatch_cycles: list[list[tuple[str, str]]]) -> None:
+ self._cycles = dispatch_cycles
+ self._leader_calls = 0
+ self.seat_tasks: list[tuple[str, str]] = []
+ self.active = 0
+ self.max_active = 0
+
+ async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
+ i = self._leader_calls
+ self._leader_calls += 1
+ disp = self._cycles[i] if i < len(self._cycles) else []
+ return LeaderTurn(final_text=f"leader-{i}", dispatched=disp)
+
+ async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
+ self.active += 1
+ self.max_active = max(self.max_active, self.active)
+ self.seat_tasks.append((agent_id, task))
+ await asyncio.sleep(0.01) # 留出并发重叠窗口
+ self.active -= 1
+ return SeatTurn(final_text=f"{agent_id} 的产出")
+
+ # Step3 关掉,不应被调用
+ async def create_report_thread(self) -> str: # pragma: no cover - 不应触发
+ raise AssertionError("enable_report=False 不应绘制报告")
+
+
+async def _run(gw: _RecGateway, *, gather_first: bool):
+ return await run_orchestration(
+ gateway=gw,
+ progress=_writer(),
+ agents=_AGENTS,
+ thread_ids=_thread_ids(),
+ coordinator_name="roundtable-coordinator",
+ model=None,
+ seed_message="背景X",
+ mode="recommend",
+ intent_text="背景X",
+ enable_report=False,
+ gather_first=gather_first,
+ )
+
+
+# 一轮派全部 3 席位 → 下一轮共识收口。
+_DISPATCH_ALL_THEN_CONSENSUS = [[("seat-a", "taskA"), ("seat-b", "taskB"), ("seat-c", "taskC")], []]
+
+
+@pytest.mark.asyncio
+async def test_recommend_dispatch_runs_in_parallel():
+ """recommend 一轮派多席位 → 批内并行(峰值并发 = 席位数,证明已由串行改并行)。"""
+ gw = _RecGateway(_DISPATCH_ALL_THEN_CONSENSUS)
+ result = await _run(gw, gather_first=False)
+ assert result.status == "done"
+ assert gw.max_active == 3 # 三席位真并行;若仍串行则恒为 1
+
+
+@pytest.mark.asyncio
+async def test_gather_first_false_skips_gather_phase():
+ """默认 gather_first=False:不跑取数阶段,run_seat 只在研讨轮被调,无取数任务。"""
+ gw = _RecGateway(_DISPATCH_ALL_THEN_CONSENSUS)
+ await _run(gw, gather_first=False)
+ assert not any(_GATHER_MARK in task for _, task in gw.seat_tasks)
+ # 研讨轮任务也不应带「已收集资料」块(没有取数)
+ assert not any(_GATHERED_BLOCK_MARK in task for _, task in gw.seat_tasks)
+ # 仅 3 次席位调用(一轮研讨派活)
+ assert len(gw.seat_tasks) == 3
+
+
+@pytest.mark.asyncio
+async def test_gather_first_runs_parallel_gather_then_injects_material():
+ """gather_first=True:研讨前对全席位并行跑「只取数」一轮,产出注入后续研讨轮任务。"""
+ gw = _RecGateway(_DISPATCH_ALL_THEN_CONSENSUS)
+ result = await _run(gw, gather_first=True)
+ assert result.status == "done"
+
+ gather_calls = [(aid, t) for aid, t in gw.seat_tasks if _GATHER_MARK in t]
+ discuss_calls = [(aid, t) for aid, t in gw.seat_tasks if _GATHER_MARK not in t]
+
+ # ① 取数阶段覆盖全部席位(每席位一次「只取数」任务)
+ assert {aid for aid, _ in gather_calls} == {"seat-a", "seat-b", "seat-c"}
+ # ② 取数任务在研讨任务之前(取数是前置阶段)
+ first_discuss_idx = gw.seat_tasks.index(discuss_calls[0])
+ last_gather_idx = max(gw.seat_tasks.index(c) for c in gather_calls)
+ assert last_gather_idx < first_discuss_idx
+ # ③ 研讨轮任务带上「研讨前各席位已并行收集的资料」块,且包含各席位取数产出
+ assert discuss_calls, "应有研讨轮任务"
+ for _, dtask in discuss_calls:
+ assert _GATHERED_BLOCK_MARK in dtask
+ assert "seat-a 的产出" in dtask # 注入了取数阶段的产物
+
+
+@pytest.mark.asyncio
+async def test_gather_phase_is_parallel():
+ """取数阶段本身并行(全席位并发跑,峰值并发 = 席位数)。"""
+ # 首轮 leader 直接共识(dispatched=[]),让研讨轮不抢占并发,单独度量取数阶段峰值。
+ gw = _RecGateway([[]])
+ await _run(gw, gather_first=True)
+ # 只有取数阶段跑了 run_seat(研讨轮因首轮共识没派活)
+ assert all(_GATHER_MARK in t for _, t in gw.seat_tasks)
+ assert len(gw.seat_tasks) == 3
+ assert gw.max_active == 3 # 取数阶段三席位真并行
diff --git a/offline-backend-20260512/backend/tests/test_roundtable_model_fallback.py b/offline-backend-20260512/backend/tests/test_roundtable_model_fallback.py
new file mode 100644
index 00000000..d4207fbc
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_roundtable_model_fallback.py
@@ -0,0 +1,207 @@
+"""会商「模型自动容错(换下一个模型)」测试。
+
+覆盖:
+ - 中间件兜底/熔断 AIMessage 带可识别标记 + ``is_llm_error_message`` 检测(标记 + 正文回退);
+ - ``fallback_model_chain`` 按 config.yaml models[] 顺序排候选(当前优先、去重、开关关闭=单个);
+ - ``reason_text`` 文案;
+ - 引擎 Step2 单席位异常隔离(一个席位抛错不拖垮整链/整批,仅置空交付);
+ - 网关带回的模型容错记录(switches)→ 引擎在时间线插「系统提示」气泡。
+
+纯单元/纯逻辑:引擎用 JobProgressWriter(store=None) 干跑 + 假网关,不需要 DB / 真模型。
+"""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+import app.gateway.roundtable_model_fallback as mf
+from app.gateway.roundtable_model_fallback import (
+ fallback_model_chain,
+ is_llm_error_message,
+ reason_text,
+)
+from deerflow.agents.middlewares.llm_error_handling_middleware import (
+ LLM_ERROR_MARKER,
+ LLMErrorHandlingMiddleware,
+)
+from deerflow.agents.middlewares.llm_error_handling_middleware import (
+ is_llm_error_message as harness_is_llm_error_message,
+)
+from deerflow.agents.roundtable_orchestrator import (
+ JobProgressWriter,
+ SeatRef,
+ run_orchestration,
+)
+from deerflow.agents.roundtable_orchestrator.types import LeaderTurn, ModelSwitch, SeatTurn
+
+# ── is_llm_error_message ─────────────────────────────────────────────────────
+
+
+def test_is_llm_error_message_marker_dict() -> None:
+ msg = {"type": "ai", "content": "随便什么", "additional_kwargs": {LLM_ERROR_MARKER: "busy"}}
+ assert is_llm_error_message(msg) == "busy"
+ assert harness_is_llm_error_message(msg) == "busy"
+
+
+def test_is_llm_error_message_content_fallback() -> None:
+ # additional_kwargs 被剥离时,按已知兜底文案正文回退识别。
+ msg = {"type": "ai", "content": "大模型本次调用未能成功完成,请稍后再试或更换模型。"}
+ assert is_llm_error_message(msg) == "generic"
+
+
+def test_is_llm_error_message_normal_is_none() -> None:
+ assert is_llm_error_message({"type": "ai", "content": "这是正常的席位交付内容"}) is None
+ assert is_llm_error_message(None) is None
+ assert is_llm_error_message({"type": "ai", "content": ""}) is None
+
+
+# ── 中间件给兜底 AIMessage 打标记 ────────────────────────────────────────────
+
+
+def _middleware() -> LLMErrorHandlingMiddleware:
+ app_config = SimpleNamespace(circuit_breaker=SimpleNamespace(failure_threshold=5, recovery_timeout_sec=30))
+ return LLMErrorHandlingMiddleware(app_config=app_config)
+
+
+def test_middleware_marks_error_message() -> None:
+ mw = _middleware()
+
+ def _boom(_req):
+ raise RuntimeError("insufficient_quota: 余额不足") # quota → 非重试 → 立即兜底
+
+ out = mw.wrap_model_call(None, _boom)
+ assert harness_is_llm_error_message(out) == "quota"
+ assert out.additional_kwargs.get(LLM_ERROR_MARKER) == "quota"
+
+
+# ── fallback_model_chain ─────────────────────────────────────────────────────
+
+
+def _patch_models(monkeypatch, names: list[str]) -> None:
+ cfg = SimpleNamespace(models=[SimpleNamespace(name=n) for n in names])
+ monkeypatch.setattr(mf, "get_app_config", lambda: cfg)
+
+
+def test_chain_current_first_then_config_order(monkeypatch) -> None:
+ _patch_models(monkeypatch, ["m1", "m2", "m3"])
+ monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK", raising=False)
+ monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK_MAX", raising=False)
+ assert fallback_model_chain("m2") == ["m2", "m1", "m3"]
+
+
+def test_chain_empty_current_uses_config_order(monkeypatch) -> None:
+ _patch_models(monkeypatch, ["m1", "m2", "m3"])
+ monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK", raising=False)
+ assert fallback_model_chain("") == ["m1", "m2", "m3"]
+ assert fallback_model_chain(None) == ["m1", "m2", "m3"]
+
+
+def test_chain_unknown_current_kept_first(monkeypatch) -> None:
+ _patch_models(monkeypatch, ["m1", "m2"])
+ monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK", raising=False)
+ assert fallback_model_chain("custom") == ["custom", "m1", "m2"]
+
+
+def test_chain_disabled_single(monkeypatch) -> None:
+ _patch_models(monkeypatch, ["m1", "m2", "m3"])
+ monkeypatch.setenv("ROUNDTABLE_MODEL_FALLBACK", "0")
+ assert fallback_model_chain("m2") == ["m2"]
+
+
+def test_chain_cap(monkeypatch) -> None:
+ _patch_models(monkeypatch, ["m1", "m2", "m3", "m4"])
+ monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK", raising=False)
+ monkeypatch.setenv("ROUNDTABLE_MODEL_FALLBACK_MAX", "2")
+ assert fallback_model_chain("m1") == ["m1", "m2"]
+
+
+def test_chain_no_config_returns_current(monkeypatch) -> None:
+ _patch_models(monkeypatch, [])
+ monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK", raising=False)
+ assert fallback_model_chain("m9") == ["m9"]
+ assert fallback_model_chain("") == []
+
+
+def test_reason_text() -> None:
+ assert reason_text("busy") == "服务繁忙"
+ assert reason_text("empty") == "未产出有效内容"
+ assert reason_text("timeout") == "长时间无响应(卡死)"
+ assert reason_text(None) == "调用失败"
+ assert reason_text("不存在的码") == "调用失败"
+
+
+# ── 引擎:单席位异常隔离 + 模型容错记录 → 系统提示气泡 ──────────────────────────
+
+_AGENTS = [SeatRef("seat-a", "情报"), SeatRef("seat-b", "环境"), SeatRef("seat-c", "方案")]
+
+
+def _writer() -> JobProgressWriter:
+ return JobProgressWriter(None, job_id="job-x", agents=_AGENTS)
+
+
+def _thread_ids() -> dict[str, str]:
+ return {a.agent_id: f"tid-{a.agent_id}" for a in _AGENTS} | {"roundtable-coordinator": "tid-co"}
+
+
+class _ChainGateway:
+ """chain 模式假网关:可指定某席位抛异常 / 带回模型容错记录。"""
+
+ def __init__(self, *, fail_agent: str | None = None, switch_agent: str | None = None) -> None:
+ self.fail_agent = fail_agent
+ self.switch_agent = switch_agent
+ self.seen: list[str] = []
+
+ async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
+ return LeaderTurn(final_text="总控说明", dispatched=[])
+
+ async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
+ self.seen.append(agent_id)
+ if agent_id == self.fail_agent:
+ raise RuntimeError("boom: 模型连接被打满")
+ switches = []
+ if agent_id == self.switch_agent:
+ switches = [ModelSwitch(failed_model="m1", next_model="m2", reason_label="服务繁忙")]
+ return SeatTurn(final_text=f"{agent_id} 的交付", switches=switches)
+
+ async def create_report_thread(self) -> str: # pragma: no cover
+ raise AssertionError("enable_report=False 不应绘制报告")
+
+
+async def _run_chain(gw: _ChainGateway) -> tuple[object, JobProgressWriter]:
+ writer = _writer()
+ result = await run_orchestration(
+ gateway=gw,
+ progress=writer,
+ agents=_AGENTS,
+ thread_ids=_thread_ids(),
+ coordinator_name="roundtable-coordinator",
+ model="m1",
+ seed_message="种子任务",
+ mode="chain",
+ enable_report=False,
+ )
+ return result, writer
+
+
+def test_chain_seat_failure_isolated() -> None:
+ gw = _ChainGateway(fail_agent="seat-b")
+ result, writer = asyncio.run(_run_chain(gw))
+ # 单席位失败不拖垮整链:仍走完三个席位并收口为 done。
+ assert result.status == "done"
+ assert gw.seen == ["seat-a", "seat-b", "seat-c"]
+ # 失败席位的交付气泡为空,其余正常。
+ seat_dlgs = {d["agentId"]: d["content"] for d in writer.dialogues() if d["role"] == "seat"}
+ assert seat_dlgs["seat-b"] == ""
+ assert seat_dlgs["seat-a"] == "seat-a 的交付"
+ assert seat_dlgs["seat-c"] == "seat-c 的交付"
+
+
+def test_model_switch_emits_system_notice() -> None:
+ gw = _ChainGateway(switch_agent="seat-b")
+ _result, writer = asyncio.run(_run_chain(gw))
+ systems = [d for d in writer.dialogues() if d["role"] == "system"]
+ assert len(systems) == 1
+ assert "模型容错" in systems[0]["content"]
+ assert "服务繁忙" in systems[0]["content"]
+ assert "m2" in systems[0]["content"]
diff --git a/offline-backend-20260512/backend/tests/test_roundtable_seat_modes.py b/offline-backend-20260512/backend/tests/test_roundtable_seat_modes.py
new file mode 100644
index 00000000..954218d9
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_roundtable_seat_modes.py
@@ -0,0 +1,118 @@
+"""每席位「推理深度覆盖」(seat_modes)在进程内网关 run_seat 的解析测试。
+
+语义(与前端 useStep2Orchestration「覆盖优先、否则跟随链条级 selectedMode」逐席位对齐):
+- 席位命中 seat_modes → 用该档(_SEAT_MODE_PARAMS)派生的 thinking/reasoning/subagent **覆盖**
+ 作业级 seat_*(不论作业级是什么);
+- 席位未命中 → 跟随作业级 seat_thinking_enabled / seat_reasoning_effort / seat_subagent_enabled;
+- 作业级仍为 None → 落 policy("seat") 默认。
+
+只验证传给 _run_agent_turn 的三个运行参数,故把 _run_agent_turn / policy / 文本解析全部打桩,
+不触发真实 LLM / 沙箱。
+"""
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+
+@pytest.fixture
+def _gw(monkeypatch):
+ """构造一个被打桩的 InProcessRoundtableGateway,返回 (build, captured)。
+
+ build(seat_modes=..., seat_thinking_enabled=..., seat_reasoning_effort=..., seat_subagent_enabled=...)
+ 返回 gateway 实例;captured 记录最近一次 _run_agent_turn 收到的 thinking/reasoning/subagent。
+ """
+ import app.gateway.roundtable_inprocess_gateway as gw
+
+ captured: dict[str, object] = {}
+
+ async def _fake_run_agent_turn(*args, **kwargs):
+ captured["thinking_enabled"] = kwargs.get("thinking_enabled")
+ captured["reasoning_effort"] = kwargs.get("reasoning_effort")
+ captured["subagent_enabled"] = kwargs.get("subagent_enabled")
+ return []
+
+ # policy("seat") 默认:thinking=True / reasoning="medium"(= 现有席位默认 pro 行为)。
+ monkeypatch.setattr(
+ gw,
+ "roundtable_run_policy",
+ lambda role: SimpleNamespace(excluded_tools=[], skill_stop_names=[], thinking_enabled=True, reasoning_effort="medium"),
+ )
+ monkeypatch.setattr(gw, "_run_agent_turn", _fake_run_agent_turn)
+ # 技能强化与本测试无关 → 原样返回任务,避免触发 load_agent_config / skill storage。
+ monkeypatch.setattr(gw, "append_seat_skill_directive", lambda agent_id, task, **k: task)
+ monkeypatch.setattr(gw, "_latest_ai_text", lambda messages: "")
+ monkeypatch.setattr(gw, "_tool_calls_since_last_human", lambda messages: [])
+
+ def _build(**kwargs):
+ return gw.InProcessRoundtableGateway(SimpleNamespace(state=SimpleNamespace()), user_id="u1", agents=[], **kwargs)
+
+ return _build, captured
+
+
+async def _run(gateway):
+ await gateway.run_seat(thread_ids={"seat-1": "tid-1"}, agent_id="seat-1", task="任务", model="m")
+
+
+@pytest.mark.asyncio
+async def test_seat_mode_override_wins_over_job_level(_gw):
+ """席位配了 flash → 覆盖作业级 pro(thinking off / minimal / 无子代理)。"""
+ build, captured = _gw
+ gateway = build(
+ seat_modes={"seat-1": "flash"},
+ seat_thinking_enabled=True,
+ seat_reasoning_effort="high",
+ seat_subagent_enabled=True,
+ )
+ await _run(gateway)
+ assert captured["thinking_enabled"] is False
+ assert captured["reasoning_effort"] == "minimal"
+ assert captured["subagent_enabled"] is False
+
+
+@pytest.mark.asyncio
+async def test_ultra_override_enables_subagent(_gw):
+ build, captured = _gw
+ gateway = build(seat_modes={"seat-1": "ultra"})
+ await _run(gateway)
+ assert captured["thinking_enabled"] is True
+ assert captured["reasoning_effort"] == "high"
+ assert captured["subagent_enabled"] is True
+
+
+@pytest.mark.asyncio
+async def test_no_override_follows_job_level(_gw):
+ """席位未命中 seat_modes → 跟随作业级 seat_*。"""
+ build, captured = _gw
+ gateway = build(
+ seat_modes={"other": "flash"},
+ seat_thinking_enabled=True,
+ seat_reasoning_effort="low",
+ seat_subagent_enabled=False,
+ )
+ await _run(gateway)
+ assert captured["thinking_enabled"] is True
+ assert captured["reasoning_effort"] == "low"
+ assert captured["subagent_enabled"] is False
+
+
+@pytest.mark.asyncio
+async def test_no_override_no_job_level_falls_back_to_policy(_gw):
+ """既无席位覆盖、作业级也为 None → 落 policy 默认(thinking=True / medium)。"""
+ build, captured = _gw
+ gateway = build(seat_modes={})
+ await _run(gateway)
+ assert captured["thinking_enabled"] is True
+ assert captured["reasoning_effort"] == "medium"
+ assert captured["subagent_enabled"] is False
+
+
+@pytest.mark.asyncio
+async def test_unknown_mode_value_ignored_falls_through(_gw):
+ """非法档位值(前端不会下发,防御)→ 安全忽略,回退作业级/policy。"""
+ build, captured = _gw
+ gateway = build(seat_modes={"seat-1": "bogus"}, seat_reasoning_effort="low", seat_thinking_enabled=True)
+ await _run(gateway)
+ assert captured["thinking_enabled"] is True
+ assert captured["reasoning_effort"] == "low"
diff --git a/offline-backend-20260512/backend/tests/test_roundtable_summary_prompt.py b/offline-backend-20260512/backend/tests/test_roundtable_summary_prompt.py
new file mode 100644
index 00000000..871eafab
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_roundtable_summary_prompt.py
@@ -0,0 +1,52 @@
+"""方案总结报告 prompt(build_summary_prompt)的意图对齐 + 缺口护栏内容测试。
+
+效果改造 B:弱席位场景下,总结智能体不能只拼接各席位交付,而要先对齐用户意图、核对覆盖、
+按护栏补强(推断标注「(综合推断)」、缺真实信息标注「待补充」交给用户、绝不凭空编)。
+prompt 是纯函数,直接断言其文本包含这些指令 + 仍带齐意图/交付/共识/编排提示原料。
+"""
+
+from __future__ import annotations
+
+from deerflow.agents.roundtable_orchestrator.engine import build_summary_prompt
+from deerflow.agents.roundtable_orchestrator.types import SeatRef
+
+_AGENTS = [SeatRef("seat-a", "情报"), SeatRef("seat-b", "方案")]
+_DELIVERIES = {"seat-a": "情报席位交付内容", "seat-b": "方案席位交付内容"}
+
+
+def test_summary_prompt_carries_intent_and_alignment_directive():
+ out = build_summary_prompt(
+ "目标:拿下华东市场\n约束条件:\n- 预算有限",
+ _AGENTS,
+ _DELIVERIES,
+ "总控共识:分两期推进",
+ coordinator_prompt="重点突出风险",
+ )
+ # 原料齐全
+ assert "拿下华东市场" in out
+ assert "预算有限" in out
+ assert "情报席位交付内容" in out and "方案席位交付内容" in out
+ assert "总控共识:分两期推进" in out
+ assert "重点突出风险" in out # 编排提示
+ # 意图对齐 + 缺口核对指令
+ assert "意图对齐与缺口核对" in out
+ assert "核心诉求清单" in out
+ # 防幻觉 + 报告文件完整干净(不足只在对话正文、不写进 .md)+ 对话必须有「核对说明」
+ assert "绝不凭空编" in out
+ assert "无需用户修改" in out
+ assert "不写进 .md" in out
+ assert "核对说明" in out
+ assert "不要省略" in out
+
+
+def test_summary_prompt_alignment_directive_present_even_without_coordinator_prompt():
+ out = build_summary_prompt("目标:X", _AGENTS, _DELIVERIES, "共识 Y")
+ assert "意图对齐与缺口核对" in out
+ assert "待补充" in out
+
+
+def test_summary_prompt_handles_empty_deliveries_gracefully():
+ out = build_summary_prompt("目标:X", _AGENTS, {}, "")
+ # 无交付时仍给出兜底说明,且意图对齐指令照常在
+ assert "暂无文字交付" in out
+ assert "意图对齐与缺口核对" in out