forum_scheduler.py 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220
  1. import asyncio
  2. import logging
  3. import time
  4. import traceback
  5. import uuid
  6. from datetime import datetime
  7. from typing import Any, Optional
  8. from app.db.session import db_manager
  9. from app.crud import (
  10. get_forum,
  11. get_forum_participants,
  12. create_message,
  13. get_forum_messages,
  14. update_forum,
  15. update_forum_participant,
  16. get_persona
  17. )
  18. from app.db.client import fetch_all
  19. from app.schemas import MessageCreate
  20. from app.agent.agent import ModeratorAgent, ParticipantAgent
  21. from hello_agents import Message
  22. from app.agent.memory import SharedMemory
  23. from app.core.websockets import manager
  24. # Removed SQLAlchemy models import as we use schemas/dicts
  25. from app.core.time_utils import get_beijing_time, get_beijing_time_iso
  26. from app.core.async_utils import async_generator_wrapper
  27. from contextlib import contextmanager
  28. logger = logging.getLogger(__name__)
  29. def restore_framework_history(agent, persisted_messages, self_name=None):
  30. """Replay persisted forum messages into a HelloAgents conversation."""
  31. for persisted in persisted_messages:
  32. role = "assistant" if self_name and persisted.speaker_name == self_name else "user"
  33. agent.add_message(
  34. Message(
  35. content=f"[{persisted.speaker_name}] {persisted.content}",
  36. role=role,
  37. )
  38. )
  39. def to_epoch_seconds(value) -> float:
  40. """Normalize LibSQL datetime representations for restart recovery."""
  41. if isinstance(value, datetime):
  42. return value.timestamp()
  43. if isinstance(value, (int, float)):
  44. # LibSQL persists Python datetimes as millisecond Unix timestamps.
  45. return float(value) / 1000 if abs(value) >= 100_000_000_000 else float(value)
  46. if isinstance(value, str):
  47. try:
  48. numeric_value = float(value)
  49. except ValueError:
  50. return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
  51. return numeric_value / 1000 if abs(numeric_value) >= 100_000_000_000 else numeric_value
  52. raise TypeError(f"Unsupported forum start_time type: {type(value).__name__}")
  53. def forum_deadline_epoch(start_time, duration_minutes: int) -> float:
  54. """Return the authoritative forum deadline in epoch seconds."""
  55. return to_epoch_seconds(start_time) + int(duration_minutes or 30) * 60
  56. class ForumScheduler:
  57. def __init__(self):
  58. self.running_tasks = {}
  59. self.child_tasks = {}
  60. self.user_message_queues = {} # forum_id -> asyncio.Queue
  61. def _spawn_forum_task(self, forum_id: int, coroutine):
  62. task = asyncio.create_task(coroutine)
  63. tasks = self.child_tasks.setdefault(forum_id, set())
  64. tasks.add(task)
  65. def finish(finished):
  66. tasks.discard(finished)
  67. if finished.cancelled():
  68. return
  69. try:
  70. finished.result()
  71. except Exception:
  72. logger.exception("Forum %s background task failed", forum_id)
  73. task.add_done_callback(finish)
  74. return task
  75. def _is_forum_running(self, forum_id: int) -> bool:
  76. with self._get_db() as db:
  77. forum = get_forum(db, forum_id)
  78. return bool(forum and forum.status == "running")
  79. async def recover_running_forums(self):
  80. with self._get_db() as db:
  81. forum_ids = [row.id for row in fetch_all(db.execute("SELECT id FROM forums WHERE status = ?", ["running"]))]
  82. for forum_id in forum_ids:
  83. await self.start_forum(forum_id, recovering=True)
  84. return forum_ids
  85. async def shutdown(self):
  86. """Cancel local tasks while preserving DB state for restart recovery."""
  87. main_tasks = list(self.running_tasks.values())
  88. child_tasks = [task for tasks in self.child_tasks.values() for task in tasks]
  89. for task in main_tasks + child_tasks:
  90. task.cancel()
  91. if main_tasks or child_tasks:
  92. await asyncio.gather(*main_tasks, *child_tasks, return_exceptions=True)
  93. self.running_tasks.clear()
  94. self.child_tasks.clear()
  95. async def push_user_message(self, forum_id: int, user_name: str, content: str):
  96. """External API calls this to inject user message"""
  97. if forum_id not in self.user_message_queues:
  98. self.user_message_queues[forum_id] = asyncio.Queue()
  99. await self.user_message_queues[forum_id].put({
  100. "speaker": user_name,
  101. "content": content,
  102. "timestamp": get_beijing_time_iso()
  103. })
  104. logger.info(f"User message queued for forum {forum_id}: {content[:20]}...")
  105. async def _process_user_messages(self, forum_id: int) -> bool:
  106. """
  107. Process all pending user messages: save to DB, broadcast, and return True if any were processed.
  108. """
  109. if forum_id not in self.user_message_queues:
  110. return False
  111. q = self.user_message_queues[forum_id]
  112. if q.empty():
  113. return False
  114. processed_any = False
  115. # Process all currently available messages
  116. while not q.empty():
  117. try:
  118. msg_data = q.get_nowait()
  119. processed_any = True
  120. # 1. Save to DB
  121. with self._get_db() as db:
  122. msg = create_message(db, MessageCreate(
  123. forum_id=forum_id,
  124. persona_id=None, # User has no persona
  125. moderator_id=None,
  126. speaker_name=msg_data["speaker"],
  127. content=msg_data["content"],
  128. turn_count=0
  129. ))
  130. # 2. Broadcast to frontend (so everyone sees it)
  131. await self._broadcast_message(
  132. forum_id,
  133. msg_data["speaker"],
  134. msg_data["content"],
  135. msg_id=msg.id,
  136. stream_id=str(uuid.uuid4())
  137. )
  138. await self._broadcast_system_log(forum_id, f"观众 [{msg_data['speaker']}] 发言: {msg_data['content']}", "info")
  139. except Exception as e:
  140. logger.error(f"Failed to process user message: {e}")
  141. return processed_any
  142. async def _close_for_unavailable_agents(self, forum_id: int):
  143. """End a forum when no participant can produce a usable thought."""
  144. with self._get_db() as db:
  145. if get_forum(db, forum_id):
  146. update_forum(db, forum_id, status="closed")
  147. await manager.broadcast(forum_id, {
  148. "type": "status_update",
  149. "status": "closed",
  150. })
  151. await self._broadcast_system_log(
  152. forum_id,
  153. "论坛已停止:当前没有可用的智能体响应,请检查模型配置后重新发起讨论。",
  154. "error",
  155. )
  156. async def start_forum(
  157. self,
  158. forum_id: int,
  159. ablation_flags: dict = None,
  160. recovering: bool = False,
  161. ):
  162. if forum_id in self.running_tasks:
  163. logger.warning(f"Forum {forum_id} is already running.")
  164. return
  165. task = asyncio.create_task(
  166. self._run_forum_loop(forum_id, ablation_flags, recovering=recovering)
  167. )
  168. self.running_tasks[forum_id] = task
  169. # Remove task from dict when done
  170. task.add_done_callback(lambda t: self.running_tasks.pop(forum_id, None))
  171. async def stop_forum(self, forum_id: int):
  172. # Close the persisted forum first. In-flight LLM threads cannot be
  173. # forcefully cancelled, so every late-result guard must observe the
  174. # closed state before local tasks are cancelled and drained.
  175. with self._get_db() as db:
  176. if get_forum(db, forum_id):
  177. update_forum(db, forum_id, status="closed")
  178. if forum_id in self.running_tasks:
  179. self.running_tasks[forum_id].cancel()
  180. try:
  181. await self.running_tasks[forum_id]
  182. except asyncio.CancelledError:
  183. pass
  184. logger.info(f"Forum {forum_id} stopped.")
  185. children = list(self.child_tasks.pop(forum_id, set()))
  186. for task in children:
  187. task.cancel()
  188. if children:
  189. await asyncio.gather(*children, return_exceptions=True)
  190. await manager.broadcast(forum_id, {
  191. "type": "status_update",
  192. "status": "closed",
  193. })
  194. @contextmanager
  195. def _get_db(self):
  196. """Helper to get a fresh DB connection and ensure it closes"""
  197. db = db_manager.get_connection()
  198. try:
  199. yield db
  200. finally:
  201. try:
  202. db.close()
  203. except:
  204. pass
  205. async def _broadcast_system_log(
  206. self,
  207. forum_id: int,
  208. message: str,
  209. level: str = "info",
  210. source: str = "System",
  211. db: Any = None,
  212. require_running: bool = False,
  213. ):
  214. """Broadcast system log to frontend for 'terminal-like' view and optionally persist"""
  215. if require_running and not self._is_forum_running(forum_id):
  216. return
  217. # 1. Broadcast immediately (async) so frontend gets it ASAP
  218. # This is the "Native" passing path - extremely fast via WebSocket
  219. timestamp = get_beijing_time_iso()
  220. try:
  221. await manager.broadcast(forum_id, {
  222. "type": "system_log",
  223. "data": {
  224. "timestamp": timestamp,
  225. "level": level,
  226. "content": message,
  227. "source": source
  228. }
  229. })
  230. except Exception as e:
  231. logger.error(f"Broadcast failed: {e}")
  232. # 2. Fire-and-forget persistence (Background Task)
  233. # Don't wait for Redis/DB write to complete before returning
  234. self._spawn_forum_task(
  235. forum_id,
  236. self._persist_log_bg(
  237. forum_id,
  238. message,
  239. level,
  240. source,
  241. timestamp,
  242. require_running=require_running,
  243. ),
  244. )
  245. async def _persist_log_bg(
  246. self,
  247. forum_id: int,
  248. message: str,
  249. level: str,
  250. source: str,
  251. timestamp: str,
  252. require_running: bool = False,
  253. ):
  254. """Background persistence logic decoupled from main flow"""
  255. from app.core.cache import cache_service
  256. if require_running and not self._is_forum_running(forum_id):
  257. return
  258. try:
  259. log_entry = {
  260. "forum_id": forum_id,
  261. "level": level,
  262. "source": source,
  263. "content": message,
  264. "timestamp": timestamp
  265. }
  266. # Push to Redis buffer
  267. if not cache_service.push_message("system_logs_buffer", log_entry):
  268. # Fallback to direct DB write if Redis fails
  269. raise Exception("Redis push failed")
  270. except Exception as e:
  271. # Fallback to direct DB persistence in thread
  272. from app.crud.crud_system_log import create_system_log
  273. from app.schemas.system_log import SystemLogCreate
  274. def persist_log_sync():
  275. local_db = None
  276. try:
  277. local_db = db_manager.get_connection()
  278. create_system_log(local_db, SystemLogCreate(
  279. forum_id=forum_id,
  280. level=level,
  281. source=source,
  282. content=message,
  283. timestamp=timestamp
  284. ))
  285. except Exception as inner_e:
  286. logger.error(f"Failed to persist system log (thread): {inner_e}")
  287. finally:
  288. if local_db:
  289. try:
  290. local_db.close()
  291. except:
  292. pass
  293. persist_task = asyncio.create_task(asyncio.to_thread(persist_log_sync))
  294. try:
  295. await asyncio.shield(persist_task)
  296. except asyncio.CancelledError:
  297. # Cancelling asyncio.to_thread does not stop its worker thread.
  298. # Drain it so stop_forum cannot return while a late DB write is
  299. # still running in the executor.
  300. await persist_task
  301. raise
  302. async def _flush_logs_to_db(self):
  303. """Batch flush logs from Redis buffer to DB"""
  304. from app.core.cache import cache_service
  305. from app.crud.crud_system_log import create_system_log
  306. from app.schemas.system_log import SystemLogCreate
  307. import json
  308. # Use cache_service wrapper
  309. # Pop up to 100 logs
  310. try:
  311. # cache_service.pop_messages returns a list of dicts (already json loaded)
  312. logs = cache_service.pop_messages("system_logs_buffer", count=100)
  313. except Exception as e:
  314. logger.error(f"Redis pop failed: {e}")
  315. return
  316. if not logs:
  317. return
  318. # Batch insert to DB
  319. # Since we use sync DB client, we should do this in a thread
  320. def batch_insert():
  321. local_db = None
  322. try:
  323. local_db = db_manager.get_connection()
  324. with local_db.transaction() as tx:
  325. for data in logs:
  326. try:
  327. # data is already a dict
  328. log_obj = SystemLogCreate(
  329. forum_id=data["forum_id"],
  330. level=data["level"],
  331. source=data["source"],
  332. content=data["content"],
  333. timestamp=data.get("timestamp") # Pass original timestamp!
  334. )
  335. create_system_log(tx, log_obj)
  336. except Exception as inner_e:
  337. logger.error(f"Failed to insert log item: {inner_e}")
  338. # FORCE COMMIT BATCH
  339. if hasattr(tx, 'commit'):
  340. tx.commit()
  341. elif hasattr(local_db, 'commit'):
  342. local_db.commit()
  343. except Exception as e:
  344. logger.error(f"Batch log insert failed: {e}")
  345. finally:
  346. if local_db:
  347. try:
  348. local_db.close()
  349. except:
  350. pass
  351. await asyncio.to_thread(batch_insert)
  352. async def _mock_stream_generator(self, content: str):
  353. # Simulate streaming
  354. chunk_size = 5
  355. for i in range(0, len(content), chunk_size):
  356. yield content[i:i+chunk_size]
  357. await asyncio.sleep(0.05)
  358. async def _run_forum_loop(
  359. self,
  360. forum_id: int,
  361. ablation_flags: dict = None,
  362. recovering: bool = False,
  363. ):
  364. ablation_flags = ablation_flags or {}
  365. logger.info(f"Starting forum loop for {forum_id} with flags: {ablation_flags}")
  366. # NOTE: We DO NOT keep a long-lived DB connection here anymore to avoid locks.
  367. # We open/close DB connections for each operation or logical block.
  368. try:
  369. # Persist the start log
  370. await self._broadcast_system_log(forum_id, f"论坛主循环启动... (配置: {ablation_flags})")
  371. await self._flush_logs_to_db() # FLUSH 1
  372. # Initial setup
  373. with self._get_db() as db:
  374. forum = get_forum(db, forum_id)
  375. if not forum:
  376. logger.error(f"Forum {forum_id} not found.")
  377. return
  378. # ForumService persists the authoritative clock before scheduling.
  379. # Recovery and a normal start both consume it without rewriting it.
  380. if recovering:
  381. persisted_start_time = forum.start_time
  382. persisted_flags = getattr(forum, "ablation_flags", {}) or {}
  383. if isinstance(persisted_flags, str):
  384. import json
  385. try:
  386. persisted_flags = json.loads(persisted_flags)
  387. except json.JSONDecodeError:
  388. persisted_flags = {}
  389. ablation_flags = persisted_flags if isinstance(persisted_flags, dict) else {}
  390. else:
  391. persisted_start_time = forum.start_time
  392. if persisted_start_time is None:
  393. raise ValueError(f"Running forum {forum_id} has no persisted start_time")
  394. # Initialize Agents
  395. participants_db = get_forum_participants(db, forum_id)
  396. persisted_messages = get_forum_messages(db, forum_id)
  397. moderator_db = forum.moderator
  398. # OPTIMIZATION: Cache participants/moderator info in memory to avoid repeated DB reads in loop
  399. # We already do this by creating `participants` list.
  400. # But we re-read forum status/messages every loop.
  401. # Setup Agents (in memory)
  402. participants = []
  403. n_participants = len(participants_db)
  404. for p_db in participants_db:
  405. persona = p_db.persona
  406. if not persona:
  407. continue
  408. persona_dict = {
  409. "name": persona.name,
  410. "title": persona.title,
  411. "bio": persona.bio,
  412. "theories": persona.theories,
  413. "stance": persona.stance,
  414. "system_prompt": persona.system_prompt
  415. }
  416. agent = ParticipantAgent(
  417. name=persona.name,
  418. persona=persona_dict,
  419. n_participants=n_participants,
  420. theme=forum.topic,
  421. ablation_flags=ablation_flags
  422. )
  423. # Rehydrate the framework conversation after process restart.
  424. # The scheduler still owns turn selection, while HelloAgents
  425. # receives the persisted transcript as explicit messages.
  426. restore_framework_history(agent, persisted_messages, self_name=agent.name)
  427. # Restore memory
  428. if not ablation_flags.get("no_private_memory"):
  429. if hasattr(p_db, 'thoughts_history') and p_db.thoughts_history:
  430. import json
  431. history = []
  432. if isinstance(p_db.thoughts_history, str):
  433. try:
  434. history = json.loads(p_db.thoughts_history)
  435. except:
  436. history = []
  437. elif isinstance(p_db.thoughts_history, list):
  438. history = p_db.thoughts_history
  439. for t in history:
  440. agent.private_memory.add_thought(t)
  441. participants.append(agent)
  442. if moderator_db:
  443. moderator = ModeratorAgent(
  444. theme=forum.topic,
  445. name=moderator_db.name,
  446. system_prompt=moderator_db.system_prompt
  447. )
  448. await self._broadcast_system_log(forum_id, f"主持人 [{moderator.name}] 已就位")
  449. else:
  450. moderator = ModeratorAgent(theme=forum.topic)
  451. await self._broadcast_system_log(forum_id, "系统默认主持人已就位")
  452. restore_framework_history(moderator, persisted_messages)
  453. # Speaker Queue for multi-speaker management
  454. speaker_queue = []
  455. # Track agents who have spoken in the current "batch" (until queue is cleared)
  456. batch_spoken_agents = set()
  457. if not recovering:
  458. await self._broadcast_system_message(forum_id, "论坛开始,主持人正在开场...")
  459. await self._broadcast_system_log(forum_id, "主持人正在进行开场白...")
  460. await self._flush_logs_to_db() # FLUSH 2
  461. await self._moderator_speak(
  462. forum_id,
  463. moderator,
  464. "opening",
  465. guests=participants,
  466. ablation_flags=ablation_flags,
  467. )
  468. await self._broadcast_system_log(forum_id, "DEBUG: 主持人开场结束,进入主循环", "info")
  469. await self._flush_logs_to_db() # FLUSH 3
  470. else:
  471. await self._broadcast_system_log(forum_id, "论坛已从上次运行状态恢复")
  472. # Main Loop
  473. end_time = forum_deadline_epoch(persisted_start_time, forum.duration_minutes or 30)
  474. turn_count = 0
  475. fallback_speaker_idx = 0
  476. while True:
  477. # --- NEW: Process User (Audience) Messages FIRST ---
  478. # If there are user messages, clear the current agent queue and force a re-think
  479. has_user_msgs = await self._process_user_messages(forum_id)
  480. if has_user_msgs:
  481. logger.info(f"Forum {forum_id}: User messages detected. Clearing queue and forcing re-think.")
  482. speaker_queue.clear()
  483. # We don't break, we just continue the loop which will rebuild context including user message
  484. # Reload forum status
  485. with self._get_db() as db:
  486. forum = get_forum(db, forum_id)
  487. if not forum:
  488. logger.error(f"Forum {forum_id} disappeared during loop.")
  489. break
  490. if forum.status != "running":
  491. logger.info(f"Forum {forum_id} status changed to {forum.status}, stopping loop.")
  492. break
  493. current_time = time.time()
  494. # 1. Check Time -> Closing
  495. if current_time >= end_time:
  496. logger.info(f"Forum {forum_id} time up. Closing.")
  497. # Push "closed" status to frontend immediately BEFORE moderator starts speaking closing remarks
  498. # This ensures UI updates (e.g. stops timer) right away.
  499. await manager.broadcast(forum_id, {
  500. "type": "status_update",
  501. "status": "closed"
  502. })
  503. # Also update DB early to prevent race conditions
  504. with self._get_db() as db:
  505. update_forum(db, forum_id, status="closed")
  506. await self._moderator_speak(forum_id, moderator, "closing", ablation_flags=ablation_flags)
  507. break
  508. # 2. Reconstruct Context (Shared Memory)
  509. # We need messages.
  510. # OPTIMIZATION: Only fetch last N messages if memory grows too large.
  511. # But SharedMemory might need full history?
  512. # Let's trust get_forum_messages to be fast enough or add limit.
  513. with self._get_db() as db:
  514. messages = get_forum_messages(db, forum_id)
  515. # OPTIMIZATION: Move SharedMemory reconstruction to background or only append new?
  516. # For now, it's fast enough.
  517. shared_memory = SharedMemory(n_participants)
  518. if forum.summary_history:
  519. summaries = forum.summary_history
  520. if isinstance(summaries, str):
  521. import json
  522. try:
  523. summaries = json.loads(summaries)
  524. except:
  525. summaries = []
  526. for s in summaries:
  527. shared_memory.add_summary(s)
  528. for m in messages:
  529. shared_memory.add_message(m.speaker_name, m.content)
  530. # Sync private memories
  531. if not ablation_flags.get("no_private_memory"):
  532. for agent in participants:
  533. agent.private_memory.speech_history = []
  534. my_msgs = [m for m in messages if m.speaker_name == agent.name]
  535. for m in my_msgs:
  536. agent.private_memory.add_speech(m.content)
  537. # 3. Check Summary
  538. # OPTIMIZATION: Check summary ASYNC? Or just skip if not needed.
  539. # Summary generation can take time (LLM call).
  540. # Move summary to background task?
  541. # Yes, but "moderator speaks" is blocking the flow usually.
  542. # If we make it non-blocking, the agents might continue speaking while mod is summarizing.
  543. # That might be confusing.
  544. # Let's keep it blocking for now but only trigger when strictly necessary.
  545. msg_count = len(messages)
  546. N_WINDOW = 20
  547. if not ablation_flags.get("no_summary"):
  548. if msg_count > 0 and msg_count % N_WINDOW == 0:
  549. last_msg = messages[-1]
  550. if last_msg.speaker_name != moderator.name:
  551. # Check if we already have a summary for this window?
  552. # (implied by turn count check)
  553. logger.info(f"Forum {forum_id} triggering summary (msg count {msg_count}).")
  554. msgs_to_summarize = messages[-N_WINDOW:]
  555. await self._moderator_speak(forum_id, moderator, "periodic_summary", messages=msgs_to_summarize, ablation_flags=ablation_flags)
  556. # 4. Select Speaker
  557. if ablation_flags.get("no_shared_memory"):
  558. if messages:
  559. last_m = messages[-1]
  560. context_str = f"【最新发言】\n{last_m.speaker_name}: {last_m.content}"
  561. else:
  562. context_str = "(暂无发言)"
  563. else:
  564. context_str = shared_memory.get_context_str()
  565. # --- NEW: Dynamic Narrative Injection ---
  566. # Check if the VERY LAST message is from a user (audience)
  567. # FIX: Ensure we don't treat the Moderator (who might have moderator_id=None if default) as a user
  568. if messages and messages[-1].speaker_name and not messages[-1].persona_id and not messages[-1].moderator_id:
  569. last_msg = messages[-1]
  570. # Double check it's not the moderator by name
  571. if last_msg.speaker_name != moderator.name:
  572. # Inject narrative description only for this turn
  573. context_str += f"\n\n(此时,台下的观众 {last_msg.speaker_name} 大声说:“{last_msg.content}”)"
  574. # --- NEW: Check for user interruption right BEFORE thinking ---
  575. # If a user message arrived while we were summarizing or reconstructing context,
  576. # we should catch it now to include it in the think context.
  577. if await self._process_user_messages(forum_id):
  578. # Loop back to reconstruct context with new message
  579. logger.info("User message detected before thinking. Restarting loop.")
  580. speaker_queue.clear()
  581. continue
  582. speaker = None
  583. thoughts_map = {}
  584. # OPTIMIZATION: If we already have a queue, maybe we don't need everyone to think?
  585. # But current logic requires everyone to think to update their internal state or react.
  586. # However, to speed up, we can start the NEXT speaker's preparation earlier?
  587. # No, because context depends on the previous speaker's FULL message.
  588. # Broadcast thinking log - Use create_task to not block thinking
  589. self._spawn_forum_task(forum_id, self._broadcast_system_log(forum_id, "所有参与者正在思考中...", "info"))
  590. logger.info(f"Forum {forum_id}: Agents start thinking...")
  591. async def agent_think(ag):
  592. try:
  593. await self._broadcast_system_log(forum_id, f"嘉宾 [{ag.name}] 正在思考...", "thought")
  594. if ablation_flags.get("mock_llm"):
  595. await asyncio.sleep(1)
  596. # Simple mock thought
  597. thought = {
  598. "action": "apply_to_speak",
  599. "mind": f"Mock thought from {ag.name}. I should speak."
  600. }
  601. else:
  602. thought = await asyncio.to_thread(ag.think, context_str)
  603. if not self._is_forum_running(forum_id):
  604. return ag, None
  605. if thought:
  606. import json
  607. display_thought = {
  608. "decision": thought.get("action", "listen"),
  609. "inner_monologue": thought.get("mind", "")
  610. }
  611. await self._broadcast_system_log(forum_id, json.dumps(display_thought, ensure_ascii=False), "thought", f"Agent:{ag.name}")
  612. return ag, thought
  613. except Exception as e:
  614. logger.error(f"Agent {ag.name} think failed: {e}")
  615. await self._broadcast_system_log(
  616. forum_id,
  617. f"嘉宾 [{ag.name}] 思考失败,已跳过本轮。",
  618. "error",
  619. )
  620. return ag, None
  621. # Execute thinking in parallel - NO DB LOCK HELD HERE
  622. # Prefetch next speaker logic? No, we don't know who speaks until they think.
  623. # Optimization: Don't wait for ALL to think if we just need ONE to speak?
  624. # But we need everyone to decide "action".
  625. # Current bottleneck: waiting for the SLOWEST thinker.
  626. # Optimization: Set a timeout? Or just let them be.
  627. # Let's keep full gather for fairness, but maybe optimize the gap after thinking.
  628. # OPTIMIZATION: Use asyncio.wait for first_completed if we have a queue?
  629. # No, we need to know if anyone ELSE wants to speak urgently.
  630. # But we can update the UI *as soon as* someone decides.
  631. # think_results = await asyncio.gather(*[agent_think(p) for p in participants])
  632. # --- NEW: Interruptible Thinking with Polling ---
  633. think_tasks = [self._spawn_forum_task(forum_id, agent_think(p)) for p in participants]
  634. think_results = []
  635. interrupted = False
  636. while think_tasks:
  637. # Poll every 0.5s
  638. done, pending = await asyncio.wait(think_tasks, timeout=0.5, return_when=asyncio.FIRST_COMPLETED)
  639. think_tasks = list(pending)
  640. for t in done:
  641. try:
  642. res = await t
  643. if res: think_results.append(res)
  644. except Exception as e:
  645. logger.error(f"Think task failed: {e}")
  646. # Check for interruption
  647. if await self._process_user_messages(forum_id):
  648. logger.info(f"Forum {forum_id}: User message detected during thinking. Interrupting.")
  649. for t in think_tasks:
  650. t.cancel()
  651. interrupted = True
  652. break
  653. if interrupted:
  654. speaker_queue.clear()
  655. continue
  656. # New Logic: Use asyncio.as_completed to process thoughts as they arrive?
  657. # But we need to collect ALL results to make a fair decision if multiple apply.
  658. # However, we can process the DB updates in parallel.
  659. # Reduce timeout risk
  660. # If someone thinks too long, should we skip?
  661. # For now, no.
  662. # think_results = await asyncio.gather(*[agent_think(p) for p in participants])
  663. logger.info(f"Forum {forum_id}: Agents finished thinking.")
  664. # --- NEW: Check for user interruption right AFTER thinking ---
  665. # If a user message arrived while agents were thinking, their thoughts are now STALE.
  666. # We must discard them, save the user message, and restart the loop to re-think.
  667. if await self._process_user_messages(forum_id):
  668. logger.info("User message detected after thinking. Discarding thoughts and restarting.")
  669. speaker_queue.clear()
  670. # Discard thoughts implicitly by continuing loop
  671. continue
  672. valid_thoughts = [thought for _, thought in think_results if thought]
  673. if participants and not valid_thoughts:
  674. logger.error(
  675. "Forum %s has no usable participant thoughts; ending to avoid retry loops.",
  676. forum_id,
  677. )
  678. await self._close_for_unavailable_agents(forum_id)
  679. break
  680. # Process thoughts (need DB to save thoughts)
  681. # Optimization: Do this ASYNC or in background if possible?
  682. # We need to know who speaks to proceed.
  683. # But saving history can be done in parallel with speaking start?
  684. # No, we need consistency.
  685. # Let's optimize the DB access pattern.
  686. # We can prepare the next speaker IMMEDIATELY after deciding,
  687. # while saving thoughts in background.
  688. speaker_candidates = []
  689. # Simple in-memory processing first
  690. for agent, thought in think_results:
  691. if thought:
  692. thoughts_map[agent] = thought
  693. if thought.get('action') == 'apply_to_speak':
  694. speaker_candidates.append(agent)
  695. # Update Queue (In-Memory)
  696. for agent in speaker_candidates:
  697. if agent not in speaker_queue:
  698. if agent not in batch_spoken_agents or not speaker_queue:
  699. speaker_queue.append(agent)
  700. # Select Speaker (In-Memory)
  701. if speaker_queue:
  702. # Enforce constraint: A speaker cannot speak twice in a row
  703. # even if they are in the queue.
  704. last_speaker_name = None
  705. if messages:
  706. last_speaker_name = messages[-1].speaker_name
  707. candidate = speaker_queue[0]
  708. # If candidate is same as last speaker, try to find another one in queue
  709. if last_speaker_name and candidate.name == last_speaker_name:
  710. # Find first non-consecutive speaker
  711. found_alt = False
  712. for i in range(1, len(speaker_queue)):
  713. alt = speaker_queue[i]
  714. if alt.name != last_speaker_name:
  715. # Swap and pop
  716. speaker = speaker_queue.pop(i)
  717. found_alt = True
  718. break
  719. if not found_alt:
  720. # If everyone in queue is the same person (unlikely) or queue has only 1 person who just spoke
  721. # Then we MUST skip them to avoid monologue.
  722. # Fallback to general pool logic below.
  723. logger.info(f"Skipping queued speaker {candidate.name} to avoid consecutive speech.")
  724. speaker = None # Force fallback
  725. # Note: We do NOT pop them, they stay in queue for next turn?
  726. # Or should we pop and discard?
  727. # Better to keep them for next turn if possible, but for now let's just not pick them.
  728. # Actually, if we don't pop, they block the queue forever if logic loops.
  729. # Let's move them to end of queue?
  730. if len(speaker_queue) > 1:
  731. # Rotate
  732. speaker_queue.append(speaker_queue.pop(0))
  733. # Try again next loop? No, we need a speaker NOW.
  734. # If we rotated, the new [0] is different (handled by swap logic above usually).
  735. # If we are here, it means we couldn't find anyone else in queue.
  736. speaker = None
  737. else:
  738. # Queue has only this guy, and he just spoke.
  739. # Ignore queue, try fallback.
  740. pass
  741. else:
  742. speaker = speaker_queue.pop(0)
  743. if speaker:
  744. batch_spoken_agents.add(speaker)
  745. # If no speaker selected from queue (empty or skipped due to consecutive rule)
  746. if not speaker and participants:
  747. remaining = [p for p in participants if p not in batch_spoken_agents]
  748. # Filter out last speaker from remaining to be safe
  749. last_speaker_name = messages[-1].speaker_name if messages else None
  750. valid_remaining = [p for p in remaining if p.name != last_speaker_name]
  751. if valid_remaining:
  752. # 随机从valid_remaining中选择一个
  753. import random
  754. speaker = random.choice(valid_remaining)
  755. else:
  756. # Reset batch if everyone spoke or valid ones exhausted
  757. batch_spoken_agents.clear()
  758. # Fallback round-robin
  759. # Ensure fallback doesn't pick last speaker either
  760. attempts = 0
  761. valid_fallbacks = [p for p in participants if p.name != last_speaker_name]
  762. if valid_fallbacks:
  763. import random
  764. speaker = random.choice(valid_fallbacks)
  765. # while attempts < len(participants):
  766. # candidate = participants[fallback_speaker_idx % len(participants)]
  767. # fallback_speaker_idx += 1
  768. # attempts += 1
  769. # if candidate.name != last_speaker_name:
  770. # speaker = candidate
  771. # break
  772. # If still None (e.g. only 1 participant total), then allow consecutive
  773. if not speaker and participants:
  774. speaker = participants[0]
  775. if speaker:
  776. batch_spoken_agents.add(speaker)
  777. # Fire and forget DB updates for thoughts (using create_task)
  778. # This removes the DB write latency from the critical path of "Next Speaker"
  779. async def save_thoughts_bg(results, f_id):
  780. if not self._is_forum_running(f_id):
  781. return
  782. with self._get_db() as db:
  783. # Re-fetch only if needed, or pass IDs.
  784. # We need persona_id. We can cache it or fetch once.
  785. parts = get_forum_participants(db, f_id)
  786. p_map = {p.persona.name: p for p in parts}
  787. for ag, th in results:
  788. if not th: continue
  789. p_db = p_map.get(ag.name)
  790. if p_db:
  791. current = []
  792. if p_db.thoughts_history:
  793. try:
  794. if isinstance(p_db.thoughts_history, str):
  795. current = json.loads(p_db.thoughts_history)
  796. elif isinstance(p_db.thoughts_history, list):
  797. current = p_db.thoughts_history
  798. except: pass
  799. update_forum_participant(db, f_id, p_db.persona_id, thoughts_history=current + [th])
  800. if think_results:
  801. self._spawn_forum_task(forum_id, save_thoughts_bg(think_results, forum_id))
  802. # --- Queue Logic Refinement ---
  803. # Broadcasting logs is fast (Redis/WS), keep it.
  804. queue_names = [a.name for a in speaker_queue]
  805. if queue_names:
  806. # Optimized: Use background task for log persistence to avoid blocking
  807. self._spawn_forum_task(forum_id, self._broadcast_system_log(forum_id, f"当前发言队列: {', '.join(queue_names)}", "info"))
  808. if speaker:
  809. # Async log to not block speaking
  810. self._spawn_forum_task(forum_id, self._broadcast_system_log(forum_id, f"下一位发言: [{speaker.name}]", "info"))
  811. thought = thoughts_map.get(speaker) or {}
  812. await self._agent_speak(forum_id, speaker, thought, context_str, ablation_flags=ablation_flags)
  813. turn_count += 1
  814. # Periodic WAL checkpoint
  815. if turn_count % 10 == 0:
  816. with self._get_db() as db:
  817. try:
  818. if not db_manager.is_postgres and not db_manager.is_remote:
  819. db.execute("PRAGMA wal_checkpoint(PASSIVE)")
  820. except Exception as e:
  821. logger.warning(f"WAL checkpoint failed: {e}")
  822. # Flush system logs
  823. await self._flush_logs_to_db()
  824. except Exception as e:
  825. logger.error(f"Forum loop crashed: {e}")
  826. logger.error(traceback.format_exc())
  827. try:
  828. await self._broadcast_system_log(forum_id, "论坛异常终止,请查看服务端日志", "error")
  829. except:
  830. pass
  831. async def _moderator_speak(self, forum_id: int, moderator: ModeratorAgent, action: str, guests=None, messages=None, ablation_flags: dict = None):
  832. content = ""
  833. gen = None
  834. stream_id = str(uuid.uuid4())
  835. ablation_flags = ablation_flags or {}
  836. # Read data
  837. with self._get_db() as db:
  838. forum = get_forum(db, forum_id)
  839. moderator_id = forum.moderator_id
  840. # await self._broadcast_system_log(forum_id, f"主持人 [{moderator.name}] 正在构思...", "info")
  841. try:
  842. if ablation_flags.get("mock_llm"):
  843. await asyncio.sleep(1)
  844. gen = self._mock_stream_generator(f"Mock moderator speech for {action} on topic {forum.topic}...")
  845. elif action == "opening":
  846. # Fix: guest object in list is ParticipantAgent, it has .persona dict attribute if we stored it?
  847. # No, ParticipantAgent stores persona data in self.title, self.stance etc.
  848. # Let's check ParticipantAgent init.
  849. # It has self.title, self.stance.
  850. guest_list = [{"name": g.name, "title": g.title, "stance": g.stance} for g in guests]
  851. gen = await asyncio.to_thread(moderator.opening, guest_list)
  852. elif action == "closing":
  853. # Need summaries
  854. summaries = forum.summary_history or []
  855. if isinstance(summaries, str):
  856. import json
  857. try:
  858. summaries = json.loads(summaries)
  859. except:
  860. summaries = []
  861. gen = await asyncio.to_thread(moderator.closing, summaries)
  862. elif action == "periodic_summary":
  863. msgs_text = [{"speaker": m.speaker_name, "content": m.content} for m in messages[-20:]]
  864. gen = await asyncio.to_thread(moderator.periodic_summary, msgs_text)
  865. if gen:
  866. try:
  867. # Async log
  868. self._spawn_forum_task(
  869. forum_id,
  870. self._broadcast_system_log(
  871. forum_id,
  872. f"主持人 [{moderator.name}] 正在构思...",
  873. "thought",
  874. require_running=True,
  875. ),
  876. )
  877. first_token = True
  878. async for chunk in async_generator_wrapper(gen):
  879. if not self._is_forum_running(forum_id):
  880. return
  881. # --- NEW: Interruption Check ---
  882. if await self._process_user_messages(forum_id):
  883. logger.info(f"Moderator {moderator.name} interrupted by user.")
  884. await self._broadcast_system_log(forum_id, f"主持人被观众打断", "warning")
  885. break
  886. if first_token:
  887. await self._broadcast_system_log(forum_id, f"主持人 [{moderator.name}] 开始发言...", "speech")
  888. first_token = False
  889. if chunk:
  890. token = chunk
  891. content += token
  892. await self._broadcast_chunk(forum_id, moderator.name, token, None, moderator_id, stream_id)
  893. except Exception as e:
  894. logger.error(f"Error consuming generator: {e}")
  895. else:
  896. logger.warning("Moderator speak returned None generator")
  897. except Exception as e:
  898. logger.error(f"Moderator speak failed: {e}")
  899. await self._broadcast_system_log(forum_id, f"主持人发言生成失败: {str(e)}", "error")
  900. return
  901. if content and (action == "closing" or self._is_forum_running(forum_id)):
  902. with self._get_db() as db:
  903. msg = create_message(db, MessageCreate(
  904. forum_id=forum_id,
  905. moderator_id=moderator_id,
  906. speaker_name=moderator.name,
  907. content=content,
  908. turn_count=0
  909. ))
  910. if action == "periodic_summary":
  911. # Refresh forum
  912. forum = get_forum(db, forum_id)
  913. current = forum.summary_history or []
  914. if isinstance(current, str):
  915. import json
  916. try:
  917. current = json.loads(current)
  918. except:
  919. current = []
  920. new_history = current + [content]
  921. update_forum(db, forum_id, summary_history=new_history)
  922. await self._broadcast_message(forum_id, moderator.name, content, None, moderator_id, stream_id, msg.id)
  923. await self._broadcast_system_log(forum_id, content, "speech", moderator.name)
  924. async def _agent_speak(self, forum_id: int, agent: ParticipantAgent, thought: dict, context: str, ablation_flags: dict = None):
  925. content = ""
  926. stream_id = str(uuid.uuid4())
  927. ablation_flags = ablation_flags or {}
  928. with self._get_db() as db:
  929. participants = get_forum_participants(db, forum_id)
  930. p_db = next((p for p in participants if p.persona.name == agent.name), None)
  931. persona_id = p_db.persona_id if p_db else None
  932. # Optimization: No need to log "thinking" again if thought is already done.
  933. # But we might need to do the actual LLM call for speaking now.
  934. try:
  935. if ablation_flags.get("mock_llm"):
  936. await asyncio.sleep(1)
  937. gen = self._mock_stream_generator(f"Mock speech from {agent.name}. My thought was: {thought.get('mind')}")
  938. else:
  939. gen = await asyncio.to_thread(agent.speak, thought, context)
  940. if not self._is_forum_running(forum_id):
  941. return
  942. if gen:
  943. try:
  944. # await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 正在构思...", "thought")
  945. first_token = True
  946. start_speak_time = time.time()
  947. thought_sent = False
  948. thought_content = thought.get('mind') if thought else None
  949. async for chunk in async_generator_wrapper(gen):
  950. if not self._is_forum_running(forum_id):
  951. return
  952. # --- NEW: Interruption Check ---
  953. if await self._process_user_messages(forum_id):
  954. logger.info(f"Agent {agent.name} interrupted by user.")
  955. await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 被观众打断", "warning")
  956. break
  957. if first_token:
  958. ttft = time.time() - start_speak_time
  959. logger.info(f"Agent {agent.name} TTFT: {ttft:.2f}s")
  960. await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 开始发言...", "speech")
  961. first_token = False
  962. if chunk:
  963. token = chunk
  964. content += token
  965. send_thought = None
  966. if not thought_sent and thought_content:
  967. send_thought = thought_content
  968. thought_sent = True
  969. await self._broadcast_chunk(forum_id, agent.name, token, persona_id, None, stream_id, thought=send_thought)
  970. except Exception as e:
  971. logger.error(f"Error consuming agent generator: {e}")
  972. await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 发言中断,请查看服务端日志", "error")
  973. else:
  974. logger.warning(f"Agent {agent.name} speak returned None")
  975. content = "(沉默)"
  976. await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 放弃发言 (API无响应或返回空)", "warning")
  977. except Exception as e:
  978. logger.error(f"Agent {agent.name} speak failed: {e}")
  979. await self._broadcast_system_log(forum_id, f"嘉宾 [{agent.name}] 发言生成失败,请查看服务端日志", "error")
  980. return
  981. if content and self._is_forum_running(forum_id):
  982. thought_content = None
  983. if thought:
  984. thought_content = thought.get('mind')
  985. with self._get_db() as db:
  986. msg = create_message(db, MessageCreate(
  987. forum_id=forum_id,
  988. persona_id=persona_id,
  989. speaker_name=agent.name,
  990. content=content,
  991. thought=thought_content,
  992. turn_count=0
  993. ))
  994. await self._broadcast_message(forum_id, agent.name, content, persona_id, None, stream_id, msg.id, thought=thought_content)
  995. await self._broadcast_system_log(forum_id, content, "speech", agent.name)
  996. 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):
  997. if not chunk:
  998. return
  999. data = {
  1000. "speaker_name": speaker,
  1001. "content": chunk,
  1002. "persona_id": persona_id,
  1003. "moderator_id": moderator_id,
  1004. "stream_id": stream_id,
  1005. "timestamp": get_beijing_time_iso()
  1006. }
  1007. if thought:
  1008. data["thought"] = thought
  1009. await manager.broadcast(forum_id, {
  1010. "type": "message_chunk",
  1011. "data": data
  1012. })
  1013. 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):
  1014. """Broadcast message immediately to WS"""
  1015. # Optimized: Send to WS immediately, do NOT wait for any DB operations or complex logic
  1016. timestamp = get_beijing_time_iso()
  1017. try:
  1018. await manager.broadcast(forum_id, {
  1019. "type": "new_message",
  1020. "data": {
  1021. "id": msg_id, # Can be None if optimized to send before DB insert (frontend should handle temp ID)
  1022. "forum_id": forum_id,
  1023. "speaker_name": speaker,
  1024. "content": content,
  1025. "persona_id": persona_id,
  1026. "moderator_id": moderator_id,
  1027. "stream_id": stream_id,
  1028. "thought": thought,
  1029. "timestamp": timestamp
  1030. }
  1031. })
  1032. except Exception as e:
  1033. logger.error(f"Message broadcast failed: {e}")
  1034. async def _broadcast_system_message(self, forum_id: int, content: str):
  1035. await manager.broadcast(forum_id, {
  1036. "type": "system",
  1037. "content": content
  1038. })
  1039. scheduler = ForumScheduler()