641 lines
26 KiB
Python
641 lines
26 KiB
Python
"""BoardOrchestrator - 私董会讨论引擎
|
||
|
||
驱动 BoardTeam 执行多轮群聊式讨论:
|
||
|
||
1. 主持人开场介绍议题和讨论规则
|
||
2. 循环 max_rounds 轮:
|
||
- 所有非主持人专家并行生成发言(基于共享讨论历史 + 角色 prompt)
|
||
- 主持人小结本轮要点
|
||
- 检查用户干预和停止命令
|
||
3. 主持人最终总结(决策建议、共识点、分歧点)
|
||
|
||
终止条件:
|
||
- 正常终止:达到最大轮次
|
||
- 用户终止:用户发送 /stop
|
||
- 异常终止:LLM 不可用或所有专家发言失败
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
|
||
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, object]:
|
||
"""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 sequentially so the user sees one
|
||
# expert at a time instead of all experts racing to print
|
||
# their full text in the same instant. Each expert streams
|
||
# its own chunks (via expert_speech_chunk events) before the
|
||
# next expert starts. This trades wall-clock latency for
|
||
# perceptual clarity, which matches the user's stated
|
||
# preference for "逐个输出". Ponytail: 1-N experts, simple
|
||
# for-loop; if board size grows past ~5 members, revisit.
|
||
members = self._team.member_experts
|
||
if members:
|
||
for expert in members:
|
||
try:
|
||
result = await self._generate_expert_speech(expert, round_num)
|
||
except Exception as e:
|
||
logger.warning(f"Expert '{expert.config.name}' speech failed: {e}")
|
||
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,
|
||
"moderator_avatar": moderator.config.avatar,
|
||
"moderator_color": moderator.config.color,
|
||
"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 (streaming).
|
||
|
||
The speech is based on:
|
||
- Expert's persona, thinking_style, speaking_style, decision_framework
|
||
- Full discussion history
|
||
- Current round / max rounds
|
||
|
||
Streams LLM output chunk-by-chunk via ``expert_speech_chunk`` events
|
||
so the UI shows content as it arrives, instead of waiting for the
|
||
full completion. Returns the accumulated content so the gather
|
||
step in ``execute()`` can still keep its parallel contract.
|
||
"""
|
||
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"
|
||
)
|
||
|
||
# ponytail: stream when the provider supports it, otherwise fall back
|
||
# to a single non-streaming call. We keep the gather contract — the
|
||
# function still returns the full text — so the surrounding execute()
|
||
# loop is unchanged.
|
||
return await self._stream_expert_speech(expert, round, prompt)
|
||
|
||
async def _stream_expert_speech(self, expert: Expert, round: int, prompt: str) -> str:
|
||
"""Stream an expert's speech via chat_stream, emitting chunks.
|
||
|
||
Falls back to non-streaming ``chat()`` when ``chat_stream`` is
|
||
unavailable (e.g. an LLM provider without streaming support) or
|
||
raises before any chunk is produced.
|
||
|
||
ponytail: when the LLM does not actually stream (returns a single
|
||
big chunk), we still want the UI to see content appearing
|
||
progressively. So we split the LLM's final content into
|
||
sentence/line chunks and emit them with a small delay. The
|
||
``expert_speech_chunk`` event already handles duplicate-sender
|
||
dedup, so emitting many small chunks is safe.
|
||
"""
|
||
gateway = self._get_llm_gateway(expert)
|
||
assert gateway is not None # checked by caller
|
||
total = ""
|
||
# Emit an opening chunk-less event so the UI can create the streaming
|
||
# placeholder before the first token arrives (keeps the first paint
|
||
# aligned with the streaming indicator).
|
||
try:
|
||
streamed_chunk_count = 0
|
||
async for chunk in gateway.chat_stream(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
model="default",
|
||
):
|
||
delta = chunk.content or ""
|
||
if not delta:
|
||
continue
|
||
total += delta
|
||
streamed_chunk_count += 1
|
||
await self._broadcast_event(
|
||
"expert_speech_chunk",
|
||
{
|
||
"expert_name": expert.config.name,
|
||
"expert_avatar": expert.config.avatar,
|
||
"expert_color": expert.config.color,
|
||
"content": delta,
|
||
"round": round,
|
||
"role": "expert",
|
||
},
|
||
)
|
||
# If the LLM "streamed" but only delivered one big chunk, still
|
||
# let the UI see content arrive progressively.
|
||
if streamed_chunk_count <= 1 and total:
|
||
await self._replay_stream(expert, round, total, delay=0.05, chunk_size=12)
|
||
return total.strip()
|
||
except (AttributeError, NotImplementedError) as e:
|
||
logger.info(
|
||
f"Provider for '{expert.config.name}' lacks chat_stream, "
|
||
f"falling back to non-streaming: {e}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Expert '{expert.config.name}' stream failed: {e}")
|
||
|
||
# Fallback: non-streaming path. Emit the whole content as small
|
||
# chunks so the UI still renders progressively rather than going
|
||
# silent and then dumping the whole text in one frame.
|
||
try:
|
||
response = await gateway.chat(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
model="default",
|
||
)
|
||
content = (response.content or "").strip()
|
||
if content:
|
||
await self._replay_stream(expert, round, content, delay=0.05, chunk_size=12)
|
||
return content
|
||
except Exception as e:
|
||
logger.warning(f"Expert '{expert.config.name}' non-stream fallback failed: {e}")
|
||
return total.strip() or f"[{expert.config.name} 发言失败]"
|
||
|
||
async def _replay_stream(
|
||
self,
|
||
expert: Expert,
|
||
round: int,
|
||
content: str,
|
||
*,
|
||
delay: float,
|
||
chunk_size: int,
|
||
) -> None:
|
||
"""Emit ``content`` as small ``expert_speech_chunk`` events.
|
||
|
||
Used when the LLM provider returns the whole response in a single
|
||
chunk — the UI otherwise sees no streaming animation. Splits on
|
||
Chinese sentence boundaries (``。!?\n``) and falls back to a
|
||
fixed character count for safety.
|
||
"""
|
||
import re
|
||
|
||
# Split on sentence/line boundaries, keeping the delimiters so the
|
||
# joined output still reads naturally.
|
||
parts = re.findall(r"[^。!?\n]+[。!?\n]?|[^。!?\n]+", content)
|
||
if not parts:
|
||
parts = [content]
|
||
for part in parts:
|
||
# If a part is huge (no delimiters hit), slice it into
|
||
# sub-chunks of ``chunk_size`` characters.
|
||
for start in range(0, len(part), chunk_size):
|
||
piece = part[start : start + chunk_size]
|
||
if not piece:
|
||
continue
|
||
await self._broadcast_event(
|
||
"expert_speech_chunk",
|
||
{
|
||
"expert_name": expert.config.name,
|
||
"expert_avatar": expert.config.avatar,
|
||
"expert_color": expert.config.color,
|
||
"content": piece,
|
||
"round": round,
|
||
"role": "expert",
|
||
},
|
||
)
|
||
if delay > 0:
|
||
await asyncio.sleep(delay)
|
||
|
||
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, object]:
|
||
"""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, object]:
|
||
"""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) -> object:
|
||
"""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, object]) -> 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}")
|