| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220 |
- import asyncio
- import logging
- import time
- import traceback
- import uuid
- from datetime import datetime
- from typing import Any, Optional
- from app.db.session import db_manager
- from app.crud import (
- get_forum,
- get_forum_participants,
- create_message,
- get_forum_messages,
- update_forum,
- update_forum_participant,
- get_persona
- )
- from app.db.client import fetch_all
- from app.schemas import MessageCreate
- from app.agent.agent import ModeratorAgent, ParticipantAgent
- from hello_agents import Message
- from app.agent.memory import SharedMemory
- from app.core.websockets import manager
- # Removed SQLAlchemy models import as we use schemas/dicts
- from app.core.time_utils import get_beijing_time, get_beijing_time_iso
- from app.core.async_utils import async_generator_wrapper
- from contextlib import contextmanager
- logger = logging.getLogger(__name__)
- def restore_framework_history(agent, persisted_messages, self_name=None):
- """Replay persisted forum messages into a HelloAgents conversation."""
- for persisted in persisted_messages:
- role = "assistant" if self_name and persisted.speaker_name == self_name else "user"
- agent.add_message(
- Message(
- content=f"[{persisted.speaker_name}] {persisted.content}",
- role=role,
- )
- )
- def to_epoch_seconds(value) -> float:
- """Normalize LibSQL datetime representations for restart recovery."""
- if isinstance(value, datetime):
- return value.timestamp()
- if isinstance(value, (int, float)):
- # LibSQL persists Python datetimes as millisecond Unix timestamps.
- return float(value) / 1000 if abs(value) >= 100_000_000_000 else float(value)
- if isinstance(value, str):
- try:
- numeric_value = float(value)
- except ValueError:
- return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
- return numeric_value / 1000 if abs(numeric_value) >= 100_000_000_000 else numeric_value
- raise TypeError(f"Unsupported forum start_time type: {type(value).__name__}")
- def forum_deadline_epoch(start_time, duration_minutes: int) -> float:
- """Return the authoritative forum deadline in epoch seconds."""
- return to_epoch_seconds(start_time) + int(duration_minutes or 30) * 60
- class ForumScheduler:
- def __init__(self):
- self.running_tasks = {}
- self.child_tasks = {}
- self.user_message_queues = {} # forum_id -> asyncio.Queue
- def _spawn_forum_task(self, forum_id: int, coroutine):
- task = asyncio.create_task(coroutine)
- tasks = self.child_tasks.setdefault(forum_id, set())
- tasks.add(task)
- def finish(finished):
- tasks.discard(finished)
- if finished.cancelled():
- return
- try:
- finished.result()
- except Exception:
- logger.exception("Forum %s background task failed", forum_id)
- task.add_done_callback(finish)
- return task
- def _is_forum_running(self, forum_id: int) -> bool:
- with self._get_db() as db:
- forum = get_forum(db, forum_id)
- return bool(forum and forum.status == "running")
- async def recover_running_forums(self):
- with self._get_db() as db:
- forum_ids = [row.id for row in fetch_all(db.execute("SELECT id FROM forums WHERE status = ?", ["running"]))]
- for forum_id in forum_ids:
- await self.start_forum(forum_id, recovering=True)
- return forum_ids
- async def shutdown(self):
- """Cancel local tasks while preserving DB state for restart recovery."""
- main_tasks = list(self.running_tasks.values())
- child_tasks = [task for tasks in self.child_tasks.values() for task in tasks]
- for task in main_tasks + child_tasks:
- task.cancel()
- if main_tasks or child_tasks:
- await asyncio.gather(*main_tasks, *child_tasks, return_exceptions=True)
- self.running_tasks.clear()
- self.child_tasks.clear()
- async def push_user_message(self, forum_id: int, user_name: str, content: str):
- """External API calls this to inject user message"""
- if forum_id not in self.user_message_queues:
- self.user_message_queues[forum_id] = asyncio.Queue()
-
- await self.user_message_queues[forum_id].put({
- "speaker": user_name,
- "content": content,
- "timestamp": get_beijing_time_iso()
- })
- logger.info(f"User message queued for forum {forum_id}: {content[:20]}...")
- async def _process_user_messages(self, forum_id: int) -> bool:
- """
- Process all pending user messages: save to DB, broadcast, and return True if any were processed.
- """
- if forum_id not in self.user_message_queues:
- return False
-
- q = self.user_message_queues[forum_id]
- if q.empty():
- return False
-
- processed_any = False
-
- # Process all currently available messages
- while not q.empty():
- try:
- msg_data = q.get_nowait()
- processed_any = True
-
- # 1. Save to DB
- with self._get_db() as db:
- msg = create_message(db, MessageCreate(
- forum_id=forum_id,
- persona_id=None, # User has no persona
- moderator_id=None,
- speaker_name=msg_data["speaker"],
- content=msg_data["content"],
- turn_count=0
- ))
-
- # 2. Broadcast to frontend (so everyone sees it)
- await self._broadcast_message(
- forum_id,
- msg_data["speaker"],
- msg_data["content"],
- msg_id=msg.id,
- stream_id=str(uuid.uuid4())
- )
-
- await self._broadcast_system_log(forum_id, f"观众 [{msg_data['speaker']}] 发言: {msg_data['content']}", "info")
-
- except Exception as e:
- logger.error(f"Failed to process user message: {e}")
-
- return processed_any
- async def _close_for_unavailable_agents(self, forum_id: int):
- """End a forum when no participant can produce a usable thought."""
- with self._get_db() as db:
- if get_forum(db, forum_id):
- update_forum(db, forum_id, status="closed")
- await manager.broadcast(forum_id, {
- "type": "status_update",
- "status": "closed",
- })
- await self._broadcast_system_log(
- forum_id,
- "论坛已停止:当前没有可用的智能体响应,请检查模型配置后重新发起讨论。",
- "error",
- )
- async def start_forum(
- self,
- forum_id: int,
- ablation_flags: dict = None,
- recovering: bool = False,
- ):
- if forum_id in self.running_tasks:
- logger.warning(f"Forum {forum_id} is already running.")
- return
- task = asyncio.create_task(
- self._run_forum_loop(forum_id, ablation_flags, recovering=recovering)
- )
- self.running_tasks[forum_id] = task
-
- # Remove task from dict when done
- task.add_done_callback(lambda t: self.running_tasks.pop(forum_id, None))
- async def stop_forum(self, forum_id: int):
- # Close the persisted forum first. In-flight LLM threads cannot be
- # forcefully cancelled, so every late-result guard must observe the
- # closed state before local tasks are cancelled and drained.
- with self._get_db() as db:
- if get_forum(db, forum_id):
- update_forum(db, forum_id, status="closed")
- if forum_id in self.running_tasks:
- self.running_tasks[forum_id].cancel()
- try:
- await self.running_tasks[forum_id]
- except asyncio.CancelledError:
- pass
- logger.info(f"Forum {forum_id} stopped.")
- children = list(self.child_tasks.pop(forum_id, set()))
- for task in children:
- task.cancel()
- if children:
- await asyncio.gather(*children, return_exceptions=True)
- await manager.broadcast(forum_id, {
- "type": "status_update",
- "status": "closed",
- })
- @contextmanager
- def _get_db(self):
- """Helper to get a fresh DB connection and ensure it closes"""
- db = db_manager.get_connection()
- try:
- yield db
- finally:
- try:
- db.close()
- except:
- pass
- async def _broadcast_system_log(
- self,
- forum_id: int,
- message: str,
- level: str = "info",
- source: str = "System",
- db: Any = None,
- require_running: bool = False,
- ):
- """Broadcast system log to frontend for 'terminal-like' view and optionally persist"""
- if require_running and not self._is_forum_running(forum_id):
- return
-
- # 1. Broadcast immediately (async) so frontend gets it ASAP
- # This is the "Native" passing path - extremely fast via WebSocket
- timestamp = get_beijing_time_iso()
-
- try:
- await manager.broadcast(forum_id, {
- "type": "system_log",
- "data": {
- "timestamp": timestamp,
- "level": level,
- "content": message,
- "source": source
- }
- })
- except Exception as e:
- logger.error(f"Broadcast failed: {e}")
- # 2. Fire-and-forget persistence (Background Task)
- # Don't wait for Redis/DB write to complete before returning
- self._spawn_forum_task(
- forum_id,
- self._persist_log_bg(
- forum_id,
- message,
- level,
- source,
- timestamp,
- require_running=require_running,
- ),
- )
- async def _persist_log_bg(
- self,
- forum_id: int,
- message: str,
- level: str,
- source: str,
- timestamp: str,
- require_running: bool = False,
- ):
- """Background persistence logic decoupled from main flow"""
- from app.core.cache import cache_service
- if require_running and not self._is_forum_running(forum_id):
- return
-
- try:
- log_entry = {
- "forum_id": forum_id,
- "level": level,
- "source": source,
- "content": message,
- "timestamp": timestamp
- }
- # Push to Redis buffer
- if not cache_service.push_message("system_logs_buffer", log_entry):
- # Fallback to direct DB write if Redis fails
- raise Exception("Redis push failed")
-
- except Exception as e:
- # Fallback to direct DB persistence in thread
- from app.crud.crud_system_log import create_system_log
- from app.schemas.system_log import SystemLogCreate
-
- def persist_log_sync():
- local_db = None
- try:
- local_db = db_manager.get_connection()
- create_system_log(local_db, SystemLogCreate(
- forum_id=forum_id,
- level=level,
- source=source,
- content=message,
- timestamp=timestamp
- ))
- except Exception as inner_e:
- logger.error(f"Failed to persist system log (thread): {inner_e}")
- finally:
- if local_db:
- try:
- local_db.close()
- except:
- pass
- persist_task = asyncio.create_task(asyncio.to_thread(persist_log_sync))
- try:
- await asyncio.shield(persist_task)
- except asyncio.CancelledError:
- # Cancelling asyncio.to_thread does not stop its worker thread.
- # Drain it so stop_forum cannot return while a late DB write is
- # still running in the executor.
- await persist_task
- raise
- async def _flush_logs_to_db(self):
- """Batch flush logs from Redis buffer to DB"""
- from app.core.cache import cache_service
- from app.crud.crud_system_log import create_system_log
- from app.schemas.system_log import SystemLogCreate
- import json
- # Use cache_service wrapper
- # Pop up to 100 logs
- try:
- # cache_service.pop_messages returns a list of dicts (already json loaded)
- logs = cache_service.pop_messages("system_logs_buffer", count=100)
- except Exception as e:
- logger.error(f"Redis pop failed: {e}")
- return
- if not logs:
- return
- # Batch insert to DB
- # Since we use sync DB client, we should do this in a thread
- def batch_insert():
- local_db = None
- try:
- local_db = db_manager.get_connection()
-
- with local_db.transaction() as tx:
- for data in logs:
- try:
- # data is already a dict
- log_obj = SystemLogCreate(
- forum_id=data["forum_id"],
- level=data["level"],
- source=data["source"],
- content=data["content"],
- timestamp=data.get("timestamp") # Pass original timestamp!
- )
- create_system_log(tx, log_obj)
- except Exception as inner_e:
- logger.error(f"Failed to insert log item: {inner_e}")
-
- # FORCE COMMIT BATCH
- if hasattr(tx, 'commit'):
- tx.commit()
- elif hasattr(local_db, 'commit'):
- local_db.commit()
-
- except Exception as e:
- logger.error(f"Batch log insert failed: {e}")
- finally:
- if local_db:
- try:
- local_db.close()
- except:
- pass
- await asyncio.to_thread(batch_insert)
- async def _mock_stream_generator(self, content: str):
- # Simulate streaming
- chunk_size = 5
- for i in range(0, len(content), chunk_size):
- yield content[i:i+chunk_size]
- await asyncio.sleep(0.05)
- async def _run_forum_loop(
- self,
- forum_id: int,
- ablation_flags: dict = None,
- recovering: bool = False,
- ):
- ablation_flags = ablation_flags or {}
- logger.info(f"Starting forum loop for {forum_id} with flags: {ablation_flags}")
-
- # NOTE: We DO NOT keep a long-lived DB connection here anymore to avoid locks.
- # We open/close DB connections for each operation or logical block.
-
- try:
- # Persist the start log
- await self._broadcast_system_log(forum_id, f"论坛主循环启动... (配置: {ablation_flags})")
- await self._flush_logs_to_db() # FLUSH 1
-
- # Initial setup
- with self._get_db() as db:
- forum = get_forum(db, forum_id)
- if not forum:
- logger.error(f"Forum {forum_id} not found.")
- return
- # ForumService persists the authoritative clock before scheduling.
- # Recovery and a normal start both consume it without rewriting it.
- if recovering:
- persisted_start_time = forum.start_time
- persisted_flags = getattr(forum, "ablation_flags", {}) or {}
- if isinstance(persisted_flags, str):
- import json
- try:
- persisted_flags = json.loads(persisted_flags)
- except json.JSONDecodeError:
- persisted_flags = {}
- ablation_flags = persisted_flags if isinstance(persisted_flags, dict) else {}
- else:
- persisted_start_time = forum.start_time
- if persisted_start_time is None:
- raise ValueError(f"Running forum {forum_id} has no persisted start_time")
-
- # Initialize Agents
- participants_db = get_forum_participants(db, forum_id)
- persisted_messages = get_forum_messages(db, forum_id)
-
- moderator_db = forum.moderator
-
- # OPTIMIZATION: Cache participants/moderator info in memory to avoid repeated DB reads in loop
- # We already do this by creating `participants` list.
- # But we re-read forum status/messages every loop.
-
- # Setup Agents (in memory)
- participants = []
- n_participants = len(participants_db)
-
- for p_db in participants_db:
- persona = p_db.persona
- if not persona:
- continue
-
- persona_dict = {
- "name": persona.name,
- "title": persona.title,
- "bio": persona.bio,
- "theories": persona.theories,
- "stance": persona.stance,
- "system_prompt": persona.system_prompt
- }
-
- agent = ParticipantAgent(
- name=persona.name,
- persona=persona_dict,
- n_participants=n_participants,
- theme=forum.topic,
- ablation_flags=ablation_flags
- )
- # Rehydrate the framework conversation after process restart.
- # The scheduler still owns turn selection, while HelloAgents
- # receives the persisted transcript as explicit messages.
- restore_framework_history(agent, persisted_messages, self_name=agent.name)
-
- # Restore memory
- if not ablation_flags.get("no_private_memory"):
- if hasattr(p_db, 'thoughts_history') and p_db.thoughts_history:
- import json
- history = []
- if isinstance(p_db.thoughts_history, str):
- try:
- history = json.loads(p_db.thoughts_history)
- except:
- history = []
- elif isinstance(p_db.thoughts_history, list):
- history = p_db.thoughts_history
-
- for t in history:
- agent.private_memory.add_thought(t)
-
- participants.append(agent)
- if moderator_db:
- moderator = ModeratorAgent(
- theme=forum.topic,
- name=moderator_db.name,
- system_prompt=moderator_db.system_prompt
- )
- await self._broadcast_system_log(forum_id, f"主持人 [{moderator.name}] 已就位")
- else:
- moderator = ModeratorAgent(theme=forum.topic)
- await self._broadcast_system_log(forum_id, "系统默认主持人已就位")
- restore_framework_history(moderator, persisted_messages)
-
- # Speaker Queue for multi-speaker management
- speaker_queue = []
- # Track agents who have spoken in the current "batch" (until queue is cleared)
- batch_spoken_agents = set()
-
- if not recovering:
- await self._broadcast_system_message(forum_id, "论坛开始,主持人正在开场...")
- await self._broadcast_system_log(forum_id, "主持人正在进行开场白...")
- await self._flush_logs_to_db() # FLUSH 2
- await self._moderator_speak(
- forum_id,
- moderator,
- "opening",
- guests=participants,
- ablation_flags=ablation_flags,
- )
- await self._broadcast_system_log(forum_id, "DEBUG: 主持人开场结束,进入主循环", "info")
- await self._flush_logs_to_db() # FLUSH 3
- else:
- await self._broadcast_system_log(forum_id, "论坛已从上次运行状态恢复")
-
- # Main Loop
- end_time = forum_deadline_epoch(persisted_start_time, forum.duration_minutes or 30)
-
- turn_count = 0
- fallback_speaker_idx = 0
-
- while True:
- # --- NEW: Process User (Audience) Messages FIRST ---
- # If there are user messages, clear the current agent queue and force a re-think
- has_user_msgs = await self._process_user_messages(forum_id)
- if has_user_msgs:
- logger.info(f"Forum {forum_id}: User messages detected. Clearing queue and forcing re-think.")
- speaker_queue.clear()
- # We don't break, we just continue the loop which will rebuild context including user message
-
- # Reload forum status
- with self._get_db() as db:
- forum = get_forum(db, forum_id)
-
- if not forum:
- logger.error(f"Forum {forum_id} disappeared during loop.")
- break
-
- if forum.status != "running":
- logger.info(f"Forum {forum_id} status changed to {forum.status}, stopping loop.")
- break
-
- current_time = time.time()
-
- # 1. Check Time -> Closing
- if current_time >= end_time:
- logger.info(f"Forum {forum_id} time up. Closing.")
-
- # Push "closed" status to frontend immediately BEFORE moderator starts speaking closing remarks
- # This ensures UI updates (e.g. stops timer) right away.
- await manager.broadcast(forum_id, {
- "type": "status_update",
- "status": "closed"
- })
-
- # Also update DB early to prevent race conditions
- with self._get_db() as db:
- update_forum(db, forum_id, status="closed")
-
- await self._moderator_speak(forum_id, moderator, "closing", ablation_flags=ablation_flags)
- break
- # 2. Reconstruct Context (Shared Memory)
- # We need messages.
- # OPTIMIZATION: Only fetch last N messages if memory grows too large.
- # But SharedMemory might need full history?
- # Let's trust get_forum_messages to be fast enough or add limit.
- with self._get_db() as db:
- messages = get_forum_messages(db, forum_id)
-
- # OPTIMIZATION: Move SharedMemory reconstruction to background or only append new?
- # For now, it's fast enough.
- shared_memory = SharedMemory(n_participants)
- if forum.summary_history:
- summaries = forum.summary_history
- if isinstance(summaries, str):
- import json
- try:
- summaries = json.loads(summaries)
- except:
- summaries = []
-
- for s in summaries:
- shared_memory.add_summary(s)
-
- for m in messages:
- shared_memory.add_message(m.speaker_name, m.content)
-
- # Sync private memories
- if not ablation_flags.get("no_private_memory"):
- for agent in participants:
- agent.private_memory.speech_history = []
- my_msgs = [m for m in messages if m.speaker_name == agent.name]
- for m in my_msgs:
- agent.private_memory.add_speech(m.content)
- # 3. Check Summary
- # OPTIMIZATION: Check summary ASYNC? Or just skip if not needed.
- # Summary generation can take time (LLM call).
- # Move summary to background task?
- # Yes, but "moderator speaks" is blocking the flow usually.
- # If we make it non-blocking, the agents might continue speaking while mod is summarizing.
- # That might be confusing.
- # Let's keep it blocking for now but only trigger when strictly necessary.
-
- msg_count = len(messages)
- N_WINDOW = 20
-
- if not ablation_flags.get("no_summary"):
- if msg_count > 0 and msg_count % N_WINDOW == 0:
- last_msg = messages[-1]
- if last_msg.speaker_name != moderator.name:
- # Check if we already have a summary for this window?
- # (implied by turn count check)
-
- logger.info(f"Forum {forum_id} triggering summary (msg count {msg_count}).")
- msgs_to_summarize = messages[-N_WINDOW:]
- await self._moderator_speak(forum_id, moderator, "periodic_summary", messages=msgs_to_summarize, ablation_flags=ablation_flags)
- # 4. Select Speaker
- if ablation_flags.get("no_shared_memory"):
- if messages:
- last_m = messages[-1]
- context_str = f"【最新发言】\n{last_m.speaker_name}: {last_m.content}"
- else:
- context_str = "(暂无发言)"
- else:
- context_str = shared_memory.get_context_str()
- # --- NEW: Dynamic Narrative Injection ---
- # Check if the VERY LAST message is from a user (audience)
- # FIX: Ensure we don't treat the Moderator (who might have moderator_id=None if default) as a user
- if messages and messages[-1].speaker_name and not messages[-1].persona_id and not messages[-1].moderator_id:
- last_msg = messages[-1]
- # Double check it's not the moderator by name
- if last_msg.speaker_name != moderator.name:
- # Inject narrative description only for this turn
- context_str += f"\n\n(此时,台下的观众 {last_msg.speaker_name} 大声说:“{last_msg.content}”)"
- # --- NEW: Check for user interruption right BEFORE thinking ---
- # If a user message arrived while we were summarizing or reconstructing context,
- # we should catch it now to include it in the think context.
- if await self._process_user_messages(forum_id):
- # Loop back to reconstruct context with new message
- logger.info("User message detected before thinking. Restarting loop.")
- speaker_queue.clear()
- continue
- speaker = None
- thoughts_map = {}
-
- # OPTIMIZATION: If we already have a queue, maybe we don't need everyone to think?
- # But current logic requires everyone to think to update their internal state or react.
- # However, to speed up, we can start the NEXT speaker's preparation earlier?
- # No, because context depends on the previous speaker's FULL message.
-
- # Broadcast thinking log - Use create_task to not block thinking
- self._spawn_forum_task(forum_id, self._broadcast_system_log(forum_id, "所有参与者正在思考中...", "info"))
- logger.info(f"Forum {forum_id}: Agents start thinking...")
-
- async def agent_think(ag):
- try:
- await self._broadcast_system_log(forum_id, f"嘉宾 [{ag.name}] 正在思考...", "thought")
-
- if ablation_flags.get("mock_llm"):
- await asyncio.sleep(1)
- # Simple mock thought
- thought = {
- "action": "apply_to_speak",
- "mind": f"Mock thought from {ag.name}. I should speak."
- }
- else:
- thought = await asyncio.to_thread(ag.think, context_str)
- if not self._is_forum_running(forum_id):
- return ag, None
-
- if thought:
- import json
- display_thought = {
- "decision": thought.get("action", "listen"),
- "inner_monologue": thought.get("mind", "")
- }
- await self._broadcast_system_log(forum_id, json.dumps(display_thought, ensure_ascii=False), "thought", f"Agent:{ag.name}")
-
- return ag, thought
- except Exception as e:
- logger.error(f"Agent {ag.name} think failed: {e}")
- await self._broadcast_system_log(
- forum_id,
- f"嘉宾 [{ag.name}] 思考失败,已跳过本轮。",
- "error",
- )
- return ag, None
- # Execute thinking in parallel - NO DB LOCK HELD HERE
- # Prefetch next speaker logic? No, we don't know who speaks until they think.
- # Optimization: Don't wait for ALL to think if we just need ONE to speak?
- # But we need everyone to decide "action".
- # Current bottleneck: waiting for the SLOWEST thinker.
- # Optimization: Set a timeout? Or just let them be.
- # Let's keep full gather for fairness, but maybe optimize the gap after thinking.
-
- # OPTIMIZATION: Use asyncio.wait for first_completed if we have a queue?
- # No, we need to know if anyone ELSE wants to speak urgently.
- # But we can update the UI *as soon as* someone decides.
-
- # think_results = await asyncio.gather(*[agent_think(p) for p in participants])
-
- # --- NEW: Interruptible Thinking with Polling ---
- think_tasks = [self._spawn_forum_task(forum_id, agent_think(p)) for p in participants]
- think_results = []
- interrupted = False
- while think_tasks:
- # Poll every 0.5s
- done, pending = await asyncio.wait(think_tasks, timeout=0.5, return_when=asyncio.FIRST_COMPLETED)
- think_tasks = list(pending)
- for t in done:
- try:
- res = await t
- if res: think_results.append(res)
- except Exception as e:
- logger.error(f"Think task failed: {e}")
-
- # Check for interruption
- if await self._process_user_messages(forum_id):
- logger.info(f"Forum {forum_id}: User message detected during thinking. Interrupting.")
- for t in think_tasks:
- t.cancel()
- interrupted = True
- break
- if interrupted:
- speaker_queue.clear()
- continue
- # New Logic: Use asyncio.as_completed to process thoughts as they arrive?
- # But we need to collect ALL results to make a fair decision if multiple apply.
- # However, we can process the DB updates in parallel.
-
- # Reduce timeout risk
- # If someone thinks too long, should we skip?
- # For now, no.
-
- # think_results = await asyncio.gather(*[agent_think(p) for p in participants])
-
- logger.info(f"Forum {forum_id}: Agents finished thinking.")
-
- # --- NEW: Check for user interruption right AFTER thinking ---
- # If a user message arrived while agents were thinking, their thoughts are now STALE.
- # We must discard them, save the user message, and restart the loop to re-think.
- if await self._process_user_messages(forum_id):
- logger.info("User message detected after thinking. Discarding thoughts and restarting.")
- speaker_queue.clear()
- # Discard thoughts implicitly by continuing loop
- continue
- valid_thoughts = [thought for _, thought in think_results if thought]
- if participants and not valid_thoughts:
- logger.error(
- "Forum %s has no usable participant thoughts; ending to avoid retry loops.",
- forum_id,
- )
- await self._close_for_unavailable_agents(forum_id)
- break
- # Process thoughts (need DB to save thoughts)
- # Optimization: Do this ASYNC or in background if possible?
- # We need to know who speaks to proceed.
- # But saving history can be done in parallel with speaking start?
- # No, we need consistency.
- # Let's optimize the DB access pattern.
-
- # We can prepare the next speaker IMMEDIATELY after deciding,
- # while saving thoughts in background.
-
- speaker_candidates = []
- # Simple in-memory processing first
- for agent, thought in think_results:
- if thought:
- thoughts_map[agent] = thought
- if thought.get('action') == 'apply_to_speak':
- speaker_candidates.append(agent)
- # Update Queue (In-Memory)
- for agent in speaker_candidates:
- if agent not in speaker_queue:
- if agent not in batch_spoken_agents or not speaker_queue:
- speaker_queue.append(agent)
- # Select Speaker (In-Memory)
- if speaker_queue:
- # Enforce constraint: A speaker cannot speak twice in a row
- # even if they are in the queue.
-
- last_speaker_name = None
- if messages:
- last_speaker_name = messages[-1].speaker_name
-
- candidate = speaker_queue[0]
-
- # If candidate is same as last speaker, try to find another one in queue
- if last_speaker_name and candidate.name == last_speaker_name:
- # Find first non-consecutive speaker
- found_alt = False
- for i in range(1, len(speaker_queue)):
- alt = speaker_queue[i]
- if alt.name != last_speaker_name:
- # Swap and pop
- speaker = speaker_queue.pop(i)
- found_alt = True
- break
-
- if not found_alt:
- # If everyone in queue is the same person (unlikely) or queue has only 1 person who just spoke
- # Then we MUST skip them to avoid monologue.
- # Fallback to general pool logic below.
- logger.info(f"Skipping queued speaker {candidate.name} to avoid consecutive speech.")
- speaker = None # Force fallback
- # Note: We do NOT pop them, they stay in queue for next turn?
- # Or should we pop and discard?
- # Better to keep them for next turn if possible, but for now let's just not pick them.
- # Actually, if we don't pop, they block the queue forever if logic loops.
- # Let's move them to end of queue?
- if len(speaker_queue) > 1:
- # Rotate
- speaker_queue.append(speaker_queue.pop(0))
- # Try again next loop? No, we need a speaker NOW.
- # If we rotated, the new [0] is different (handled by swap logic above usually).
- # If we are here, it means we couldn't find anyone else in queue.
- speaker = None
- else:
- # Queue has only this guy, and he just spoke.
- # Ignore queue, try fallback.
- pass
- else:
- speaker = speaker_queue.pop(0)
- if speaker:
- batch_spoken_agents.add(speaker)
-
- # If no speaker selected from queue (empty or skipped due to consecutive rule)
- if not speaker and participants:
- remaining = [p for p in participants if p not in batch_spoken_agents]
-
- # Filter out last speaker from remaining to be safe
- last_speaker_name = messages[-1].speaker_name if messages else None
- valid_remaining = [p for p in remaining if p.name != last_speaker_name]
-
- if valid_remaining:
- # 随机从valid_remaining中选择一个
- import random
- speaker = random.choice(valid_remaining)
- else:
- # Reset batch if everyone spoke or valid ones exhausted
- batch_spoken_agents.clear()
-
- # Fallback round-robin
- # Ensure fallback doesn't pick last speaker either
- attempts = 0
- valid_fallbacks = [p for p in participants if p.name != last_speaker_name]
- if valid_fallbacks:
- import random
- speaker = random.choice(valid_fallbacks)
-
- # while attempts < len(participants):
- # candidate = participants[fallback_speaker_idx % len(participants)]
- # fallback_speaker_idx += 1
- # attempts += 1
- # if candidate.name != last_speaker_name:
- # speaker = candidate
- # break
-
- # If still None (e.g. only 1 participant total), then allow consecutive
- if not speaker and participants:
- speaker = participants[0]
- if speaker:
- batch_spoken_agents.add(speaker)
-
- # Fire and forget DB updates for thoughts (using create_task)
- # This removes the DB write latency from the critical path of "Next Speaker"
- async def save_thoughts_bg(results, f_id):
- if not self._is_forum_running(f_id):
- return
- with self._get_db() as db:
- # Re-fetch only if needed, or pass IDs.
- # We need persona_id. We can cache it or fetch once.
- parts = get_forum_participants(db, f_id)
- p_map = {p.persona.name: p for p in parts}
-
- for ag, th in results:
- if not th: continue
- p_db = p_map.get(ag.name)
- if p_db:
- current = []
- if p_db.thoughts_history:
- try:
- if isinstance(p_db.thoughts_history, str):
- current = json.loads(p_db.thoughts_history)
- elif isinstance(p_db.thoughts_history, list):
- current = p_db.thoughts_history
- except: pass
- update_forum_participant(db, f_id, p_db.persona_id, thoughts_history=current + [th])
-
- if think_results:
- self._spawn_forum_task(forum_id, save_thoughts_bg(think_results, forum_id))
- # --- Queue Logic Refinement ---
- # Broadcasting logs is fast (Redis/WS), keep it.
- queue_names = [a.name for a in speaker_queue]
- if queue_names:
- # Optimized: Use background task for log persistence to avoid blocking
- self._spawn_forum_task(forum_id, self._broadcast_system_log(forum_id, f"当前发言队列: {', '.join(queue_names)}", "info"))
-
- if speaker:
- # Async log to not block speaking
- self._spawn_forum_task(forum_id, self._broadcast_system_log(forum_id, f"下一位发言: [{speaker.name}]", "info"))
-
- thought = thoughts_map.get(speaker) or {}
-
- await self._agent_speak(forum_id, speaker, thought, context_str, ablation_flags=ablation_flags)
-
- turn_count += 1
-
- # Periodic WAL checkpoint
- if turn_count % 10 == 0:
- with self._get_db() as db:
- try:
- if not db_manager.is_postgres and not db_manager.is_remote:
- db.execute("PRAGMA wal_checkpoint(PASSIVE)")
- except Exception as e:
- logger.warning(f"WAL checkpoint failed: {e}")
-
- # Flush system logs
- await self._flush_logs_to_db()
- except Exception as e:
- logger.error(f"Forum loop crashed: {e}")
- logger.error(traceback.format_exc())
- try:
- await self._broadcast_system_log(forum_id, "论坛异常终止,请查看服务端日志", "error")
- except:
- pass
- async def _moderator_speak(self, forum_id: int, moderator: ModeratorAgent, action: str, guests=None, messages=None, ablation_flags: dict = None):
- content = ""
- gen = None
- stream_id = str(uuid.uuid4())
- ablation_flags = ablation_flags or {}
-
- # Read data
- with self._get_db() as db:
- forum = get_forum(db, forum_id)
- moderator_id = forum.moderator_id
-
- # await self._broadcast_system_log(forum_id, f"主持人 [{moderator.name}] 正在构思...", "info")
- try:
- if ablation_flags.get("mock_llm"):
- await asyncio.sleep(1)
- gen = self._mock_stream_generator(f"Mock moderator speech for {action} on topic {forum.topic}...")
- elif action == "opening":
- # Fix: guest object in list is ParticipantAgent, it has .persona dict attribute if we stored it?
- # No, ParticipantAgent stores persona data in self.title, self.stance etc.
- # Let's check ParticipantAgent init.
- # It has self.title, self.stance.
- guest_list = [{"name": g.name, "title": g.title, "stance": g.stance} for g in guests]
- gen = await asyncio.to_thread(moderator.opening, guest_list)
- elif action == "closing":
- # Need summaries
- summaries = forum.summary_history or []
- if isinstance(summaries, str):
- import json
- try:
- summaries = json.loads(summaries)
- except:
- summaries = []
-
- gen = await asyncio.to_thread(moderator.closing, summaries)
- elif action == "periodic_summary":
- msgs_text = [{"speaker": m.speaker_name, "content": m.content} for m in messages[-20:]]
- gen = await asyncio.to_thread(moderator.periodic_summary, msgs_text)
- if gen:
- try:
- # Async log
- self._spawn_forum_task(
- forum_id,
- self._broadcast_system_log(
- forum_id,
- f"主持人 [{moderator.name}] 正在构思...",
- "thought",
- require_running=True,
- ),
- )
-
- first_token = True
- async for chunk in async_generator_wrapper(gen):
- if not self._is_forum_running(forum_id):
- return
- # --- NEW: Interruption Check ---
- if await self._process_user_messages(forum_id):
- logger.info(f"Moderator {moderator.name} interrupted by user.")
- await self._broadcast_system_log(forum_id, f"主持人被观众打断", "warning")
- break
-
- if first_token:
- await self._broadcast_system_log(forum_id, f"主持人 [{moderator.name}] 开始发言...", "speech")
- first_token = False
- if chunk:
- token = chunk
- content += token
- await self._broadcast_chunk(forum_id, moderator.name, token, None, moderator_id, stream_id)
- except Exception as e:
- logger.error(f"Error consuming generator: {e}")
- else:
- logger.warning("Moderator speak returned None generator")
-
- except Exception as e:
- logger.error(f"Moderator speak failed: {e}")
- await self._broadcast_system_log(forum_id, f"主持人发言生成失败: {str(e)}", "error")
- return
- if content and (action == "closing" or self._is_forum_running(forum_id)):
- with self._get_db() as db:
- msg = create_message(db, MessageCreate(
- forum_id=forum_id,
- moderator_id=moderator_id,
- speaker_name=moderator.name,
- content=content,
- turn_count=0
- ))
-
- if action == "periodic_summary":
- # Refresh forum
- forum = get_forum(db, forum_id)
- current = forum.summary_history or []
- if isinstance(current, str):
- import json
- try:
- current = json.loads(current)
- except:
- current = []
- new_history = current + [content]
- update_forum(db, forum_id, summary_history=new_history)
- await self._broadcast_message(forum_id, moderator.name, content, None, moderator_id, stream_id, msg.id)
- await self._broadcast_system_log(forum_id, content, "speech", moderator.name)
- async def _agent_speak(self, forum_id: int, agent: ParticipantAgent, thought: dict, context: str, ablation_flags: dict = None):
- content = ""
- stream_id = str(uuid.uuid4())
- ablation_flags = ablation_flags or {}
-
- with self._get_db() as db:
- participants = get_forum_participants(db, forum_id)
- p_db = next((p for p in participants if p.persona.name == agent.name), None)
- persona_id = p_db.persona_id if p_db else None
- # Optimization: No need to log "thinking" again if thought is already done.
- # But we might need to do the actual LLM call for speaking now.
-
- try:
- if ablation_flags.get("mock_llm"):
- await asyncio.sleep(1)
- gen = self._mock_stream_generator(f"Mock speech from {agent.name}. My thought was: {thought.get('mind')}")
- else:
- gen = await asyncio.to_thread(agent.speak, thought, context)
- if not self._is_forum_running(forum_id):
- return
-
- if gen:
- try:
- # await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 正在构思...", "thought")
-
- first_token = True
- start_speak_time = time.time()
- thought_sent = False
- thought_content = thought.get('mind') if thought else None
-
- async for chunk in async_generator_wrapper(gen):
- if not self._is_forum_running(forum_id):
- return
- # --- NEW: Interruption Check ---
- if await self._process_user_messages(forum_id):
- logger.info(f"Agent {agent.name} interrupted by user.")
- await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 被观众打断", "warning")
- break
- if first_token:
- ttft = time.time() - start_speak_time
- logger.info(f"Agent {agent.name} TTFT: {ttft:.2f}s")
- await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 开始发言...", "speech")
- first_token = False
-
- if chunk:
- token = chunk
- content += token
-
- send_thought = None
- if not thought_sent and thought_content:
- send_thought = thought_content
- thought_sent = True
-
- await self._broadcast_chunk(forum_id, agent.name, token, persona_id, None, stream_id, thought=send_thought)
- except Exception as e:
- logger.error(f"Error consuming agent generator: {e}")
- await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 发言中断,请查看服务端日志", "error")
- else:
- logger.warning(f"Agent {agent.name} speak returned None")
- content = "(沉默)"
- await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 放弃发言 (API无响应或返回空)", "warning")
- except Exception as e:
- logger.error(f"Agent {agent.name} speak failed: {e}")
- await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 发言生成失败,请查看服务端日志", "error")
- return
- if content and self._is_forum_running(forum_id):
- thought_content = None
- if thought:
- thought_content = thought.get('mind')
-
- with self._get_db() as db:
- msg = create_message(db, MessageCreate(
- forum_id=forum_id,
- persona_id=persona_id,
- speaker_name=agent.name,
- content=content,
- thought=thought_content,
- turn_count=0
- ))
-
- await self._broadcast_message(forum_id, agent.name, content, persona_id, None, stream_id, msg.id, thought=thought_content)
- await self._broadcast_system_log(forum_id, content, "speech", agent.name)
- async def _broadcast_chunk(self, forum_id: int, speaker: str, chunk: str, persona_id: int = None, moderator_id: int = None, stream_id: str = None, thought: str = None):
- if not chunk:
- return
-
- data = {
- "speaker_name": speaker,
- "content": chunk,
- "persona_id": persona_id,
- "moderator_id": moderator_id,
- "stream_id": stream_id,
- "timestamp": get_beijing_time_iso()
- }
-
- if thought:
- data["thought"] = thought
-
- await manager.broadcast(forum_id, {
- "type": "message_chunk",
- "data": data
- })
- async def _broadcast_message(self, forum_id: int, speaker: str, content: str, persona_id: int = None, moderator_id: int = None, stream_id: str = None, msg_id: int = None, thought: str = None):
- """Broadcast message immediately to WS"""
- # Optimized: Send to WS immediately, do NOT wait for any DB operations or complex logic
- timestamp = get_beijing_time_iso()
-
- try:
- await manager.broadcast(forum_id, {
- "type": "new_message",
- "data": {
- "id": msg_id, # Can be None if optimized to send before DB insert (frontend should handle temp ID)
- "forum_id": forum_id,
- "speaker_name": speaker,
- "content": content,
- "persona_id": persona_id,
- "moderator_id": moderator_id,
- "stream_id": stream_id,
- "thought": thought,
- "timestamp": timestamp
- }
- })
- except Exception as e:
- logger.error(f"Message broadcast failed: {e}")
- async def _broadcast_system_message(self, forum_id: int, content: str):
- await manager.broadcast(forum_id, {
- "type": "system",
- "content": content
- })
- scheduler = ForumScheduler()
|