From 9a1b6749507d72ab968197dc813d947524279d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 20 Jun 2026 13:37:29 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat(roundtable):=20=E4=BC=9A=E5=95=86?= =?UTF-8?q?=E5=B8=AD=E4=BD=8D=E3=80=8C=E6=8A=80=E8=83=BD=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E5=BC=BA=E5=8C=96=E3=80=8D=E6=94=B9=E4=B8=BA=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E5=BC=80=E5=85=B3(=E6=99=BA=E8=83=BD=E4=BD=93=E7=BA=A7=20+=20?= =?UTF-8?q?=E9=93=BE=E6=9D=A1=E6=AF=8F=E5=B8=AD=E4=BD=8D=E7=BA=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 弱模型补偿指令从全域生效改为默认关闭、两个 opt-in 开关取 OR: - 智能体级: AgentConfig.seat_skill_directive(config.yaml),在智能体编辑弹窗 (agent-card / agent-detail-panel)开关;agents 路由 create/update/读写全程穿。 - 业务链条每席位级: 存进链条 seats JSON(ChainSeatSchema.seat_skill_directive, 无需 DB 列/迁移),在链条编辑器每个席位卡片右侧 ✨ 图标→下拉框(开关+说明)配置; 按 agentId 查表穿到前台 useStep2Orchestration 各席位调用 + 后台作业 seat_skill_directives:{agentId:bool} → InProcessRoundtableGateway.run_seat。 - ROUNDTABLE_SEAT_SKILL_DIRECTIVE 降级为总熔断开关(默认放行,不再 force-on)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/agents/agent-card.tsx | 23 +++++++ .../workspace/agents/agent-detail-panel.tsx | 20 +++++++ frontend-web/src/core/agents/types.ts | 8 +++ frontend-web/src/pages/NewAgentPage.tsx | 5 ++ .../roundtable-planning/api/multi-agent.ts | 8 +++ .../api/roundtable-jobs.ts | 3 + .../components/BusinessChainPicker.tsx | 7 ++- .../hooks/useStep2Orchestration.ts | 21 +++++++ .../roundtable-planning/lib/business-chain.ts | 17 +++++- .../roundtable-planning/lib/chain-stages.ts | 14 +++++ .../src/roundtable-planning/lib/job-types.ts | 5 ++ .../pages/BusinessChainEditorPage.tsx | 60 ++++++++++++++++++- .../pages/RoundtablePlanningPage.tsx | 15 +++++ offline-backend-20260512/backend/CLAUDE.md | 2 +- .../backend/app/gateway/deps.py | 1 + .../gateway/roundtable_inprocess_gateway.py | 13 +++- .../app/gateway/roundtable_job_executor.py | 6 +- .../app/gateway/roundtable_seat_skills.py | 46 ++++++++++---- .../backend/app/gateway/routers/agents.py | 22 ++++++- .../app/gateway/routers/multi_agent.py | 11 +++- .../app/gateway/routers/roundtable_chains.py | 5 ++ .../app/gateway/routers/roundtable_jobs.py | 5 ++ .../harness/deerflow/config/agents_config.py | 6 ++ .../backend/tests/test_agents_backend.py | 4 +- 24 files changed, 298 insertions(+), 29 deletions(-) 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 + 再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在业务链条配置中打开(任一即生效)。 +

+
+ + setDraft({ ...draft, seatSkillDirective: checked }) + } + /> +

