cc-discussion 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,908 @@
1
+ """
2
+ Parallel Discussion Orchestrator
3
+ ================================
4
+
5
+ Enhanced orchestrator that allows participants to prepare in the background
6
+ while maintaining turn-based discussion order.
7
+
8
+ Architecture:
9
+ - Current speaker: Active process generating response
10
+ - Next speaker(s): Background processes preparing (reading files, searching, etc.)
11
+ - Preparation results are cached and used when it's their turn to speak
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import logging
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import threading
22
+ from pathlib import Path
23
+ from typing import AsyncGenerator, Optional, List, Callable, Dict
24
+
25
+ from .history_reader import (
26
+ load_session_history,
27
+ format_context_for_injection,
28
+ decode_project_id,
29
+ get_original_path_from_dir,
30
+ )
31
+ from .codex_history_reader import _decode_path as decode_codex_path
32
+ from .meeting_prompts import get_facilitator_opening
33
+ from ..models.database import (
34
+ DiscussionRoom,
35
+ RoomParticipant,
36
+ DiscussionMessage,
37
+ RoomStatus,
38
+ MeetingType,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # Path to the agent scripts
44
+ PARTICIPANT_AGENT_PATH = Path(__file__).parent / "participant_agent.py"
45
+ CODEX_AGENT_PATH = Path(__file__).parent / "codex_agent.py"
46
+
47
+ # How many participants ahead to prepare
48
+ PREPARATION_LOOKAHEAD = 2
49
+
50
+
51
+ class ParticipantAgent:
52
+ """
53
+ Manages a single Claude participant with background preparation support.
54
+
55
+ Supports two modes:
56
+ - prepare: Background preparation (reading files, gathering info)
57
+ - speak: Generate actual discussion response
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ participant: RoomParticipant,
63
+ context_text: str,
64
+ room_topic: str,
65
+ meeting_type: Optional[str] = None,
66
+ custom_meeting_description: str = "",
67
+ language: str = "ja",
68
+ ):
69
+ self.participant = participant
70
+ self.context_text = context_text
71
+ self.room_topic = room_topic
72
+ self.meeting_type = meeting_type
73
+ self.custom_meeting_description = custom_meeting_description
74
+ self.language = language
75
+ self.cwd: Optional[str] = None
76
+ self.is_facilitator = participant.is_facilitator or False
77
+ self.agent_type = participant.agent_type.value if participant.agent_type else "claude"
78
+
79
+ # Process management
80
+ self._speak_process: Optional[subprocess.Popen] = None
81
+ self._prepare_process: Optional[subprocess.Popen] = None
82
+
83
+ # Preparation state
84
+ self.preparation_notes: str = ""
85
+ self.is_preparing: bool = False
86
+ self.preparation_complete: bool = False
87
+
88
+ # Thread safety
89
+ self._lock = threading.Lock()
90
+
91
+ def resolve_cwd(self) -> None:
92
+ """Resolve the working directory from participant's project context."""
93
+ if self.participant.context_project_dir:
94
+ try:
95
+ if self.agent_type == "codex":
96
+ # Codex projects: context_project_dir is base64-encoded path
97
+ actual_path = decode_codex_path(self.participant.context_project_dir)
98
+ if actual_path and Path(actual_path).exists():
99
+ self.cwd = actual_path
100
+ logger.info(f"Resolved cwd for {self.participant.name} (Codex): {self.cwd}")
101
+ else:
102
+ # Claude Code projects: use internal directory structure
103
+ internal_dir = Path(decode_project_id(self.participant.context_project_dir))
104
+ actual_path = get_original_path_from_dir(internal_dir)
105
+ if actual_path and Path(actual_path).exists():
106
+ self.cwd = actual_path
107
+ logger.info(f"Resolved cwd for {self.participant.name} (Claude): {self.cwd}")
108
+ except Exception as e:
109
+ logger.warning(f"Failed to resolve cwd for {self.participant.name}: {e}")
110
+
111
+ def _create_data_file(
112
+ self,
113
+ conversation_history: str,
114
+ preparation_notes: str = "",
115
+ ) -> str:
116
+ """Create temporary file with context data."""
117
+ data = {
118
+ "room_topic": self.room_topic,
119
+ "context_text": self.context_text,
120
+ "conversation_history": conversation_history,
121
+ "preparation_notes": preparation_notes,
122
+ "meeting_type": self.meeting_type,
123
+ "custom_meeting_description": self.custom_meeting_description,
124
+ "language": self.language,
125
+ }
126
+
127
+ fd, data_file = tempfile.mkstemp(suffix=".json", prefix="participant_")
128
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
129
+ json.dump(data, f, ensure_ascii=False)
130
+ return data_file
131
+
132
+ def _build_command(self, data_file: str, mode: str) -> List[str]:
133
+ """Build the subprocess command."""
134
+ # Choose agent script based on agent_type
135
+ agent_script = CODEX_AGENT_PATH if self.agent_type == "codex" else PARTICIPANT_AGENT_PATH
136
+
137
+ cmd = [
138
+ sys.executable, "-u",
139
+ str(agent_script),
140
+ "--participant-id", str(self.participant.id),
141
+ "--participant-name", self.participant.name,
142
+ "--participant-role", self.participant.role or "",
143
+ "--data-file", data_file,
144
+ "--mode", mode,
145
+ "--language", self.language,
146
+ ]
147
+ if self.cwd:
148
+ cmd.extend(["--cwd", self.cwd])
149
+ if self.meeting_type:
150
+ cmd.extend(["--meeting-type", self.meeting_type])
151
+ if self.is_facilitator:
152
+ cmd.append("--is-facilitator")
153
+ return cmd
154
+
155
+ def start_preparation(
156
+ self,
157
+ conversation_history: str,
158
+ on_activity: Optional[Callable[[str], None]] = None,
159
+ on_complete: Optional[Callable[[str], None]] = None,
160
+ ) -> None:
161
+ """
162
+ Start background preparation process.
163
+
164
+ Args:
165
+ conversation_history: Current conversation for context
166
+ on_activity: Callback for activity updates (tool use, etc.)
167
+ on_complete: Callback when preparation completes
168
+ """
169
+ with self._lock:
170
+ if self.is_preparing or self.preparation_complete:
171
+ return
172
+
173
+ self.is_preparing = True
174
+ self.preparation_notes = ""
175
+
176
+ data_file = self._create_data_file(conversation_history)
177
+ cmd = self._build_command(data_file, "prepare")
178
+
179
+ logger.info(f"Starting preparation for {self.participant.name}")
180
+
181
+ env = os.environ.copy()
182
+ env["PYTHONUNBUFFERED"] = "1"
183
+
184
+ self._prepare_process = subprocess.Popen(
185
+ cmd,
186
+ stdout=subprocess.PIPE,
187
+ stderr=subprocess.PIPE,
188
+ text=True,
189
+ encoding="utf-8",
190
+ errors="replace",
191
+ env=env,
192
+ cwd=str(Path(__file__).parent.parent.parent),
193
+ )
194
+
195
+ # Start reader thread
196
+ # Store local reference to avoid race condition with stop_preparation()
197
+ proc = self._prepare_process
198
+
199
+ def read_preparation_output():
200
+ try:
201
+ notes = ""
202
+ if proc and proc.stdout:
203
+ for line in proc.stdout:
204
+ line = line.strip()
205
+ if not line:
206
+ continue
207
+
208
+ try:
209
+ msg = json.loads(line)
210
+
211
+ if msg.get("type") == "tool_use":
212
+ if on_activity:
213
+ on_activity(f"Using {msg.get('tool')}: {msg.get('input', '')[:50]}...")
214
+
215
+ elif msg.get("type") == "text":
216
+ notes += msg.get("content", "")
217
+
218
+ elif msg.get("type") == "response_complete":
219
+ notes = msg.get("full_content", notes)
220
+
221
+ except json.JSONDecodeError:
222
+ pass
223
+
224
+ if proc:
225
+ proc.wait()
226
+
227
+ with self._lock:
228
+ self.preparation_notes = notes
229
+ self.is_preparing = False
230
+ self.preparation_complete = True
231
+
232
+ if on_complete:
233
+ on_complete(notes[:200] + "..." if len(notes) > 200 else notes)
234
+
235
+ logger.info(f"Preparation complete for {self.participant.name}: {len(notes)} chars")
236
+
237
+ except Exception as e:
238
+ logger.error(f"Error in preparation for {self.participant.name}: {e}")
239
+ with self._lock:
240
+ self.is_preparing = False
241
+
242
+ finally:
243
+ # Clean up data file
244
+ try:
245
+ Path(data_file).unlink(missing_ok=True)
246
+ except Exception:
247
+ pass
248
+
249
+ thread = threading.Thread(target=read_preparation_output, daemon=True)
250
+ thread.start()
251
+
252
+ async def speak(
253
+ self,
254
+ conversation_history: str,
255
+ ) -> AsyncGenerator[dict, None]:
256
+ """
257
+ Generate a speaking response.
258
+
259
+ Uses preparation notes if available.
260
+ """
261
+ with self._lock:
262
+ prep_notes = self.preparation_notes
263
+ # Reset preparation state for next turn
264
+ self.preparation_notes = ""
265
+ self.preparation_complete = False
266
+
267
+ data_file = self._create_data_file(conversation_history, prep_notes)
268
+ cmd = self._build_command(data_file, "speak")
269
+
270
+ logger.info(f"Starting speech for {self.participant.name} (prep_notes: {len(prep_notes)} chars)")
271
+
272
+ env = os.environ.copy()
273
+ env["PYTHONUNBUFFERED"] = "1"
274
+
275
+ logger.info(f"speak: Spawning subprocess for {self.participant.name}")
276
+ logger.info(f"speak: Command: {' '.join(cmd)}")
277
+
278
+ self._speak_process = subprocess.Popen(
279
+ cmd,
280
+ stdout=subprocess.PIPE,
281
+ stderr=subprocess.PIPE,
282
+ text=True,
283
+ encoding="utf-8",
284
+ errors="replace",
285
+ env=env,
286
+ cwd=str(Path(__file__).parent.parent.parent),
287
+ )
288
+
289
+ logger.info(f"speak: Process started, PID={self._speak_process.pid}")
290
+
291
+ try:
292
+ loop = asyncio.get_event_loop()
293
+ full_response = ""
294
+
295
+ while True:
296
+ logger.debug(f"speak: Waiting for output line...")
297
+ line = await loop.run_in_executor(
298
+ None, self._speak_process.stdout.readline
299
+ )
300
+
301
+ if not line:
302
+ logger.info(f"speak: No more output, breaking loop")
303
+ break
304
+
305
+ line = line.strip()
306
+ if not line:
307
+ continue
308
+
309
+ logger.debug(f"speak: Got line: {line[:100]}...")
310
+
311
+ try:
312
+ msg = json.loads(line)
313
+ msg_type = msg.get("type")
314
+ logger.info(f"speak: Received message type: {msg_type}")
315
+
316
+ # Log debug messages from Codex agent
317
+ if msg_type == "debug":
318
+ logger.info(f"speak: DEBUG from agent: {msg.get('event_type')} - {msg.get('event_data', '')[:300]}")
319
+ continue # Don't yield debug messages
320
+
321
+ yield msg
322
+
323
+ if msg_type == "text":
324
+ full_response += msg.get("content", "")
325
+ elif msg_type == "response_complete":
326
+ full_response = msg.get("full_content", full_response)
327
+
328
+ except json.JSONDecodeError:
329
+ logger.warning(f"Non-JSON output: {line}")
330
+
331
+ # Store local reference to avoid race condition with stop_speech()
332
+ proc = self._speak_process
333
+ if proc:
334
+ await loop.run_in_executor(None, proc.wait)
335
+
336
+ if proc.returncode != 0:
337
+ stderr = proc.stderr.read() if proc.stderr else ""
338
+ logger.error(f"Speech process failed: {stderr}")
339
+ if not full_response:
340
+ yield {"type": "error", "content": f"Process failed: {stderr[:500]}"}
341
+
342
+ except Exception as e:
343
+ logger.error(f"Error in speech for {self.participant.name}: {e}")
344
+ yield {"type": "error", "content": str(e)}
345
+
346
+ finally:
347
+ try:
348
+ Path(data_file).unlink(missing_ok=True)
349
+ except Exception:
350
+ pass
351
+
352
+ proc = self._speak_process
353
+ if proc:
354
+ try:
355
+ proc.kill()
356
+ except Exception:
357
+ pass
358
+ self._speak_process = None
359
+
360
+ def stop_preparation(self):
361
+ """Stop the preparation process if running."""
362
+ proc = self._prepare_process
363
+ self._prepare_process = None
364
+ if proc:
365
+ try:
366
+ proc.terminate()
367
+ proc.wait(timeout=2)
368
+ except Exception:
369
+ try:
370
+ proc.kill()
371
+ except Exception:
372
+ pass
373
+
374
+ with self._lock:
375
+ self.is_preparing = False
376
+
377
+ def stop_speech(self):
378
+ """Stop the speech process if running."""
379
+ proc = self._speak_process
380
+ self._speak_process = None
381
+ if proc:
382
+ try:
383
+ proc.terminate()
384
+ proc.wait(timeout=2)
385
+ except Exception:
386
+ try:
387
+ proc.kill()
388
+ except Exception:
389
+ pass
390
+
391
+ def stop_all(self):
392
+ """Stop all processes."""
393
+ self.stop_preparation()
394
+ self.stop_speech()
395
+
396
+ def generate_facilitator_opening(
397
+ self,
398
+ participants: List["ParticipantAgent"],
399
+ first_speaker_name: str,
400
+ ) -> str:
401
+ """Generate the facilitator opening message."""
402
+ if not self.is_facilitator:
403
+ return ""
404
+
405
+ meeting_type_enum = None
406
+ if self.meeting_type:
407
+ try:
408
+ meeting_type_enum = MeetingType(self.meeting_type)
409
+ except ValueError:
410
+ meeting_type_enum = MeetingType.TECHNICAL_REVIEW
411
+ else:
412
+ meeting_type_enum = MeetingType.TECHNICAL_REVIEW
413
+
414
+ participant_names = [p.participant.name for p in participants if not p.is_facilitator]
415
+
416
+ return get_facilitator_opening(
417
+ meeting_type=meeting_type_enum,
418
+ topic=self.room_topic,
419
+ participants=participant_names,
420
+ first_speaker=first_speaker_name,
421
+ custom_description=self.custom_meeting_description,
422
+ )
423
+
424
+
425
+ class ParallelDiscussionOrchestrator:
426
+ """
427
+ Orchestrates a multi-Claude discussion with parallel preparation.
428
+
429
+ While one participant is speaking, the next participant(s) can prepare
430
+ in the background by reading files, searching code, etc.
431
+ """
432
+
433
+ def __init__(
434
+ self,
435
+ room: DiscussionRoom,
436
+ db_session,
437
+ on_event: Optional[Callable] = None,
438
+ ):
439
+ self.room = room
440
+ self.db = db_session
441
+ self.participants: List[ParticipantAgent] = []
442
+ self.regular_participants: List[ParticipantAgent] = [] # Non-facilitator participants
443
+ self.facilitator: Optional[ParticipantAgent] = None
444
+ self.current_speaker_idx = 0
445
+ self._running = False
446
+ self._paused = False
447
+ self.on_event = on_event
448
+
449
+ # Event queue for background activity
450
+ self._event_queue: asyncio.Queue = asyncio.Queue()
451
+
452
+ async def initialize_participants(self):
453
+ """Initialize all participant agents with their contexts."""
454
+ # Get room-level settings
455
+ meeting_type = self.room.meeting_type.value if self.room.meeting_type else None
456
+ custom_meeting_description = self.room.custom_meeting_description or ""
457
+ language = self.room.language or "ja"
458
+
459
+ for participant in self.room.participants:
460
+ context_text = ""
461
+ if participant.context_project_dir and participant.context_session_id:
462
+ try:
463
+ messages = load_session_history(
464
+ participant.context_project_dir,
465
+ participant.context_session_id
466
+ )
467
+ context_text = format_context_for_injection(messages, max_chars=50000)
468
+ logger.info(
469
+ f"Loaded context for {participant.name}: "
470
+ f"{len(messages)} messages, {len(context_text)} chars"
471
+ )
472
+ except Exception as e:
473
+ logger.warning(f"Failed to load context for {participant.name}: {e}")
474
+ context_text = participant.context_summary or ""
475
+ elif participant.context_summary:
476
+ context_text = participant.context_summary
477
+
478
+ agent = ParticipantAgent(
479
+ participant=participant,
480
+ context_text=context_text,
481
+ room_topic=self.room.topic or "General discussion",
482
+ meeting_type=meeting_type,
483
+ custom_meeting_description=custom_meeting_description,
484
+ language=language,
485
+ )
486
+ agent.resolve_cwd()
487
+
488
+ # Set default cwd to project root if not resolved
489
+ project_root = str(Path(__file__).parent.parent.parent)
490
+ if not agent.cwd:
491
+ agent.cwd = project_root
492
+
493
+ # Track facilitator separately
494
+ if agent.is_facilitator:
495
+ self.facilitator = agent
496
+ else:
497
+ self.regular_participants.append(agent)
498
+
499
+ self.participants.append(agent)
500
+
501
+ logger.info(
502
+ f"Initialized {len(self.participants)} participants "
503
+ f"(lang={language}, type={meeting_type}, facilitator={self.facilitator is not None})"
504
+ )
505
+
506
+ def _build_conversation_history(self) -> str:
507
+ """Build conversation history string from all messages."""
508
+ messages = self.db.query(DiscussionMessage).filter(
509
+ DiscussionMessage.room_id == self.room.id
510
+ ).order_by(DiscussionMessage.created_at).all()
511
+
512
+ lines = []
513
+ for msg in messages:
514
+ if msg.role == "system":
515
+ lines.append(f"[System]: {msg.content}\n")
516
+ elif msg.role == "moderator":
517
+ lines.append(f"[Moderator]: {msg.content}\n")
518
+ else:
519
+ participant = next(
520
+ (p for p in self.room.participants if p.id == msg.participant_id),
521
+ None
522
+ )
523
+ name = participant.name if participant else "Unknown"
524
+ lines.append(f"[{name}]: {msg.content}\n")
525
+
526
+ return "\n".join(lines)
527
+
528
+ def _get_next_speakers(self, count: int = PREPARATION_LOOKAHEAD) -> List[int]:
529
+ """Get indices of next regular speakers to prepare."""
530
+ if not self.regular_participants:
531
+ return []
532
+
533
+ indices = []
534
+ for i in range(1, count + 1):
535
+ idx = (self.current_speaker_idx + i) % len(self.regular_participants)
536
+ if idx != self.current_speaker_idx:
537
+ indices.append(idx)
538
+ return indices
539
+
540
+ def _start_preparations(self, history: str):
541
+ """Start preparation for upcoming regular speakers."""
542
+ for idx in self._get_next_speakers():
543
+ agent = self.regular_participants[idx]
544
+
545
+ if not agent.is_preparing and not agent.preparation_complete:
546
+ def on_activity(activity: str, agent=agent):
547
+ # Queue activity event for broadcast
548
+ try:
549
+ self._event_queue.put_nowait({
550
+ "type": "background_activity",
551
+ "participant_id": agent.participant.id,
552
+ "participant_name": agent.participant.name,
553
+ "activity": activity,
554
+ })
555
+ except asyncio.QueueFull:
556
+ pass
557
+
558
+ def on_complete(notes_preview: str, agent=agent):
559
+ try:
560
+ self._event_queue.put_nowait({
561
+ "type": "preparation_complete",
562
+ "participant_id": agent.participant.id,
563
+ "participant_name": agent.participant.name,
564
+ "notes_preview": notes_preview,
565
+ })
566
+ except asyncio.QueueFull:
567
+ pass
568
+
569
+ agent.start_preparation(
570
+ history,
571
+ on_activity=on_activity,
572
+ on_complete=on_complete,
573
+ )
574
+
575
+ # Queue preparation start event
576
+ try:
577
+ self._event_queue.put_nowait({
578
+ "type": "preparation_start",
579
+ "participant_id": agent.participant.id,
580
+ "participant_name": agent.participant.name,
581
+ })
582
+ except asyncio.QueueFull:
583
+ pass
584
+
585
+ async def _drain_event_queue(self) -> AsyncGenerator[dict, None]:
586
+ """Drain and yield events from the background event queue."""
587
+ while True:
588
+ try:
589
+ event = self._event_queue.get_nowait()
590
+ yield event
591
+ except asyncio.QueueEmpty:
592
+ break
593
+
594
+ async def run_turn(self) -> AsyncGenerator[dict, None]:
595
+ """Run a single turn of discussion with parallel preparation."""
596
+ logger.info(f"run_turn: starting turn {self.room.current_turn + 1}")
597
+
598
+ if not self.regular_participants:
599
+ logger.error("run_turn: no regular participants initialized")
600
+ yield {"type": "error", "content": "No participants initialized"}
601
+ return
602
+
603
+ # Get current speaker (from regular participants, not facilitator)
604
+ speaker = self.regular_participants[self.current_speaker_idx]
605
+ participant = speaker.participant
606
+ logger.info(f"run_turn: speaker is {participant.name} (idx={self.current_speaker_idx})")
607
+
608
+ yield {
609
+ "type": "turn_start",
610
+ "participant_id": participant.id,
611
+ "participant_name": participant.name,
612
+ "turn_number": self.room.current_turn + 1,
613
+ }
614
+
615
+ # Mark as speaking
616
+ participant.is_speaking = True
617
+ self.db.commit()
618
+
619
+ # Build history
620
+ history = self._build_conversation_history()
621
+ logger.info(f"run_turn: built history ({len(history)} chars)")
622
+
623
+ # Start preparations for next speakers
624
+ self._start_preparations(history)
625
+
626
+ # Yield any queued background events
627
+ async for event in self._drain_event_queue():
628
+ yield event
629
+
630
+ # Generate speech
631
+ logger.info(f"run_turn: calling speaker.speak()")
632
+ full_content = ""
633
+ async for chunk in speaker.speak(history):
634
+ if chunk["type"] == "text":
635
+ yield {
636
+ "type": "text",
637
+ "content": chunk["content"],
638
+ "participant_id": participant.id,
639
+ }
640
+ elif chunk["type"] == "tool_use":
641
+ yield {
642
+ "type": "tool_use",
643
+ "tool": chunk.get("tool"),
644
+ "input": chunk.get("input"),
645
+ "participant_id": participant.id,
646
+ }
647
+ elif chunk["type"] == "response_complete":
648
+ full_content = chunk["full_content"]
649
+ elif chunk["type"] == "error":
650
+ yield {
651
+ "type": "error",
652
+ "content": chunk["content"],
653
+ "participant_id": participant.id,
654
+ }
655
+
656
+ # Also yield any background events that came in
657
+ async for event in self._drain_event_queue():
658
+ yield event
659
+
660
+ # Save message to database
661
+ message = DiscussionMessage(
662
+ room_id=self.room.id,
663
+ participant_id=participant.id,
664
+ role="participant",
665
+ content=full_content,
666
+ turn_number=self.room.current_turn + 1,
667
+ )
668
+ self.db.add(message)
669
+
670
+ # Update state
671
+ participant.is_speaking = False
672
+ participant.message_count += 1
673
+ self.room.current_turn += 1
674
+ self.current_speaker_idx = (
675
+ self.current_speaker_idx + 1
676
+ ) % len(self.regular_participants)
677
+ self.db.commit()
678
+
679
+ yield {
680
+ "type": "turn_complete",
681
+ "participant_id": participant.id,
682
+ "message_id": message.id,
683
+ "turn_number": self.room.current_turn,
684
+ }
685
+
686
+ async def run_facilitator_opening(self) -> AsyncGenerator[dict, None]:
687
+ """Generate facilitator opening message."""
688
+ if not self.facilitator:
689
+ return
690
+
691
+ participant = self.facilitator.participant
692
+
693
+ yield {
694
+ "type": "turn_start",
695
+ "participant_id": participant.id,
696
+ "participant_name": participant.name,
697
+ "turn_number": 0,
698
+ "is_facilitator": True,
699
+ }
700
+
701
+ # Mark as speaking
702
+ participant.is_speaking = True
703
+ self.db.commit()
704
+
705
+ # Get first regular speaker name
706
+ first_speaker_name = (
707
+ self.regular_participants[0].participant.name
708
+ if self.regular_participants
709
+ else "参加者"
710
+ )
711
+
712
+ # Generate opening message
713
+ opening_message = self.facilitator.generate_facilitator_opening(
714
+ participants=self.participants,
715
+ first_speaker_name=first_speaker_name,
716
+ )
717
+
718
+ if opening_message:
719
+ # Yield the message as text chunks
720
+ yield {
721
+ "type": "text",
722
+ "content": opening_message,
723
+ "participant_id": participant.id,
724
+ }
725
+
726
+ # Save to database
727
+ message = DiscussionMessage(
728
+ room_id=self.room.id,
729
+ participant_id=participant.id,
730
+ role="participant",
731
+ content=opening_message,
732
+ turn_number=0,
733
+ )
734
+ self.db.add(message)
735
+
736
+ participant.is_speaking = False
737
+ participant.message_count += 1
738
+ self.db.commit()
739
+
740
+ yield {
741
+ "type": "turn_complete",
742
+ "participant_id": participant.id,
743
+ "message_id": message.id,
744
+ "turn_number": 0,
745
+ "is_facilitator": True,
746
+ }
747
+
748
+ async def run_facilitator_closing(self) -> AsyncGenerator[dict, None]:
749
+ """Generate facilitator closing message."""
750
+ if not self.facilitator:
751
+ return
752
+
753
+ participant = self.facilitator.participant
754
+
755
+ yield {
756
+ "type": "turn_start",
757
+ "participant_id": participant.id,
758
+ "participant_name": participant.name,
759
+ "turn_number": self.room.current_turn + 1,
760
+ "is_facilitator": True,
761
+ "is_closing": True,
762
+ }
763
+
764
+ # Mark as speaking
765
+ participant.is_speaking = True
766
+ self.db.commit()
767
+
768
+ # Build conversation history for closing summary
769
+ history = self._build_conversation_history()
770
+
771
+ # Generate closing using the agent (so it can summarize the discussion)
772
+ full_content = ""
773
+ async for chunk in self.facilitator.speak(history):
774
+ if chunk["type"] == "text":
775
+ yield {
776
+ "type": "text",
777
+ "content": chunk["content"],
778
+ "participant_id": participant.id,
779
+ }
780
+ elif chunk["type"] == "response_complete":
781
+ full_content = chunk["full_content"]
782
+
783
+ # Save to database
784
+ message = DiscussionMessage(
785
+ room_id=self.room.id,
786
+ participant_id=participant.id,
787
+ role="participant",
788
+ content=full_content,
789
+ turn_number=self.room.current_turn + 1,
790
+ )
791
+ self.db.add(message)
792
+
793
+ participant.is_speaking = False
794
+ participant.message_count += 1
795
+ self.room.current_turn += 1
796
+ self.db.commit()
797
+
798
+ yield {
799
+ "type": "turn_complete",
800
+ "participant_id": participant.id,
801
+ "message_id": message.id,
802
+ "turn_number": self.room.current_turn,
803
+ "is_facilitator": True,
804
+ "is_closing": True,
805
+ }
806
+
807
+ async def run_discussion(self) -> AsyncGenerator[dict, None]:
808
+ """Run the full discussion with parallel preparation."""
809
+ self._running = True
810
+ self._paused = False
811
+ self.room.status = RoomStatus.ACTIVE
812
+ self.db.commit()
813
+
814
+ yield {
815
+ "type": "discussion_start",
816
+ "room_id": self.room.id,
817
+ "max_turns": self.room.max_turns,
818
+ "has_facilitator": self.facilitator is not None,
819
+ }
820
+
821
+ # Facilitator opening (if present)
822
+ if self.facilitator:
823
+ async for chunk in self.run_facilitator_opening():
824
+ yield chunk
825
+ await asyncio.sleep(1)
826
+
827
+ while (
828
+ self._running
829
+ and not self._paused
830
+ and self.room.current_turn < self.room.max_turns
831
+ ):
832
+ async for chunk in self.run_turn():
833
+ yield chunk
834
+
835
+ # Small delay between turns
836
+ await asyncio.sleep(1)
837
+
838
+ # Refresh room state
839
+ self.db.refresh(self.room)
840
+ if self.room.status == RoomStatus.PAUSED:
841
+ self._paused = True
842
+ yield {"type": "discussion_paused", "turn": self.room.current_turn}
843
+ break
844
+
845
+ # Facilitator closing (if present and not paused)
846
+ if not self._paused and self.facilitator:
847
+ async for chunk in self.run_facilitator_closing():
848
+ yield chunk
849
+
850
+ if not self._paused:
851
+ self.room.status = RoomStatus.COMPLETED
852
+ self.db.commit()
853
+ yield {
854
+ "type": "discussion_complete",
855
+ "total_turns": self.room.current_turn,
856
+ }
857
+
858
+ def pause(self):
859
+ """Pause the discussion."""
860
+ self._paused = True
861
+ self.room.status = RoomStatus.PAUSED
862
+ self.db.commit()
863
+
864
+ for p in self.participants:
865
+ p.stop_all()
866
+
867
+ def stop(self):
868
+ """Stop the discussion."""
869
+ self._running = False
870
+ self.room.status = RoomStatus.COMPLETED
871
+ self.db.commit()
872
+
873
+ for p in self.participants:
874
+ p.stop_all()
875
+
876
+ async def cleanup(self):
877
+ """Clean up all participant agents."""
878
+ for agent in self.participants:
879
+ agent.stop_all()
880
+ self.participants.clear()
881
+ logger.info("Cleaned up all participant agents")
882
+
883
+
884
+ # Global registry of active orchestrators
885
+ _active_parallel_orchestrators: Dict[int, ParallelDiscussionOrchestrator] = {}
886
+
887
+
888
+ def get_parallel_orchestrator(room_id: int) -> Optional[ParallelDiscussionOrchestrator]:
889
+ """Get an active parallel orchestrator by room ID."""
890
+ return _active_parallel_orchestrators.get(room_id)
891
+
892
+
893
+ def register_parallel_orchestrator(room_id: int, orchestrator: ParallelDiscussionOrchestrator):
894
+ """Register an active parallel orchestrator."""
895
+ _active_parallel_orchestrators[room_id] = orchestrator
896
+
897
+
898
+ def unregister_parallel_orchestrator(room_id: int):
899
+ """Unregister a parallel orchestrator."""
900
+ if room_id in _active_parallel_orchestrators:
901
+ del _active_parallel_orchestrators[room_id]
902
+
903
+
904
+ async def cleanup_all_parallel_orchestrators():
905
+ """Clean up all active parallel orchestrators."""
906
+ for room_id, orchestrator in list(_active_parallel_orchestrators.items()):
907
+ await orchestrator.cleanup()
908
+ _active_parallel_orchestrators.clear()