524 lines
21 KiB
Python
524 lines
21 KiB
Python
"""BoardOrchestrator - 私董会讨论引擎
|
||
|
||
驱动 BoardTeam 执行多轮群聊式讨论:
|
||
|
||
1. 主持人开场介绍议题和讨论规则
|
||
2. 循环 max_rounds 轮:
|
||
- 所有非主持人专家并行生成发言(基于共享讨论历史 + 角色 prompt)
|
||
- 主持人小结本轮要点
|
||
- 检查用户干预和停止命令
|
||
3. 主持人最终总结(决策建议、共识点、分歧点)
|
||
|
||
终止条件:
|
||
- 正常终止:达到最大轮次
|
||
- 用户终止:用户发送 /stop
|
||
- 异常终止:LLM 不可用或所有专家发言失败
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from typing import Any
|
||
|
||
from .expert import Expert
|
||
from .board import BoardTeam, BoardStatus
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class BoardOrchestrator:
|
||
"""Board meeting orchestration engine.
|
||
|
||
The moderator (lead expert) facilitates the discussion:
|
||
- Opens with topic introduction
|
||
- Summarizes each round
|
||
- Gives final decision advice
|
||
|
||
Member experts give speeches each round based on shared history.
|
||
"""
|
||
|
||
STOP_COMMANDS = frozenset({"/stop", "停止讨论", "stop", "结束讨论"})
|
||
|
||
def __init__(self, team: BoardTeam) -> None:
|
||
self._team = team
|
||
|
||
async def execute(self, topic: str) -> dict[str, Any]:
|
||
"""Execute a board meeting discussion.
|
||
|
||
Flow:
|
||
1. Broadcast board_started event
|
||
2. Moderator opens the discussion
|
||
3. Loop max_rounds times:
|
||
- Parallel generate member speeches
|
||
- Moderator summarizes the round
|
||
- Check for user intervention / stop
|
||
4. Moderator gives final conclusion
|
||
5. Broadcast board_concluded event
|
||
|
||
Returns:
|
||
Dict with status, summary, decision_advice, total_rounds,
|
||
consensus_points, dissent_points
|
||
"""
|
||
moderator = self._team.moderator
|
||
if not moderator or not moderator.is_active:
|
||
active = self._team.active_experts
|
||
if not active:
|
||
return {
|
||
"status": "failed",
|
||
"summary": "",
|
||
"decision_advice": "",
|
||
"total_rounds": 0,
|
||
"consensus_points": [],
|
||
"dissent_points": [],
|
||
"error": "No active expert available",
|
||
}
|
||
# Promote first active expert to moderator
|
||
self._team._moderator_name = active[0].config.name
|
||
moderator = active[0]
|
||
logger.warning(
|
||
f"Moderator not available, falling back to '{moderator.config.name}'"
|
||
)
|
||
|
||
self._team.set_status(BoardStatus.DISCUSSING)
|
||
|
||
# 1. Broadcast board_started event
|
||
await self._broadcast_event(
|
||
"board_started",
|
||
{
|
||
"team_id": self._team.team_id,
|
||
"topic": topic,
|
||
"experts": [
|
||
{
|
||
"name": e.config.name,
|
||
"avatar": e.config.avatar,
|
||
"color": e.config.color,
|
||
"is_moderator": e.config.name == self._team._moderator_name,
|
||
"persona": e.config.persona[:100],
|
||
}
|
||
for e in self._team.active_experts
|
||
],
|
||
"max_rounds": self._team.max_rounds,
|
||
},
|
||
)
|
||
|
||
try:
|
||
# 2. Moderator opens the discussion
|
||
opening = await self._generate_moderator_opening(moderator, topic)
|
||
if opening:
|
||
await self._team.add_to_history(0, moderator.config.name, opening, "moderator")
|
||
await self._broadcast_event(
|
||
"expert_speech",
|
||
{
|
||
"expert_name": moderator.config.name,
|
||
"expert_avatar": moderator.config.avatar,
|
||
"expert_color": moderator.config.color,
|
||
"content": opening,
|
||
"round": 0,
|
||
"role": "moderator",
|
||
},
|
||
)
|
||
|
||
# 3. Discussion rounds
|
||
for round_num in range(1, self._team.max_rounds + 1):
|
||
self._team.increment_round()
|
||
|
||
# Check for stop command before starting the round
|
||
interventions = self._team.consume_user_interventions()
|
||
if self._has_stop_command(interventions):
|
||
logger.info(f"Discussion stopped by user at round {round_num}")
|
||
break
|
||
|
||
# Generate member speeches in parallel
|
||
members = self._team.member_experts
|
||
if members:
|
||
speech_results = await asyncio.gather(
|
||
*[self._generate_expert_speech(e, round_num) for e in members],
|
||
return_exceptions=True,
|
||
)
|
||
|
||
# Broadcast speeches in order (not parallel broadcast)
|
||
for expert, result in zip(members, speech_results):
|
||
if isinstance(result, Exception):
|
||
logger.warning(
|
||
f"Expert '{expert.config.name}' speech failed: {result}"
|
||
)
|
||
continue
|
||
|
||
await self._team.add_to_history(
|
||
round_num, expert.config.name, result, "expert"
|
||
)
|
||
await self._broadcast_event(
|
||
"expert_speech",
|
||
{
|
||
"expert_name": expert.config.name,
|
||
"expert_avatar": expert.config.avatar,
|
||
"expert_color": expert.config.color,
|
||
"content": result,
|
||
"round": round_num,
|
||
"role": "expert",
|
||
},
|
||
)
|
||
|
||
# Moderator summarizes the round
|
||
summary = await self._generate_moderator_summary(moderator, round_num)
|
||
if summary:
|
||
await self._team.add_to_history(
|
||
round_num, moderator.config.name, summary, "moderator"
|
||
)
|
||
await self._broadcast_event(
|
||
"round_summary",
|
||
{
|
||
"moderator_name": moderator.config.name,
|
||
"content": summary,
|
||
"round": round_num,
|
||
"continue": round_num < self._team.max_rounds,
|
||
},
|
||
)
|
||
|
||
# Check history length and compress if needed
|
||
gateway = self._get_llm_gateway(moderator)
|
||
if gateway and len(self._team.history) > 20:
|
||
await self._team.compress_history(moderator, gateway)
|
||
|
||
# 4. Final conclusion
|
||
self._team.set_status(BoardStatus.CONCLUDING)
|
||
conclusion = await self._generate_final_conclusion(moderator, topic)
|
||
|
||
self._team.set_status(BoardStatus.COMPLETED)
|
||
|
||
# 5. Broadcast board_concluded event
|
||
await self._broadcast_event(
|
||
"board_concluded",
|
||
{
|
||
"summary": conclusion.get("summary", ""),
|
||
"decision_advice": conclusion.get("decision_advice", ""),
|
||
"total_rounds": self._team.current_round,
|
||
"consensus_points": conclusion.get("consensus_points", []),
|
||
"dissent_points": conclusion.get("dissent_points", []),
|
||
},
|
||
)
|
||
|
||
return {
|
||
"status": "completed",
|
||
"summary": conclusion.get("summary", ""),
|
||
"decision_advice": conclusion.get("decision_advice", ""),
|
||
"total_rounds": self._team.current_round,
|
||
"consensus_points": conclusion.get("consensus_points", []),
|
||
"dissent_points": conclusion.get("dissent_points", []),
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Board meeting execution failed: {e}")
|
||
self._team.set_status(BoardStatus.DISSOLVED)
|
||
|
||
# Try to give a fallback conclusion
|
||
fallback = await self._generate_fallback_conclusion(moderator, topic)
|
||
|
||
await self._broadcast_event(
|
||
"board_concluded",
|
||
{
|
||
"summary": fallback.get("summary", ""),
|
||
"decision_advice": fallback.get("decision_advice", ""),
|
||
"total_rounds": self._team.current_round,
|
||
"consensus_points": [],
|
||
"dissent_points": [],
|
||
"error": str(e),
|
||
},
|
||
)
|
||
|
||
return {
|
||
"status": "failed",
|
||
"summary": fallback.get("summary", ""),
|
||
"decision_advice": fallback.get("decision_advice", ""),
|
||
"total_rounds": self._team.current_round,
|
||
"consensus_points": [],
|
||
"dissent_points": [],
|
||
"error": str(e),
|
||
}
|
||
|
||
async def _generate_moderator_opening(self, moderator: Expert, topic: str) -> str:
|
||
"""Generate moderator's opening speech.
|
||
|
||
The moderator introduces the topic and sets the stage for discussion.
|
||
"""
|
||
gateway = self._get_llm_gateway(moderator)
|
||
if not gateway:
|
||
return f"欢迎来到私董会。今天的讨论主题是:{topic}。请各位专家发表看法。"
|
||
|
||
prompt = (
|
||
f"你是私董会主持人 {moderator.config.name}。\n"
|
||
f"你的角色:{moderator.config.persona}\n"
|
||
f"你的表达风格:{moderator.config.speaking_style}\n\n"
|
||
f"讨论主题:{topic}\n\n"
|
||
"请作为主持人开场,介绍议题并邀请各位专家发表看法。"
|
||
"开场应该简洁有力,2-3 段话,点明讨论的核心问题。"
|
||
)
|
||
|
||
try:
|
||
response = await gateway.chat(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
model="default",
|
||
)
|
||
return response.content.strip()
|
||
except Exception as e:
|
||
logger.warning(f"Moderator opening generation failed: {e}")
|
||
return f"欢迎来到私董会。今天的讨论主题是:{topic}。请各位专家发表看法。"
|
||
|
||
async def _generate_expert_speech(self, expert: Expert, round: int) -> str:
|
||
"""Generate an expert's speech for the current round.
|
||
|
||
The speech is based on:
|
||
- Expert's persona, thinking_style, speaking_style, decision_framework
|
||
- Full discussion history
|
||
- Current round / max rounds
|
||
"""
|
||
gateway = self._get_llm_gateway(expert)
|
||
if not gateway:
|
||
return f"[{expert.config.name} 因 LLM 不可用无法发言]"
|
||
|
||
history_text = self._team.get_history_text()
|
||
|
||
prompt = (
|
||
f"你是 {expert.config.name},正在参加私董会讨论。\n\n"
|
||
f"你的角色:{expert.config.persona}\n"
|
||
f"你的思维风格:{expert.config.thinking_style}\n"
|
||
f"你的表达风格:{expert.config.speaking_style}\n"
|
||
f"你的决策框架:{expert.config.decision_framework}\n\n"
|
||
f"讨论主题:{self._team.topic}\n"
|
||
f"当前轮次:第 {round} 轮 / 共 {self._team.max_rounds} 轮\n\n"
|
||
)
|
||
|
||
if history_text:
|
||
prompt += f"之前的讨论历史:\n{history_text}\n\n"
|
||
|
||
prompt += (
|
||
"请基于你的角色和决策框架,就当前讨论主题发表你的看法。"
|
||
"要求:\n"
|
||
"- 保持角色一致性,用你的思维方式和表达风格发言\n"
|
||
"- 2-4 段话,简洁但有洞察力\n"
|
||
"- 可以引用或反驳之前发言者的观点\n"
|
||
"- 给出明确的立场或建议\n"
|
||
)
|
||
|
||
response = await gateway.chat(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
model="default",
|
||
)
|
||
return response.content.strip()
|
||
|
||
async def _generate_moderator_summary(self, moderator: Expert, round: int) -> str:
|
||
"""Generate moderator's round summary.
|
||
|
||
The moderator summarizes the key points of the current round.
|
||
"""
|
||
gateway = self._get_llm_gateway(moderator)
|
||
if not gateway:
|
||
return f"[第 {round} 轮小结因 LLM 不可用无法生成]"
|
||
|
||
# Get only current round's speeches
|
||
round_history = [
|
||
h for h in self._team.history if h["round"] == round
|
||
]
|
||
if not round_history:
|
||
return ""
|
||
|
||
round_text = "\n\n".join(
|
||
f"[{h['expert_name']}]: {h['content']}" for h in round_history
|
||
)
|
||
|
||
prompt = (
|
||
f"你是私董会主持人 {moderator.config.name}。\n"
|
||
f"你的角色:{moderator.config.persona}\n"
|
||
f"你的表达风格:{moderator.config.speaking_style}\n\n"
|
||
f"讨论主题:{self._team.topic}\n"
|
||
f"当前轮次:第 {round} 轮 / 共 {self._team.max_rounds} 轮\n\n"
|
||
f"本轮发言:\n{round_text}\n\n"
|
||
"请作为主持人小结本轮讨论:\n"
|
||
"- 归纳各方核心观点(2-3 句话)\n"
|
||
"- 指出共识点和分歧点\n"
|
||
"- 提示下一轮可以深入的方向\n"
|
||
"- 保持简洁,3-5 句话\n"
|
||
)
|
||
|
||
try:
|
||
response = await gateway.chat(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
model="default",
|
||
)
|
||
return response.content.strip()
|
||
except Exception as e:
|
||
logger.warning(f"Moderator summary generation failed: {e}")
|
||
return f"[第 {round} 轮讨论完成,主持人小结生成失败]"
|
||
|
||
async def _generate_final_conclusion(self, moderator: Expert, topic: str) -> dict[str, Any]:
|
||
"""Generate moderator's final conclusion.
|
||
|
||
The moderator gives:
|
||
- Overall summary of the discussion
|
||
- Decision advice
|
||
- Consensus points
|
||
- Dissent points
|
||
"""
|
||
gateway = self._get_llm_gateway(moderator)
|
||
if not gateway:
|
||
return {
|
||
"summary": "讨论已完成,但 LLM 不可用无法生成总结。",
|
||
"decision_advice": "建议参考讨论历史自行判断。",
|
||
"consensus_points": [],
|
||
"dissent_points": [],
|
||
}
|
||
|
||
history_text = self._team.get_history_text()
|
||
|
||
prompt = (
|
||
f"你是私董会主持人 {moderator.config.name}。\n"
|
||
f"你的角色:{moderator.config.persona}\n"
|
||
f"你的表达风格:{moderator.config.speaking_style}\n"
|
||
f"你的决策框架:{moderator.config.decision_framework}\n\n"
|
||
f"讨论主题:{topic}\n"
|
||
f"总轮次:{self._team.current_round}\n\n"
|
||
f"完整讨论历史:\n{history_text}\n\n"
|
||
"请作为主持人给出最终总结。输出 JSON 格式:\n"
|
||
"```json\n"
|
||
"{\n"
|
||
' "summary": "整体讨论总结,3-5句话",\n'
|
||
' "decision_advice": "基于讨论的决策建议,明确给出你的推荐",\n'
|
||
' "consensus_points": ["共识点1", "共识点2"],\n'
|
||
' "dissent_points": ["分歧点1", "分歧点2"]\n'
|
||
"}\n"
|
||
"```\n"
|
||
"只输出 JSON,不要其他文字。"
|
||
)
|
||
|
||
try:
|
||
import json
|
||
import re
|
||
|
||
response = await gateway.chat(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
model="default",
|
||
)
|
||
content = response.content.strip()
|
||
|
||
# Extract JSON from response
|
||
json_match = re.search(r"\{.*\}", content, re.DOTALL)
|
||
if json_match:
|
||
result = json.loads(json_match.group(0))
|
||
return {
|
||
"summary": result.get("summary", ""),
|
||
"decision_advice": result.get("decision_advice", ""),
|
||
"consensus_points": result.get("consensus_points", []),
|
||
"dissent_points": result.get("dissent_points", []),
|
||
}
|
||
|
||
# If JSON parsing fails, return raw content as summary
|
||
return {
|
||
"summary": content,
|
||
"decision_advice": "",
|
||
"consensus_points": [],
|
||
"dissent_points": [],
|
||
}
|
||
except Exception as e:
|
||
logger.warning(f"Final conclusion generation failed: {e}")
|
||
return {
|
||
"summary": f"讨论已完成({self._team.current_round}轮),总结生成失败。",
|
||
"decision_advice": "建议参考讨论历史自行判断。",
|
||
"consensus_points": [],
|
||
"dissent_points": [],
|
||
}
|
||
|
||
async def _generate_fallback_conclusion(self, moderator: Expert, topic: str) -> dict[str, Any]:
|
||
"""Generate a fallback conclusion when execution fails.
|
||
|
||
Uses existing discussion history to provide a basic summary.
|
||
"""
|
||
history_text = self._team.get_history_text()
|
||
if not history_text:
|
||
return {
|
||
"summary": "讨论未能正常完成,无历史记录。",
|
||
"decision_advice": "",
|
||
}
|
||
|
||
gateway = self._get_llm_gateway(moderator)
|
||
if not gateway:
|
||
# Return truncated history as summary
|
||
return {
|
||
"summary": f"讨论异常终止。已有历史({len(self._team.history)}条):\n"
|
||
+ history_text[:500],
|
||
"decision_advice": "建议参考讨论历史自行判断。",
|
||
}
|
||
|
||
prompt = (
|
||
f"你是私董会主持人 {moderator.config.name}。\n"
|
||
f"讨论主题:{topic}\n"
|
||
f"讨论因异常终止,已完成 {self._team.current_round} 轮。\n\n"
|
||
f"已有讨论历史:\n{history_text}\n\n"
|
||
"请基于已有历史给出总结和决策建议。输出 JSON:\n"
|
||
"```json\n"
|
||
'{"summary": "...", "decision_advice": "..."}\n'
|
||
"```\n"
|
||
)
|
||
|
||
try:
|
||
import json
|
||
import re
|
||
|
||
response = await gateway.chat(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
model="default",
|
||
)
|
||
content = response.content.strip()
|
||
json_match = re.search(r"\{.*\}", content, re.DOTALL)
|
||
if json_match:
|
||
result = json.loads(json_match.group(0))
|
||
return {
|
||
"summary": result.get("summary", content),
|
||
"decision_advice": result.get("decision_advice", ""),
|
||
}
|
||
return {"summary": content, "decision_advice": ""}
|
||
except Exception:
|
||
return {
|
||
"summary": f"讨论异常终止,已完成 {self._team.current_round} 轮。",
|
||
"decision_advice": "",
|
||
}
|
||
|
||
def _has_stop_command(self, interventions: list[str]) -> bool:
|
||
"""Check if any user intervention contains a stop command."""
|
||
for msg in interventions:
|
||
msg_lower = msg.strip().lower()
|
||
if msg_lower in self.STOP_COMMANDS:
|
||
return True
|
||
return False
|
||
|
||
def _get_llm_gateway(self, expert: Expert | None = None) -> Any:
|
||
"""Get LLM gateway from the given expert or the moderator's agent.
|
||
|
||
Falls back to other active experts if the primary target has no gateway.
|
||
"""
|
||
target = expert or self._team.moderator
|
||
if target and hasattr(target, "agent") and hasattr(target.agent, "_llm_gateway"):
|
||
gateway = target.agent._llm_gateway
|
||
if gateway is not None:
|
||
return gateway
|
||
# Fallback: try first active expert with a gateway
|
||
for exp in self._team.active_experts:
|
||
if hasattr(exp, "agent") and hasattr(exp.agent, "_llm_gateway"):
|
||
gateway = exp.agent._llm_gateway
|
||
if gateway is not None:
|
||
return gateway
|
||
return None
|
||
|
||
async def _broadcast_event(self, event_type: str, data: dict[str, Any]) -> None:
|
||
"""Broadcast a board event to the team channel.
|
||
|
||
Events are emitted via handoff_transport for WebSocket relay.
|
||
"""
|
||
if self._team.handoff_transport:
|
||
try:
|
||
await self._team.handoff_transport.send(
|
||
self._team.team_channel, {"type": event_type, **data}
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Failed to broadcast event '{event_type}': {e}")
|