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,394 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Participant Agent
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
Subprocess-based participant agent for discussions.
|
|
6
|
+
Each invocation creates a fresh ClaudeSDKClient, generates one response, and exits.
|
|
7
|
+
|
|
8
|
+
This solves the client reuse problem where max_turns=1 causes empty responses
|
|
9
|
+
on subsequent queries to the same client.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python -m backend.services.participant_agent \
|
|
13
|
+
--participant-id 1 \
|
|
14
|
+
--participant-name "Claude A" \
|
|
15
|
+
--participant-role "Tech Lead" \
|
|
16
|
+
--data-file /tmp/context.json \
|
|
17
|
+
--mode speak
|
|
18
|
+
|
|
19
|
+
Output (JSON Lines to stdout):
|
|
20
|
+
{"type": "text", "content": "..."}
|
|
21
|
+
{"type": "tool_use", "tool": "Read", "input": "..."}
|
|
22
|
+
{"type": "response_complete", "full_content": "..."}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import asyncio
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
# Lazy import for ClaudeSDK - check availability at runtime
|
|
34
|
+
ClaudeSDKClient = None
|
|
35
|
+
ClaudeAgentOptions = None
|
|
36
|
+
|
|
37
|
+
def _ensure_claude_sdk():
|
|
38
|
+
"""Ensure ClaudeSDK is available, exit with error if not."""
|
|
39
|
+
global ClaudeSDKClient, ClaudeAgentOptions
|
|
40
|
+
if ClaudeSDKClient is None:
|
|
41
|
+
try:
|
|
42
|
+
from claude_agent_sdk import ClaudeSDKClient as _Client, ClaudeAgentOptions as _Options
|
|
43
|
+
ClaudeSDKClient = _Client
|
|
44
|
+
ClaudeAgentOptions = _Options
|
|
45
|
+
except ImportError:
|
|
46
|
+
print(json.dumps({
|
|
47
|
+
"type": "error",
|
|
48
|
+
"content": "ClaudeCode SDK is not installed. Please run: pip install claude-agent-sdk"
|
|
49
|
+
}), flush=True)
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
# Import meeting prompts - handle both module and standalone execution
|
|
53
|
+
try:
|
|
54
|
+
from .meeting_prompts import (
|
|
55
|
+
get_meeting_type_prompt,
|
|
56
|
+
get_language_instruction,
|
|
57
|
+
FACILITATOR_SYSTEM_PROMPT,
|
|
58
|
+
)
|
|
59
|
+
except ImportError:
|
|
60
|
+
# Running as standalone script - use direct import
|
|
61
|
+
import sys
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
64
|
+
from meeting_prompts import (
|
|
65
|
+
get_meeting_type_prompt,
|
|
66
|
+
get_language_instruction,
|
|
67
|
+
FACILITATOR_SYSTEM_PROMPT,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Configure logging to stderr (stdout is reserved for JSON output)
|
|
71
|
+
logging.basicConfig(
|
|
72
|
+
level=logging.INFO,
|
|
73
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
74
|
+
stream=sys.stderr,
|
|
75
|
+
)
|
|
76
|
+
logger = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
# Read-only tools allowed for discussion participants
|
|
79
|
+
READ_ONLY_TOOLS = [
|
|
80
|
+
"Read", # Read files
|
|
81
|
+
"Grep", # Search file contents
|
|
82
|
+
"Glob", # Find files by pattern
|
|
83
|
+
"WebFetch", # Fetch web content (read-only)
|
|
84
|
+
"WebSearch", # Search the web (read-only)
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_system_prompt(
|
|
89
|
+
name: str,
|
|
90
|
+
role: str,
|
|
91
|
+
context: str,
|
|
92
|
+
topic: str,
|
|
93
|
+
mode: str = "speak",
|
|
94
|
+
meeting_type: Optional[str] = None,
|
|
95
|
+
custom_meeting_description: str = "",
|
|
96
|
+
language: str = "ja",
|
|
97
|
+
is_facilitator: bool = False,
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Build the system prompt for this participant."""
|
|
100
|
+
role_desc = role or "discussion participant"
|
|
101
|
+
|
|
102
|
+
# Get meeting type prompt and language instruction
|
|
103
|
+
# meeting_type is already a string, pass it directly
|
|
104
|
+
meeting_type_prompt = ""
|
|
105
|
+
if meeting_type:
|
|
106
|
+
meeting_type_prompt = get_meeting_type_prompt(meeting_type, custom_meeting_description)
|
|
107
|
+
|
|
108
|
+
language_instruction = get_language_instruction(language)
|
|
109
|
+
|
|
110
|
+
# Facilitator closing prompt
|
|
111
|
+
if is_facilitator and mode == "speak":
|
|
112
|
+
return f"""You are {name}, the facilitator of this multi-Claude discussion room.
|
|
113
|
+
|
|
114
|
+
{language_instruction}
|
|
115
|
+
|
|
116
|
+
{FACILITATOR_SYSTEM_PROMPT}
|
|
117
|
+
|
|
118
|
+
{meeting_type_prompt}
|
|
119
|
+
|
|
120
|
+
## Discussion Topic
|
|
121
|
+
{topic}
|
|
122
|
+
|
|
123
|
+
## Your Task
|
|
124
|
+
You are generating the CLOSING message for this discussion.
|
|
125
|
+
Please summarize:
|
|
126
|
+
1. The key discussion points
|
|
127
|
+
2. Decisions made (if any)
|
|
128
|
+
3. Next actions or open items
|
|
129
|
+
|
|
130
|
+
## Response Format
|
|
131
|
+
- Start with [{name}]:
|
|
132
|
+
- Be concise but comprehensive
|
|
133
|
+
- Thank the participants at the end
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
if mode == "prepare":
|
|
137
|
+
# Preparation mode: gather information but don't speak yet
|
|
138
|
+
prompt = f"""You are {name}, a {role_desc} preparing for a multi-Claude discussion.
|
|
139
|
+
|
|
140
|
+
{language_instruction}
|
|
141
|
+
|
|
142
|
+
## PREPARATION MODE
|
|
143
|
+
You are preparing to contribute to a discussion. Your task is to:
|
|
144
|
+
1. Read relevant files to understand the codebase
|
|
145
|
+
2. Search for information that will be useful for the discussion
|
|
146
|
+
3. Take notes on key findings
|
|
147
|
+
|
|
148
|
+
**DO NOT** generate a discussion response yet. Instead, output a summary of your findings
|
|
149
|
+
that will help you when it's your turn to speak.
|
|
150
|
+
|
|
151
|
+
{meeting_type_prompt}
|
|
152
|
+
|
|
153
|
+
## Discussion Topic
|
|
154
|
+
{topic}
|
|
155
|
+
|
|
156
|
+
## Your Background Context
|
|
157
|
+
{context if context else "(No prior context provided)"}
|
|
158
|
+
|
|
159
|
+
## Output Format
|
|
160
|
+
Summarize your findings in 2-3 paragraphs that will help you contribute to the discussion.
|
|
161
|
+
Focus on technical details, code patterns, and insights relevant to the topic.
|
|
162
|
+
"""
|
|
163
|
+
else:
|
|
164
|
+
# Speaking mode: generate a discussion response
|
|
165
|
+
prompt = f"""You are {name}, a {role_desc} in a multi-Claude discussion room.
|
|
166
|
+
|
|
167
|
+
{language_instruction}
|
|
168
|
+
|
|
169
|
+
## CRITICAL: READ-ONLY DISCUSSION MODE
|
|
170
|
+
This is a DISCUSSION-ONLY environment.
|
|
171
|
+
|
|
172
|
+
**Allowed Actions:**
|
|
173
|
+
- Read files to understand code structure
|
|
174
|
+
- Search code using Grep and Glob
|
|
175
|
+
- Fetch web content for reference
|
|
176
|
+
- Discuss, analyze, and share insights
|
|
177
|
+
|
|
178
|
+
**STRICTLY FORBIDDEN:**
|
|
179
|
+
- Writing, editing, or modifying any files
|
|
180
|
+
- Executing any bash commands
|
|
181
|
+
- Implementing any features or fixes
|
|
182
|
+
- Making any changes to the codebase
|
|
183
|
+
|
|
184
|
+
Your role is to discuss, analyze, share insights, and exchange ideas with other participants.
|
|
185
|
+
You may read files to support your discussion points, but you must NEVER modify anything.
|
|
186
|
+
If asked to implement or modify anything, politely decline and redirect to discussing the approach instead.
|
|
187
|
+
|
|
188
|
+
{meeting_type_prompt}
|
|
189
|
+
|
|
190
|
+
## Discussion Topic
|
|
191
|
+
{topic}
|
|
192
|
+
|
|
193
|
+
## Your Background Context
|
|
194
|
+
The following is conversation history from your previous work that is relevant to this discussion:
|
|
195
|
+
|
|
196
|
+
{context if context else "(No prior context provided)"}
|
|
197
|
+
|
|
198
|
+
## Discussion Guidelines
|
|
199
|
+
1. Build on what others have said - reference their points by name
|
|
200
|
+
2. Share insights from your background context when relevant
|
|
201
|
+
3. Be concise but thorough - aim for 2-4 paragraphs per response
|
|
202
|
+
4. If you disagree, explain your reasoning respectfully
|
|
203
|
+
5. Ask clarifying questions when needed
|
|
204
|
+
6. When the discussion seems complete, suggest concrete next steps or conclusions
|
|
205
|
+
7. Focus on analysis, architecture discussions, code review feedback, and sharing knowledge
|
|
206
|
+
8. Do NOT offer to implement anything - only discuss approaches and trade-offs
|
|
207
|
+
|
|
208
|
+
## Response Format
|
|
209
|
+
- Start your response with [{name}]:
|
|
210
|
+
- Write in a conversational but professional tone
|
|
211
|
+
- Focus on substance over pleasantries
|
|
212
|
+
"""
|
|
213
|
+
return prompt
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def emit_json(data: dict) -> None:
|
|
217
|
+
"""Emit a JSON line to stdout."""
|
|
218
|
+
print(json.dumps(data, ensure_ascii=False), flush=True)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def run_participant_agent(
|
|
222
|
+
participant_id: int,
|
|
223
|
+
participant_name: str,
|
|
224
|
+
participant_role: str,
|
|
225
|
+
room_topic: str,
|
|
226
|
+
context_text: str,
|
|
227
|
+
conversation_history: str,
|
|
228
|
+
cwd: Optional[str] = None,
|
|
229
|
+
mode: str = "speak",
|
|
230
|
+
preparation_notes: str = "",
|
|
231
|
+
meeting_type: Optional[str] = None,
|
|
232
|
+
custom_meeting_description: str = "",
|
|
233
|
+
language: str = "ja",
|
|
234
|
+
is_facilitator: bool = False,
|
|
235
|
+
) -> None:
|
|
236
|
+
"""
|
|
237
|
+
Run the participant agent to generate one response.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
participant_id: Database ID of the participant
|
|
241
|
+
participant_name: Display name of the participant
|
|
242
|
+
participant_role: Role description
|
|
243
|
+
room_topic: Discussion topic
|
|
244
|
+
context_text: Background context from ClaudeCode history
|
|
245
|
+
conversation_history: Current conversation history
|
|
246
|
+
cwd: Working directory for file operations
|
|
247
|
+
mode: "speak" for generating response, "prepare" for preparation
|
|
248
|
+
preparation_notes: Notes from preparation phase (if any)
|
|
249
|
+
meeting_type: Type of meeting (from MeetingType enum)
|
|
250
|
+
custom_meeting_description: Custom description for "other" meeting type
|
|
251
|
+
language: Language for the discussion (ja or en)
|
|
252
|
+
is_facilitator: Whether this participant is the facilitator
|
|
253
|
+
"""
|
|
254
|
+
# Ensure SDK is available
|
|
255
|
+
_ensure_claude_sdk()
|
|
256
|
+
|
|
257
|
+
logger.info(f"Starting participant agent: {participant_name} (mode={mode}, lang={language}, facilitator={is_facilitator})")
|
|
258
|
+
|
|
259
|
+
system_prompt = build_system_prompt(
|
|
260
|
+
name=participant_name,
|
|
261
|
+
role=participant_role,
|
|
262
|
+
context=context_text,
|
|
263
|
+
topic=room_topic,
|
|
264
|
+
mode=mode,
|
|
265
|
+
meeting_type=meeting_type,
|
|
266
|
+
custom_meeting_description=custom_meeting_description,
|
|
267
|
+
language=language,
|
|
268
|
+
is_facilitator=is_facilitator,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Build the prompt based on mode
|
|
272
|
+
if mode == "prepare":
|
|
273
|
+
prompt = f"""## Discussion Topic
|
|
274
|
+
{room_topic}
|
|
275
|
+
|
|
276
|
+
## Current Discussion (for context)
|
|
277
|
+
{conversation_history if conversation_history else "(Discussion not started yet)"}
|
|
278
|
+
|
|
279
|
+
Please analyze the codebase and prepare notes for your upcoming contribution to this discussion.
|
|
280
|
+
"""
|
|
281
|
+
else:
|
|
282
|
+
prompt = f"""## Current Discussion
|
|
283
|
+
{conversation_history}
|
|
284
|
+
|
|
285
|
+
"""
|
|
286
|
+
if preparation_notes:
|
|
287
|
+
prompt += f"""## Your Preparation Notes
|
|
288
|
+
{preparation_notes}
|
|
289
|
+
|
|
290
|
+
"""
|
|
291
|
+
prompt += f"""Please provide your response to continue the discussion. Remember to start with [{participant_name}]:"""
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
async with ClaudeSDKClient(
|
|
295
|
+
options=ClaudeAgentOptions(
|
|
296
|
+
model="claude-sonnet-4-20250514",
|
|
297
|
+
system_prompt=system_prompt,
|
|
298
|
+
max_turns=10, # Allow multiple tool uses within one response
|
|
299
|
+
allowed_tools=READ_ONLY_TOOLS,
|
|
300
|
+
permission_mode="bypassPermissions",
|
|
301
|
+
cwd=cwd,
|
|
302
|
+
)
|
|
303
|
+
) as client:
|
|
304
|
+
await client.query(prompt)
|
|
305
|
+
|
|
306
|
+
full_response = ""
|
|
307
|
+
async for msg in client.receive_response():
|
|
308
|
+
msg_type = type(msg).__name__
|
|
309
|
+
|
|
310
|
+
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
|
311
|
+
for block in msg.content:
|
|
312
|
+
block_type = type(block).__name__
|
|
313
|
+
|
|
314
|
+
if block_type == "TextBlock" and hasattr(block, "text"):
|
|
315
|
+
text = block.text
|
|
316
|
+
full_response += text
|
|
317
|
+
emit_json({"type": "text", "content": text})
|
|
318
|
+
|
|
319
|
+
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
|
320
|
+
tool_name = block.name
|
|
321
|
+
tool_input = getattr(block, "input", {})
|
|
322
|
+
emit_json({
|
|
323
|
+
"type": "tool_use",
|
|
324
|
+
"tool": tool_name,
|
|
325
|
+
"input": str(tool_input)[:200], # Truncate for display
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
|
329
|
+
# Tool results
|
|
330
|
+
for block in msg.content:
|
|
331
|
+
block_type = type(block).__name__
|
|
332
|
+
if block_type == "ToolResultBlock":
|
|
333
|
+
is_error = getattr(block, "is_error", False)
|
|
334
|
+
if is_error:
|
|
335
|
+
emit_json({"type": "tool_error", "error": "Tool execution failed"})
|
|
336
|
+
|
|
337
|
+
# Emit completion
|
|
338
|
+
emit_json({
|
|
339
|
+
"type": "response_complete",
|
|
340
|
+
"full_content": full_response,
|
|
341
|
+
"mode": mode,
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
except Exception as e:
|
|
345
|
+
logger.error(f"Error in participant agent: {e}")
|
|
346
|
+
emit_json({
|
|
347
|
+
"type": "error",
|
|
348
|
+
"content": str(e),
|
|
349
|
+
})
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def main():
|
|
354
|
+
parser = argparse.ArgumentParser(description="Participant agent for discussions")
|
|
355
|
+
parser.add_argument("--participant-id", type=int, required=True, help="Participant database ID")
|
|
356
|
+
parser.add_argument("--participant-name", required=True, help="Participant display name")
|
|
357
|
+
parser.add_argument("--participant-role", default="", help="Participant role")
|
|
358
|
+
parser.add_argument("--data-file", required=True, help="JSON file with context data")
|
|
359
|
+
parser.add_argument("--cwd", default=None, help="Working directory for file operations")
|
|
360
|
+
parser.add_argument("--mode", choices=["speak", "prepare"], default="speak", help="Agent mode")
|
|
361
|
+
parser.add_argument("--meeting-type", default=None, help="Meeting type (from MeetingType enum)")
|
|
362
|
+
parser.add_argument("--language", default="ja", help="Language for discussion (ja or en)")
|
|
363
|
+
parser.add_argument("--is-facilitator", action="store_true", help="Whether this is a facilitator")
|
|
364
|
+
|
|
365
|
+
args = parser.parse_args()
|
|
366
|
+
|
|
367
|
+
# Load context data from file
|
|
368
|
+
try:
|
|
369
|
+
with open(args.data_file, "r", encoding="utf-8") as f:
|
|
370
|
+
data = json.load(f)
|
|
371
|
+
except Exception as e:
|
|
372
|
+
emit_json({"type": "error", "content": f"Failed to load data file: {e}"})
|
|
373
|
+
sys.exit(1)
|
|
374
|
+
|
|
375
|
+
# Run the agent
|
|
376
|
+
asyncio.run(run_participant_agent(
|
|
377
|
+
participant_id=args.participant_id,
|
|
378
|
+
participant_name=args.participant_name,
|
|
379
|
+
participant_role=args.participant_role,
|
|
380
|
+
room_topic=data.get("room_topic", ""),
|
|
381
|
+
context_text=data.get("context_text", ""),
|
|
382
|
+
conversation_history=data.get("conversation_history", ""),
|
|
383
|
+
cwd=args.cwd,
|
|
384
|
+
mode=args.mode,
|
|
385
|
+
preparation_notes=data.get("preparation_notes", ""),
|
|
386
|
+
meeting_type=args.meeting_type or data.get("meeting_type"),
|
|
387
|
+
custom_meeting_description=data.get("custom_meeting_description", ""),
|
|
388
|
+
language=args.language or data.get("language", "ja"),
|
|
389
|
+
is_facilitator=args.is_facilitator or data.get("is_facilitator", False),
|
|
390
|
+
))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
if __name__ == "__main__":
|
|
394
|
+
main()
|
backend/websocket.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket Handler
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
WebSocket endpoint for real-time discussion updates.
|
|
6
|
+
Supports both serial and parallel discussion orchestration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Dict, List
|
|
13
|
+
|
|
14
|
+
from fastapi import WebSocket, WebSocketDisconnect
|
|
15
|
+
|
|
16
|
+
from .models.database import (
|
|
17
|
+
DiscussionRoom,
|
|
18
|
+
RoomStatus,
|
|
19
|
+
get_session_maker,
|
|
20
|
+
)
|
|
21
|
+
from .services.parallel_orchestrator import (
|
|
22
|
+
ParallelDiscussionOrchestrator,
|
|
23
|
+
get_parallel_orchestrator,
|
|
24
|
+
register_parallel_orchestrator,
|
|
25
|
+
unregister_parallel_orchestrator,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Active WebSocket connections per room
|
|
31
|
+
_connections: Dict[int, List[WebSocket]] = {}
|
|
32
|
+
|
|
33
|
+
# Type alias for orchestrator
|
|
34
|
+
Orchestrator = ParallelDiscussionOrchestrator
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def broadcast_to_room(room_id: int, message: dict):
|
|
38
|
+
"""Broadcast a message to all connected clients in a room."""
|
|
39
|
+
if room_id not in _connections:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
disconnected = []
|
|
43
|
+
for ws in _connections[room_id]:
|
|
44
|
+
try:
|
|
45
|
+
await ws.send_json(message)
|
|
46
|
+
except Exception:
|
|
47
|
+
disconnected.append(ws)
|
|
48
|
+
|
|
49
|
+
# Remove disconnected clients
|
|
50
|
+
for ws in disconnected:
|
|
51
|
+
if ws in _connections[room_id]:
|
|
52
|
+
_connections[room_id].remove(ws)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def room_websocket(websocket: WebSocket, room_id: int):
|
|
56
|
+
"""
|
|
57
|
+
WebSocket endpoint for a discussion room.
|
|
58
|
+
|
|
59
|
+
Handles:
|
|
60
|
+
- Connection management
|
|
61
|
+
- Starting discussions (with parallel preparation)
|
|
62
|
+
- Streaming updates to clients
|
|
63
|
+
- Moderator message injection
|
|
64
|
+
- Background activity notifications
|
|
65
|
+
"""
|
|
66
|
+
await websocket.accept()
|
|
67
|
+
|
|
68
|
+
# Get database session
|
|
69
|
+
SessionMaker = get_session_maker()
|
|
70
|
+
db = SessionMaker()
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
# Get room
|
|
74
|
+
room = db.query(DiscussionRoom).filter(
|
|
75
|
+
DiscussionRoom.id == room_id
|
|
76
|
+
).first()
|
|
77
|
+
|
|
78
|
+
if not room:
|
|
79
|
+
await websocket.send_json({
|
|
80
|
+
"type": "error",
|
|
81
|
+
"content": "Room not found"
|
|
82
|
+
})
|
|
83
|
+
await websocket.close()
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Register connection
|
|
87
|
+
if room_id not in _connections:
|
|
88
|
+
_connections[room_id] = []
|
|
89
|
+
_connections[room_id].append(websocket)
|
|
90
|
+
|
|
91
|
+
logger.info(f"WebSocket connected to room {room_id}")
|
|
92
|
+
|
|
93
|
+
# Send initial state
|
|
94
|
+
await websocket.send_json({
|
|
95
|
+
"type": "room_state",
|
|
96
|
+
"room_id": room_id,
|
|
97
|
+
"status": room.status.value,
|
|
98
|
+
"current_turn": room.current_turn,
|
|
99
|
+
"max_turns": room.max_turns,
|
|
100
|
+
"participants": [
|
|
101
|
+
{
|
|
102
|
+
"id": p.id,
|
|
103
|
+
"name": p.name,
|
|
104
|
+
"role": p.role,
|
|
105
|
+
"color": p.color,
|
|
106
|
+
"is_speaking": p.is_speaking,
|
|
107
|
+
}
|
|
108
|
+
for p in room.participants
|
|
109
|
+
],
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
# Discussion task (started manually via 'start' message)
|
|
113
|
+
discussion_task = None
|
|
114
|
+
|
|
115
|
+
# Keep connection alive and handle client messages
|
|
116
|
+
while True:
|
|
117
|
+
try:
|
|
118
|
+
data = await asyncio.wait_for(
|
|
119
|
+
websocket.receive_text(),
|
|
120
|
+
timeout=60.0
|
|
121
|
+
)
|
|
122
|
+
message = json.loads(data)
|
|
123
|
+
|
|
124
|
+
if message.get("type") == "ping":
|
|
125
|
+
await websocket.send_json({"type": "pong"})
|
|
126
|
+
|
|
127
|
+
elif message.get("type") == "start":
|
|
128
|
+
# Start discussion if not already running
|
|
129
|
+
if not get_parallel_orchestrator(room_id):
|
|
130
|
+
# Refresh room state
|
|
131
|
+
db.refresh(room)
|
|
132
|
+
if room.status in (RoomStatus.WAITING, RoomStatus.PAUSED):
|
|
133
|
+
discussion_task = asyncio.create_task(
|
|
134
|
+
run_discussion_with_broadcast(room_id, db)
|
|
135
|
+
)
|
|
136
|
+
await websocket.send_json({
|
|
137
|
+
"type": "discussion_starting"
|
|
138
|
+
})
|
|
139
|
+
else:
|
|
140
|
+
await websocket.send_json({
|
|
141
|
+
"type": "info",
|
|
142
|
+
"content": "Discussion already running"
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
elif message.get("type") == "pause":
|
|
146
|
+
orchestrator = get_parallel_orchestrator(room_id)
|
|
147
|
+
if orchestrator:
|
|
148
|
+
orchestrator.pause()
|
|
149
|
+
await broadcast_to_room(room_id, {
|
|
150
|
+
"type": "discussion_paused"
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
elif message.get("type") == "stop":
|
|
154
|
+
orchestrator = get_parallel_orchestrator(room_id)
|
|
155
|
+
if orchestrator:
|
|
156
|
+
orchestrator.stop()
|
|
157
|
+
|
|
158
|
+
elif message.get("type") == "moderate":
|
|
159
|
+
# Handle moderator message injection
|
|
160
|
+
content = message.get("content", "").strip()
|
|
161
|
+
if content:
|
|
162
|
+
from .models.database import DiscussionMessage
|
|
163
|
+
msg = DiscussionMessage(
|
|
164
|
+
room_id=room_id,
|
|
165
|
+
participant_id=None,
|
|
166
|
+
role="moderator",
|
|
167
|
+
content=content,
|
|
168
|
+
turn_number=room.current_turn,
|
|
169
|
+
)
|
|
170
|
+
db.add(msg)
|
|
171
|
+
db.commit()
|
|
172
|
+
db.refresh(msg)
|
|
173
|
+
|
|
174
|
+
await broadcast_to_room(room_id, {
|
|
175
|
+
"type": "moderator_message",
|
|
176
|
+
"message_id": msg.id,
|
|
177
|
+
"content": content,
|
|
178
|
+
"turn_number": msg.turn_number,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
except asyncio.TimeoutError:
|
|
182
|
+
# Send ping to keep connection alive
|
|
183
|
+
try:
|
|
184
|
+
await websocket.send_json({"type": "ping"})
|
|
185
|
+
except Exception:
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
except WebSocketDisconnect:
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
except json.JSONDecodeError:
|
|
192
|
+
await websocket.send_json({
|
|
193
|
+
"type": "error",
|
|
194
|
+
"content": "Invalid JSON"
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error(f"WebSocket error: {e}")
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.error(f"WebSocket handler error: {e}")
|
|
203
|
+
|
|
204
|
+
finally:
|
|
205
|
+
# Remove connection
|
|
206
|
+
if room_id in _connections:
|
|
207
|
+
_connections[room_id] = [
|
|
208
|
+
ws for ws in _connections[room_id] if ws != websocket
|
|
209
|
+
]
|
|
210
|
+
if not _connections[room_id]:
|
|
211
|
+
del _connections[room_id]
|
|
212
|
+
|
|
213
|
+
db.close()
|
|
214
|
+
logger.info(f"WebSocket disconnected from room {room_id}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def run_discussion_with_broadcast(room_id: int, _db=None):
|
|
218
|
+
"""
|
|
219
|
+
Run a discussion with parallel preparation and broadcast updates.
|
|
220
|
+
|
|
221
|
+
Uses ParallelDiscussionOrchestrator which allows participants to
|
|
222
|
+
prepare in the background while maintaining turn-based order.
|
|
223
|
+
|
|
224
|
+
Note: Creates its own database session to avoid session conflicts
|
|
225
|
+
with the WebSocket handler's session.
|
|
226
|
+
"""
|
|
227
|
+
logger.info(f"Starting discussion broadcast for room {room_id}")
|
|
228
|
+
|
|
229
|
+
# Create a fresh session for the discussion to avoid session conflicts
|
|
230
|
+
SessionMaker = get_session_maker()
|
|
231
|
+
db = SessionMaker()
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
# Get fresh room instance
|
|
235
|
+
room = db.query(DiscussionRoom).filter(
|
|
236
|
+
DiscussionRoom.id == room_id
|
|
237
|
+
).first()
|
|
238
|
+
|
|
239
|
+
if not room:
|
|
240
|
+
logger.error(f"Room {room_id} not found")
|
|
241
|
+
await broadcast_to_room(room_id, {
|
|
242
|
+
"type": "error",
|
|
243
|
+
"content": "Room not found"
|
|
244
|
+
})
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
logger.info(f"Found room: {room.name}, participants: {len(room.participants)}")
|
|
248
|
+
|
|
249
|
+
if not room.participants:
|
|
250
|
+
logger.error(f"Room {room_id} has no participants")
|
|
251
|
+
await broadcast_to_room(room_id, {
|
|
252
|
+
"type": "error",
|
|
253
|
+
"content": "No participants in room"
|
|
254
|
+
})
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# Use parallel orchestrator for background preparation support
|
|
258
|
+
orchestrator = ParallelDiscussionOrchestrator(room, db)
|
|
259
|
+
register_parallel_orchestrator(room_id, orchestrator)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
logger.info("Initializing participants...")
|
|
263
|
+
await orchestrator.initialize_participants()
|
|
264
|
+
logger.info("Participants initialized, starting discussion...")
|
|
265
|
+
|
|
266
|
+
async for event in orchestrator.run_discussion():
|
|
267
|
+
logger.debug(f"Broadcasting event: {event.get('type')}")
|
|
268
|
+
await broadcast_to_room(room_id, event)
|
|
269
|
+
|
|
270
|
+
logger.info("Discussion completed normally")
|
|
271
|
+
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.error(f"Discussion error: {e}", exc_info=True)
|
|
274
|
+
await broadcast_to_room(room_id, {
|
|
275
|
+
"type": "error",
|
|
276
|
+
"content": str(e)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
finally:
|
|
280
|
+
await orchestrator.cleanup()
|
|
281
|
+
unregister_parallel_orchestrator(room_id)
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.error(f"Unexpected error in run_discussion_with_broadcast: {e}", exc_info=True)
|
|
285
|
+
await broadcast_to_room(room_id, {
|
|
286
|
+
"type": "error",
|
|
287
|
+
"content": f"Unexpected error: {str(e)}"
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
finally:
|
|
291
|
+
db.close()
|
|
292
|
+
logger.info(f"Discussion broadcast ended for room {room_id}")
|