公开发布

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/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 4ab2564a..f3c165a3 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -83,6 +83,8 @@ export default function NewAgentPage() { const [modelInput, setModelInput] = useState(""); const [chatModelInput, setChatModelInput] = useState(""); const [templateNameInput, setTemplateNameInput] = useState(""); + // 会商席位「技能识别强化」开关(弱模型补偿)。默认关。 + const [seatSkillDirectiveInput, setSeatSkillDirectiveInput] = useState(false); const [formError, setFormError] = useState(""); const [agentId, setAgentId] = useState(""); const [agent, setAgent] = useState(null); @@ -230,6 +232,7 @@ export default function NewAgentPage() { skills: selectedSkills, model: modelOverride, template_name: templateNameInput.trim(), + seat_skill_directive: seatSkillDirectiveInput, }); setAgent(updated); return updated; @@ -251,6 +254,7 @@ export default function NewAgentPage() { skills: selectedSkills, model: modelOverride, template_name: templateNameInput.trim(), + seat_skill_directive: seatSkillDirectiveInput, }); setAgentId(created.id); setAgent(created); @@ -281,6 +285,7 @@ export default function NewAgentPage() { selectedSkills, soulInput, templateNameInput, + seatSkillDirectiveInput, t.agents.nameStepCheckError, t.agents.nameStepNetworkError, validateForm, diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index 316120fc..02c7fd99 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -234,6 +234,12 @@ export interface StreamMultiAgentRequest { reasoningEffort?: "minimal" | "low" | "medium" | "high"; /** 仅 special 用;ultra 档置真,给席位解锁 `task` 子代理工具。 */ subagentEnabled?: boolean; + /** + * 仅 special 用。会商席位「技能识别强化」开关(沿用所选业务链条配置)。置 true 时后端把该 + * 席位配置的技能(含 SKILL.md 路径 + 必须先 read_file 的硬指令)拼进本轮任务,补偿弱模型。 + * 与该席位 agent 自身 config.yaml 的同名开关取 OR。省略 = false(关)。 + */ + seatSkillDirective?: boolean; skillStopNames?: string[]; /** * 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付 @@ -485,6 +491,8 @@ export async function streamMultiAgent( thinking_enabled: req.thinkingEnabled, reasoning_effort: req.reasoningEffort, subagent_enabled: req.subagentEnabled, + // 仅 special 用;会商技能识别强化(沿用所选业务链条开关)。省略 → 后端按 false(关)处理。 + seat_skill_directive: req.seatSkillDirective ?? false, skill_stop_names: req.skillStopNames ?? [], // 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。 synthesis_mode: req.synthesisMode ?? false, diff --git a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts index bcf251cd..befe38e1 100644 --- a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts +++ b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts @@ -98,6 +98,9 @@ function toStartBody(p: StartJobPayload) { seedMessage: p.seedMessage, threadIds: p.threadIds ?? null, coordinatorName: p.coordinatorName ?? "roundtable-coordinator", + // 每席位技能识别强化映射 {agentId: bool}:沿用所选业务链条里各席位的开关;缺省 {}。 + // 后端 run_seat 按 agentId 查表,与各席位 agent 自身开关取 OR。 + seat_skill_directives: p.seatSkillDirectives ?? {}, ...(seat ? { thinking_enabled: seat.thinking_enabled, diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx index 0254d886..9a1b5b36 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx @@ -150,18 +150,21 @@ export function BusinessChainPicker({ // 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局模型。 const modelById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.model ?? null])); const modelOf = (id: string) => modelById.get(id) ?? null; + // 席位「会商技能识别强化」开关(链条配置):按 agent_id 取,缺省 = 关。 + const skillById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.seat_skill_directive ?? false])); + const skillOf = (id: string) => skillById.get(id) ?? false; // 链条带分层、过滤存活后仍并行 → dag 编排(携带快速/沙箱子模式);否则退回线性 chain。 if (dagPlan) { // dag:席位顺序按 stage 展平(与 plan 一致)。 const agents = dagPlan.stages .flatMap((st) => st.agentIds) - .map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) })); + .map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id) })); onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode }); return; } - const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) })); + const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id) })); onConfirm({ agents, mode: "chain", chain: chainMeta }); }; diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 70bfb38f..bf8e86e1 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -279,6 +279,12 @@ export interface UseStep2OrchestrationOptions { * 智能体」弹窗确认时的 `SeatSelectionResult.seatMode` 决定。缺省 `pro`。 */ selectedMode: SeatMode; + /** + * 每席位「技能识别强化」开关映射 {agentId: bool}(沿用所选业务链条里每个席位的配置)。 + * 按 agentId 查表后透传给该 special 席位 run,与该席位 agent 自身 config.yaml 的同名开关取 + * OR。recommend / 未选链条 → 空 {}(各席位仅靠其自身开关)。 + */ + seatSkillDirectives?: Record; /** * 编排模式: * - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`); @@ -434,6 +440,7 @@ export function useStep2Orchestration( currentStep, selectedModel, selectedMode, + seatSkillDirectives = {}, orchestrationMode, orchestrationPlan, dagExecMode = "fast", @@ -567,6 +574,8 @@ export function useStep2Orchestration( const selectedModelRef = useRef(selectedModel); // 席位执行模式同样打到 ref,让席位调用在编排循环里读到最新值而不重建回调。 const selectedModeRef = useRef(selectedMode); + // 每席位技能识别强化映射(所选业务链条各席位开关)同样打到 ref,席位调用按 agentId 读最新值。 + const seatSkillDirectivesRef = useRef>(seatSkillDirectives); const agentConfigsRef = useRef>(agentConfigs); const agentNameToRoleRef = useRef(agentNameToRole); const threadIdsRef = useRef(threadIds); @@ -606,6 +615,9 @@ export function useStep2Orchestration( useEffect(() => { selectedModeRef.current = selectedMode; }, [selectedMode]); + useEffect(() => { + seatSkillDirectivesRef.current = seatSkillDirectives; + }, [seatSkillDirectives]); useEffect(() => { agentConfigsRef.current = agentConfigs; }, [agentConfigs]); @@ -1602,6 +1614,9 @@ export function useStep2Orchestration( model: perSeatModel || selectedModelRef.current || undefined, // 席位执行模式(快速/思考/专业问答/多智能体问答)→ thinking/reasoning/subagent。 ...seatModeToRequestFields(selectedModeRef.current), + // 会商技能识别强化(该席位在所选业务链条里的开关,按 agentId 查表);与该席位 + // agent 自身开关取 OR。 + seatSkillDirective: seatSkillDirectivesRef.current[a.agentId] ?? false, signal: subAborter.signal, onTextDelta: (text) => a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text), @@ -2031,6 +2046,9 @@ export function useStep2Orchestration( model: perSeatModel || selectedModelRef.current || undefined, // 席位执行模式 → thinking/reasoning/subagent(与推荐/DAG 路径一致)。 ...seatModeToRequestFields(selectedModeRef.current), + // 会商技能识别强化(该席位在所选业务链条里的开关,按 agentId 查表);与该席位 + // agent 自身开关取 OR。 + seatSkillDirective: seatSkillDirectivesRef.current[seat.agent_id] ?? false, signal: subAborter.signal, onTextDelta: (text) => appendToDialogue(subBubbleId, text), onPhaseChange: (phase, toolName, args) => { @@ -2371,6 +2389,9 @@ export function useStep2Orchestration( model: perSeatModel || selectedModelRef.current || undefined, // 席位执行模式 → thinking/reasoning/subagent(与推荐/链条路径一致)。 ...seatModeToRequestFields(selectedModeRef.current), + // 会商技能识别强化(该席位在所选业务链条里的开关,按 agentId 查表);与该 + // 席位 agent 自身开关取 OR。 + seatSkillDirective: seatSkillDirectivesRef.current[agentId] ?? false, signal: subAborter.signal, // §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点 // stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。 diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index 46b8d2a6..dfd68c94 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -29,6 +29,13 @@ export interface ChainSeat { * 的 `handleConfirmAgents`)→ 走现有 per-seat 模型链路。 */ model?: string | null; + /** + * 可选「会商技能识别强化」开关(弱模型补偿)。true = 该席位作为会商子智能体执行时,把其 + * 配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 的硬指令)拼进本轮任务。与该席位 + * 智能体自身的同名开关(智能体编辑里的开关)取 OR。缺省 / false = 关。随 `seats` JSON 落库 + * (无需 DB 列),在链条编辑器里逐席位开关。 + */ + seat_skill_directive?: boolean; } /** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */ @@ -80,8 +87,9 @@ export interface SeatSelectionResult { /** * 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。 * `model` 为该席位在来源链条里配置的模型覆盖(空 / 缺省 = 跟随全局模型)。 + * `seat_skill_directive` 为该席位在来源链条里配置的「技能识别强化」开关(缺省 = 关)。 */ - agents: Array<{ agent_id: string; name: string; model?: string | null }>; + agents: Array<{ agent_id: string; name: string; model?: string | null; seat_skill_directive?: boolean }>; /** * 决定 Step 2 走哪种编排: * - `recommend` 总控自由派活; @@ -135,6 +143,9 @@ export function buildChainSeatSelection( // 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局。 const modelById = new Map(chain.seats.map((s) => [s.agent_id, s.model ?? null])); const modelOf = (id: string) => modelById.get(id) ?? null; + // 席位「技能识别强化」开关(链条配置):按 agent_id 取,缺省 = 关。 + const skillById = new Map(chain.seats.map((s) => [s.agent_id, s.seat_skill_directive ?? false])); + const skillOf = (id: string) => skillById.get(id) ?? false; const survivingSeats = chain.seats.filter((s) => existingIds.has(s.agent_id)); if (survivingSeats.length < CHAIN_MIN_SEATS) return null; @@ -156,12 +167,12 @@ export function buildChainSeatSelection( if (hasParallel || hasGoal || hasCoord) { const agents = plan.stages .flatMap((st) => st.agentIds) - .map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) })); + .map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id) })); return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast", seatMode: chain.seatMode }; } } - const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) })); + const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id) })); return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode }; } diff --git a/frontend-web/src/roundtable-planning/lib/chain-stages.ts b/frontend-web/src/roundtable-planning/lib/chain-stages.ts index a077cc0c..0fc1c20f 100644 --- a/frontend-web/src/roundtable-planning/lib/chain-stages.ts +++ b/frontend-web/src/roundtable-planning/lib/chain-stages.ts @@ -126,6 +126,20 @@ export function setSeatModel(groups: StageGroups, agentId: string, model: string }); } +/** + * 设置某个席位的「会商技能识别强化」开关(不可变,供编辑器席位下拉 onChange)。 + * 按 agent_id 定位,跨 stage 适用。随 `seats` JSON 落库。 + */ +export function setSeatSkillDirective(groups: StageGroups, agentId: string, enabled: boolean): StageGroups { + return groups.map((g) => { + if (!g.seats.some((s) => s.agent_id === agentId)) return g; + return { + ...g, + seats: g.seats.map((s) => (s.agent_id === agentId ? { ...s, seat_skill_directive: enabled } : s)), + }; + }); +} + // ── 变换(均返回新结构,不可变)────────────────────────────────────────────── /** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */ diff --git a/frontend-web/src/roundtable-planning/lib/job-types.ts b/frontend-web/src/roundtable-planning/lib/job-types.ts index 7c1da33e..6c3a9f4e 100644 --- a/frontend-web/src/roundtable-planning/lib/job-types.ts +++ b/frontend-web/src/roundtable-planning/lib/job-types.ts @@ -132,6 +132,11 @@ export interface StartJobPayload { * reasoning/subagent 三字段发给后端。 */ seatMode?: SeatMode; + /** + * 每席位「技能识别强化」开关映射 {agentId: bool}(沿用所选业务链条里每个席位的配置)。 + * 后台席位 run 按 agentId 查表(与各席位智能体自身的同名开关取 OR)决定是否注入。缺省 {}。 + */ + seatSkillDirectives?: Record; } /** 终态集合——SSE/轮询判定作业是否结束。 */ diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 9fd1e2eb..5278e721 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -37,6 +37,7 @@ import { Plus, Save, Sliders, + Sparkles, SplitSquareVertical, Trash2, UserPlus, @@ -63,6 +64,12 @@ import { import { CSS } from "@dnd-kit/utilities"; import { cn } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Switch } from "@/components/ui/switch"; import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { ModelNameSelect } from "@/components/workspace/model-name-select"; @@ -105,6 +112,7 @@ import { removeSeat as removeSeatFromGroups, seatExistsInGroups, setSeatModel, + setSeatSkillDirective, setStageGoal, splitSeatToNewStage, stageGroupsToSeats, @@ -366,6 +374,11 @@ export default function BusinessChainEditorPage() { setStageGroups((prev) => setSeatModel(prev, agentId, model)); }, []); + // 席位「会商技能识别强化」开关:编辑器席位下拉 onChange。 + const setSeatSkillDirectiveCb = useCallback((agentId: string, enabled: boolean) => { + setStageGroups((prev) => setSeatSkillDirective(prev, agentId, enabled)); + }, []); + // ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)──────────────────────── const [optimizingCoordinator, setOptimizingCoordinator] = useState(false); const [goalOptimizingIdx, setGoalOptimizingIdx] = useState(null); @@ -564,6 +577,8 @@ export default function BusinessChainEditorPage() { description: live?.description ?? s.description ?? "", // 席位模型覆盖:随席位一起落库(空 → null = 跟随全局)。 model: s.model ?? null, + // 席位「会商技能识别强化」开关:随席位一起落库(缺省 = false 关)。 + seat_skill_directive: s.seat_skill_directive ?? false, }; }); // 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。 @@ -1131,6 +1146,7 @@ export default function BusinessChainEditorPage() { onMergeSeat={mergeSeatToStage} onSplitSeat={splitSeat} onSeatModelChange={setSeatModelCb} + onSeatSkillDirectiveChange={setSeatSkillDirectiveCb} onStageGoalChange={setGoal} goalOptimizingIdx={goalOptimizingIdx} onOptimizeGoal={handleOptimizeStageGoal} @@ -1309,6 +1325,7 @@ function StagesColumn({ onMergeSeat, onSplitSeat, onSeatModelChange, + onSeatSkillDirectiveChange, onStageGoalChange, goalOptimizingIdx, onOptimizeGoal, @@ -1325,6 +1342,7 @@ function StagesColumn({ onMergeSeat: (agentId: string, targetStageIdx: number) => void; onSplitSeat: (agentId: string) => void; onSeatModelChange: (agentId: string, model: string | null) => void; + onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void; onStageGoalChange: (stageIdx: number, goal: string) => void; /** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */ goalOptimizingIdx: number | null; @@ -1423,6 +1441,7 @@ function StagesColumn({ onMergeSeat={onMergeSeat} onSplitSeat={onSplitSeat} onSeatModelChange={onSeatModelChange} + onSeatSkillDirectiveChange={onSeatSkillDirectiveChange} onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)} goalOptimizing={goalOptimizingIdx === stageIdx} anyGoalOptimizing={goalOptimizingIdx !== null} @@ -1452,6 +1471,7 @@ function StageSection({ onMergeSeat, onSplitSeat, onSeatModelChange, + onSeatSkillDirectiveChange, onGoalChange, goalOptimizing, anyGoalOptimizing, @@ -1470,6 +1490,7 @@ function StageSection({ onMergeSeat: (agentId: string, targetStageIdx: number) => void; onSplitSeat: (agentId: string) => void; onSeatModelChange: (agentId: string, model: string | null) => void; + onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void; onGoalChange: (goal: string) => void; /** 本 Stage 的阶段目标正在 AI 优化中。 */ goalOptimizing: boolean; @@ -1631,6 +1652,7 @@ function StageSection({ onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)} onSplit={() => onSplitSeat(seat.agent_id)} onModelChange={(model) => onSeatModelChange(seat.agent_id, model)} + onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)} onDetail={() => onSeatDetail(seat.agent_id, seat.name)} /> ))} @@ -1654,6 +1676,7 @@ function SeatStrip({ onMergeDown, onSplit, onModelChange, + onSkillDirectiveChange, onDetail, }: { seat: ChainSeat; @@ -1668,6 +1691,8 @@ function SeatStrip({ onSplit: () => void; /** 席位模型覆盖变更(null = 清除,跟随全局)。 */ onModelChange: (model: string | null) => void; + /** 席位「会商技能识别强化」开关变更。 */ + onSkillDirectiveChange: (enabled: boolean) => void; onDetail: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = @@ -1739,8 +1764,41 @@ function SeatStrip({
- {/* 控制:查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */} + {/* 控制:技能识别强化 / 查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
+ {/* 会商技能识别强化:小图标 → 下拉框(开关 + 说明)。开启 = 高亮。 */} + + + + + +
+
+

会商技能识别强化

+

+ 开启后,该席位作为会商子智能体执行时,会把已配置的技能(含 SKILL.md + 路径与「先 read_file 再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在该智能体编辑里打开(任一即生效)。 +

+
+ +
+
+
+ {/* 研讨前并行取数:逐条 opt-in 的提速开关(默认关)。 */} +
+
+ +

+ 开启后,选入研讨时会**先并行跑一轮「只取数」**:各席位同时用技能把相关信息收集回来(不研讨), + 产出作为共享资料注入后续研讨轮。把最慢的技能检索前置、并行做一次,给(尤其按链条顺序串行的) + 研讨段大幅提速;研讨阶段席位仍可按需再调技能。 +

+
+ +
+ {/* 总控提示词:label + AI 优化(配色沿用现有 primary)+ 大号等宽编辑区 */}
@@ -1147,6 +1182,7 @@ export default function BusinessChainEditorPage() { onSplitSeat={splitSeat} onSeatModelChange={setSeatModelCb} onSeatSkillDirectiveChange={setSeatSkillDirectiveCb} + onSeatModeChange={setSeatModeCb} onStageGoalChange={setGoal} goalOptimizingIdx={goalOptimizingIdx} onOptimizeGoal={handleOptimizeStageGoal} @@ -1326,6 +1362,7 @@ function StagesColumn({ onSplitSeat, onSeatModelChange, onSeatSkillDirectiveChange, + onSeatModeChange, onStageGoalChange, goalOptimizingIdx, onOptimizeGoal, @@ -1343,6 +1380,7 @@ function StagesColumn({ onSplitSeat: (agentId: string) => void; onSeatModelChange: (agentId: string, model: string | null) => void; onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void; + onSeatModeChange: (agentId: string, mode: SeatMode | null) => void; onStageGoalChange: (stageIdx: number, goal: string) => void; /** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */ goalOptimizingIdx: number | null; @@ -1442,6 +1480,7 @@ function StagesColumn({ onSplitSeat={onSplitSeat} onSeatModelChange={onSeatModelChange} onSeatSkillDirectiveChange={onSeatSkillDirectiveChange} + onSeatModeChange={onSeatModeChange} onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)} goalOptimizing={goalOptimizingIdx === stageIdx} anyGoalOptimizing={goalOptimizingIdx !== null} @@ -1472,6 +1511,7 @@ function StageSection({ onSplitSeat, onSeatModelChange, onSeatSkillDirectiveChange, + onSeatModeChange, onGoalChange, goalOptimizing, anyGoalOptimizing, @@ -1491,6 +1531,7 @@ function StageSection({ onSplitSeat: (agentId: string) => void; onSeatModelChange: (agentId: string, model: string | null) => void; onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void; + onSeatModeChange: (agentId: string, mode: SeatMode | null) => void; onGoalChange: (goal: string) => void; /** 本 Stage 的阶段目标正在 AI 优化中。 */ goalOptimizing: boolean; @@ -1653,6 +1694,7 @@ function StageSection({ onSplit={() => onSplitSeat(seat.agent_id)} onModelChange={(model) => onSeatModelChange(seat.agent_id, model)} onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)} + onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)} onDetail={() => onSeatDetail(seat.agent_id, seat.name)} /> ))} @@ -1677,6 +1719,7 @@ function SeatStrip({ onSplit, onModelChange, onSkillDirectiveChange, + onSeatModeChange, onDetail, }: { seat: ChainSeat; @@ -1693,6 +1736,8 @@ function SeatStrip({ onModelChange: (model: string | null) => void; /** 席位「会商技能识别强化」开关变更。 */ onSkillDirectiveChange: (enabled: boolean) => void; + /** 席位「推理深度覆盖」变更(null = 跟随链条级 seatMode)。 */ + onSeatModeChange: (mode: SeatMode | null) => void; onDetail: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = @@ -1764,8 +1809,52 @@ function SeatStrip({
- {/* 控制:技能识别强化 / 查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */} + {/* 控制:推理深度 / 技能识别强化 / 查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
+ {/* 席位推理深度覆盖:小图标 → 下拉框(跟随链条默认 + 四档)。设了覆盖 = 高亮。 + 「卡顿提速」用:把信息收集/边角席位降到「快速」,只给关键席位留「专业/多智能体」。 */} + + + + + + + 席位推理深度 + + 覆盖链条级默认,仅本席位生效。模型卡顿时把次要席位降到「快速」可大幅提速。 + + + + onSeatModeChange(null)} + > + 跟随链条默认 + + {SEAT_MODE_OPTIONS.map((opt) => ( + onSeatModeChange(opt.value)} + > + {opt.label} + {opt.description} + + ))} + + {/* 会商技能识别强化:小图标 → 下拉框(开关 + 说明)。开启 = 高亮。 */} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index a82cd0c6..4813c6ce 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -315,6 +315,13 @@ export default function RoundtablePlanningPage() { // Step 2 每席位「技能识别强化」开关映射 {agentId: bool}:由所选业务链条里**每个席位**的同名 // 配置决定(recommend 无链条 → 空 {})。透传给每个席位 run,与各席位智能体自身的开关取 OR。 const [seatSkillDirectives, setSeatSkillDirectives] = useState>({}); + // Step 2 每席位「推理深度覆盖」映射 {agentId: SeatMode|null}:由所选业务链条里**每个席位**单独配 + // 的推理深度决定(null/未配 → 跟随链条级 seatMode)。透传给席位 run(覆盖优先、否则跟随 seatMode)。 + // 「卡顿提速」用:把次要席位降到 flash 即可大幅缩短第二步耗时。 + const [seatModes, setSeatModes] = useState>({}); + // Step 2「研讨前并行取数」开关:由所选业务链条的 gatherFirst 决定(recommend / 无链条 → false)。 + // true = 研讨前先并行跑一轮全席位『取数』,产出作为共享资料注入研讨轮,给(尤其 chain 串行)研讨段提速。 + const [gatherFirst, setGatherFirst] = useState(false); // Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活; // dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。 // 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。 @@ -471,6 +478,8 @@ export default function RoundtablePlanningPage() { selectedModel, selectedMode: seatMode, seatSkillDirectives, + seatModes, + gatherFirst, orchestrationMode, orchestrationPlan, dagExecMode, @@ -953,6 +962,13 @@ export default function RoundtablePlanningPage() { setSeatSkillDirectives( Object.fromEntries(result.agents.map((a) => [a.agent_id, a.seat_skill_directive ?? false])), ); + // 每席位推理深度覆盖:取所选业务链条里**每席位**单独配的 seatMode → {agentId: SeatMode|null} + // 映射(recommend / 未配 → null,跟随链条级 seatMode)。 + setSeatModes( + Object.fromEntries(result.agents.map((a) => [a.agent_id, a.seatMode ?? null])), + ); + // 研讨前并行取数:取所选业务链条的开关(recommend / 旧调用未带 → false)。 + setGatherFirst(result.gatherFirst ?? false); setChainMeta(result.mode === "recommend" ? null : result.chain ?? null); setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null); setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast"); @@ -1528,6 +1544,8 @@ export default function RoundtablePlanningPage() { model: selectedModel || undefined, // 模型沿用第二步选择 seatMode, // 席位执行模式沿用第二步选择(后台续跑同样按此模式跑席位) seatSkillDirectives, // 每席位技能识别强化沿用所选业务链条里各席位的开关 + seatModes, // 每席位推理深度覆盖沿用所选业务链条里各席位的配置 + gatherFirst, // 研讨前并行取数沿用所选业务链条的开关 // 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)—— // 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。 orchestrationMode, @@ -1545,6 +1563,8 @@ export default function RoundtablePlanningPage() { selectedModel, seatMode, seatSkillDirectives, + seatModes, + gatherFirst, orchestrationMode, orchestrationPlan, chainMeta, @@ -1569,6 +1589,8 @@ export default function RoundtablePlanningPage() { const model = selectedModel || undefined; const seatModeSnap = seatMode; const seatSkillDirectivesSnap = seatSkillDirectives; + const seatModesSnap = seatModes; + const gatherFirstSnap = gatherFirst; const mode = orchestrationMode; const chainSnap = chainMeta; const planSnap = mode === "dag" ? orchestrationPlan : null; @@ -1604,6 +1626,8 @@ export default function RoundtablePlanningPage() { model, seatMode: seatModeSnap, seatSkillDirectives: seatSkillDirectivesSnap, + seatModes: seatModesSnap, + gatherFirst: gatherFirstSnap, orchestrationMode: mode, chain: chainSnap, orchestrationPlan: planSnap, @@ -1628,6 +1652,8 @@ export default function RoundtablePlanningPage() { selectedModel, seatMode, seatSkillDirectives, + seatModes, + gatherFirst, orchestrationMode, orchestrationPlan, chainMeta, @@ -1669,6 +1695,8 @@ export default function RoundtablePlanningPage() { model: selectedModel || undefined, seatMode, seatSkillDirectives, + seatModes, + gatherFirst, orchestrationMode, chain: chainMeta, orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null, diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 53a399e4..30b9d866 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -247,7 +247,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. | | **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. | | **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`; `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. | -| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (info/warning/error), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task, *, chain_enabled=False)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") to the **seat's per-turn task (human turn)** — far stronger pull on weak models than the system prompt. Wired into **both** seat paths: foreground `multi_agent._special_run` (only `role in {seat, seat_parallel}`; Step3 singletons report/summary/dashboard/ingest excluded) + background `InProcessRoundtableGateway.run_seat`. No-op (returns task unchanged) when the seat has no configured skills. **2026-06-20: default-off + two opt-in switches (OR semantics)** — was global-on, now injects only when **either** the seat agent's own switch (`AgentConfig.seat_skill_directive`, edited in the agent edit dialog `agent-card.tsx` + `agent-detail-panel.tsx`) **or** the **per-seat** switch on that agent **within the selected business chain** is on. The per-seat chain switch lives **in the chain's `seats` JSON** (`ChainSeatSchema.seat_skill_directive`, no DB column — rides the existing JSON like the per-seat `model` override), edited via a small ✨ icon → dropdown (Switch + description) on each agent card in `BusinessChainEditorPage`. It is threaded **per-seat** to each seat run: foreground `multi_agent` per-call field `seat_skill_directive` (orchestration looks it up per agentId from a `{agentId: bool}` session map) → `_special_run` → `chain_enabled`; background as the job field `seat_skill_directives: dict[str,bool]` (StartJobRequest → StartParams → deps factory → `InProcessRoundtableGateway._seat_skill_directives`) → `run_seat` looks up `agent_id` → `chain_enabled`. `ROUNDTABLE_SEAT_SKILL_DIRECTIVE` is only a **master kill-switch** (default allow; set `0/false` to hard-disable regardless of the switches — never force-enables). Tests: `tests/test_roundtable_seat_skills.py`, `tests/test_agents_backend.py`. | +| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (info/warning/error), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task, *, chain_enabled=False)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") to the **seat's per-turn task (human turn)** — far stronger pull on weak models than the system prompt. Wired into **both** seat paths: foreground `multi_agent._special_run` (only `role in {seat, seat_parallel}`; Step3 singletons report/summary/dashboard/ingest excluded) + background `InProcessRoundtableGateway.run_seat`. No-op (returns task unchanged) when the seat has no configured skills. **2026-06-20: default-off + two opt-in switches (OR semantics)** — was global-on, now injects only when **either** the seat agent's own switch (`AgentConfig.seat_skill_directive`, edited in the agent edit dialog `agent-card.tsx` + `agent-detail-panel.tsx`) **or** the **per-seat** switch on that agent **within the selected business chain** is on. The per-seat chain switch lives **in the chain's `seats` JSON** (`ChainSeatSchema.seat_skill_directive`, no DB column — rides the existing JSON like the per-seat `model` override), edited via a small ✨ icon → dropdown (Switch + description) on each agent card in `BusinessChainEditorPage`. It is threaded **per-seat** to each seat run: foreground `multi_agent` per-call field `seat_skill_directive` (orchestration looks it up per agentId from a `{agentId: bool}` session map) → `_special_run` → `chain_enabled`; background as the job field `seat_skill_directives: dict[str,bool]` (StartJobRequest → StartParams → deps factory → `InProcessRoundtableGateway._seat_skill_directives`) → `run_seat` looks up `agent_id` → `chain_enabled`. `ROUNDTABLE_SEAT_SKILL_DIRECTIVE` is only a **master kill-switch** (default allow; set `0/false` to hard-disable regardless of the switches — never force-enables). Tests: `tests/test_roundtable_seat_skills.py`, `tests/test_agents_backend.py`. **2026-06-20 Step2 提速三件套**:(1) **每席位推理深度覆盖** —— 业务链条 `seats` JSON 里逐席位可配 `seat_mode`(flash/thinking/pro/ultra,覆盖链条级 `seatMode`;编辑器席位卡右侧 ⏱Gauge 图标下拉,null=跟随链条默认)。前台经 `useStep2Orchestration` 三处 dispatch 的 `seatModeToRequestFields(seatModes[id] ?? selectedMode)` 透传;后台经 job `seat_modes: dict[str,str]` → `StartParams` → `InProcessRoundtableGateway._seat_modes`,`run_seat` 用 `_SEAT_MODE_PARAMS`(与前端 `seatModeToRunParams` 逐字对齐)解析覆盖作业级 seat_*(未命中→跟随作业级→policy 默认)。无 DB 列(ride `seats` JSON,但 `ChainSeatSchema.seat_mode` 须显式声明否则 Pydantic 剥掉)。Tests: `tests/test_roundtable_seat_modes.py`。(2) **recommend 批内并行** —— 后端 `engine._run_recommend` 由 for-await 串行改 `asyncio.gather`(对齐前端 2026-06-13 已并行;总控一轮派多席位=判定相互独立→并发;批内用派活前 deliveries 快照、批后统一并入,语义不变)。(3) **研讨前并行『取数』两段式**(业务链条 `gather_first` 列,逐条 opt-in,migration `20260620_02`,editor「研讨前并行取数」开关)—— `gather_first=True` 时 `run_orchestration` 在分发前先 `engine._run_gather_phase`(全席位 `asyncio.gather` 跑 `_GATHER_TASK`「只取数不研讨」),产出 `gathered: dict` 经 `_seat_message(gathered=...)` 作为「已收集资料」块注入 recommend/chain/dag 各研讨轮席位任务(与「前序交付」块分开),把最慢的技能延迟前置并行、给(尤其 chain 串行的)研讨段提速;研讨阶段不限制再调技能。前台对称实现:`useStep2Orchestration.runGatherPhase` 在 `runActiveOrchestration` 分发前跑一次(`gatherPhaseDoneRef` 闸门,resetInitGate 复位),产出存 `gatheredDeliveriesRef` 经 `peerDeliveries`/初始 `priorDeliveries` 注入研讨席位。链条级 `gatherFirst`(camel)↔`gather_first`(snake) 经 `api/chains.ts` 映射;job 经 `StartJobRequest.gather_first`→`StartParams`→`run_orchestration(gather_first=…)`。Tests: `tests/test_roundtable_gather_phase.py`。(4) **削减重复广播**(前端 only,多智能体 foreground 路径)—— `suppress_pre_broadcast`(仅 leader:抑制「总控本轮输入→各席位 thread」预广播 + 「总控给X派活」→兄弟席位的 post 广播,二者一个 flag)此前只在 DAG 派活轮开;现扩到 recommend(`safetyCounter > 1`,即第 2 轮起的续轮/综合)+ chain 逐席位(`i > 0`,第 2 个席位起)+ chain 收口轮(恒 true)。**保留每种模式的首轮预广播**(它为席位打底任务背景),只 suppress 重复的续轮广播(N 次读写巨大席位 thread 的纯浪费 + 弱模型模仿派活);席位的前序交付另经「席位交付广播」`broadcastSeatDeliveryToOthers` 送达,不受影响。后台引擎路径无此广播(`_seat_message` 直接拼上下文),故 #4 仅前端。 | | **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. | | **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/`. 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"`. | diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 1f45f004..d8dd17e6 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -340,6 +340,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: 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 712082ff..b589bcee 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py @@ -509,6 +509,18 @@ 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), +} + + # ── 网关实现 ───────────────────────────────────────────────────────────────── @@ -526,6 +538,7 @@ class InProcessRoundtableGateway: 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, @@ -552,6 +565,9 @@ class InProcessRoundtableGateway: # 的配置)。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 @@ -626,7 +642,18 @@ class InProcessRoundtableGateway: seat_task = append_seat_skill_directive( agent_id, task, chain_enabled=bool(self._seat_skill_directives.get(agent_id, False)) ) - # 席位模式覆盖 policy 的 thinking/reasoning(None → policy 默认),与前端 _special_run 一致。 + # 每席位「推理深度覆盖」:业务链条里给该席位单独配了 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 = await _run_agent_turn( self._app, user_id=self._user_id, @@ -637,16 +664,12 @@ class InProcessRoundtableGateway: 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, + subagent_enabled=seat_subagent, diag=self._diag, stage="step2_seat", ) 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 6a552be4..f0d83a43 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py @@ -193,6 +193,13 @@ class StartParams: # 配置)。透传给进程内网关,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 # 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。 @@ -280,6 +287,7 @@ class RoundtableJobExecutor: intent_text=params.intent_text, enable_report=params.enable_report, plan=params.orchestration_plan, + gather_first=params.gather_first, ) # 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。 await self._write_run_to_draft( 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..cda907f5 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,19 @@ 请通读全部原料:提炼每个席位的核心观点 / 交付,串成一份完整的方案总结。 +## 动笔前:意图对齐与缺口核对(质量关键,每轮首次生成报告都要做) +各席位可能由能力较弱的模型产出,交付未必充分、未必都扣住了用户意图。你是这份报告的**质量兜底**——不能只是把各席位说的拼接起来,而要**对齐用户意图、核对覆盖、补强缺口**。动笔前先在脑内完成(这张核对表不必输出): + +1. **拆解意图**:把「任务意图」(目标 / 约束 / 关键假设)拆成若干条**用户真正想要的诉求**;业务链总控编排提示里对最终产出的要求也并入诉求清单。 +2. **逐条核对覆盖**:对每条诉求,检查各席位交付 + 总控共识**是否覆盖、覆盖得够不够、有没有跑题或自相矛盾**。 +3. **据此组织报告**,并按下面的「缺口处理护栏」补强——让最终报告明显**比"各席位交付的简单拼接"更扣用户意图**:意图里要的、席位没说清的,你补强或标缺口;意图里没要的、席位跑题的,不要喧宾夺主。 + +### 缺口处理护栏(避免弱模型把幻觉带进报告) +- **能从已有原料重组 / 推理补上的** → 直接补全、讲透,正常写进报告。这是你最该做的增值:把零散、跑题、含糊的席位产物,按用户意图重新梳理成扣题的方案。 +- **超出席位原料、属于你自己的合理推断 / 建议** → 可以写,但**显式标注「(综合推断)」**,让用户能区分哪些有席位依据、哪些是你的补充判断。 +- **缺真实信息、靠现有原料补不了的**(需要最新数据 / 外部事实,而席位没给)→ **绝不凭空编**。在报告对应位置用一行醒目标注:`> ⚠️ 待补充:<具体缺什么>。如需补全,可在下方对话里告诉我(例如「补一下 X 的最新情况」),我会据此完善。` 把球交给用户——你本身不联网、不调研,真补由用户在对话里发起。 +- 始终:**宁可标注缺口,也不要假装完整**;绝不编造与原料矛盾、或无依据的"事实"。 + ## 产出要求(最重要) ### ⓪ 先判断模式:用户是否要求了结构化输出 / 流程图 @@ -122,5 +135,8 @@ - ❌ 修订流程图时只输出改动的节点(必须输出全量 flow-json,前端整体重绘) - ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印 - ❌ 编造与原料明显矛盾的内容 +- ❌ 只把各席位交付简单拼接、不做「意图对齐与缺口核对」(席位可能弱,你要兜底扣住用户意图) +- ❌ 缺真实信息时凭空编造来"填满"报告(应按护栏标注「> ⚠️ 待补充:…」交给用户在对话里补) +- ❌ 自己的推断不加「(综合推断)」标注,让用户误以为有席位依据 - ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高) - ❌ 编排提示已指定输出结构 / 重点席位,却仍按默认章节平均用力(结构要完全跟用户走,重点席位要明显加权) 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 f476a9d7..b06926ee 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py @@ -53,6 +53,11 @@ class ChainSeatSchema(BaseModel): # 与该席位 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): @@ -69,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)。 @@ -96,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): @@ -107,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 @@ -289,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 639ffce1..b06c59e2 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py @@ -81,6 +81,12 @@ class StartJobRequest(BaseModel): # 同名配置后下发)。后台席位 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 class ResumeJobRequest(BaseModel): @@ -240,6 +246,8 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes 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), ) ) return _to_response(row) 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..b28d054b 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 @@ -80,6 +80,7 @@ async def run_orchestration( intent_text: str = "", enable_report: bool = True, plan: dict | None = None, + gather_first: bool = False, ) -> OrchestrationResult: """运行一次完整编排(循环 → 共识 → 报告绘制)。 @@ -97,23 +98,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 "总控需要补充信息,请补充后继续。" @@ -199,11 +209,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 +241,72 @@ 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 + 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 +316,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 = "" @@ -246,24 +343,35 @@ 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: await progress.seat_active(agent_id) - seat_msg = _seat_message(intent_text, deliveries, name_by_id, task) + seat_msg = _seat_message(intent_text, prior_snapshot, name_by_id, task, gathered=gathered) seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=seat_msg, model=model) - deliveries[agent_id] = seat.final_text + 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, + agent_id=agent_id, agent_name=name_by_id.get(agent_id, agent_id), cycle=cyc, ) await progress.add_seat_dialogue( - agent_id, name_by_id.get(agent_id, agent_id), seat.final_text, cycle, + agent_id, name_by_id.get(agent_id, agent_id), 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 +395,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 = "" @@ -323,9 +433,10 @@ 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_msg = _seat_message(intent_text, deliveries, name_by_id, task, gathered=gathered) seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model) deliveries[seat.agent_id] = seat_turn.final_text if not (seat_turn.final_text or "").strip(): @@ -376,6 +487,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 完成后由总控收口综合。 @@ -429,9 +542,9 @@ 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) -> 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_msg = _seat_message(intent_text, deliveries, name_by_id, task, gathered=_gathered) seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model) deliveries[seat.agent_id] = seat_turn.final_text if not (seat_turn.final_text or "").strip(): @@ -693,6 +806,17 @@ def build_summary_prompt( lines.append("# 三、总控最终共识结论") lines.append((consensus_text or "").strip() or "(未提供)") lines.append("") + # 意图对齐 + 缺口核对 + 防幻觉护栏(弱席位场景的质量兜底)。即便部署里的 SOUL 未重新 seed, + # 这段也保证后台总结会主动扣用户意图、标注推断/缺口,而非简单拼接各席位交付。 + lines.append( + "【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。" + "你是这份报告的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)" + "拆成用户的核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、" + "有无跑题或矛盾;再据此组织报告并按护栏补强:① 能从已有原料重组/推理补上的→直接补全讲透;" + "② 属于你自己的推断/建议→写,但显式标注「(综合推断)」;③ 缺真实信息、靠现有原料补不了的→**绝不凭空编**," + "用一行 `> ⚠️ 待补充:<具体缺什么>。可在对话里让我补全` 标出,把球交给用户。" + "最终报告要明显比「各席位交付的简单拼接」更扣用户意图。" + ) lines.append( "请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" "报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" 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_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_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..b2f92741 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_roundtable_summary_prompt.py @@ -0,0 +1,50 @@ +"""方案总结报告 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 + # 防幻觉护栏三条 + assert "综合推断" 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 From b1b1ff98f5a2330704a600db8db42ba382844996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 20 Jun 2026 18:13:03 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat(roundtable):=20=E6=B7=B1=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E4=B8=9A=E5=8A=A1=E9=93=BE=E6=8A=BD=E5=8F=96=E8=A7=84?= =?UTF-8?q?=E8=8C=83=20+=20=E6=80=BB=E7=BB=93=E6=8A=A5=E5=91=8A=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=B9=B2=E5=87=80/=E5=AF=B9=E8=AF=9D=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E6=A0=B8=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 深链接会商(taskId+goPath,businessCode)下按所选业务链完整层级强制校正 结构化抽取(flow-json) 与 方案总结报告: - 新增 harness 层 extraction_spec(app+harness 共用,单份领域知识两角色用法): build_structure_directive / build_summary_directive 按 businessCode 取 · rwfx(6BF):线性 任务→目的→行为体→预期行为→驱动因素→叙事;预期行为↔驱动因素 1:1、余 1:N · xdfx(7BF):分叉汇聚 DAG,根=行动;行动→受众目标/关键传播节点(1:N)→脆弱点(1:N)→策略(多对一) · 其余业务通用规范(强约束完整性+命名+取数、不套固定模板);business_code 空→零副作用 - 结构化抽取:flow_extract._hierarchy_hint + _validate_messages 用 structure_directive 权威覆盖默认层级(根治"无论什么业务都按目的→行为体抽/停在行为体");前端 streamFlowExtract 传 business_code(源 ingestBusinessCode) - 方案总结报告:前台 multi_agent._special_run(summary) + 后台 build_summary_prompt 注入 business 完整性核对(StartJobRequest/StartParams 透传) - 报告产物策略:.md 报告文件保持完整、干净、无需修改(不写 待补充/不足/⚠️); 「核对说明」(意图对齐+业务链完整性+不足/可补充)改为只在对话回复正文、且每次必出 - 种子 roundtable-structure SOUL 层级顺序改 spec-driven、去 rwfx 写死示例 Tests: test_roundtable_extraction_spec / test_roundtable_summary_prompt Co-Authored-By: Claude Opus 4.8 (1M context) --- .../roundtable-planning/api/flow-extract.ts | 3 + .../roundtable-planning/api/multi-agent.ts | 7 + .../api/roundtable-jobs.ts | 2 + .../hooks/useStep3Summary.ts | 10 +- .../src/roundtable-planning/lib/job-types.ts | 5 + .../pages/RoundtablePlanningPage.tsx | 3 + offline-backend-20260512/backend/CLAUDE.md | 2 +- .../app/gateway/roundtable_job_executor.py | 4 + .../roundtable-structure/SOUL.md | 15 +- .../roundtable-summary/SOUL.md | 20 ++- .../app/gateway/routers/flow_extract.py | 22 ++- .../app/gateway/routers/multi_agent.py | 13 ++ .../app/gateway/routers/roundtable_jobs.py | 3 + .../agents/roundtable_orchestrator/engine.py | 19 ++- .../extraction_spec.py | 160 ++++++++++++++++++ .../tests/test_roundtable_extraction_spec.py | 107 ++++++++++++ .../tests/test_roundtable_summary_prompt.py | 8 +- 17 files changed, 378 insertions(+), 25 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/extraction_spec.py create mode 100644 offline-backend-20260512/backend/tests/test_roundtable_extraction_spec.py diff --git a/frontend-web/src/roundtable-planning/api/flow-extract.ts b/frontend-web/src/roundtable-planning/api/flow-extract.ts index 44c735de..89b36dac 100644 --- a/frontend-web/src/roundtable-planning/api/flow-extract.ts +++ b/frontend-web/src/roundtable-planning/api/flow-extract.ts @@ -82,6 +82,8 @@ export interface FlowExtractRequest { chainLevels?: string[]; /** 业务链总控编排提示(写明层级时优先用它)。 */ coordinatorPrompt?: string | null; + /** 深链接业务码(rwfx→6BF 等)。非空 → 后端按业务链抽取规范强制注入完整层级。 */ + businessCode?: string | null; /** 推理模型名(沿用第三步所选)。 */ model?: string; /** Phase 2 问答修改:当前流程图 + 指令 → 按指令改图(其余不变)。 */ @@ -109,6 +111,7 @@ export async function streamFlowExtract(req: FlowExtractRequest): Promise ({ sender: s.sender, content: s.content })), chain_levels: req.chainLevels ?? [], coordinator_prompt: req.coordinatorPrompt ?? null, + business_code: req.businessCode ?? null, model: req.model, current_graph: req.currentGraph ?? null, instruction: req.instruction ?? null, diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index 02c7fd99..a14c7ab1 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -240,6 +240,11 @@ export interface StreamMultiAgentRequest { * 与该席位 agent 自身 config.yaml 的同名开关取 OR。省略 = false(关)。 */ seatSkillDirective?: boolean; + /** + * 深链接业务码(rwfx→6BF 等)。**仅方案总结报告(summary)单例消费**:非空 → 后端按业务链抽取规范 + * 强制注入「完整性核对」指令,让总结核对产出相对业务链完不完整。普通会商省略 = 不注入。 + */ + businessCode?: string | null; skillStopNames?: string[]; /** * 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付 @@ -493,6 +498,8 @@ export async function streamMultiAgent( subagent_enabled: req.subagentEnabled, // 仅 special 用;会商技能识别强化(沿用所选业务链条开关)。省略 → 后端按 false(关)处理。 seat_skill_directive: req.seatSkillDirective ?? false, + // 仅 summary 单例用;深链接业务码 → 后端注入业务链完整性核对。省略/空 → 不注入。 + business_code: req.businessCode ?? null, skill_stop_names: req.skillStopNames ?? [], // 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。 synthesis_mode: req.synthesisMode ?? false, diff --git a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts index 8045b638..5bf1790a 100644 --- a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts +++ b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts @@ -108,6 +108,8 @@ function toStartBody(p: StartJobPayload) { ), // 研讨前并行取数开关(来源链条 gatherFirst):true = 后台研讨前先并行取数再研讨。缺省 false。 gather_first: p.gatherFirst ?? false, + // 深链接业务码:后台总结报告据此做业务链完整性核对(写实业务如 rwfx 才生效)。空 → 不注入。 + business_code: p.businessCode ?? null, ...(seat ? { thinking_enabled: seat.thinking_enabled, diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts index 1a55216d..d14c88dd 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts @@ -321,8 +321,10 @@ function buildSummaryPrompt(opts: { "【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。" + "你是这份总结的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)拆成" + "用户核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、有无跑题或矛盾;" + - "再据此组织并按护栏补强:① 能从已有原料重组/推理补上的→直接补全讲透;② 属于你自己的推断/建议→显式标注「(综合推断)」;" + - "③ 缺真实信息、靠现有原料补不了的→绝不凭空编,用一行『> ⚠️ 待补充:<具体缺什么>。可在对话里让我补全』标出,交给用户。" + + "再据此组织:① 能从已有原料重组/推理补上的→直接补全讲透;② 缺真实信息补不了的→绝不凭空编。" + + "**写进 .md 报告文件的必须是一份完整、干净、可直接使用、无需用户修改的报告——不要在报告文件里写「待补充/不足/⚠️」之类提示或占位**。" + + "**但在对话回复正文、报告之后,必须追加一段简短「核对说明」**(只在对话里、**不写进 .md 文件**):逐条说明产出是否覆盖了用户意图、哪些充分/哪些不足/有哪些可补充(可在对话里让我补全);" + + "**即便都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求均已覆盖,无明显缺口」,这段核对说明任何时候都不要省略。**" + "最终产出要明显比各席位交付的简单拼接更扣用户意图。", ); if (opts.flowMode) { @@ -1041,6 +1043,8 @@ export function useStep3Summary( dAgentThreadId: { [agentName]: threadId }, displayName: "方案总结报告", newMessage: prompt, + // 深链接业务码:后端为 summary 单例注入「业务链完整性核对」(核对产出相对业务链完不完整)。 + businessCode: ingestBusinessCodeRef.current, model: selectedModel || undefined, files: files && files.length > 0 ? files : undefined, signal: aborter.signal, @@ -1136,6 +1140,8 @@ export function useStep3Summary( seatDeliveries, chainLevels, coordinatorPrompt, + // 深链接业务码:后端按业务链抽取规范强制注入完整层级(根治"无论什么业务都按目的→行为体抽")。 + businessCode: ingestBusinessCodeRef.current, model: selectedModel || undefined, currentGraph: opts?.currentGraph ?? null, instruction: opts?.instruction, diff --git a/frontend-web/src/roundtable-planning/lib/job-types.ts b/frontend-web/src/roundtable-planning/lib/job-types.ts index 9d5ee618..922edd66 100644 --- a/frontend-web/src/roundtable-planning/lib/job-types.ts +++ b/frontend-web/src/roundtable-planning/lib/job-types.ts @@ -147,6 +147,11 @@ export interface StartJobPayload { * 在 `toStartBody` 里下发为后端 `gather_first`。缺省 false。 */ gatherFirst?: boolean; + /** + * 深链接业务码(rwfx→6BF 等)。透传给后台总结报告的「业务链完整性核对」(写实业务如 rwfx 才生效)。 + * 普通会商省略。`toStartBody` 里下发为后端 `business_code`。 + */ + businessCode?: string | null; } /** 终态集合——SSE/轮询判定作业是否结束。 */ diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 4813c6ce..7c83f6c5 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -1546,6 +1546,7 @@ export default function RoundtablePlanningPage() { seatSkillDirectives, // 每席位技能识别强化沿用所选业务链条里各席位的开关 seatModes, // 每席位推理深度覆盖沿用所选业务链条里各席位的配置 gatherFirst, // 研讨前并行取数沿用所选业务链条的开关 + businessCode: prefillRef.current?.businessCode ?? undefined, // 深链接业务码 → 后台总结业务链完整性核对 // 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)—— // 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。 orchestrationMode, @@ -1628,6 +1629,7 @@ export default function RoundtablePlanningPage() { seatSkillDirectives: seatSkillDirectivesSnap, seatModes: seatModesSnap, gatherFirst: gatherFirstSnap, + businessCode: prefillRef.current?.businessCode ?? undefined, orchestrationMode: mode, chain: chainSnap, orchestrationPlan: planSnap, @@ -1697,6 +1699,7 @@ export default function RoundtablePlanningPage() { seatSkillDirectives, seatModes, gatherFirst, + businessCode: prefillRef.current?.businessCode ?? undefined, orchestrationMode, chain: chainMeta, orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null, diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 30b9d866..01c702c6 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -247,7 +247,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. | | **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. | | **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`; `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. | -| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (info/warning/error), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task, *, chain_enabled=False)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") to the **seat's per-turn task (human turn)** — far stronger pull on weak models than the system prompt. Wired into **both** seat paths: foreground `multi_agent._special_run` (only `role in {seat, seat_parallel}`; Step3 singletons report/summary/dashboard/ingest excluded) + background `InProcessRoundtableGateway.run_seat`. No-op (returns task unchanged) when the seat has no configured skills. **2026-06-20: default-off + two opt-in switches (OR semantics)** — was global-on, now injects only when **either** the seat agent's own switch (`AgentConfig.seat_skill_directive`, edited in the agent edit dialog `agent-card.tsx` + `agent-detail-panel.tsx`) **or** the **per-seat** switch on that agent **within the selected business chain** is on. The per-seat chain switch lives **in the chain's `seats` JSON** (`ChainSeatSchema.seat_skill_directive`, no DB column — rides the existing JSON like the per-seat `model` override), edited via a small ✨ icon → dropdown (Switch + description) on each agent card in `BusinessChainEditorPage`. It is threaded **per-seat** to each seat run: foreground `multi_agent` per-call field `seat_skill_directive` (orchestration looks it up per agentId from a `{agentId: bool}` session map) → `_special_run` → `chain_enabled`; background as the job field `seat_skill_directives: dict[str,bool]` (StartJobRequest → StartParams → deps factory → `InProcessRoundtableGateway._seat_skill_directives`) → `run_seat` looks up `agent_id` → `chain_enabled`. `ROUNDTABLE_SEAT_SKILL_DIRECTIVE` is only a **master kill-switch** (default allow; set `0/false` to hard-disable regardless of the switches — never force-enables). Tests: `tests/test_roundtable_seat_skills.py`, `tests/test_agents_backend.py`. **2026-06-20 Step2 提速三件套**:(1) **每席位推理深度覆盖** —— 业务链条 `seats` JSON 里逐席位可配 `seat_mode`(flash/thinking/pro/ultra,覆盖链条级 `seatMode`;编辑器席位卡右侧 ⏱Gauge 图标下拉,null=跟随链条默认)。前台经 `useStep2Orchestration` 三处 dispatch 的 `seatModeToRequestFields(seatModes[id] ?? selectedMode)` 透传;后台经 job `seat_modes: dict[str,str]` → `StartParams` → `InProcessRoundtableGateway._seat_modes`,`run_seat` 用 `_SEAT_MODE_PARAMS`(与前端 `seatModeToRunParams` 逐字对齐)解析覆盖作业级 seat_*(未命中→跟随作业级→policy 默认)。无 DB 列(ride `seats` JSON,但 `ChainSeatSchema.seat_mode` 须显式声明否则 Pydantic 剥掉)。Tests: `tests/test_roundtable_seat_modes.py`。(2) **recommend 批内并行** —— 后端 `engine._run_recommend` 由 for-await 串行改 `asyncio.gather`(对齐前端 2026-06-13 已并行;总控一轮派多席位=判定相互独立→并发;批内用派活前 deliveries 快照、批后统一并入,语义不变)。(3) **研讨前并行『取数』两段式**(业务链条 `gather_first` 列,逐条 opt-in,migration `20260620_02`,editor「研讨前并行取数」开关)—— `gather_first=True` 时 `run_orchestration` 在分发前先 `engine._run_gather_phase`(全席位 `asyncio.gather` 跑 `_GATHER_TASK`「只取数不研讨」),产出 `gathered: dict` 经 `_seat_message(gathered=...)` 作为「已收集资料」块注入 recommend/chain/dag 各研讨轮席位任务(与「前序交付」块分开),把最慢的技能延迟前置并行、给(尤其 chain 串行的)研讨段提速;研讨阶段不限制再调技能。前台对称实现:`useStep2Orchestration.runGatherPhase` 在 `runActiveOrchestration` 分发前跑一次(`gatherPhaseDoneRef` 闸门,resetInitGate 复位),产出存 `gatheredDeliveriesRef` 经 `peerDeliveries`/初始 `priorDeliveries` 注入研讨席位。链条级 `gatherFirst`(camel)↔`gather_first`(snake) 经 `api/chains.ts` 映射;job 经 `StartJobRequest.gather_first`→`StartParams`→`run_orchestration(gather_first=…)`。Tests: `tests/test_roundtable_gather_phase.py`。(4) **削减重复广播**(前端 only,多智能体 foreground 路径)—— `suppress_pre_broadcast`(仅 leader:抑制「总控本轮输入→各席位 thread」预广播 + 「总控给X派活」→兄弟席位的 post 广播,二者一个 flag)此前只在 DAG 派活轮开;现扩到 recommend(`safetyCounter > 1`,即第 2 轮起的续轮/综合)+ chain 逐席位(`i > 0`,第 2 个席位起)+ chain 收口轮(恒 true)。**保留每种模式的首轮预广播**(它为席位打底任务背景),只 suppress 重复的续轮广播(N 次读写巨大席位 thread 的纯浪费 + 弱模型模仿派活);席位的前序交付另经「席位交付广播」`broadcastSeatDeliveryToOthers` 送达,不受影响。后台引擎路径无此广播(`_seat_message` 直接拼上下文),故 #4 仅前端。 | +| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (info/warning/error), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task, *, chain_enabled=False)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") to the **seat's per-turn task (human turn)** — far stronger pull on weak models than the system prompt. Wired into **both** seat paths: foreground `multi_agent._special_run` (only `role in {seat, seat_parallel}`; Step3 singletons report/summary/dashboard/ingest excluded) + background `InProcessRoundtableGateway.run_seat`. No-op (returns task unchanged) when the seat has no configured skills. **2026-06-20: default-off + two opt-in switches (OR semantics)** — was global-on, now injects only when **either** the seat agent's own switch (`AgentConfig.seat_skill_directive`, edited in the agent edit dialog `agent-card.tsx` + `agent-detail-panel.tsx`) **or** the **per-seat** switch on that agent **within the selected business chain** is on. The per-seat chain switch lives **in the chain's `seats` JSON** (`ChainSeatSchema.seat_skill_directive`, no DB column — rides the existing JSON like the per-seat `model` override), edited via a small ✨ icon → dropdown (Switch + description) on each agent card in `BusinessChainEditorPage`. It is threaded **per-seat** to each seat run: foreground `multi_agent` per-call field `seat_skill_directive` (orchestration looks it up per agentId from a `{agentId: bool}` session map) → `_special_run` → `chain_enabled`; background as the job field `seat_skill_directives: dict[str,bool]` (StartJobRequest → StartParams → deps factory → `InProcessRoundtableGateway._seat_skill_directives`) → `run_seat` looks up `agent_id` → `chain_enabled`. `ROUNDTABLE_SEAT_SKILL_DIRECTIVE` is only a **master kill-switch** (default allow; set `0/false` to hard-disable regardless of the switches — never force-enables). Tests: `tests/test_roundtable_seat_skills.py`, `tests/test_agents_backend.py`. **2026-06-20 Step2 提速三件套**:(1) **每席位推理深度覆盖** —— 业务链条 `seats` JSON 里逐席位可配 `seat_mode`(flash/thinking/pro/ultra,覆盖链条级 `seatMode`;编辑器席位卡右侧 ⏱Gauge 图标下拉,null=跟随链条默认)。前台经 `useStep2Orchestration` 三处 dispatch 的 `seatModeToRequestFields(seatModes[id] ?? selectedMode)` 透传;后台经 job `seat_modes: dict[str,str]` → `StartParams` → `InProcessRoundtableGateway._seat_modes`,`run_seat` 用 `_SEAT_MODE_PARAMS`(与前端 `seatModeToRunParams` 逐字对齐)解析覆盖作业级 seat_*(未命中→跟随作业级→policy 默认)。无 DB 列(ride `seats` JSON,但 `ChainSeatSchema.seat_mode` 须显式声明否则 Pydantic 剥掉)。Tests: `tests/test_roundtable_seat_modes.py`。(2) **recommend 批内并行** —— 后端 `engine._run_recommend` 由 for-await 串行改 `asyncio.gather`(对齐前端 2026-06-13 已并行;总控一轮派多席位=判定相互独立→并发;批内用派活前 deliveries 快照、批后统一并入,语义不变)。(3) **研讨前并行『取数』两段式**(业务链条 `gather_first` 列,逐条 opt-in,migration `20260620_02`,editor「研讨前并行取数」开关)—— `gather_first=True` 时 `run_orchestration` 在分发前先 `engine._run_gather_phase`(全席位 `asyncio.gather` 跑 `_GATHER_TASK`「只取数不研讨」),产出 `gathered: dict` 经 `_seat_message(gathered=...)` 作为「已收集资料」块注入 recommend/chain/dag 各研讨轮席位任务(与「前序交付」块分开),把最慢的技能延迟前置并行、给(尤其 chain 串行的)研讨段提速;研讨阶段不限制再调技能。前台对称实现:`useStep2Orchestration.runGatherPhase` 在 `runActiveOrchestration` 分发前跑一次(`gatherPhaseDoneRef` 闸门,resetInitGate 复位),产出存 `gatheredDeliveriesRef` 经 `peerDeliveries`/初始 `priorDeliveries` 注入研讨席位。链条级 `gatherFirst`(camel)↔`gather_first`(snake) 经 `api/chains.ts` 映射;job 经 `StartJobRequest.gather_first`→`StartParams`→`run_orchestration(gather_first=…)`。Tests: `tests/test_roundtable_gather_phase.py`。(4) **削减重复广播**(前端 only,多智能体 foreground 路径)—— `suppress_pre_broadcast`(仅 leader:抑制「总控本轮输入→各席位 thread」预广播 + 「总控给X派活」→兄弟席位的 post 广播,二者一个 flag)此前只在 DAG 派活轮开;现扩到 recommend(`safetyCounter > 1`,即第 2 轮起的续轮/综合)+ chain 逐席位(`i > 0`,第 2 个席位起)+ chain 收口轮(恒 true)。**保留每种模式的首轮预广播**(它为席位打底任务背景),只 suppress 重复的续轮广播(N 次读写巨大席位 thread 的纯浪费 + 弱模型模仿派活);席位的前序交付另经「席位交付广播」`broadcastSeatDeliveryToOthers` 送达,不受影响。后台引擎路径无此广播(`_seat_message` 直接拼上下文),故 #4 仅前端。**2026-06-20 深链接业务链抽取规范**:深链接会商(taskId+goPath,businessCode 如 rwfx→6BF)下,结构化抽取(flow-json)与方案总结报告都按所选业务链**完整层级**强制校正。结构化抽取实为 `roundtable-structure` 智能体(`flow_extract.py` 的 `_system_prompt()=load_agent_soul(STRUCTURE_AGENT_ID)`,抽取→校验两段式 `model.astream`);其 SOUL 全局一份,旧实现把某业务层级写死进 SOUL→所有业务都按「目的→行为体」抽(bug)。修法:**单份领域知识、两角色用法**的 `deerflow.agents.roundtable_orchestrator.extraction_spec`(放 harness 层,app+harness 都可 import;`build_structure_directive`/`build_summary_directive` 按 businessCode 取;rwfx=6BF 写实「任务→目的→行为体→预期行为→驱动因素→叙事,预期行为↔驱动因素 1:1、余 1:N、label 不含步骤/阶段名、优先报告回退席位」,其余业务通用规范=强约束完整性+命名+取数且明令不套固定模板;business_code 空→""零副作用)。注入:(1) 结构化抽取 `flow_extract._hierarchy_hint`+`_validate_messages` 用 `build_structure_directive` 权威覆盖默认层级(前端 `streamFlowExtract` 传 `business_code`,源 `ingestBusinessCode`);(2) 方案总结报告 `build_summary_directive`——前台 `multi_agent._special_run`(role==summary 且 business_code) 追加、后台 `engine.build_summary_prompt(business_code=…)`(StartParams/StartJobRequest 透传)。**仅深链接生效**。种子 `roundtable-structure/SOUL.md` 层级顺序改 spec-driven、连接示例去 rwfx 写死(存量 live SOUL 不被 seed 覆盖,但注入规范权威可纠正)。Tests: `tests/test_roundtable_extraction_spec.py`。 | | **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. | | **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/`. 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"`. | 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 f0d83a43..7f3b11cd 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py @@ -200,6 +200,9 @@ class StartParams: # 是否在研讨前先跑一轮**全席位并行『取数』**(业务链条逐条 opt-in):各席位并行只调技能取数、 # 不研讨,产出作为共享资料注入后续研讨轮。透传给 run_orchestration 的 gather_first。默认 False。 gather_first: bool = False + # 深链接业务码(rwfx→6BF 等)。透传给 run_orchestration → build_summary_prompt:写实业务额外注入 + # 「业务链完整性核对」,让后台总结报告核对产出相对业务链完不完整。普通会商为空 → 不注入。 + business_code: str | None = None # 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。 @@ -288,6 +291,7 @@ class RoundtableJobExecutor: 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/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 cda907f5..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 @@ -29,11 +29,12 @@ 2. **逐条核对覆盖**:对每条诉求,检查各席位交付 + 总控共识**是否覆盖、覆盖得够不够、有没有跑题或自相矛盾**。 3. **据此组织报告**,并按下面的「缺口处理护栏」补强——让最终报告明显**比"各席位交付的简单拼接"更扣用户意图**:意图里要的、席位没说清的,你补强或标缺口;意图里没要的、席位跑题的,不要喧宾夺主。 -### 缺口处理护栏(避免弱模型把幻觉带进报告) -- **能从已有原料重组 / 推理补上的** → 直接补全、讲透,正常写进报告。这是你最该做的增值:把零散、跑题、含糊的席位产物,按用户意图重新梳理成扣题的方案。 -- **超出席位原料、属于你自己的合理推断 / 建议** → 可以写,但**显式标注「(综合推断)」**,让用户能区分哪些有席位依据、哪些是你的补充判断。 -- **缺真实信息、靠现有原料补不了的**(需要最新数据 / 外部事实,而席位没给)→ **绝不凭空编**。在报告对应位置用一行醒目标注:`> ⚠️ 待补充:<具体缺什么>。如需补全,可在下方对话里告诉我(例如「补一下 X 的最新情况」),我会据此完善。` 把球交给用户——你本身不联网、不调研,真补由用户在对话里发起。 -- 始终:**宁可标注缺口,也不要假装完整**;绝不编造与原料矛盾、或无依据的"事实"。 +### 缺口处理护栏(报告文件要完整干净、把不足说在对话里) +**核心原则:写进 `.md` 报告文件的,必须是一份完整、干净、可直接使用、无需用户再修改的报告——报告文件里不要出现「待补充 / 不足 / ⚠️ / 尚需完善」之类的提示或占位。** 着急的用户拿到文件就能用。 +- **能从已有原料重组 / 推理补上的** → 直接补全、讲透,正常写进报告。这是你最该做的增值:把零散、跑题、含糊的席位产物,按用户意图重新梳理成扣题、完整的方案。 +- **超出席位原料、属于你自己的合理推断 / 建议** → 可以写进报告,让报告完整连贯;不必在报告里逐处打标签(保持可读),绝不编造与原料矛盾的内容。 +- **缺真实信息、靠现有原料补不了的**(需要最新数据 / 外部事实,而席位没给)→ **绝不凭空编**,但也**不要在报告文件里写「待补充」之类的警告**。报告文件就按现有信息给出尽量完整的版本;这些「不足 / 尚可补充之处」**只在对话回复正文里、报告之后单独说明**(见下「B」)。 +- 始终:报告文件 = 一份完整可用的报告(无缺口提示);不足只在对话文字里点出;绝不编造无依据的"事实"。 ## 产出要求(最重要) @@ -53,9 +54,10 @@ - **严禁**写 Python / shell / 任何脚本去"生成"文件,也**严禁**用 bash——你必须自己把 Markdown 文本直接写进 `write_file`; - `write_file` **支持很长的内容,不会截断**——请放心一次性把整篇报告写进去。 -### B. 在对话回复里给出报告正文 +### B. 在对话回复里给出报告正文(不足只写在这里) - 写完文件后,**把这份报告的完整正文也输出到对话回复里**(直接给用户读),不要只回一句「已写入文件」。 -- 用户既能在对话里直接读到报告,也能在右侧预览 / 下载这个 `.md` 文件——两边内容应当一致。 +- 报告正文部分与 `.md` 文件**应当一致**(都是那份完整干净的报告)。 +- **在对话回复里、报告正文之后,必须追加一段简短「核对说明」**(这段**只在对话里**、**绝不写进 `.md` 报告文件**):对照用户意图(及业务链各层级,如有)**逐条说明产出是否覆盖、哪些充分 / 哪些不足、有哪些可补充完善之处**,并提示可在对话里让你补全(如「---\n**核对说明**:① 已覆盖:…;② 资料不足 / 尚可补充:…(可直接告诉我补全)」)。**即便核对下来各诉求 / 层级都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求 / 层级均已覆盖,无明显缺口」——这段核对说明任何时候都不要省略。** 报告 `.md` 文件本身始终是一份完整可用、无需修改、不含任何缺口提示的版本。 ## 报告结构(叙事型,文字为主线) **下面是默认结构——仅当「业务链总控编排提示」没有指定输出结构时使用。** 用户指定了结构(章节、表格、清单、固定模板等)就完全按用户的来;两者只部分重叠时,以用户要求为骨架、默认章节做补充。按下面的章节顺序组织,缺料的章节可省略或合并: @@ -136,7 +138,7 @@ - ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印 - ❌ 编造与原料明显矛盾的内容 - ❌ 只把各席位交付简单拼接、不做「意图对齐与缺口核对」(席位可能弱,你要兜底扣住用户意图) -- ❌ 缺真实信息时凭空编造来"填满"报告(应按护栏标注「> ⚠️ 待补充:…」交给用户在对话里补) -- ❌ 自己的推断不加「(综合推断)」标注,让用户误以为有席位依据 +- ❌ 缺真实信息时凭空编造来"填满"报告(按现有信息给出完整版即可;不足只在对话正文点出,不写进 .md 文件) +- ❌ 在 `.md` 报告文件里写「待补充 / 不足 / ⚠️ / 尚需完善」等缺口提示或占位(报告文件要完整干净、无需修改;不足只写在对话正文里) - ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高) - ❌ 编排提示已指定输出结构 / 重点席位,却仍按默认章节平均用力(结构要完全跟用户走,重点席位要明显加权) 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..36e6ff53 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py +++ b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py @@ -31,6 +31,8 @@ from fastapi.responses import StreamingResponse from langchain_core.messages import HumanMessage, SystemMessage from pydantic import BaseModel, Field +from deerflow.agents.roundtable_orchestrator.extraction_spec import build_structure_directive + from app.gateway.routers._roundtable_seed import ( STRUCTURE_AGENT_ID, ensure_roundtable_functional_agents, @@ -59,6 +61,9 @@ 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) model: str | None = Field(default=None) # Phase 2 问答修改:带上当前图 + 用户指令 → 进 edit 模式(按指令改图,其余不变)。 current_graph: dict[str, Any] | None = Field(default=None) @@ -438,6 +443,17 @@ def _material(req: FlowExtractRequest) -> str: 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()] @@ -504,8 +520,12 @@ 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 + "\n\n") if spec else "" user = ( - _material(req) + spec_block + + _material(req) + "\n\n===== 已抽取的 flow-json(待校验修复)=====\n" + extracted + "\n===== 待校验结束 =====\n\n请对照数据来源,**逐个节点检查上面这份 flow-json 是不是缺字段**、并修复它:" 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 888c01a3..7457d9af 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -64,6 +64,8 @@ from langgraph.checkpoint.base import empty_checkpoint from app.gateway.roundtable_diag import record_foreground_diag 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, @@ -1738,6 +1740,7 @@ async def _special_run( 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。 @@ -1796,6 +1799,12 @@ async def _special_run( seat_message = new_message if role in ("seat", "seat_parallel"): 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}" body = _run_payload( agent_name=agent_name, model_name=model_name, @@ -1963,6 +1972,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> # 业务链条级「技能识别强化」开关(仅 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( @@ -2014,6 +2026,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> 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_jobs.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py index b06c59e2..5116d0b8 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py @@ -87,6 +87,8 @@ class StartJobRequest(BaseModel): 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): @@ -248,6 +250,7 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes 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/roundtable_orchestrator/engine.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py index b28d054b..fe36c3b0 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, @@ -81,6 +82,7 @@ async def run_orchestration( enable_report: bool = True, plan: dict | None = None, gather_first: bool = False, + business_code: str | None = None, ) -> OrchestrationResult: """运行一次完整编排(循环 → 共识 → 报告绘制)。 @@ -162,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( @@ -597,6 +600,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 由串行改并行, @@ -617,7 +621,8 @@ 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 @@ -772,6 +777,7 @@ def build_summary_prompt( consensus_text: str, *, coordinator_prompt: str | None = None, + business_code: str | None = None, ) -> str: """拼装 Step3「方案总结报告」prompt —— 对齐前端 ``useStep3Summary.buildSummaryPrompt`` (非流程图分支)+ ``buildMaterials`` 的原料结构,使后台产出与手动调用一致。""" @@ -812,11 +818,16 @@ def build_summary_prompt( "【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。" "你是这份报告的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)" "拆成用户的核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、" - "有无跑题或矛盾;再据此组织报告并按护栏补强:① 能从已有原料重组/推理补上的→直接补全讲透;" - "② 属于你自己的推断/建议→写,但显式标注「(综合推断)」;③ 缺真实信息、靠现有原料补不了的→**绝不凭空编**," - "用一行 `> ⚠️ 待补充:<具体缺什么>。可在对话里让我补全` 标出,把球交给用户。" + "有无跑题或矛盾;再据此组织:① 能从已有原料重组/推理补上的→直接补全讲透;② 缺真实信息补不了的→**绝不凭空编**。" + "**写进 .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..200e48d7 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/extraction_spec.py @@ -0,0 +1,160 @@ +"""按 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 = "" # 该业务额外补充(可空) + + +# 节点命名 / 取数优先:各业务通用,单独抽出避免重复。 +_NAMING_NOTE = ( + "节点 label 必须用该层级下的**具体实体 / 概念短名**(≤12 字)," + "**绝不能含「步骤 / 阶段 / 环节 / 第N步 / 阶段①②③」等流程或编号词**,也不要拿层级名本身当 label。" +) +_SOURCE_NOTE = "**优先**从《方案总结报告》抽取;没有报告时再用各席位交付内容。逐条抽取、勿改写、勿精简理由。" + + +# ── rwfx(6BF):六步法完整链(线性) ───────────────────────────────────────── +_RWFX = ChainSpec( + code="6BF", + label="rwfx", + levels=["任务", "目的", "行为体", "预期行为", "驱动因素", "叙事"], + relations_note=( + "层间关系:**「预期行为」与「驱动因素」一一对应**(两层数量必须相等、一个预期行为恰对一个驱动因素," + "按相同位次配对);其余相邻层级(任务→目的、目的→行为体、行为体→预期行为、驱动因素→叙事)均为" + "**一对多**(上层一条可带下层多条)。" + ), + completeness_note=( + "以上 6 个层级都要出节点;一一对应的层级(预期行为↔驱动因素)两层节点数必须相等并按位次配对。" + ), +) + + +# ── 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 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"- {_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"- {_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 文件里写「待补充」之类提示**);" + "并在**对话回复正文、报告之后的「核对说明」里逐层说明各层级是否覆盖、缺了哪层 / 哪层条目不足**" + "(即便各层级都已覆盖,也要写一句「各层级均已覆盖」),可在对话里让我补全;这段核对只在对话里、不写进 .md 文件,不要跳层。" + ) 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_summary_prompt.py b/offline-backend-20260512/backend/tests/test_roundtable_summary_prompt.py index b2f92741..871eafab 100644 --- a/offline-backend-20260512/backend/tests/test_roundtable_summary_prompt.py +++ b/offline-backend-20260512/backend/tests/test_roundtable_summary_prompt.py @@ -31,10 +31,12 @@ def test_summary_prompt_carries_intent_and_alignment_directive(): # 意图对齐 + 缺口核对指令 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(): From b513a0d56c1d602d53a823a8993d30191f522be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 20 Jun 2026 20:05:27 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat(roundtable):=20=E6=B7=B1=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E7=AC=AC=E4=B8=80=E6=AD=A5=E4=BB=BB=E5=8A=A1/?= =?UTF-8?q?=E8=A1=8C=E5=8A=A8=E5=B1=95=E7=A4=BA=20+=20xdfx=20=E8=A1=8C?= =?UTF-8?q?=E5=8A=A8=E8=AF=A6=E6=83=85=E6=8E=A5=E5=85=A5=E4=BC=9A=E5=95=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Step1 深链接(taskId)下:左栏顶部「业务链选择」隐藏,改显当前任务(rwfx/3qfx:任务名称+描述) 或当前行动(xdfx:行动名称+叙事);顶栏「业务链条」入口隐藏;「新建任务」改「重新分析」 (复位 autoStarted + resetToStep1,对同一任务重做意图分析) - xdfx 行动详情接入会商:fetchActionDetail 取到即 persistActionDetail 缓存;新增 readActionDetail/persistActionDetail + ACTION_DETAIL_STORAGE_KEY;resolveTaskDeeplinkTarget 会商分支为 xdfx 把 actionId/actionName 带入 roundtablePrefill - xdfx 首条交给任务理解的消息 = 任务名称 + 任务描述 + 行动名称 - 「原任务已有结果」(目的树预览 headerLeftSlot) 仅 rwfx-A(6BF) 显示 注:行动详情 mock(actionName/narration) 在 config.yaml(按惯例不提交,单独管理)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/core/auth/task-deeplink.ts | 39 ++++- .../pages/RoundtablePlanningPage.tsx | 136 ++++++++++++++++-- 2 files changed, 160 insertions(+), 15 deletions(-) diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index c8a2322c..8a149342 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 = { "3qfx": "3Q", // 三情分析 @@ -111,6 +114,25 @@ export function readCopTaskDetail(): CopTaskDetail | null { } } +/** 把行动详情整包暂存到 sessionStorage(xdfx 行动分析用;隐私模式/quota 异常时静默失败)。 */ +export function persistActionDetail(detail: ActionDetail): void { + try { + sessionStorage.setItem(ACTION_DETAIL_STORAGE_KEY, JSON.stringify(detail)); + } catch { + /* noop */ + } +} + +/** 读取暂存的行动详情(会商左上角「当前行动」、单智能体行动详情卡都可读)。 */ +export function readActionDetail(): ActionDetail | null { + try { + const raw = sessionStorage.getItem(ACTION_DETAIL_STORAGE_KEY); + return raw ? (JSON.parse(raw) as ActionDetail) : null; + } catch { + return null; + } +} + /** 行动详情接口 ``{action_detail_url}/{actionId}`` 返回的 ``data`` 字段(xdfx 行动分析用)。 */ export interface ActionDetail { /** 行动id(深链 ?taskId= 传入的就是它)。 */ @@ -148,7 +170,7 @@ export async function fetchActionDetail(actionId: string): Promise; - return { + const detail: ActionDetail = { id: d.id != null ? String(d.id) : "", taskId: d.taskId != null ? String(d.taskId) : "", purposeId: d.purposeId != null ? String(d.purposeId) : "", @@ -157,6 +179,10 @@ export async function fetchActionDetail(actionId: string): Promise { + autoStartedRef.current = false; + resetToStep1(); + triggerToast("🔄 已重新分析当前任务"); + }, [resetToStep1, triggerToast]); + // 无界抽屉保活复用:父页面重开抽屉时 `bus.$emit('roundtable:reset')` → 子应用回到第一步。 // 非无界环境下 onWujieEvent 返回 no-op,安全无副作用。 useEffect(() => { @@ -1857,7 +1878,8 @@ export default function RoundtablePlanningPage() { )} - {!inWujieEmbed && ( + {/* 深链接(taskId)模式:业务链由 businessCode 自动决定,隐藏手动「业务链条」入口。 */} + {!inWujieEmbed && !prefillRef.current?.taskId && ( )} - + {/* 深链接(taskId)模式:任务固定,把「新建任务」换成「重新分析」——清空会话、对同一任务重做意图分析。 */} + {prefillRef.current?.taskId ? ( + + ) : ( + + )}