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.
- backend/__init__.py +1 -0
- backend/__main__.py +7 -0
- backend/cli.py +44 -0
- backend/main.py +167 -0
- backend/models/__init__.py +2 -0
- backend/models/database.py +244 -0
- backend/routers/__init__.py +3 -0
- backend/routers/history.py +202 -0
- backend/routers/rooms.py +377 -0
- backend/services/__init__.py +1 -0
- backend/services/codex_agent.py +357 -0
- backend/services/codex_history_reader.py +287 -0
- backend/services/discussion_orchestrator.py +461 -0
- backend/services/history_reader.py +455 -0
- backend/services/meeting_prompts.py +291 -0
- backend/services/parallel_orchestrator.py +908 -0
- backend/services/participant_agent.py +394 -0
- backend/websocket.py +292 -0
- cc_discussion-1.0.0.dist-info/METADATA +527 -0
- cc_discussion-1.0.0.dist-info/RECORD +23 -0
- cc_discussion-1.0.0.dist-info/WHEEL +5 -0
- cc_discussion-1.0.0.dist-info/entry_points.txt +2 -0
- cc_discussion-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|