NeonClary Cursor commited on
Commit
0bb4dfa
·
1 Parent(s): c44fab6

Build CCAI Vibe Demo on top of LLMChats3 baseline

Browse files

Multi-participant Collaborative Conversational AI demo: the orchestrator
runs a six-phase state machine (Initial Opinions, Critique x2, Status
Assessment, Finalization, Consensus, Closure), produces a JSON
Credential Summary keyed on credibility-for-question, and routes
addressed-to messages through alliance detection. Two failsafes - 60+20
participant messages and 100+50 orchestrator calls - pause the loop
until the user clicks Continue. Every LLM response is funneled through
a centralized strip_thinking sanitizer before being stored, displayed,
or fed back to the summarizer.

Backend
- New: services/{models,prompts/*,credential,consensus,context_budget,
json_calls,extra_personas,orchestrator}.py, utils/sanitize.py,
api/personas.py, data/demo_questions.json (10 long-context prompts).
- Rewrote api/chat.py with /chat/start (N participants, summarizer +
max-participants overrides), /chat/{id}/continue, table-view +
csv-table exports.
- Rate limit bumped to 30/day per IP; HF org bypass kept.
- Sanitizer wired into both OpenAI-compat and HANA paths.

Frontend
- New: Header, ParticipantDropdown (Neon / Extra / Expert + Create...),
ParticipantSidebar (slider on/off, Remove when off, accordion),
ExpertPersonaModal (tabbed Structured/Freeform + role-style),
ChatTableView, OrchestratorMessage, FailsafePauseBanner, storage.js.
- DevMenu rewritten: orchestrator + summarizer pickers (summarizer
defaults to "Same as Orchestrator"), 3-9 max-participants stepper,
per-participant model assignment, table + CSV download buttons.
- localStorage namespace 'ccai-vibe-demo' for personas, selections,
enabled state, model assignments, theme, etc.
- Removed dead LLMSelector / PersonaAccordion / ExportBar.

Infra
- docker-compose.yml port 7860:7860 to match HF Space.
- README rewritten for CCAI architecture, secrets, and HF deployment.
- Pytest suite: 32 tests (sanitize, CSV escaping, tolerant JSON parser,
context-budget thresholds) - all passing.

Co-authored-by: Cursor <cursoragent@cursor.com>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +1 -0
  2. README.md +86 -16
  3. backend/app/api/chat.py +344 -67
  4. backend/app/api/personas.py +65 -0
  5. backend/app/clients/llm_router.py +7 -2
  6. backend/app/clients/openai_compat.py +7 -24
  7. backend/app/data/__init__.py +0 -0
  8. backend/app/data/demo_questions.json +65 -0
  9. backend/app/main.py +6 -5
  10. backend/app/middleware/rate_limit.py +5 -1
  11. backend/app/services/consensus.py +178 -0
  12. backend/app/services/context_budget.py +244 -0
  13. backend/app/services/credential.py +153 -0
  14. backend/app/services/extra_personas.py +128 -0
  15. backend/app/services/json_calls.py +145 -0
  16. backend/app/services/models.py +138 -0
  17. backend/app/services/orchestrator.py +983 -303
  18. backend/app/services/prompts/__init__.py +60 -0
  19. backend/app/services/prompts/closure.py +79 -0
  20. backend/app/services/prompts/consensus.py +112 -0
  21. backend/app/services/prompts/credential_summary.py +48 -0
  22. backend/app/services/prompts/critique.py +22 -0
  23. backend/app/services/prompts/directives.py +39 -0
  24. backend/app/services/prompts/finalization.py +22 -0
  25. backend/app/services/prompts/initial_opinions.py +24 -0
  26. backend/app/services/prompts/status_assessment.py +38 -0
  27. backend/app/utils/__init__.py +0 -0
  28. backend/app/utils/sanitize.py +81 -0
  29. backend/requirements.txt +2 -1
  30. backend/tests/__init__.py +0 -0
  31. backend/tests/test_context_budget.py +97 -0
  32. backend/tests/test_csv_export.py +98 -0
  33. backend/tests/test_json_calls.py +48 -0
  34. backend/tests/test_sanitize.py +60 -0
  35. docker-compose.yml +5 -2
  36. frontend/package-lock.json +0 -17
  37. frontend/src/App.js +357 -216
  38. frontend/src/components/AuthBadge.js +3 -2
  39. frontend/src/components/ChatArea.js +54 -18
  40. frontend/src/components/ChatControls.js +30 -21
  41. frontend/src/components/ChatTableView.js +72 -0
  42. frontend/src/components/DevMenu.js +180 -75
  43. frontend/src/components/ExpertPersonaModal.js +302 -0
  44. frontend/src/components/ExportBar.js +0 -79
  45. frontend/src/components/FailsafePauseBanner.js +27 -0
  46. frontend/src/components/Header.js +64 -0
  47. frontend/src/components/LLMSelector.js +0 -159
  48. frontend/src/components/MessageBubble.js +42 -9
  49. frontend/src/components/OrchestratorMessage.js +32 -0
  50. frontend/src/components/ParticipantDropdown.js +136 -0
.gitignore CHANGED
@@ -9,3 +9,4 @@ dist/
9
  .venv/
10
  venv/
11
  *.log
 
 
9
  .venv/
10
  venv/
11
  *.log
12
+ .commit-msg.txt
README.md CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
- title: AI Conversations
3
- emoji: 💬
4
  colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
@@ -11,23 +11,72 @@ hf_oauth_scopes:
11
  pinned: false
12
  ---
13
 
14
- # AI Conversations (LLMChats3)
15
 
16
- A web app that lets two LLMs have a natural conversation. Select two LLMs, configure their personas, and watch them chat — complete with an orchestrator that manages natural conversation endings.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  ## Quick Start (local development)
19
 
20
  ```bash
21
- # 1. Clone and set up environment
22
  cp .env.example .env
23
  # Edit .env with your API keys
24
 
25
- # 2. Backend
26
  cd backend
27
  pip install -r requirements.txt
28
  uvicorn app.main:app --reload --port 8000
29
 
30
- # 3. Frontend (in a separate terminal)
31
  cd frontend
32
  npm install
33
  npm start
@@ -39,20 +88,41 @@ npm start
39
  cp .env.example .env
40
  # Edit .env with your API keys
41
  docker compose up --build
 
42
  ```
43
 
44
  ## HuggingFace Spaces Deployment
45
 
46
- This app is deployed as a Docker Space at [neongeckocom/AI_Conversations](https://huggingface.co/spaces/neongeckocom/AI_Conversations). API keys are stored as Space Secrets.
 
 
47
 
48
- Rate limiting: 20 conversations/day per IP for anonymous users. Sign in with HuggingFace as a neongeckocom org member for unlimited access.
 
 
 
 
 
 
 
49
 
50
  ## Features
51
 
52
- - Select any two LLMs from multiple providers (OpenAI, Gemini, Fireworks, Together, Neon)
53
- - Configure rich personas with names, profiles, identity prompts, and writing samples
54
- - Structured or freeform persona input modes with file upload support
55
- - Watch LLMs converse naturally with an orchestrator managing conversation flow
56
- - Automatic conversation ending detection with graceful wrap-up
57
- - Export chats as .txt or .md, plus full API logs for developers
58
- - HuggingFace OAuth integration with org-based rate limiting
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: CCAI Vibe Demo
3
+ emoji: 🤝
4
  colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
 
11
  pinned: false
12
  ---
13
 
14
+ # CCAI Vibe Demo
15
 
16
+ A demo of **Collaborative Conversational AI (CCAI)** - Neon.ai's patented
17
+ group-discussion technology. Up to 9 participants (any mix of AI personas,
18
+ human-defined "expert" personas, or - in the future - real humans, agents,
19
+ tools, or sensors) hold a structured group conversation facilitated by a
20
+ neutral **Orchestrator**, with the goal of reaching a real group decision
21
+ the way a thoughtful human meeting would.
22
+
23
+ This repo descends from
24
+ [NeonClary/LLMChats3](https://github.com/NeonClary/LLMChats3) and reuses
25
+ its color scheme, branding, settings menu structure, and chat formatting
26
+ verbatim. The CCAI multi-participant orchestration, expert-persona modal,
27
+ participant sidebar, and table view are layered on top.
28
+
29
+ ## Architecture (one-pager)
30
+
31
+ - **Frontend** (React 19, react-markdown, lucide-react) - lives in
32
+ `frontend/`. Talks SSE to the backend.
33
+ - **Backend** (FastAPI, httpx) - lives in `backend/`. Routes:
34
+ - `GET /api/personas` — Neon HANA personas (vanilla/RAG filtered),
35
+ bundled extra personas, and (echoed) expert personas.
36
+ - `GET /api/demo-questions` — the bank of 10 long-context demo prompts.
37
+ - `POST /api/chat/start` — kicks off a CCAI session and returns SSE.
38
+ - `POST /api/chat/{id}/continue?reason=…` — resumes a paused session.
39
+ - `GET /api/chat/{id}/export?fmt=txt|md|csv-table` — exports.
40
+ - `GET /api/chat/{id}/table` — JSON for the table view.
41
+ - **Orchestrator state machine** — six phases (Initial Opinions,
42
+ Critique x2, Status Assessment, Finalization, Consensus, Closure)
43
+ with two failsafes (60+20 messages, 100+50 orchestrator calls) and
44
+ per-participant on-demand context summarization.
45
+
46
+ ## CCAI Phase Overview
47
+
48
+ 1. **Initial Opinions.** Each participant offers an independent first
49
+ opinion. The orchestrator builds a per-participant **Credential
50
+ Summary** in the background.
51
+ 2. **Critique x 2.** Each participant gets two turns to critique
52
+ others, ask follow-ups, and revise. After Phase 2 the Credential
53
+ Summary is refreshed.
54
+ 3. **Status Assessment.** The orchestrator either proceeds or runs
55
+ targeted follow-ups (max 3 iterations).
56
+ 4. **Finalization.** Each participant either revises their own opinion
57
+ or endorses another's.
58
+ 5. **Consensus Gathering.** Allied participants advocate, solo
59
+ participants seek allies / switch / propose compromises. The
60
+ orchestrator routes addressed-to messages.
61
+ 6. **Closure.** Majority-report (with weighted dissent), or
62
+ unaddressed-factor probe + retry, or no-consensus report.
63
+
64
+ Thinking traces (`<think>`, `<reasoning>`, etc.) are stripped from every
65
+ LLM response in `backend/app/utils/sanitize.py` before being stored,
66
+ displayed, or fed back to the orchestrator/summarizer/Credential builder.
67
 
68
  ## Quick Start (local development)
69
 
70
  ```bash
 
71
  cp .env.example .env
72
  # Edit .env with your API keys
73
 
74
+ # Backend
75
  cd backend
76
  pip install -r requirements.txt
77
  uvicorn app.main:app --reload --port 8000
78
 
79
+ # Frontend (separate terminal)
80
  cd frontend
81
  npm install
82
  npm start
 
88
  cp .env.example .env
89
  # Edit .env with your API keys
90
  docker compose up --build
91
+ # Open http://localhost:7860
92
  ```
93
 
94
  ## HuggingFace Spaces Deployment
95
 
96
+ Deployed as a Docker Space (`app_port: 7860`).
97
+
98
+ Required Space Secrets:
99
 
100
+ - `HANA_USERNAME`, `HANA_PASSWORD` Neon HANA credentials.
101
+ - `HANA_KLATCHAT_PASSWORD` (optional) — BrainForge/Security vLLM access.
102
+ - Provider keys: `OPENAI_API_KEY`, `GEMINI_API_KEY`, `FIREWORKS_API_KEY`,
103
+ `TOGETHER_API_KEY`, `MISTRAL_API_KEY`.
104
+ - `HF_RATE_LIMIT_DAILY=30` — daily per-IP cap (defaults to 30; org
105
+ members are unlimited).
106
+ - `HF_RATE_LIMIT_ORG=neongeckocom` — bypass org name.
107
+ - `SESSION_SECRET` — cookie session secret for OAuth.
108
 
109
  ## Features
110
 
111
+ - **Participant dropdown** in the header with three sections (Neon /
112
+ Extra / Expert) and a `Create Expert Persona...` shortcut.
113
+ - **Participant sidebar** with on/off slider per participant; flipping
114
+ off does not deselect, and a `Remove` button appears for actually
115
+ dropping someone from the conversation.
116
+ - **Settings menu** with searchable orchestrator-model and summarizer-
117
+ model pickers (summarizer defaults to "Same as Orchestrator"),
118
+ a 3-9 max-participants stepper, per-participant model overrides, and
119
+ the same display-options + downloads structure as LLMChats3.
120
+ - **Two failsafes** with explicit Continue buttons (60+20 messages,
121
+ 100+50 orchestrator calls).
122
+ - **Exports**: `.txt`, `.md`, RFC-4180 `.csv` table, JSON API log.
123
+ - **Table view** of the whole conversation with per-participant first /
124
+ contribution / revised / final columns.
125
+ - **localStorage persistence** for expert personas, participant
126
+ selection, on/off state, model assignments, orchestrator/summarizer
127
+ picks, and max-participants.
128
+ - **HuggingFace OAuth** with `neongeckocom` org bypass.
backend/app/api/chat.py CHANGED
@@ -1,17 +1,41 @@
 
 
 
1
  from __future__ import annotations
2
 
 
 
3
  import json
4
  import logging
5
  import time
6
 
7
  from fastapi import APIRouter, HTTPException, Request
8
- from fastapi.responses import StreamingResponse, JSONResponse
9
- from pydantic import BaseModel
10
 
11
  from app.config import settings
12
- from app.services.persona import generate_role_prompt, generate_role_prompt_freeform
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  from app.services.orchestrator import (
14
- Session, Persona, create_session, get_session, run_conversation,
 
 
 
 
 
 
15
  )
16
 
17
  router = APIRouter()
@@ -46,20 +70,43 @@ class SetSpeedPriorityRequest(BaseModel):
46
  enabled: bool
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  class StartChatRequest(BaseModel):
50
- persona_a_model_id: str
51
- persona_a_name: str
52
- persona_a_role: str
53
 
54
- persona_b_model_id: str
55
- persona_b_name: str
56
- persona_b_role: str
57
 
58
- starter_text: str | None = None
 
 
59
 
60
 
61
  # ---------------------------------------------------------------------------
62
- # Endpoints
63
  # ---------------------------------------------------------------------------
64
 
65
  @router.get("/chat/orchestrator")
@@ -84,6 +131,10 @@ async def api_set_speed_priority(req: SetSpeedPriorityRequest):
84
  return {"enabled": settings.speed_priority}
85
 
86
 
 
 
 
 
87
  @router.post("/chat/generate-role")
88
  async def api_generate_role(req: GenerateRoleRequest):
89
  result = await generate_role_prompt(
@@ -112,68 +163,195 @@ async def api_generate_role_freeform(req: GenerateRoleFreeformRequest):
112
  return result
113
 
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  @router.post("/chat/start")
116
  async def api_start_chat(req: StartChatRequest, request: Request):
117
  """Create a session and return a streaming SSE response for the conversation."""
118
- from app.middleware.rate_limit import check_rate_limit, record_conversation
 
119
 
120
- allowed, remaining = check_rate_limit(request)
121
  if not allowed:
122
  return JSONResponse(
123
  status_code=429,
124
  content={
125
- "detail": "Daily conversation limit reached (20/day). Sign in with HuggingFace as a neongeckocom org member for unlimited access.",
 
 
 
 
126
  "remaining": 0,
127
  },
128
  )
129
- record_conversation(request)
130
 
131
- ra = settings.resolve_model(req.persona_a_model_id)
132
- rb = settings.resolve_model(req.persona_b_model_id)
 
 
 
 
 
 
 
133
 
134
- if not ra:
135
- raise HTTPException(400, f"Unknown model: {req.persona_a_model_id}")
136
- if not rb:
137
- raise HTTPException(400, f"Unknown model: {req.persona_b_model_id}")
 
138
 
139
  session = create_session()
140
- session.persona_a = Persona(
141
- name=req.persona_a_name or "Persona A",
142
- model_id=ra["model_id"],
143
- role_prompt=req.persona_a_role,
144
- base_url=ra.get("base_url", ""),
145
- api_key=ra.get("api_key", ""),
146
- display_name=ra["display_name"],
147
- is_neon=ra.get("is_neon", False),
148
- hana_model_id=ra.get("hana_model_id", ""),
149
- persona_name=ra.get("persona_name", ""),
150
- neon_direct_vllm=ra.get("neon_direct_vllm", False),
151
- vllm_base_url=ra.get("vllm_base_url", ""),
152
- vllm_api_key=ra.get("vllm_api_key", ""),
153
- )
154
- session.persona_b = Persona(
155
- name=req.persona_b_name or "Persona B",
156
- model_id=rb["model_id"],
157
- role_prompt=req.persona_b_role,
158
- base_url=rb.get("base_url", ""),
159
- api_key=rb.get("api_key", ""),
160
- display_name=rb["display_name"],
161
- is_neon=rb.get("is_neon", False),
162
- hana_model_id=rb.get("hana_model_id", ""),
163
- persona_name=rb.get("persona_name", ""),
164
- neon_direct_vllm=rb.get("neon_direct_vllm", False),
165
- vllm_base_url=rb.get("vllm_base_url", ""),
166
- vllm_api_key=rb.get("vllm_api_key", ""),
167
- )
168
 
169
  async def event_stream():
170
- yield f"event: session\ndata: {json.dumps({'session_id': session.session_id})}\n\n"
171
- async for chunk in run_conversation(session, req.starter_text):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  yield chunk
173
 
174
  return StreamingResponse(event_stream(), media_type="text/event-stream")
175
 
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  @router.get("/chat/{session_id}/export")
178
  async def api_export_chat(session_id: str, fmt: str = "txt"):
179
  session = get_session(session_id)
@@ -182,6 +360,8 @@ async def api_export_chat(session_id: str, fmt: str = "txt"):
182
 
183
  if fmt == "md":
184
  return _export_md(session)
 
 
185
  return _export_txt(session)
186
 
187
 
@@ -190,38 +370,135 @@ async def api_export_log(session_id: str):
190
  session = get_session(session_id)
191
  if not session:
192
  raise HTTPException(404, "Session not found")
 
 
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  return {
195
  "session_id": session_id,
196
- "log": session.api_log,
 
 
 
197
  }
198
 
199
 
 
 
 
 
 
 
 
 
 
 
 
200
  # ---------------------------------------------------------------------------
201
  # Export helpers
202
  # ---------------------------------------------------------------------------
203
 
 
 
 
 
 
 
 
204
  def _export_txt(session: Session) -> dict:
205
- lines = [f"LLMChats3 Conversation Log", "=" * 40, ""]
206
- if session.persona_a:
207
- lines.append(f"Participant 1: {session.persona_a.name} ({session.persona_a.display_name})")
208
- if session.persona_b:
209
- lines.append(f"Participant 2: {session.persona_b.name} ({session.persona_b.display_name})")
 
210
  lines.append("")
211
  for m in session.messages:
212
- lines.append(f"{m['speaker']}: {m['text']}")
 
 
 
213
  lines.append("")
214
- return {"filename": "chat_export.txt", "content": "\n".join(lines)}
 
 
 
 
215
 
216
 
217
  def _export_md(session: Session) -> dict:
218
- lines = ["# LLMChats3 Conversation Log", ""]
219
- if session.persona_a:
220
- lines.append(f"**Participant 1:** {session.persona_a.name} (*{session.persona_a.display_name}*)")
221
- if session.persona_b:
222
- lines.append(f"**Participant 2:** {session.persona_b.name} (*{session.persona_b.display_name}*)")
 
 
 
 
223
  lines.append("\n---\n")
224
  for m in session.messages:
225
- lines.append(f"**{m['speaker']}:** {m['text']}")
 
 
 
 
 
 
 
 
 
 
 
 
226
  lines.append("")
227
- return {"filename": "chat_export.md", "content": "\n".join(lines)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat API: start a CCAI conversation, stream SSE, drive failsafe-pause
2
+ continues, and export results.
3
+ """
4
  from __future__ import annotations
5
 
6
+ import csv
7
+ import io
8
  import json
9
  import logging
10
  import time
11
 
12
  from fastapi import APIRouter, HTTPException, Request
13
+ from fastapi.responses import JSONResponse, StreamingResponse
14
+ from pydantic import BaseModel, Field
15
 
16
  from app.config import settings
17
+ from app.middleware.rate_limit import (
18
+ DAILY_LIMIT,
19
+ check_rate_limit,
20
+ record_conversation,
21
+ )
22
+ from app.services.extra_personas import get_extra_persona
23
+ from app.services.models import (
24
+ DEFAULT_MAX_PARTICIPANTS,
25
+ MAX_MAX_PARTICIPANTS,
26
+ MIN_MAX_PARTICIPANTS,
27
+ Participant,
28
+ Phase,
29
+ Session,
30
+ )
31
  from app.services.orchestrator import (
32
+ create_session,
33
+ get_session,
34
+ run_conversation,
35
+ )
36
+ from app.services.persona import (
37
+ generate_role_prompt,
38
+ generate_role_prompt_freeform,
39
  )
40
 
41
  router = APIRouter()
 
70
  enabled: bool
71
 
72
 
73
+ class ExpertPersonaPayload(BaseModel):
74
+ """Expert Persona created by the user via the popup. Already carries a
75
+ finished `role_prompt` (the frontend calls /generate-role-* for that)."""
76
+
77
+ participant_id: str
78
+ name: str
79
+ model_id: str
80
+ role_prompt: str
81
+
82
+
83
+ class ParticipantSelectionPayload(BaseModel):
84
+ """Reference to a participant the user has chosen for this conversation."""
85
+
86
+ participant_id: str
87
+ kind: str # "neon" | "extra" | "expert"
88
+ # For Neon entries: the model_id IS the persona id (neon:model@ver:persona)
89
+ # For extra/expert: defaults to the persona's bound model_id, but the
90
+ # user can override via per-participant model_assignments.
91
+ name: str
92
+ role_prompt: str | None = None
93
+ model_id_override: str | None = None
94
+
95
+
96
  class StartChatRequest(BaseModel):
97
+ question: str | None = None
 
 
98
 
99
+ participants: list[ParticipantSelectionPayload]
100
+ expert_personas: list[ExpertPersonaPayload] = Field(default_factory=list)
101
+ model_assignments: dict[str, str] = Field(default_factory=dict)
102
 
103
+ orchestrator_model_id: str | None = None
104
+ summarizer_model_id: str | None = None
105
+ max_participants: int = DEFAULT_MAX_PARTICIPANTS
106
 
107
 
108
  # ---------------------------------------------------------------------------
109
+ # Settings endpoints (orchestrator default + speed priority)
110
  # ---------------------------------------------------------------------------
111
 
112
  @router.get("/chat/orchestrator")
 
131
  return {"enabled": settings.speed_priority}
132
 
133
 
134
+ # ---------------------------------------------------------------------------
135
+ # Role-prompt generation (used by the Expert Persona modal)
136
+ # ---------------------------------------------------------------------------
137
+
138
  @router.post("/chat/generate-role")
139
  async def api_generate_role(req: GenerateRoleRequest):
140
  result = await generate_role_prompt(
 
163
  return result
164
 
165
 
166
+ # ---------------------------------------------------------------------------
167
+ # Demo questions
168
+ # ---------------------------------------------------------------------------
169
+
170
+ @router.get("/demo-questions")
171
+ async def api_demo_questions():
172
+ from pathlib import Path
173
+
174
+ path = Path(__file__).resolve().parent.parent / "data" / "demo_questions.json"
175
+ with open(path, "r", encoding="utf-8") as f:
176
+ data = json.load(f)
177
+ return data
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Start chat
182
+ # ---------------------------------------------------------------------------
183
+
184
+ def _build_participant(
185
+ sel: ParticipantSelectionPayload,
186
+ expert_lookup: dict[str, ExpertPersonaPayload],
187
+ model_assignments: dict[str, str],
188
+ ) -> Participant:
189
+ """Resolve a selection payload into a runnable Participant.
190
+
191
+ Resolution order for the model:
192
+ 1. Explicit per-conversation override in `model_assignments`
193
+ 2. The persona's selection-time `model_id_override`
194
+ 3. The bundled extra persona's default model
195
+ 4. For Neon participants, the model_id is the participant_id itself
196
+ 5. For Expert personas, the persona's bound model_id
197
+
198
+ Resolution order for the role_prompt:
199
+ 1. The selection's role_prompt (most flexible)
200
+ 2. The matching expert persona's role_prompt
201
+ 3. The bundled extra persona's role_prompt
202
+ 4. For Neon participants: a thin role wrapper just naming the persona
203
+ """
204
+ pid = sel.participant_id
205
+ kind = sel.kind
206
+ name = sel.name
207
+
208
+ role_prompt = sel.role_prompt or ""
209
+ model_id = sel.model_id_override or model_assignments.get(pid, "")
210
+
211
+ if kind == "expert":
212
+ ep = expert_lookup.get(pid)
213
+ if ep is None:
214
+ raise HTTPException(400, f"Expert persona payload missing for id {pid}")
215
+ if not role_prompt:
216
+ role_prompt = ep.role_prompt
217
+ if not model_id:
218
+ model_id = ep.model_id
219
+ if not name:
220
+ name = ep.name
221
+ elif kind == "extra":
222
+ ep = get_extra_persona(pid)
223
+ if ep is None:
224
+ raise HTTPException(400, f"Unknown extra persona: {pid}")
225
+ if not role_prompt:
226
+ role_prompt = ep.role_prompt
227
+ if not model_id:
228
+ model_id = ep.default_model_id
229
+ if not name:
230
+ name = ep.name
231
+ elif kind == "neon":
232
+ # The participant_id IS the model id for Neon personas, so it's
233
+ # required to be a `neon:model@ver:persona` style string.
234
+ if not pid.startswith("neon:"):
235
+ raise HTTPException(
236
+ 400, f"Neon participant_id must start with 'neon:': {pid}",
237
+ )
238
+ if not model_id:
239
+ model_id = pid
240
+ if not role_prompt:
241
+ role_prompt = (
242
+ f"You are {name}, a Neon.ai persona. Speak naturally in your "
243
+ "own voice and bring the perspective your background suggests."
244
+ )
245
+ else:
246
+ raise HTTPException(400, f"Unknown participant kind: {kind}")
247
+
248
+ resolved = settings.resolve_model(model_id)
249
+ if not resolved:
250
+ raise HTTPException(400, f"Unknown model: {model_id}")
251
+
252
+ return Participant(
253
+ participant_id=pid,
254
+ name=name,
255
+ role_prompt=role_prompt,
256
+ model_id=resolved["model_id"],
257
+ kind=kind,
258
+ enabled=True,
259
+ base_url=resolved.get("base_url", ""),
260
+ api_key=resolved.get("api_key", ""),
261
+ display_name=resolved.get("display_name", model_id),
262
+ is_neon=resolved.get("is_neon", False),
263
+ hana_model_id=resolved.get("hana_model_id", ""),
264
+ persona_name=resolved.get("persona_name", ""),
265
+ neon_direct_vllm=resolved.get("neon_direct_vllm", False),
266
+ vllm_base_url=resolved.get("vllm_base_url", ""),
267
+ vllm_api_key=resolved.get("vllm_api_key", ""),
268
+ )
269
+
270
+
271
  @router.post("/chat/start")
272
  async def api_start_chat(req: StartChatRequest, request: Request):
273
  """Create a session and return a streaming SSE response for the conversation."""
274
+ if not req.question or not req.question.strip():
275
+ raise HTTPException(400, "Question is required")
276
 
277
+ allowed, _ = check_rate_limit(request)
278
  if not allowed:
279
  return JSONResponse(
280
  status_code=429,
281
  content={
282
+ "detail": (
283
+ f"Daily conversation limit reached ({DAILY_LIMIT}/day). "
284
+ "Sign in with HuggingFace as a neongeckocom org member "
285
+ "for unlimited access."
286
+ ),
287
  "remaining": 0,
288
  },
289
  )
 
290
 
291
+ expert_lookup = {ep.participant_id: ep for ep in req.expert_personas}
292
+
293
+ max_p = max(MIN_MAX_PARTICIPANTS, min(MAX_MAX_PARTICIPANTS, req.max_participants))
294
+ if len(req.participants) < 2:
295
+ raise HTTPException(400, "Need at least 2 participants")
296
+ if len(req.participants) > max_p:
297
+ raise HTTPException(
298
+ 400, f"Got {len(req.participants)} participants but max is {max_p}",
299
+ )
300
 
301
+ participants: list[Participant] = []
302
+ for sel in req.participants:
303
+ participants.append(_build_participant(sel, expert_lookup, req.model_assignments))
304
+
305
+ record_conversation(request)
306
 
307
  session = create_session()
308
+ session.question = req.question.strip()
309
+ session.participants = participants
310
+ session.orchestrator_model_id = req.orchestrator_model_id
311
+ session.summarizer_model_id = req.summarizer_model_id
312
+ session.max_participants = max_p
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
  async def event_stream():
315
+ yield (
316
+ "event: session\ndata: "
317
+ + json.dumps({
318
+ "session_id": session.session_id,
319
+ "participants": [
320
+ {
321
+ "participant_id": p.participant_id,
322
+ "name": p.name,
323
+ "model_id": p.model_id,
324
+ "model_display": p.display_name,
325
+ "kind": p.kind,
326
+ } for p in session.participants
327
+ ],
328
+ "max_participants": session.max_participants,
329
+ "orchestrator_model_id": session.orchestrator_model_id or settings.orchestrator_model,
330
+ "summarizer_model_id": session.summarizer_model_id,
331
+ })
332
+ + "\n\n"
333
+ )
334
+ async for chunk in run_conversation(session):
335
  yield chunk
336
 
337
  return StreamingResponse(event_stream(), media_type="text/event-stream")
338
 
339
 
340
+ @router.post("/chat/{session_id}/continue")
341
+ async def api_continue(session_id: str, reason: str = "messages"):
342
+ session = get_session(session_id)
343
+ if not session:
344
+ raise HTTPException(404, "Session not found")
345
+ if not session.paused_for_continue:
346
+ raise HTTPException(409, "Session is not paused")
347
+ session.pending_continue = True
348
+ return {"ok": True, "reason": reason}
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Exports
353
+ # ---------------------------------------------------------------------------
354
+
355
  @router.get("/chat/{session_id}/export")
356
  async def api_export_chat(session_id: str, fmt: str = "txt"):
357
  session = get_session(session_id)
 
360
 
361
  if fmt == "md":
362
  return _export_md(session)
363
+ if fmt == "csv-table":
364
+ return _export_csv_table(session)
365
  return _export_txt(session)
366
 
367
 
 
370
  session = get_session(session_id)
371
  if not session:
372
  raise HTTPException(404, "Session not found")
373
+ return {"session_id": session_id, "log": session.api_log}
374
+
375
 
376
+ @router.get("/chat/{session_id}/table")
377
+ async def api_table_view(session_id: str):
378
+ session = get_session(session_id)
379
+ if not session:
380
+ raise HTTPException(404, "Session not found")
381
+
382
+ rows = []
383
+ for p in session.participants:
384
+ first = (session.initial_opinions or {}).get(p.participant_id, "")
385
+ contribution = (session.contribution_summaries or {}).get(p.participant_id, "")
386
+ revised = (session.final_opinions or {}).get(p.participant_id, "")
387
+ final_msg = _last_consensus_message_for(session, p.participant_id) or revised
388
+ rows.append({
389
+ "participant_id": p.participant_id,
390
+ "name": p.name,
391
+ "model_display": p.display_name,
392
+ "first_opinion": first,
393
+ "contribution_summary": contribution,
394
+ "revised_opinion": revised,
395
+ "final_opinion": final_msg,
396
+ })
397
+ final_report = (session.final_report or {}).get("text", "")
398
  return {
399
  "session_id": session_id,
400
+ "question": session.question,
401
+ "final_report": final_report,
402
+ "final_report_kind": (session.final_report or {}).get("kind", ""),
403
+ "rows": rows,
404
  }
405
 
406
 
407
+ def _last_consensus_message_for(session: Session, participant_id: str) -> str:
408
+ """Return the participant's most recent message in the consensus or
409
+ finalization phase - used as the 'final opinion' column."""
410
+ for m in reversed(session.messages):
411
+ if m.get("speaker_id") != participant_id:
412
+ continue
413
+ if m.get("phase") in {Phase.CONSENSUS.value, Phase.FINALIZATION.value}:
414
+ return m.get("text", "")
415
+ return ""
416
+
417
+
418
  # ---------------------------------------------------------------------------
419
  # Export helpers
420
  # ---------------------------------------------------------------------------
421
 
422
+ def _format_participants_block(session: Session) -> list[str]:
423
+ return [
424
+ f"- {p.name} ({p.display_name})"
425
+ for p in session.participants
426
+ ]
427
+
428
+
429
  def _export_txt(session: Session) -> dict:
430
+ lines = ["CCAI Conversation Log", "=" * 40, ""]
431
+ lines.append("Question:")
432
+ lines.append(session.question)
433
+ lines.append("")
434
+ lines.append("Participants:")
435
+ lines.extend(_format_participants_block(session))
436
  lines.append("")
437
  for m in session.messages:
438
+ speaker = m.get("speaker_name") or "(anon)"
439
+ if m.get("role") == "orchestrator":
440
+ speaker = "Orchestrator"
441
+ lines.append(f"{speaker}: {m.get('text', '')}")
442
  lines.append("")
443
+ if session.final_report and session.final_report.get("text"):
444
+ lines.append("---")
445
+ lines.append("Final Report:")
446
+ lines.append(session.final_report["text"])
447
+ return {"filename": "ccai_chat.txt", "content": "\n".join(lines)}
448
 
449
 
450
  def _export_md(session: Session) -> dict:
451
+ lines = ["# CCAI Conversation Log", ""]
452
+ lines.append("## Question")
453
+ lines.append("")
454
+ lines.append(f"> {session.question}")
455
+ lines.append("")
456
+ lines.append("## Participants")
457
+ lines.append("")
458
+ for p in session.participants:
459
+ lines.append(f"- **{p.name}** (*{p.display_name}*)")
460
  lines.append("\n---\n")
461
  for m in session.messages:
462
+ speaker = m.get("speaker_name") or "(anon)"
463
+ is_orch = m.get("role") == "orchestrator"
464
+ if is_orch:
465
+ speaker = "Orchestrator"
466
+ text = m.get("text", "")
467
+ if is_orch:
468
+ lines.append(f"_**{speaker}:**_ {text}")
469
+ else:
470
+ lines.append(f"**{speaker}:** {text}")
471
+ lines.append("")
472
+ if session.final_report and session.final_report.get("text"):
473
+ lines.append("\n---\n")
474
+ lines.append("## Final Report")
475
  lines.append("")
476
+ lines.append(session.final_report["text"])
477
+ return {"filename": "ccai_chat.md", "content": "\n".join(lines)}
478
+
479
+
480
+ def _export_csv_table(session: Session) -> dict:
481
+ """RFC-4180 compliant CSV. csv.writer handles quoting/escaping."""
482
+ buf = io.StringIO()
483
+ writer = csv.writer(buf, quoting=csv.QUOTE_MINIMAL, lineterminator="\n")
484
+
485
+ writer.writerow(["Question", session.question])
486
+ final_text = (session.final_report or {}).get("text", "")
487
+ writer.writerow(["Final Group Opinion", final_text])
488
+ writer.writerow([])
489
+ writer.writerow([
490
+ "Participant",
491
+ "First opinion",
492
+ "Conversation contribution",
493
+ "Revised opinion",
494
+ "Final opinion",
495
+ ])
496
+ for p in session.participants:
497
+ writer.writerow([
498
+ p.name,
499
+ (session.initial_opinions or {}).get(p.participant_id, ""),
500
+ (session.contribution_summaries or {}).get(p.participant_id, ""),
501
+ (session.final_opinions or {}).get(p.participant_id, ""),
502
+ _last_consensus_message_for(session, p.participant_id),
503
+ ])
504
+ return {"filename": "ccai_chat_table.csv", "content": buf.getvalue()}
backend/app/api/personas.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Participant catalog API.
2
+
3
+ `GET /api/personas` returns three sections - Neon HANA personas (with
4
+ vanilla/RAG personas filtered out), the four bundled extra personas, and
5
+ the user-supplied expert personas (which are local-only on the frontend
6
+ but echoed here for completeness so the API can act as the source of
7
+ truth for participant choices when needed).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ from fastapi import APIRouter
14
+ from fastapi.responses import JSONResponse
15
+
16
+ from app.clients.hana_client import hana_client
17
+ from app.services.extra_personas import list_extra_personas
18
+
19
+ router = APIRouter()
20
+ LOG = logging.getLogger(__name__)
21
+
22
+
23
+ def _is_vanilla_or_rag(persona_name: str) -> bool:
24
+ pn = (persona_name or "").lower()
25
+ return "vanilla" in pn or "rag" in pn
26
+
27
+
28
+ @router.get("/personas")
29
+ async def get_personas():
30
+ """Return the participant catalog the frontend dropdown shows."""
31
+ try:
32
+ neon_models = await hana_client.get_models()
33
+ except Exception as exc:
34
+ LOG.warning("HANA models unavailable: %s", exc)
35
+ neon_models = []
36
+
37
+ neon_personas = []
38
+ for nm in neon_models or []:
39
+ for p in nm.get("personas", []) or []:
40
+ if p.get("enabled") is False:
41
+ continue
42
+ persona_name = p.get("persona_name") or ""
43
+ if _is_vanilla_or_rag(persona_name):
44
+ continue
45
+ participant_id = f"neon:{nm['model_id']}:{persona_name}"
46
+ neon_personas.append({
47
+ "participant_id": participant_id,
48
+ "kind": "neon",
49
+ "name": persona_name,
50
+ "model_display": f"Neon / {nm['name'].split('/')[-1]}",
51
+ "default_model_id": participant_id,
52
+ "description": p.get("description") or "",
53
+ })
54
+
55
+ extras = list_extra_personas()
56
+ for e in extras:
57
+ e["model_display"] = e["default_model_id"]
58
+
59
+ return JSONResponse(
60
+ content={
61
+ "neon": neon_personas,
62
+ "extra": extras,
63
+ },
64
+ headers={"Cache-Control": "no-store"},
65
+ )
backend/app/clients/llm_router.py CHANGED
@@ -6,6 +6,7 @@ from typing import Any
6
 
7
  from app.clients.openai_compat import openai_chat_completion
8
  from app.clients.hana_client import hana_client
 
9
 
10
  LOG = logging.getLogger(__name__)
11
 
@@ -166,7 +167,7 @@ async def _call_neon_direct_vllm(
166
  max_tokens=max_tokens,
167
  )
168
  return {
169
- "response": result.get("response", ""),
170
  "elapsed_seconds": result.get("elapsed_seconds", 0),
171
  "model": resolved["model_id"],
172
  }
@@ -210,8 +211,12 @@ async def _call_hana(
210
  temperature=temperature,
211
  max_tokens=max_tokens,
212
  )
 
 
 
 
213
  return {
214
- "response": result.get("response", ""),
215
  "elapsed_seconds": result.get("elapsed_seconds", 0),
216
  "model": resolved["model_id"],
217
  }
 
6
 
7
  from app.clients.openai_compat import openai_chat_completion
8
  from app.clients.hana_client import hana_client
9
+ from app.utils.sanitize import strip_thinking, response_has_thinking
10
 
11
  LOG = logging.getLogger(__name__)
12
 
 
167
  max_tokens=max_tokens,
168
  )
169
  return {
170
+ "response": strip_thinking(result.get("response", "")),
171
  "elapsed_seconds": result.get("elapsed_seconds", 0),
172
  "model": resolved["model_id"],
173
  }
 
211
  temperature=temperature,
212
  max_tokens=max_tokens,
213
  )
214
+ raw = result.get("response", "")
215
+ cleaned = strip_thinking(raw)
216
+ if response_has_thinking(raw):
217
+ LOG.info("Stripped thinking content from HANA %s response", resolved["model_id"])
218
  return {
219
+ "response": cleaned,
220
  "elapsed_seconds": result.get("elapsed_seconds", 0),
221
  "model": resolved["model_id"],
222
  }
backend/app/clients/openai_compat.py CHANGED
@@ -2,22 +2,17 @@ from __future__ import annotations
2
 
3
  import asyncio
4
  import logging
5
- import re
6
  import time
7
  from typing import Any
8
 
9
  import httpx
10
 
 
 
11
  LOG = logging.getLogger(__name__)
12
 
13
  _shared_client: httpx.AsyncClient | None = None
14
 
15
- _THINK_TAG_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
16
- _REASONING_BLOCK_RE = re.compile(
17
- r"<(reasoning|reflection|inner_thoughts|scratchpad)>.*?</\1>",
18
- re.DOTALL,
19
- )
20
-
21
  _MAX_COMPLETION_TOKEN_MODELS = {
22
  "o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini",
23
  "gpt-5", "gpt-oss",
@@ -32,21 +27,9 @@ def _get_client() -> httpx.AsyncClient:
32
  return _shared_client
33
 
34
 
35
- def _strip_thinking(text: str) -> str:
36
- """Remove chain-of-thought artifacts from any model's response."""
37
- text = _THINK_TAG_RE.sub("", text)
38
- text = _REASONING_BLOCK_RE.sub("", text)
39
- return text.strip()
40
-
41
-
42
- def _detect_thinking_model(model: str, msg: dict) -> bool:
43
- """Detect thinking models from the response itself, not just the model name."""
44
- if msg.get("reasoning_content") or msg.get("reasoning"):
45
- return True
46
- content = msg.get("content") or ""
47
- if _THINK_TAG_RE.search(content):
48
- return True
49
- return False
50
 
51
 
52
  async def openai_chat_completion(
@@ -102,8 +85,8 @@ async def openai_chat_completion(
102
  msg = choices[0].get("message") or {}
103
  text = msg.get("content") or ""
104
  finish_reason = choices[0].get("finish_reason") or ""
105
- had_thinking = _detect_thinking_model(model, msg)
106
- text = _strip_thinking(text)
107
 
108
  if had_thinking:
109
  LOG.info("Stripped thinking content from %s response", model)
 
2
 
3
  import asyncio
4
  import logging
 
5
  import time
6
  from typing import Any
7
 
8
  import httpx
9
 
10
+ from app.utils.sanitize import strip_thinking, response_has_thinking
11
+
12
  LOG = logging.getLogger(__name__)
13
 
14
  _shared_client: httpx.AsyncClient | None = None
15
 
 
 
 
 
 
 
16
  _MAX_COMPLETION_TOKEN_MODELS = {
17
  "o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini",
18
  "gpt-5", "gpt-oss",
 
27
  return _shared_client
28
 
29
 
30
+ # Thinking-trace detection and stripping live in app.utils.sanitize so every
31
+ # code path (HANA, vLLM-direct, OpenAI-compat, summarizer inputs, credential
32
+ # inputs) uses the same logic. See backend/app/utils/sanitize.py.
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
 
35
  async def openai_chat_completion(
 
85
  msg = choices[0].get("message") or {}
86
  text = msg.get("content") or ""
87
  finish_reason = choices[0].get("finish_reason") or ""
88
+ had_thinking = response_has_thinking(text, msg)
89
+ text = strip_thinking(text)
90
 
91
  if had_thinking:
92
  LOG.info("Stripped thinking content from %s response", model)
backend/app/data/__init__.py ADDED
File without changes
backend/app/data/demo_questions.json ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": 1,
3
+ "questions": [
4
+ {
5
+ "id": "undergrad_majors",
6
+ "category": "education",
7
+ "title": "Best undergraduate majors for career prospects",
8
+ "text": "What are the three best majors for my undergraduate degree? I live in Washington State, USA, and I am starting college in the fall. My biggest priority is to make sure I have good job prospects when I graduate in 2031. I love using AI and exploring new technology, math is one of my strongest subjects, I don't want to travel for my job, and I don't want to have to publish papers as part of my job. I like to talk to people, and to travel. I know a lot about finance because my parents are both accountants. I also love animals."
9
+ },
10
+ {
11
+ "id": "small_business_ai",
12
+ "category": "small business",
13
+ "title": "Should a small bakery introduce AI?",
14
+ "text": "Should I introduce AI tools into my small bakery, and if so, where should I start? I run a single-storefront bakery in a tourist town in Vermont with three full-time employees and four part-timers. We do counter sales, weekend wholesale to two coffee shops, and a small custom-cake business. I'm 54, comfortable with tech but not a developer. My priorities, in order: keep the staff feeling valued, hold the food quality steady, save myself maybe 5-10 hours a week of paperwork, and slowly grow custom-cake revenue. I have about $3,000 a year I could redirect into software or services without straining the books."
15
+ },
16
+ {
17
+ "id": "rural_water",
18
+ "category": "public policy",
19
+ "title": "Best fix for a small town's failing water system",
20
+ "text": "Our rural town in central Pennsylvania has roughly 1,800 residents and a 1960s-era municipal water system that has had three boil-water advisories in the last two years. We have a small budget and an aging volunteer water board. The state has offered a 50% matching grant but only if we commit to a project plan within nine months. The three options on the table are: (a) replace the existing main lines incrementally over twelve years, (b) consolidate with a neighboring town's larger system, losing local control but gaining redundancy, or (c) install a smaller modern treatment plant of our own with recurring maintenance contracts. Residents are split, and the median household income is around $52,000."
21
+ },
22
+ {
23
+ "id": "elder_parent",
24
+ "category": "family",
25
+ "title": "Aging parent who wants to stay in their home",
26
+ "text": "My 82-year-old father lives alone in the rural Midwest after losing my mother last year. He's mentally sharp, drives short distances, and absolutely insists on staying in the house they built together. In the last six months he's had two minor falls (no fractures), forgot to take his blood pressure medication twice, and started to lose weight. My sister and I both live three to four hours away with full-time jobs and our own kids. He has roughly $90,000 in savings beyond Social Security and the house is paid off. We're trying to figure out the right next step that respects his autonomy without ignoring the warning signs."
27
+ },
28
+ {
29
+ "id": "ai_in_classroom",
30
+ "category": "education / technology ethics",
31
+ "title": "AI policy for a public middle school",
32
+ "text": "I'm a principal at a public middle school in Texas, grades 6-8, 480 students. Many of my teachers are quietly using ChatGPT and similar tools to plan lessons and draft feedback. About a third of students are using AI for homework, sometimes well, sometimes to skip thinking entirely. The school board wants me to publish a formal AI policy by the end of the semester. I have to balance student learning, equity (not all students have the same access at home), staff workload, parental concerns about screen time and privacy, and our district's tight budget. Should I lean toward restricting AI, embracing it, or some structured middle path - and what would the most important rules be?"
33
+ },
34
+ {
35
+ "id": "career_pivot_30s",
36
+ "category": "career",
37
+ "title": "Pivot from journalism to tech in your 30s",
38
+ "text": "I'm 34, a print journalist for the past 11 years at a regional paper that just announced a 30% layoff. I'm probably going to be cut. I have an undergraduate degree in English, no formal coding experience but I've been an enthusiastic Python tinkerer for two years. My wife is a public school teacher; we have one toddler and a small mortgage in a mid-sized U.S. city. We have about six months of savings. I'm considering three paths: (a) try to land another reporter job and write fiction on the side, (b) do a 9-12 month immersive transition into a junior product/data role at a smaller tech company, or (c) start a paid local newsletter business solo. I want a stable enough income, but I'd also like work that uses my voice and isn't soul-crushing."
39
+ },
40
+ {
41
+ "id": "climate_homestead",
42
+ "category": "environment / personal finance",
43
+ "title": "Buying property knowing the climate is shifting",
44
+ "text": "My partner and I are looking to buy a 5-15 acre property within a four-hour drive of Denver. We want a small homestead - some chickens, a vegetable garden, room for two large dogs - and a place we can live in long term. Our budget is around $550,000 including a modest house. We're worried about climate exposure: wildfire smoke, water-rights pressure on small wells, longer droughts, and how much insurance is going to cost ten years from now. We're also worried about being too far from a hospital. Neither of us has farming experience but we're hands-on. How do we think about which properties to look at, what to actually optimize for, and what we should be willing to compromise on?"
45
+ },
46
+ {
47
+ "id": "open_source_strategy",
48
+ "category": "tech / business strategy",
49
+ "title": "Open-source vs proprietary for a 12-person AI startup",
50
+ "text": "I'm CTO of a 12-person AI startup that's about to release our first real product: a domain-specific reasoning agent for legal-contract review. We've raised a Series A. The founding team is split on whether to release our core inference stack as open source under a permissive license. The arguments for: faster developer adoption, recruiting, brand-building, the ecosystem moves fast and we'll be left behind otherwise. The arguments against: a well-funded competitor could fork us, our investors are nervous, and our actual moat is the domain training data, not the inference code. I'd like to land on a defensible decision in the next six weeks. What framework should we use, and what's the right answer for a company in roughly our position?"
51
+ },
52
+ {
53
+ "id": "personal_health_diet",
54
+ "category": "health",
55
+ "title": "Diet overhaul with conflicting advice",
56
+ "text": "I'm 41, somewhat overweight, with borderline-high blood pressure and slightly elevated LDL cholesterol. My doctor wants me to lose roughly 30 pounds and lower my blood pressure without yet starting medication. I have done short stints with several diets over the years - low carb, intermittent fasting, Mediterranean, and a vegan stretch - and lost weight on each before regaining it. I have a desk job, two school-age kids, and I cook most of our meals. I'm not interested in supplements with weak evidence, but I am willing to commit to a structured plan if it's actually likely to work for someone like me long-term. What should the plan look like, and what trade-offs am I implicitly making by picking one?"
57
+ },
58
+ {
59
+ "id": "early_retirement",
60
+ "category": "personal finance",
61
+ "title": "FIRE-curious couple weighing semi-retirement at 50",
62
+ "text": "My spouse and I are both 50, two kids in college, a paid-off house in Minnesota, and about $1.6M in retirement and brokerage accounts (~80% in low-cost index funds). Our combined gross income is ~$240k/year and we save aggressively. We're not unhappy at our jobs but we're also not in love with them. We're debating whether to keep working at full pace through 60, semi-retire next year by both moving to ~25 hours/week each (income halves), or one of us fully retires and the other continues full time. We expect health-care costs to be the wildest variable. None of us has a strong intuition about how a 30-40 year retirement actually plays out financially or psychologically."
63
+ }
64
+ ]
65
+ }
backend/app/main.py CHANGED
@@ -14,10 +14,10 @@ from starlette.middleware.sessions import SessionMiddleware
14
  from app.config import settings
15
  from app.clients.hana_client import hana_client
16
  from app.clients.openai_compat import close_shared_client
17
- from app.api import models, chat
18
  from app.middleware.rate_limit import (
19
- get_oauth_username, is_org_member, get_remaining, check_rate_limit,
20
- record_conversation,
21
  )
22
 
23
  logging.basicConfig(level=logging.INFO)
@@ -41,7 +41,7 @@ async def lifespan(app: FastAPI):
41
  await close_shared_client()
42
 
43
 
44
- app = FastAPI(title="AI Conversations", version="1.0.0", lifespan=lifespan)
45
 
46
  app.add_middleware(
47
  SessionMiddleware,
@@ -66,6 +66,7 @@ except Exception as exc:
66
 
67
  app.include_router(models.router, prefix="/api")
68
  app.include_router(chat.router, prefix="/api")
 
69
 
70
 
71
  @app.get("/api/health")
@@ -97,7 +98,7 @@ async def auth_status(request: Request):
97
  @app.get("/api/rate-limit/status")
98
  async def rate_limit_status(request: Request):
99
  remaining = get_remaining(request)
100
- return {"remaining": remaining, "daily_limit": 20}
101
 
102
 
103
  if STATIC_DIR.is_dir():
 
14
  from app.config import settings
15
  from app.clients.hana_client import hana_client
16
  from app.clients.openai_compat import close_shared_client
17
+ from app.api import models, chat, personas
18
  from app.middleware.rate_limit import (
19
+ DAILY_LIMIT, get_oauth_username, is_org_member, get_remaining,
20
+ check_rate_limit, record_conversation,
21
  )
22
 
23
  logging.basicConfig(level=logging.INFO)
 
41
  await close_shared_client()
42
 
43
 
44
+ app = FastAPI(title="CCAI Vibe Demo", version="1.0.0", lifespan=lifespan)
45
 
46
  app.add_middleware(
47
  SessionMiddleware,
 
66
 
67
  app.include_router(models.router, prefix="/api")
68
  app.include_router(chat.router, prefix="/api")
69
+ app.include_router(personas.router, prefix="/api")
70
 
71
 
72
  @app.get("/api/health")
 
98
  @app.get("/api/rate-limit/status")
99
  async def rate_limit_status(request: Request):
100
  remaining = get_remaining(request)
101
+ return {"remaining": remaining, "daily_limit": DAILY_LIMIT}
102
 
103
 
104
  if STATIC_DIR.is_dir():
backend/app/middleware/rate_limit.py CHANGED
@@ -10,7 +10,11 @@ from fastapi import Request
10
  LOG = logging.getLogger(__name__)
11
 
12
  ORG_NAME = os.getenv("HF_RATE_LIMIT_ORG", "neongeckocom")
13
- DAILY_LIMIT = int(os.getenv("HF_RATE_LIMIT_DAILY", "20"))
 
 
 
 
14
 
15
  _ip_counts: dict[str, dict] = defaultdict(lambda: {"date": "", "count": 0})
16
 
 
10
  LOG = logging.getLogger(__name__)
11
 
12
  ORG_NAME = os.getenv("HF_RATE_LIMIT_ORG", "neongeckocom")
13
+ # CCAI demo bumps the per-IP daily cap from LLMChats3's 20 to 30 to match
14
+ # the heavier multi-participant conversation pattern (the orchestrator-call
15
+ # backstop and the participant-message failsafe handle per-conversation
16
+ # cost).
17
+ DAILY_LIMIT = int(os.getenv("HF_RATE_LIMIT_DAILY", "30"))
18
 
19
  _ip_counts: dict[str, dict] = defaultdict(lambda: {"date": "", "count": 0})
20
 
backend/app/services/consensus.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase-5 consensus helpers: alliance detection, addressed-to
2
+ classification, status-checks, and unaddressed-factor probing.
3
+
4
+ All four are short JSON-shaped orchestrator calls layered on top of
5
+ `json_calls.orchestrator_call`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ from app.services.json_calls import orchestrator_call
13
+ from app.services.prompts import (
14
+ ALLIANCE_DETECTION_PROMPT,
15
+ ADDRESSED_TO_PROMPT,
16
+ CONSENSUS_STATUS_PROMPT,
17
+ UNADDRESSED_FACTOR_PROMPT,
18
+ )
19
+
20
+ LOG = logging.getLogger(__name__)
21
+
22
+
23
+ def _format_finalization_block(
24
+ participants: list[Any],
25
+ final_opinions: dict[str, str],
26
+ ) -> str:
27
+ lines: list[str] = []
28
+ for p in participants:
29
+ text = final_opinions.get(p.participant_id, "(no final opinion)").strip()
30
+ lines.append(f"--- {p.name} (id={p.participant_id}) ---")
31
+ lines.append(text)
32
+ lines.append("")
33
+ return "\n".join(lines).strip()
34
+
35
+
36
+ def _format_roster_block(participants: list[Any]) -> str:
37
+ return "\n".join(
38
+ f"- id: {p.participant_id} | name: {p.name}" for p in participants
39
+ )
40
+
41
+
42
+ def _format_alliance_block(groups: list[dict[str, Any]]) -> str:
43
+ lines: list[str] = []
44
+ for i, g in enumerate(groups):
45
+ members = ", ".join(g.get("members") or [])
46
+ lines.append(f"Group {i}: stance=\"{g.get('stance', '')}\" members=[{members}]")
47
+ return "\n".join(lines)
48
+
49
+
50
+ async def detect_alliances(
51
+ *,
52
+ orchestrator_model_id: str,
53
+ question: str,
54
+ participants: list[Any],
55
+ final_opinions: dict[str, str],
56
+ api_log: list[dict[str, Any]] | None = None,
57
+ ) -> list[dict[str, Any]]:
58
+ prompt = ALLIANCE_DETECTION_PROMPT.format(
59
+ question=question,
60
+ finalization_block=_format_finalization_block(participants, final_opinions),
61
+ )
62
+ _raw, parsed = await orchestrator_call(
63
+ orchestrator_model_id=orchestrator_model_id,
64
+ user_prompt=prompt,
65
+ label="alliances",
66
+ api_log=api_log,
67
+ max_tokens=1024,
68
+ )
69
+ if isinstance(parsed, dict) and isinstance(parsed.get("groups"), list):
70
+ groups = parsed["groups"]
71
+ return _normalize_groups(groups, participants)
72
+
73
+ # Fallback: every participant in their own group.
74
+ return [
75
+ {"stance": "(unclassified)", "members": [p.participant_id]}
76
+ for p in participants
77
+ ]
78
+
79
+
80
+ def _normalize_groups(
81
+ groups: list[dict[str, Any]],
82
+ participants: list[Any],
83
+ ) -> list[dict[str, Any]]:
84
+ """Make sure every participant id appears in exactly one group."""
85
+ valid_ids = {p.participant_id for p in participants}
86
+ seen: set[str] = set()
87
+ out: list[dict[str, Any]] = []
88
+ for g in groups:
89
+ members = [m for m in (g.get("members") or []) if m in valid_ids and m not in seen]
90
+ seen.update(members)
91
+ if members:
92
+ out.append({
93
+ "stance": g.get("stance", "(unspecified)"),
94
+ "members": members,
95
+ })
96
+ leftovers = [pid for pid in valid_ids if pid not in seen]
97
+ for pid in leftovers:
98
+ out.append({"stance": "(unclassified)", "members": [pid]})
99
+ return out
100
+
101
+
102
+ async def classify_addressed_to(
103
+ *,
104
+ orchestrator_model_id: str,
105
+ participants: list[Any],
106
+ speaker_name: str,
107
+ message: str,
108
+ api_log: list[dict[str, Any]] | None = None,
109
+ ) -> str | None:
110
+ prompt = ADDRESSED_TO_PROMPT.format(
111
+ roster_block=_format_roster_block(participants),
112
+ speaker=speaker_name,
113
+ message=message,
114
+ )
115
+ _raw, parsed = await orchestrator_call(
116
+ orchestrator_model_id=orchestrator_model_id,
117
+ user_prompt=prompt,
118
+ label="addressed_to",
119
+ api_log=api_log,
120
+ max_tokens=128,
121
+ )
122
+ if isinstance(parsed, dict):
123
+ target = parsed.get("addressed_to")
124
+ if target and any(p.participant_id == target for p in participants):
125
+ return target
126
+ return None
127
+
128
+
129
+ async def assess_consensus_status(
130
+ *,
131
+ orchestrator_model_id: str,
132
+ question: str,
133
+ transcript: str,
134
+ alliance_groups: list[dict[str, Any]],
135
+ api_log: list[dict[str, Any]] | None = None,
136
+ ) -> dict[str, Any]:
137
+ prompt = CONSENSUS_STATUS_PROMPT.format(
138
+ question=question,
139
+ transcript=transcript,
140
+ alliance_block=_format_alliance_block(alliance_groups),
141
+ )
142
+ _raw, parsed = await orchestrator_call(
143
+ orchestrator_model_id=orchestrator_model_id,
144
+ user_prompt=prompt,
145
+ label="consensus_status",
146
+ api_log=api_log,
147
+ max_tokens=256,
148
+ )
149
+ if isinstance(parsed, dict) and parsed.get("status") in {"majority", "productive", "unproductive"}:
150
+ return parsed
151
+ # Default: treat as productive so we keep iterating, but give it a
152
+ # bounded number of attempts via the orchestrator-call cap.
153
+ return {"status": "productive", "majority_group_index": None, "rationale": ""}
154
+
155
+
156
+ async def find_unaddressed_factor(
157
+ *,
158
+ orchestrator_model_id: str,
159
+ question: str,
160
+ credential_summary_block: str,
161
+ transcript: str,
162
+ api_log: list[dict[str, Any]] | None = None,
163
+ ) -> dict[str, Any] | None:
164
+ prompt = UNADDRESSED_FACTOR_PROMPT.format(
165
+ question=question,
166
+ credential_summary=credential_summary_block,
167
+ transcript=transcript,
168
+ )
169
+ _raw, parsed = await orchestrator_call(
170
+ orchestrator_model_id=orchestrator_model_id,
171
+ user_prompt=prompt,
172
+ label="unaddressed_factor",
173
+ api_log=api_log,
174
+ max_tokens=512,
175
+ )
176
+ if isinstance(parsed, dict) and parsed.get("factor"):
177
+ return parsed
178
+ return None
backend/app/services/context_budget.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-participant context budgeting with on-demand summarization.
2
+
3
+ Ported from the Ask-A-Neon-LLM-Demos AskJerry pattern: estimate input
4
+ tokens with chars/4, trigger a background summarize at 55% of the model's
5
+ input budget, and once a summary exists trim history aggressively at
6
+ 70%. The summarizer model defaults to whichever model is selected as the
7
+ Orchestrator (so changing one auto-changes the other) and is overridable
8
+ in the settings menu.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from app.clients.llm_router import chat_completion
17
+ from app.config import settings
18
+ from app.utils.sanitize import strip_thinking
19
+
20
+ LOG = logging.getLogger(__name__)
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Per-model context windows (input + output tokens)
24
+ # ---------------------------------------------------------------------------
25
+ #
26
+ # Lookup precedence: exact model_id match -> prefix match -> fallback.
27
+ # Numbers are deliberately conservative (real windows often advertise a
28
+ # bigger absolute max but degrade well before that).
29
+ DEFAULT_CONTEXT = 8_192
30
+
31
+ EXACT_CONTEXT: dict[str, int] = {
32
+ "gpt-5.4": 200_000,
33
+ "gpt-4.1": 128_000,
34
+ "gpt-4.1-mini": 128_000,
35
+ "gpt-4o": 128_000,
36
+ "gpt-4o-mini": 128_000,
37
+ "o4-mini": 128_000,
38
+ "gemini-2.0-flash": 1_000_000,
39
+ "gemini-2.5-flash": 1_000_000,
40
+ "gemini-2.5-pro": 1_000_000,
41
+ "mistral-small-2506": 131_000,
42
+ "mistral-small-2603": 131_000,
43
+ "devstral-2512": 131_000,
44
+ "meta-llama/Llama-3.3-70B-Instruct-Turbo": 128_000,
45
+ "meta-llama/Meta-Llama-3-8B-Instruct-Lite": 8_192,
46
+ "Qwen/Qwen3-VL-8B-Instruct": 32_000,
47
+ }
48
+
49
+ PREFIX_CONTEXT: list[tuple[str, int]] = [
50
+ ("accounts/fireworks/models/kimi-", 256_000),
51
+ ("accounts/fireworks/models/deepseek-", 128_000),
52
+ ("accounts/fireworks/models/gpt-oss-", 128_000),
53
+ ("openai/gpt-oss-", 128_000),
54
+ ]
55
+
56
+
57
+ def context_window_for(model_id: str) -> int:
58
+ """Return the configured input+output token window for a model.
59
+
60
+ BrainForge / unknown Neon models fall back to DEFAULT_CONTEXT (8K).
61
+ """
62
+ if model_id in EXACT_CONTEXT:
63
+ return EXACT_CONTEXT[model_id]
64
+ for prefix, window in PREFIX_CONTEXT:
65
+ if model_id.startswith(prefix):
66
+ return window
67
+ if model_id.startswith("neon:"):
68
+ return DEFAULT_CONTEXT
69
+ return DEFAULT_CONTEXT
70
+
71
+
72
+ # Reserve at least this many tokens for the model's reply.
73
+ DEFAULT_REPLY_BUDGET = 2_048
74
+
75
+ # Trigger a summarize when input estimate >= SUMMARIZE_THRESHOLD * input_budget.
76
+ SUMMARIZE_THRESHOLD = 0.55
77
+ # When a summary exists and history still over-fills, trim to last K rounds.
78
+ TRIM_THRESHOLD = 0.70
79
+ # How many of the most recent messages to keep when trimming.
80
+ KEEP_RECENT_MESSAGES = 6
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Per-participant summary state
85
+ # ---------------------------------------------------------------------------
86
+
87
+ @dataclass
88
+ class ContextSummary:
89
+ """Running summary for a single participant.
90
+
91
+ `summary_text` is the latest condensed summary; `summarized_through_idx`
92
+ is the index of the last message included in that summary so we don't
93
+ re-summarize old history every turn.
94
+ """
95
+
96
+ summary_text: str = ""
97
+ summarized_through_idx: int = -1
98
+ last_estimate: int = 0
99
+
100
+ def is_active(self) -> bool:
101
+ return bool(self.summary_text.strip())
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Token estimator (chars/4, no real tokenizer)
106
+ # ---------------------------------------------------------------------------
107
+
108
+ def _estimate_str_tokens(text: str | None) -> int:
109
+ if not text:
110
+ return 1
111
+ return max(1, len(text) // 4)
112
+
113
+
114
+ def estimate_messages_tokens(messages: list[dict[str, Any]]) -> int:
115
+ total = 0
116
+ for m in messages:
117
+ total += _estimate_str_tokens(m.get("content"))
118
+ total += 4 # per-message framing overhead
119
+ return total
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Decision: does this participant need a summarize/trim?
124
+ # ---------------------------------------------------------------------------
125
+
126
+ def should_summarize(
127
+ model_id: str,
128
+ api_messages: list[dict[str, Any]],
129
+ summary: ContextSummary,
130
+ ) -> tuple[bool, bool, int]:
131
+ """Return (should_summarize, should_trim, input_budget).
132
+
133
+ `should_summarize` is True when raw input tokens >= 55% of the input
134
+ budget. `should_trim` is True when the budget is so tight (>= 70%)
135
+ that we should drop older messages and rely on the running summary.
136
+ """
137
+ window = context_window_for(model_id)
138
+ input_budget = max(2_048, window - DEFAULT_REPLY_BUDGET)
139
+ est = estimate_messages_tokens(api_messages)
140
+ summary.last_estimate = est
141
+ return (
142
+ est >= input_budget * SUMMARIZE_THRESHOLD,
143
+ est >= input_budget * TRIM_THRESHOLD and summary.is_active(),
144
+ input_budget,
145
+ )
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Build the actual outbound message list for a participant turn
150
+ # ---------------------------------------------------------------------------
151
+
152
+ def build_compressed_messages(
153
+ api_messages: list[dict[str, Any]],
154
+ summary: ContextSummary,
155
+ needs_trim: bool,
156
+ ) -> list[dict[str, Any]]:
157
+ """If we need to trim, replace older messages with a system-summary message.
158
+
159
+ The first message is assumed to be the system prompt for the participant
160
+ and is always preserved. Every other message older than the last
161
+ KEEP_RECENT_MESSAGES is dropped in favor of the running summary.
162
+ """
163
+ if not needs_trim or not api_messages:
164
+ return api_messages
165
+
166
+ head = api_messages[:1] # original system prompt
167
+ tail = api_messages[-KEEP_RECENT_MESSAGES:]
168
+ summary_msg = {
169
+ "role": "system",
170
+ "content": (
171
+ "Summary of earlier discussion (auto-condensed for context):\n"
172
+ + summary.summary_text
173
+ ),
174
+ }
175
+ return head + [summary_msg] + tail
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Run a summarize call against the configured summarizer model
180
+ # ---------------------------------------------------------------------------
181
+
182
+ SUMMARIZER_SYSTEM_PROMPT = (
183
+ "You are a concise discussion summarizer. Condense the following multi-"
184
+ "participant conversation into a tight summary that preserves: who said "
185
+ "what (by name), the key positions taken, agreements and disagreements, "
186
+ "any open questions, and the overall direction. Keep the summary under "
187
+ "300 words. Write in third-person narrative. Do not editorialize, vote, "
188
+ "or take a side. Output only the summary text — no preamble, no "
189
+ "reasoning, no meta-commentary."
190
+ )
191
+
192
+
193
+ async def run_summarize(
194
+ summarizer_model_id: str,
195
+ transcript_text: str,
196
+ timeout: float = 30.0,
197
+ ) -> str:
198
+ """Call the summarizer model on a plain-text transcript and return the summary.
199
+
200
+ Empty / failed summaries return an empty string so callers can fall back
201
+ gracefully.
202
+ """
203
+ if not transcript_text.strip():
204
+ return ""
205
+
206
+ resolved = settings.resolve_model(summarizer_model_id)
207
+ if not resolved:
208
+ LOG.warning("Summarizer model %s not resolvable, skipping summarize", summarizer_model_id)
209
+ return ""
210
+
211
+ messages = [
212
+ {"role": "system", "content": SUMMARIZER_SYSTEM_PROMPT},
213
+ {"role": "user", "content": transcript_text},
214
+ ]
215
+ result = await chat_completion(
216
+ resolved=resolved,
217
+ messages=messages,
218
+ temperature=0.2,
219
+ max_tokens=512,
220
+ timeout=timeout,
221
+ )
222
+ if result.get("error"):
223
+ LOG.warning("Summarizer call failed: %s", result.get("response"))
224
+ return ""
225
+ # Defense-in-depth: even if a summarizer model emitted reasoning,
226
+ # never let it leak into participant context.
227
+ return strip_thinking(result.get("response", ""))
228
+
229
+
230
+ def select_summarizer_model_id(
231
+ summarizer_override: str | None,
232
+ orchestrator_model_id: str | None,
233
+ ) -> str:
234
+ """Resolve the summarizer model id to use, with the rule from the plan:
235
+
236
+ - explicit override wins
237
+ - else fall back to whatever model is selected as the Orchestrator
238
+ - else fall back to the global settings default
239
+ """
240
+ if summarizer_override:
241
+ return summarizer_override
242
+ if orchestrator_model_id:
243
+ return orchestrator_model_id
244
+ return settings.orchestrator_model
backend/app/services/credential.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Credential Summary builder + refresher.
2
+
3
+ The Credential Summary is a JSON dict (participant_id -> assessment)
4
+ threaded into every later participant turn. It is built once after Phase
5
+ 1 and refreshed once after Phase 2 critique.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from typing import Any
12
+
13
+ from app.services.json_calls import orchestrator_call
14
+ from app.services.prompts import (
15
+ CREDENTIAL_BUILD_PROMPT,
16
+ CREDENTIAL_REFRESH_PROMPT,
17
+ )
18
+ from app.utils.sanitize import strip_thinking
19
+
20
+ LOG = logging.getLogger(__name__)
21
+
22
+
23
+ def _format_participants_block(
24
+ participants: list[Any],
25
+ initial_opinions: dict[str, str],
26
+ ) -> str:
27
+ """Render one block per participant containing role prompt + first opinion."""
28
+ lines: list[str] = []
29
+ for p in participants:
30
+ opinion = strip_thinking(initial_opinions.get(p.participant_id, ""))
31
+ lines.append(f"--- Participant id: {p.participant_id} ---")
32
+ lines.append(f"Name: {p.name}")
33
+ lines.append(f"Role prompt: {p.role_prompt}")
34
+ lines.append(f"First opinion: {opinion}")
35
+ lines.append("")
36
+ return "\n".join(lines).strip()
37
+
38
+
39
+ def credentials_to_block(credentials: list[dict[str, Any]]) -> str:
40
+ """Render the credentials list back into a string for use inside
41
+ participant prompts (so we can keep them readable rather than
42
+ embedding raw JSON in role prompts)."""
43
+ if not credentials:
44
+ return "(no credential summary available yet)"
45
+ lines: list[str] = []
46
+ for c in credentials:
47
+ lines.append(f"- {c.get('name', c.get('participant_id', '?'))} "
48
+ f"(id={c.get('participant_id', '?')})")
49
+ if c.get("expertise"):
50
+ lines.append(f" Expertise: {c['expertise']}")
51
+ if c.get("personality"):
52
+ lines.append(f" Style: {c['personality']}")
53
+ if c.get("credibility_for_question") is not None:
54
+ lines.append(f" Credibility on this question: {c['credibility_for_question']:.2f}")
55
+ if c.get("bias_to_watch"):
56
+ lines.append(f" Bias to watch: {c['bias_to_watch']}")
57
+ return "\n".join(lines)
58
+
59
+
60
+ async def build_credential_summary(
61
+ *,
62
+ orchestrator_model_id: str,
63
+ question: str,
64
+ participants: list[Any],
65
+ initial_opinions: dict[str, str],
66
+ api_log: list[dict[str, Any]] | None = None,
67
+ ) -> list[dict[str, Any]]:
68
+ """Build the Credential Summary list. Returns an empty list on parse failure."""
69
+ block = _format_participants_block(participants, initial_opinions)
70
+ prompt = CREDENTIAL_BUILD_PROMPT.format(
71
+ question=question,
72
+ participants_block=block,
73
+ )
74
+ _raw, parsed = await orchestrator_call(
75
+ orchestrator_model_id=orchestrator_model_id,
76
+ user_prompt=prompt,
77
+ label="build_credentials",
78
+ api_log=api_log,
79
+ max_tokens=2048,
80
+ )
81
+
82
+ creds: list[dict[str, Any]] = []
83
+ if isinstance(parsed, dict) and isinstance(parsed.get("credentials"), list):
84
+ creds = parsed["credentials"]
85
+
86
+ creds = _normalize_creds(creds, participants)
87
+ return creds
88
+
89
+
90
+ async def refresh_credential_summary(
91
+ *,
92
+ orchestrator_model_id: str,
93
+ question: str,
94
+ participants: list[Any],
95
+ existing: list[dict[str, Any]],
96
+ critique_transcript: str,
97
+ api_log: list[dict[str, Any]] | None = None,
98
+ ) -> list[dict[str, Any]]:
99
+ """Refresh the Credential Summary after Phase 2 critique."""
100
+ if not existing:
101
+ return existing
102
+ prompt = CREDENTIAL_REFRESH_PROMPT.format(
103
+ question=question,
104
+ credential_summary_json=json.dumps({"credentials": existing}, indent=2),
105
+ critique_transcript=critique_transcript,
106
+ )
107
+ _raw, parsed = await orchestrator_call(
108
+ orchestrator_model_id=orchestrator_model_id,
109
+ user_prompt=prompt,
110
+ label="refresh_credentials",
111
+ api_log=api_log,
112
+ max_tokens=2048,
113
+ )
114
+ if isinstance(parsed, dict) and isinstance(parsed.get("credentials"), list):
115
+ return _normalize_creds(parsed["credentials"], participants)
116
+ return existing
117
+
118
+
119
+ def _normalize_creds(
120
+ creds: list[dict[str, Any]],
121
+ participants: list[Any],
122
+ ) -> list[dict[str, Any]]:
123
+ """Defensive cleanup: ensure credibility is a float in [0, 1] and that
124
+ every participant has a row (fill in placeholders if the model dropped
125
+ one)."""
126
+ by_id: dict[str, dict[str, Any]] = {}
127
+ for c in creds:
128
+ pid = c.get("participant_id") or c.get("id") or ""
129
+ if not pid:
130
+ continue
131
+ try:
132
+ score = float(c.get("credibility_for_question", 0.5))
133
+ except Exception:
134
+ score = 0.5
135
+ c["credibility_for_question"] = max(0.0, min(1.0, score))
136
+ by_id[pid] = c
137
+
138
+ out: list[dict[str, Any]] = []
139
+ for p in participants:
140
+ if p.participant_id in by_id:
141
+ row = by_id[p.participant_id]
142
+ row.setdefault("name", p.name)
143
+ out.append(row)
144
+ else:
145
+ out.append({
146
+ "participant_id": p.participant_id,
147
+ "name": p.name,
148
+ "expertise": "(no credential available)",
149
+ "personality": "",
150
+ "credibility_for_question": 0.5,
151
+ "bias_to_watch": "",
152
+ })
153
+ return out
backend/app/services/extra_personas.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Four bundled "extra" personas powered by non-Neon LLMs.
2
+
3
+ Each pairs a discussion lens with a complementary area of expertise so
4
+ they generalize to any question. The user can replace any of them by
5
+ creating an Expert Persona, or change which LLM powers each one in the
6
+ settings menu.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ExtraPersonaSpec:
15
+ participant_id: str
16
+ name: str
17
+ default_model_id: str
18
+ role_prompt: str
19
+
20
+
21
+ EXTRA_PERSONAS: list[ExtraPersonaSpec] = [
22
+ ExtraPersonaSpec(
23
+ participant_id="extra_pragmatic_generalist",
24
+ name="The Pragmatic Generalist",
25
+ default_model_id="gpt-5.4",
26
+ role_prompt=(
27
+ "You are The Pragmatic Generalist with a complementary specialty in "
28
+ "finance and economics. You have broad general knowledge across "
29
+ "many domains and you instinctively look for what is feasible, "
30
+ "cost-effective, and likely to actually work in practice. Even on "
31
+ "questions that aren't financial, you anchor your reasoning in "
32
+ "monetary cost, return on investment, opportunity cost, time "
33
+ "horizons, and budget realism, and you call out when an idea "
34
+ "sounds great but the numbers don't add up. Your tone is calm, "
35
+ "measured, and faintly skeptical of utopian framing. You speak "
36
+ "like an experienced advisor: short paragraphs, concrete examples, "
37
+ "and a habit of comparing options head-to-head on cost vs benefit "
38
+ "rather than treating any one option as obvious. You are willing "
39
+ "to change your mind when shown a credible argument, but you ask "
40
+ "for a back-of-envelope calculation before doing so."
41
+ ),
42
+ ),
43
+ ExtraPersonaSpec(
44
+ participant_id="extra_skeptical_critic",
45
+ name="The Skeptical Critic",
46
+ default_model_id="gemini-2.5-flash",
47
+ role_prompt=(
48
+ "You are The Skeptical Critic with a complementary specialty in "
49
+ "philosophy. Your role in a group discussion is to play the "
50
+ "principled devil's advocate: surface assumptions nobody is "
51
+ "examining, pressure-test claims with counterexamples, and ask "
52
+ "the unpopular questions. You frame your challenges through "
53
+ "philosophical fundamentals - epistemology (how do we know that?), "
54
+ "ethics (utilitarian vs deontological framings, consequentialist "
55
+ "tradeoffs), and edge-case thought experiments that expose the "
56
+ "limits of a position. Your tone is sharp but not hostile; you "
57
+ "respect arguments more than people, including your own. You "
58
+ "speak in concise, well-structured sentences, you cite specific "
59
+ "claims by other participants when challenging them, and you are "
60
+ "happy to concede when someone refutes you cleanly - because to "
61
+ "you the goal is the truth, not winning."
62
+ ),
63
+ ),
64
+ ExtraPersonaSpec(
65
+ participant_id="extra_empathetic_humanist",
66
+ name="The Empathetic Humanist",
67
+ default_model_id="devstral-2512",
68
+ role_prompt=(
69
+ "You are The Empathetic Humanist with a complementary specialty in "
70
+ "world history. You center human, ethical, social, and values "
71
+ "impact in every discussion. You instinctively ask: who is "
72
+ "affected, whose voice is missing, and what does this mean for "
73
+ "the people on the receiving end? You ground your arguments in "
74
+ "historical precedent - how comparable choices have played out "
75
+ "across cultures, civilizations, and eras - and you draw lessons "
76
+ "from them without being preachy. Your tone is warm, thoughtful, "
77
+ "and a bit reflective; you speak in flowing sentences and you "
78
+ "name the human stakes explicitly. You're willing to slow the "
79
+ "group down when something matters morally, and you push back "
80
+ "gently but firmly when an argument treats people as variables. "
81
+ "You change your mind when shown that the human consequences "
82
+ "you feared are not real, or that historical analogues don't "
83
+ "apply."
84
+ ),
85
+ ),
86
+ ExtraPersonaSpec(
87
+ participant_id="extra_data_driven_analyst",
88
+ name="The Data-Driven Analyst",
89
+ default_model_id="meta-llama/Llama-3.3-70B-Instruct-Turbo",
90
+ role_prompt=(
91
+ "You are The Data-Driven Analyst with a complementary specialty in "
92
+ "geology and Earth-science / physical-systems thinking. You want "
93
+ "evidence: numbers, studies, measurements, and falsifiable claims. "
94
+ "When others speak in generalities, you ask 'how would we measure "
95
+ "that?' or 'what's the magnitude?'. You bring a long-time-horizon, "
96
+ "physical-systems mindset shaped by Earth science: resources are "
97
+ "finite, environmental constraints are real, infrastructure has "
98
+ "lifespans, and feedback loops can take decades to reveal "
99
+ "themselves. Your tone is precise, dry, and quietly rigorous; "
100
+ "you cite figures even when approximate, you flag uncertainty "
101
+ "ranges rather than pretending precision you don't have, and you "
102
+ "respect any participant who shows their work. You are willing "
103
+ "to update your view when better data is presented, and you are "
104
+ "openly suspicious of any claim that has 'never' or 'always' in "
105
+ "it."
106
+ ),
107
+ ),
108
+ ]
109
+
110
+
111
+ def list_extra_personas() -> list[dict]:
112
+ return [
113
+ {
114
+ "participant_id": p.participant_id,
115
+ "name": p.name,
116
+ "default_model_id": p.default_model_id,
117
+ "role_prompt": p.role_prompt,
118
+ "kind": "extra",
119
+ }
120
+ for p in EXTRA_PERSONAS
121
+ ]
122
+
123
+
124
+ def get_extra_persona(participant_id: str) -> ExtraPersonaSpec | None:
125
+ for p in EXTRA_PERSONAS:
126
+ if p.participant_id == participant_id:
127
+ return p
128
+ return None
backend/app/services/json_calls.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers for orchestrator-side LLM calls that need JSON-shaped output."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import re
7
+ import time
8
+ from typing import Any
9
+
10
+ from app.clients.openai_compat import openai_chat_completion
11
+ from app.config import settings
12
+ from app.services.prompts import ORCHESTRATOR_BASE_DIRECTIVE
13
+ from app.utils.sanitize import strip_thinking
14
+
15
+ LOG = logging.getLogger(__name__)
16
+
17
+
18
+ def _strip_json_fences(raw: str) -> str:
19
+ """Some models wrap JSON in ```json ... ``` fences. Peel them off."""
20
+ raw = raw.strip()
21
+ if raw.startswith("```"):
22
+ # drop the first fence line
23
+ first_nl = raw.find("\n")
24
+ if first_nl != -1:
25
+ raw = raw[first_nl + 1:]
26
+ raw = raw.rstrip()
27
+ if raw.endswith("```"):
28
+ raw = raw[:-3].rstrip()
29
+ return raw
30
+
31
+
32
+ def _extract_json_blob(raw: str) -> str:
33
+ """Best-effort: pull out the first balanced { ... } or [ ... ] block."""
34
+ raw = _strip_json_fences(raw)
35
+ for opener, closer in [("{", "}"), ("[", "]")]:
36
+ start = raw.find(opener)
37
+ if start == -1:
38
+ continue
39
+ depth = 0
40
+ in_str = False
41
+ esc = False
42
+ for i in range(start, len(raw)):
43
+ ch = raw[i]
44
+ if in_str:
45
+ if esc:
46
+ esc = False
47
+ elif ch == "\\":
48
+ esc = True
49
+ elif ch == '"':
50
+ in_str = False
51
+ continue
52
+ if ch == '"':
53
+ in_str = True
54
+ continue
55
+ if ch == opener:
56
+ depth += 1
57
+ elif ch == closer:
58
+ depth -= 1
59
+ if depth == 0:
60
+ return raw[start:i + 1]
61
+ return raw
62
+
63
+
64
+ def parse_json_response(raw: str) -> dict | list | None:
65
+ """Tolerant JSON parser for orchestrator outputs.
66
+
67
+ Handles markdown fences, leading/trailing prose, and falls back to
68
+ extracting the first balanced bracket block. Returns None if nothing
69
+ parseable is found.
70
+ """
71
+ if not raw:
72
+ return None
73
+ candidates = [raw, _strip_json_fences(raw), _extract_json_blob(raw)]
74
+ seen: set[str] = set()
75
+ for c in candidates:
76
+ c = c.strip()
77
+ if not c or c in seen:
78
+ continue
79
+ seen.add(c)
80
+ try:
81
+ return json.loads(c)
82
+ except Exception:
83
+ continue
84
+ LOG.warning("parse_json_response failed; raw=%r", raw[:200])
85
+ return None
86
+
87
+
88
+ async def orchestrator_call(
89
+ *,
90
+ orchestrator_model_id: str,
91
+ user_prompt: str,
92
+ label: str,
93
+ api_log: list[dict[str, Any]] | None = None,
94
+ expect_json: bool = True,
95
+ temperature: float = 0.2,
96
+ max_tokens: int = 1024,
97
+ timeout: float = 45.0,
98
+ ) -> tuple[str, dict | list | None]:
99
+ """Run an orchestrator-side LLM call.
100
+
101
+ Returns (raw_text_after_strip, parsed_json_or_None). When `expect_json`
102
+ is False the parsed value will always be None and the caller should use
103
+ the raw text. Any exception is converted into a ("", None) result so
104
+ the orchestrator state machine can degrade gracefully.
105
+ """
106
+ resolved = settings.resolve_model(orchestrator_model_id)
107
+ if not resolved:
108
+ LOG.warning("Orchestrator model %s not resolvable", orchestrator_model_id)
109
+ return "", None
110
+
111
+ messages = [
112
+ {"role": "system", "content": ORCHESTRATOR_BASE_DIRECTIVE},
113
+ {"role": "user", "content": user_prompt},
114
+ ]
115
+
116
+ log_entry: dict[str, Any] = {
117
+ "timestamp": time.time(),
118
+ "label": f"orchestrator:{label}",
119
+ "model": resolved["model_id"],
120
+ "request": {"messages": messages, "max_tokens": max_tokens},
121
+ }
122
+ try:
123
+ result = await openai_chat_completion(
124
+ base_url=resolved["base_url"],
125
+ api_key=resolved["api_key"],
126
+ model=resolved["model_id"],
127
+ messages=messages,
128
+ temperature=temperature,
129
+ max_tokens=max_tokens,
130
+ timeout=timeout,
131
+ )
132
+ except Exception as exc:
133
+ LOG.exception("orchestrator_call %s failed: %s", label, exc)
134
+ log_entry["response"] = {"error": str(exc)}
135
+ if api_log is not None:
136
+ api_log.append(log_entry)
137
+ return "", None
138
+
139
+ log_entry["response"] = result
140
+ if api_log is not None:
141
+ api_log.append(log_entry)
142
+
143
+ raw = strip_thinking(result.get("response", ""))
144
+ parsed = parse_json_response(raw) if expect_json else None
145
+ return raw, parsed
backend/app/services/models.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core dataclasses for a CCAI session.
2
+
3
+ Kept in their own module so `orchestrator.py` can import from them
4
+ cleanly and the API layer doesn't need to reach into the orchestrator
5
+ to construct one.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Any
13
+
14
+ from app.services.context_budget import ContextSummary
15
+
16
+
17
+ class Phase(str, Enum):
18
+ INITIAL_OPINIONS = "initial_opinions"
19
+ CRITIQUE_ROUND_1 = "critique_round_1"
20
+ CRITIQUE_ROUND_2 = "critique_round_2"
21
+ STATUS_ASSESSMENT = "status_assessment"
22
+ FINALIZATION = "finalization"
23
+ CONSENSUS = "consensus"
24
+ CLOSURE = "closure"
25
+ FAILSAFE_PAUSED = "failsafe_paused"
26
+ FINISHED = "finished"
27
+
28
+
29
+ # How many participants a session may include (overridable by the user
30
+ # via Settings; values outside [3, 9] are clamped server-side).
31
+ DEFAULT_MAX_PARTICIPANTS = 5
32
+ MIN_MAX_PARTICIPANTS = 3
33
+ MAX_MAX_PARTICIPANTS = 9
34
+
35
+
36
+ # Failsafe defaults from the plan: pause every N=60 participant messages
37
+ # (then every +20), and every M=100 orchestrator calls (then every +50).
38
+ PARTICIPANT_MESSAGE_PAUSE_AT = 60
39
+ PARTICIPANT_MESSAGE_PAUSE_INC = 20
40
+ ORCHESTRATOR_CALL_PAUSE_AT = 100
41
+ ORCHESTRATOR_CALL_PAUSE_INC = 50
42
+
43
+
44
+ @dataclass
45
+ class Participant:
46
+ """One member of the CCAI forum.
47
+
48
+ `kind` distinguishes Neon HANA personas, the four bundled "extra"
49
+ personas, and user-created Expert Personas. `enabled` reflects the
50
+ sidebar slider. Disabled participants are kept on the session so
51
+ the user can re-enable mid-conversation, but they don't take turns.
52
+ """
53
+
54
+ participant_id: str
55
+ name: str
56
+ role_prompt: str
57
+ model_id: str
58
+
59
+ kind: str = "expert" # "neon" | "extra" | "expert"
60
+ enabled: bool = True
61
+
62
+ # Resolved provider routing (populated from settings.resolve_model)
63
+ base_url: str = ""
64
+ api_key: str = ""
65
+ display_name: str = ""
66
+
67
+ # Neon-specific routing
68
+ is_neon: bool = False
69
+ hana_model_id: str = ""
70
+ persona_name: str = ""
71
+ neon_direct_vllm: bool = False
72
+ vllm_base_url: str = ""
73
+ vllm_api_key: str = ""
74
+
75
+ # Per-participant context summary (managed by services.context_budget)
76
+ summary: ContextSummary = field(default_factory=ContextSummary)
77
+
78
+ # Robustness counter: 3 consecutive failures auto-disables.
79
+ consecutive_failures: int = 0
80
+
81
+
82
+ @dataclass
83
+ class Session:
84
+ session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
85
+
86
+ question: str = ""
87
+ participants: list[Participant] = field(default_factory=list)
88
+
89
+ # Both fall through to settings.orchestrator_model when None. The
90
+ # summarizer additionally falls through to the orchestrator's id when
91
+ # None (so changing one auto-changes the other unless overridden).
92
+ orchestrator_model_id: str | None = None
93
+ summarizer_model_id: str | None = None
94
+
95
+ max_participants: int = DEFAULT_MAX_PARTICIPANTS
96
+
97
+ phase: Phase = Phase.INITIAL_OPINIONS
98
+
99
+ # Phase 1 outputs
100
+ initial_opinions: dict[str, str] = field(default_factory=dict)
101
+ credential_summary: list[dict[str, Any]] = field(default_factory=list)
102
+
103
+ # Phase 2 / 3 / 4 / 5 message store. Each entry:
104
+ # { speaker_id, speaker_name, role: "participant"|"orchestrator",
105
+ # text, phase, timestamp, elapsed_seconds, addressed_to,
106
+ # model_id, model_display }
107
+ messages: list[dict[str, Any]] = field(default_factory=list)
108
+
109
+ # Phase-4 state: per-participant final opinion text (for alliances)
110
+ final_opinions: dict[str, str] = field(default_factory=dict)
111
+ alliance_groups: list[dict[str, Any]] = field(default_factory=list)
112
+
113
+ # Phase-3 status-assessment loop counter (max 3)
114
+ status_assessment_iterations: int = 0
115
+
116
+ # Phase-5 / Phase-6 attempts at consensus before giving up (max 2)
117
+ consensus_attempts: int = 0
118
+
119
+ # Final structured report after closure
120
+ final_report: dict[str, Any] | None = None
121
+
122
+ # Per-participant contribution summaries for the table view
123
+ contribution_summaries: dict[str, str] = field(default_factory=dict)
124
+
125
+ # Failsafes
126
+ total_participant_messages: int = 0
127
+ participant_message_cap: int = PARTICIPANT_MESSAGE_PAUSE_AT
128
+ orchestrator_call_count: int = 0
129
+ orchestrator_call_cap: int = ORCHESTRATOR_CALL_PAUSE_AT
130
+
131
+ paused_for_continue: bool = False
132
+ pause_reason: str | None = None # "messages" | "orchestrator"
133
+ finished: bool = False
134
+
135
+ # Streaming control: the orchestrator state-machine writes to this and
136
+ # the API layer reads it.
137
+ api_log: list[dict[str, Any]] = field(default_factory=list)
138
+ pending_continue: bool = False
backend/app/services/orchestrator.py CHANGED
@@ -1,108 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
 
3
  import json
4
  import logging
5
- import random
6
  import time
7
- import uuid
8
- from dataclasses import dataclass, field
9
  from typing import Any, AsyncIterator
10
 
11
- from app.clients.openai_compat import openai_chat_completion
12
- from app.clients.llm_router import chat_completion as unified_chat_completion
13
  from app.config import settings
14
-
15
- LOG = logging.getLogger(__name__)
16
-
17
- # ---------------------------------------------------------------------------
18
- # Prompt templates
19
- # ---------------------------------------------------------------------------
20
-
21
- _BREVITY = (
22
- " Keep your reply short — 2-4 sentences, like a casual chat message, not an email or essay."
23
  )
24
-
25
- AUTO_START_PROMPT = (
26
- "You're starting a conversation with someone new. Here is some information about them: "
27
- "{other_role}\n\n"
28
- "Consider connections between this information and what you know about yourself, and say "
29
- "something that could start a conversation with that new person. Speak in the first person, "
30
- "as if directly to the other person." + _BREVITY
31
- )
32
-
33
- FIRST_REPLY_PROMPT = (
34
- "Someone just started a conversation with you, this is what they said: {last_message}\n\n"
35
- "Consider connections between this conversation starter and what you know about yourself, "
36
- "and say something that could continue the conversation with that new person. Speak in the "
37
- "first person, as if directly to the other person." + _BREVITY
38
  )
39
-
40
- CONTINUE_PROMPT = (
41
- "You are having a conversation with another person, here is the conversation so far:\n\n"
42
- "{history}\n\n"
43
- "Consider how human conversations generally progress, and provide a response. If the last "
44
- "reply in the conversation is one which might indicate a human is losing interest in or "
45
- "wrapping up the conversation, then make a response which will help wrap up and close the "
46
- "conversation." + _BREVITY
47
  )
48
-
49
- WINDING_NEXT_PROMPT = (
50
- "You are having a conversation with another person, here is the conversation so far:\n\n"
51
- "{history}\n\n"
52
- "Consider how human conversations generally progress, and provide a response which will "
53
- "wrap up and close the conversation. This is the last reply you will give in this "
54
- "conversation." + _BREVITY
 
 
 
55
  )
56
-
57
- WINDING_FINAL_PROMPT = (
58
- "You are having a conversation with another person, here is the conversation so far:\n\n"
59
- "{history}\n\n"
60
- "Consider how human conversations generally progress, and focus on the last two messages "
61
- "in this conversation. Provide a very short response which closes the conversation."
62
- + _BREVITY
 
 
 
 
 
 
 
63
  )
 
64
 
65
- ORCHESTRATOR_CHECK_PROMPT = (
66
- "You are monitoring a conversation between two people. Your job is to determine whether "
67
- "the latest message indicates the speaker is losing interest or wrapping up the conversation. "
68
- "Reply with ONLY a JSON object: {{\"winding_down\": true}} or {{\"winding_down\": false}}. "
69
- "No other text.\n\nLatest message:\n{message}"
70
- )
71
 
72
 
73
  # ---------------------------------------------------------------------------
74
- # Session data
75
  # ---------------------------------------------------------------------------
76
 
77
- @dataclass
78
- class Persona:
79
- name: str
80
- model_id: str
81
- role_prompt: str
82
- base_url: str = ""
83
- api_key: str = ""
84
- display_name: str = ""
85
- is_neon: bool = False
86
- hana_model_id: str = ""
87
- persona_name: str = ""
88
- neon_direct_vllm: bool = False
89
- vllm_base_url: str = ""
90
- vllm_api_key: str = ""
91
-
92
-
93
- @dataclass
94
- class Session:
95
- session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
96
- persona_a: Persona | None = None
97
- persona_b: Persona | None = None
98
- messages: list[dict[str, str]] = field(default_factory=list)
99
- api_log: list[dict[str, Any]] = field(default_factory=list)
100
- a_count: int = 0
101
- b_count: int = 0
102
- end_mode: bool = False
103
- finished: bool = False
104
-
105
-
106
  _sessions: dict[str, Session] = {}
107
 
108
 
@@ -116,276 +102,970 @@ def create_session() -> Session:
116
  return s
117
 
118
 
 
 
 
 
 
 
 
 
119
  # ---------------------------------------------------------------------------
120
  # Helpers
121
  # ---------------------------------------------------------------------------
122
 
123
- def _format_history(messages: list[dict[str, str]]) -> str:
124
- lines = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  for m in messages:
126
- lines.append(f"{m['speaker']}: {m['text']}")
 
 
 
 
 
127
  return "\n".join(lines)
128
 
129
 
130
- async def _call_llm(
131
- persona: Persona,
132
- system_content: str,
133
- user_content: str,
134
- session: Session,
135
- label: str = "",
136
- max_tokens: int = 500,
137
- timeout: float = 20,
138
  ) -> str:
139
- system_with_directive = (
140
- system_content + "\n\nIMPORTANT: Respond ONLY with your in-character dialogue. "
141
- "Do NOT include your reasoning, thought process, analysis of the prompt, "
142
- "meta-commentary, internal monologue, or draft notes. Output ONLY the words "
143
- "your character would actually say aloud."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  )
145
- messages = [
146
- {"role": "system", "content": system_with_directive},
147
- {"role": "user", "content": user_content},
148
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  log_entry: dict[str, Any] = {
150
  "timestamp": time.time(),
151
- "label": label,
152
- "model": persona.model_id,
153
- "request": {"messages": messages, "max_tokens": max_tokens},
154
  }
155
 
156
- resolved = {
157
- "model_id": persona.model_id,
158
- "base_url": persona.base_url,
159
- "api_key": persona.api_key,
160
- "is_neon": persona.is_neon,
161
- "hana_model_id": persona.hana_model_id,
162
- "persona_name": persona.persona_name,
163
- "neon_direct_vllm": persona.neon_direct_vllm,
164
- "vllm_base_url": persona.vllm_base_url,
165
- "vllm_api_key": persona.vllm_api_key,
166
- }
167
- result = await unified_chat_completion(
168
- resolved=resolved,
169
- messages=messages,
170
- temperature=0.7,
171
- max_tokens=max_tokens,
172
- timeout=timeout,
173
- )
174
 
175
  log_entry["response"] = result
176
  session.api_log.append(log_entry)
177
 
178
- return result.get("response", ""), result.get("elapsed_seconds", 0)
 
 
 
 
 
 
 
179
 
180
 
181
- async def _call_orchestrator(
182
- prompt: str,
183
  session: Session,
184
- label: str = "",
185
- ) -> str:
186
- resolved = settings.resolve_model(settings.orchestrator_model)
187
- if not resolved:
188
- LOG.warning("Orchestrator model %s not found, using first available", settings.orchestrator_model)
189
- for prov in settings.providers:
190
- for m in prov["models"]:
191
- resolved = {
192
- "base_url": m.get("base_url", prov["base_url"]),
193
- "api_key": m.get("api_key", prov["api_key"]),
194
- "model_id": m["id"],
195
- }
196
- break
197
- if resolved:
198
- break
199
-
200
- if not resolved:
201
- return '{"winding_down": false}'
202
-
203
- messages = [
204
- {"role": "system", "content": "You are a conversation monitor. Respond only with the requested JSON."},
205
- {"role": "user", "content": prompt},
206
- ]
207
- log_entry: dict[str, Any] = {
208
  "timestamp": time.time(),
209
- "label": f"orchestrator:{label}",
210
- "model": resolved["model_id"],
211
- "request": {"messages": messages},
 
212
  }
 
 
 
213
 
214
- result = await openai_chat_completion(
215
- base_url=resolved["base_url"],
216
- api_key=resolved["api_key"],
217
- model=resolved["model_id"],
218
- messages=messages,
219
- temperature=0.2,
220
- max_tokens=256,
221
- timeout=20,
222
- )
223
-
224
- log_entry["response"] = result
225
- session.api_log.append(log_entry)
226
 
227
- return result.get("response", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
 
230
- def _parse_json_bool(raw: str, key: str) -> bool:
231
- try:
232
- raw = raw.strip()
233
- if raw.startswith("```"):
234
- raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0]
235
- return json.loads(raw).get(key, False)
236
- except Exception:
237
- lower = raw.lower()
238
- return f'"{key}": true' in lower or f'"{key}":true' in lower
239
 
240
 
241
  # ---------------------------------------------------------------------------
242
- # Main conversation loop (yields SSE events)
243
  # ---------------------------------------------------------------------------
244
 
245
- async def run_conversation(
246
- session: Session,
247
- starter_text: str | None = None,
248
- ) -> AsyncIterator[str]:
249
- pa = session.persona_a
250
- pb = session.persona_b
251
- if not pa or not pb:
252
- yield _sse("error", {"message": "Both personas must be configured"})
253
- return
254
-
255
- participants = [pa, pb]
256
- starter_idx = random.randint(0, 1)
257
- starter = participants[starter_idx]
258
- responder = participants[1 - starter_idx]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
- yield _sse("status", {"message": "Starting conversation..."})
261
 
262
- # --- First message ---
263
- if starter_text:
264
- # Show the user-provided starter as the first message from the starter LLM,
265
- # then send it to the responder to reply to (no LLM call for the opener).
266
- _add_message(session, starter, starter_text.strip(), starter_idx)
267
- yield _sse("message", _msg_payload(session.messages[-1], starter_idx))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
- reply_prompt = FIRST_REPLY_PROMPT.format(last_message=starter_text.strip())
270
- second_msg, second_elapsed = await _call_llm(
271
- responder, responder.role_prompt, reply_prompt, session,
272
- label=f"first_reply:{responder.name}",
273
  )
274
- _add_message(session, responder, second_msg, 1 - starter_idx, second_elapsed)
275
- yield _sse("message", _msg_payload(session.messages[-1], 1 - starter_idx))
276
- else:
277
- user_prompt = AUTO_START_PROMPT.format(other_role=responder.role_prompt)
278
- first_msg, first_elapsed = await _call_llm(
279
- starter, starter.role_prompt, user_prompt, session,
280
- label=f"start:{starter.name}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  )
282
- _add_message(session, starter, first_msg, starter_idx, first_elapsed)
283
- yield _sse("message", _msg_payload(session.messages[-1], starter_idx))
 
 
 
 
 
 
284
 
285
- reply_prompt = FIRST_REPLY_PROMPT.format(last_message=first_msg)
286
- second_msg, second_elapsed = await _call_llm(
287
- responder, responder.role_prompt, reply_prompt, session,
288
- label=f"first_reply:{responder.name}",
289
  )
290
- _add_message(session, responder, second_msg, 1 - starter_idx, second_elapsed)
291
- yield _sse("message", _msg_payload(session.messages[-1], 1 - starter_idx))
292
-
293
- # --- Continue loop ---
294
- current_idx = starter_idx
295
- while not session.finished:
296
- current = participants[current_idx]
297
- history_text = _format_history(session.messages)
298
-
299
- # Check orchestrator on the last message
300
- last_msg_text = session.messages[-1]["text"]
301
-
302
- if not session.end_mode:
303
- orch_raw = await _call_orchestrator(
304
- ORCHESTRATOR_CHECK_PROMPT.format(message=last_msg_text),
305
- session,
306
- label="winding_check",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  )
308
- winding = _parse_json_bool(orch_raw, "winding_down")
309
-
310
- if winding:
311
- session.end_mode = True
312
-
313
- # Force wrap-up at 8 messages each
314
- if not session.end_mode:
315
- if session.a_count >= 8 and session.b_count >= 8:
316
- session.end_mode = True
317
-
318
- if session.end_mode:
319
- # Penultimate message: current speaker wraps up
320
- history_text = _format_history(session.messages)
321
- wrap_msg, wrap_elapsed = await _call_llm(
322
- current, current.role_prompt,
323
- WINDING_NEXT_PROMPT.format(history=history_text),
324
- session, label=f"winding_next:{current.name}",
325
  )
326
- _add_message(session, current, wrap_msg, current_idx, wrap_elapsed)
327
- yield _sse("message", _msg_payload(session.messages[-1], current_idx))
328
-
329
- # Final message: other speaker closes
330
- other_idx = 1 - current_idx
331
- other = participants[other_idx]
332
- history_text = _format_history(session.messages)
333
- final_msg, final_elapsed = await _call_llm(
334
- other, other.role_prompt,
335
- WINDING_FINAL_PROMPT.format(history=history_text),
336
- session, label=f"winding_final:{other.name}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  )
338
- _add_message(session, other, final_msg, other_idx, final_elapsed)
339
- yield _sse("message", _msg_payload(session.messages[-1], other_idx))
340
-
341
- session.finished = True
342
- yield _sse("system", {"text": "End of Chat"})
343
- break
344
-
345
- # Normal continue
346
- prompt = CONTINUE_PROMPT.format(history=history_text)
347
- response, resp_elapsed = await _call_llm(
348
- current, current.role_prompt, prompt, session,
349
- label=f"continue:{current.name}",
 
 
 
350
  )
351
- _add_message(session, current, response, current_idx, resp_elapsed)
352
- yield _sse("message", _msg_payload(session.messages[-1], current_idx))
353
 
354
- current_idx = 1 - current_idx
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
 
356
- yield _sse("done", {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
 
358
 
359
  # ---------------------------------------------------------------------------
360
- # SSE helpers
361
  # ---------------------------------------------------------------------------
362
 
363
- def _add_message(session: Session, persona: Persona, text: str, speaker_idx: int, elapsed: float = 0) -> None:
364
- session.messages.append({
365
- "speaker": persona.name,
366
- "speaker_idx": speaker_idx,
367
- "model_id": persona.model_id,
368
- "model_display": persona.display_name,
369
- "text": text,
370
- "timestamp": time.time(),
371
- "elapsed_seconds": round(elapsed, 2),
372
- })
373
- if speaker_idx == 0:
374
- session.a_count += 1
375
- else:
376
- session.b_count += 1
377
 
 
 
378
 
379
- def _msg_payload(msg: dict, speaker_idx: int) -> dict:
380
- return {
381
- "speaker": msg["speaker"],
382
- "speaker_idx": speaker_idx,
383
- "model_display": msg["model_display"],
384
- "text": msg["text"],
385
- "timestamp": msg["timestamp"],
386
- "elapsed_seconds": msg.get("elapsed_seconds", 0),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  }
 
 
388
 
389
 
390
- def _sse(event: str, data: dict) -> str:
391
- return f"event: {event}\ndata: {json.dumps(data)}\n\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CCAI orchestrator: six-phase state machine driving a multi-participant
2
+ group discussion to a consensus (or to a documented failure-to-consense).
3
+
4
+ Phase outline (matches the build plan):
5
+
6
+ 1. Initial Opinions (independent, no peeking)
7
+ 1.5. Build Credential Summary
8
+ 2. Critique x 2 rounds (full history visible)
9
+ 3. Status Assessment (max 3 iterations of targeted follow-ups)
10
+ 4. Opinion Finalization
11
+ 5. Consensus Gathering (alliance-aware, addressed-to aware)
12
+ 6. Closure (majority report, or unaddressed-factor probe + retry,
13
+ or failure report)
14
+
15
+ Two failsafes pause the loop until the user clicks "Continue":
16
+ - Participant-message cap: 60, then +20.
17
+ - Orchestrator-call cap: 100, then +50.
18
+
19
+ Every LLM response runs through `app.utils.sanitize.strip_thinking` on
20
+ its way into history, into the orchestrator's prompts, and into the
21
+ summarizer.
22
+ """
23
  from __future__ import annotations
24
 
25
+ import asyncio
26
  import json
27
  import logging
 
28
  import time
29
+ from dataclasses import asdict
 
30
  from typing import Any, AsyncIterator
31
 
32
+ from app.clients.llm_router import chat_completion
 
33
  from app.config import settings
34
+ from app.services import context_budget
35
+ from app.services.consensus import (
36
+ assess_consensus_status,
37
+ classify_addressed_to,
38
+ detect_alliances,
39
+ find_unaddressed_factor,
 
 
 
40
  )
41
+ from app.services.context_budget import (
42
+ ContextSummary,
43
+ DEFAULT_REPLY_BUDGET,
44
+ KEEP_RECENT_MESSAGES,
45
+ build_compressed_messages,
46
+ context_window_for,
47
+ estimate_messages_tokens,
48
+ run_summarize,
49
+ select_summarizer_model_id,
50
+ should_summarize,
 
 
 
 
51
  )
52
+ from app.services.credential import (
53
+ build_credential_summary,
54
+ credentials_to_block,
55
+ refresh_credential_summary,
 
 
 
 
56
  )
57
+ from app.services.json_calls import orchestrator_call
58
+ from app.services.models import (
59
+ DEFAULT_MAX_PARTICIPANTS,
60
+ MAX_MAX_PARTICIPANTS,
61
+ MIN_MAX_PARTICIPANTS,
62
+ ORCHESTRATOR_CALL_PAUSE_INC,
63
+ PARTICIPANT_MESSAGE_PAUSE_INC,
64
+ Participant,
65
+ Phase,
66
+ Session,
67
  )
68
+ from app.services.prompts import (
69
+ CONSENSUS_ALLIED_PROMPT,
70
+ CONSENSUS_SOLO_PROMPT,
71
+ CONSENSUS_TARGETED_RESPONSE_PROMPT,
72
+ CONTRIBUTION_SUMMARY_PROMPT,
73
+ CRITIQUE_PROMPT,
74
+ FINALIZATION_PROMPT,
75
+ INITIAL_OPINION_PROMPT,
76
+ MAJORITY_REPORT_PROMPT,
77
+ NO_CONSENSUS_REPORT_PROMPT,
78
+ NO_REASONING_DIRECTIVE,
79
+ PARTICIPANT_BASE_DIRECTIVE,
80
+ STATUS_ASSESSMENT_PROMPT,
81
+ TARGETED_FOLLOWUP_PROMPT,
82
  )
83
+ from app.utils.sanitize import strip_thinking
84
 
85
+ LOG = logging.getLogger(__name__)
 
 
 
 
 
86
 
87
 
88
  # ---------------------------------------------------------------------------
89
+ # Session registry
90
  # ---------------------------------------------------------------------------
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  _sessions: dict[str, Session] = {}
93
 
94
 
 
102
  return s
103
 
104
 
105
+ # ---------------------------------------------------------------------------
106
+ # SSE helpers
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def _sse(event: str, data: dict[str, Any]) -> str:
110
+ return f"event: {event}\ndata: {json.dumps(data)}\n\n"
111
+
112
+
113
  # ---------------------------------------------------------------------------
114
  # Helpers
115
  # ---------------------------------------------------------------------------
116
 
117
+ def _active_participants(session: Session) -> list[Participant]:
118
+ return [p for p in session.participants if p.enabled]
119
+
120
+
121
+ def _orchestrator_model_id(session: Session) -> str:
122
+ return session.orchestrator_model_id or settings.orchestrator_model
123
+
124
+
125
+ def _summarizer_model_id(session: Session) -> str:
126
+ return select_summarizer_model_id(
127
+ session.summarizer_model_id,
128
+ session.orchestrator_model_id,
129
+ )
130
+
131
+
132
+ def _format_history(
133
+ messages: list[dict[str, Any]],
134
+ *,
135
+ include_orchestrator: bool = True,
136
+ ) -> str:
137
+ lines: list[str] = []
138
  for m in messages:
139
+ if m.get("role") == "orchestrator" and not include_orchestrator:
140
+ continue
141
+ speaker = m.get("speaker_name") or m.get("speaker_id") or "(anon)"
142
+ if m.get("role") == "orchestrator":
143
+ speaker = "Orchestrator"
144
+ lines.append(f"{speaker}: {m.get('text', '')}")
145
  return "\n".join(lines)
146
 
147
 
148
+ def _participant_roster_string(
149
+ speaker: Participant,
150
+ participants: list[Participant],
 
 
 
 
 
151
  ) -> str:
152
+ others = [p.name for p in participants if p.participant_id != speaker.participant_id]
153
+ return ", ".join(others) if others else "(no other participants)"
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Failsafe checks
158
+ # ---------------------------------------------------------------------------
159
+
160
+ def _participant_msg_cap_hit(session: Session) -> bool:
161
+ return session.total_participant_messages >= session.participant_message_cap
162
+
163
+
164
+ def _orchestrator_cap_hit(session: Session) -> bool:
165
+ return session.orchestrator_call_count >= session.orchestrator_call_cap
166
+
167
+
168
+ def _bump_orchestrator_count(session: Session) -> None:
169
+ session.orchestrator_call_count += 1
170
+
171
+
172
+ async def _wait_for_continue(
173
+ session: Session,
174
+ reason: str,
175
+ ) -> AsyncIterator[str]:
176
+ """Pause the state machine until the user clicks Continue."""
177
+ session.paused_for_continue = True
178
+ session.pause_reason = reason
179
+ if reason == "messages":
180
+ msg = (
181
+ f"Conversation paused after {session.total_participant_messages} "
182
+ "participant messages. Click Continue to allow another "
183
+ f"{PARTICIPANT_MESSAGE_PAUSE_INC} messages."
184
+ )
185
+ evt = "failsafe_pause"
186
+ bump_inc = PARTICIPANT_MESSAGE_PAUSE_INC
187
+ else:
188
+ msg = (
189
+ f"Conversation paused after {session.orchestrator_call_count} "
190
+ "orchestrator calls. Click Continue to allow another "
191
+ f"{ORCHESTRATOR_CALL_PAUSE_INC} orchestrator calls."
192
+ )
193
+ evt = "orchestrator_cap_pause"
194
+ bump_inc = ORCHESTRATOR_CALL_PAUSE_INC
195
+
196
+ yield _sse(evt, {
197
+ "reason": reason,
198
+ "message": msg,
199
+ "participant_messages": session.total_participant_messages,
200
+ "orchestrator_calls": session.orchestrator_call_count,
201
+ })
202
+
203
+ # Block until pending_continue is flipped by the API layer.
204
+ while session.paused_for_continue and not session.pending_continue:
205
+ await asyncio.sleep(0.25)
206
+ session.pending_continue = False
207
+ session.paused_for_continue = False
208
+ if reason == "messages":
209
+ session.participant_message_cap += bump_inc
210
+ else:
211
+ session.orchestrator_call_cap += bump_inc
212
+ session.pause_reason = None
213
+ yield _sse("status", {"message": "Resuming conversation..."})
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Participant turn (with context budgeting + summarize-on-demand)
218
+ # ---------------------------------------------------------------------------
219
+
220
+ async def _maybe_summarize_for_participant(
221
+ session: Session,
222
+ participant: Participant,
223
+ api_messages: list[dict[str, Any]],
224
+ ) -> None:
225
+ """If this participant's input estimate exceeds the threshold, run a
226
+ summarize call against the configured summarizer model and update
227
+ `participant.summary` in place."""
228
+ needs_sum, _trim, _budget = should_summarize(
229
+ participant.model_id, api_messages, participant.summary,
230
+ )
231
+ if not needs_sum:
232
+ return
233
+
234
+ # Build a transcript that excludes orchestrator status banners (those
235
+ # don't add information value to a summary) but keeps everything the
236
+ # participant has said and heard.
237
+ summarizable_msgs = [
238
+ m for m in session.messages
239
+ if m.get("role") != "orchestrator_status"
240
+ ]
241
+ if not summarizable_msgs:
242
+ return
243
+
244
+ transcript = _format_history(summarizable_msgs, include_orchestrator=False)
245
+ if not transcript.strip():
246
+ return
247
+
248
+ summarizer_id = _summarizer_model_id(session)
249
+ summary_text = await run_summarize(summarizer_id, transcript)
250
+ # The summarizer counts as an orchestrator-side call for cap purposes.
251
+ session.orchestrator_call_count += 1
252
+ if summary_text:
253
+ participant.summary.summary_text = summary_text
254
+ participant.summary.summarized_through_idx = len(session.messages) - 1
255
+
256
+
257
+ async def _call_participant(
258
+ *,
259
+ session: Session,
260
+ participant: Participant,
261
+ user_prompt: str,
262
+ label: str,
263
+ max_tokens: int = 600,
264
+ timeout: float = 45.0,
265
+ ) -> tuple[str, float, bool]:
266
+ """Run one participant turn and return (text, elapsed_seconds, ok).
267
+
268
+ The state-machine handles auto-disable on repeated failure.
269
+ """
270
+ others = _participant_roster_string(participant, _active_participants(session))
271
+ base_directive = PARTICIPANT_BASE_DIRECTIVE.format(
272
+ n_participants=len(_active_participants(session)),
273
+ other_participants=others,
274
+ )
275
+ system_text = (
276
+ f"{participant.role_prompt}\n\n{base_directive}\n\n{NO_REASONING_DIRECTIVE}"
277
  )
278
+ api_messages: list[dict[str, Any]] = [
279
+ {"role": "system", "content": system_text},
280
+ {"role": "user", "content": user_prompt},
281
  ]
282
+
283
+ await _maybe_summarize_for_participant(session, participant, api_messages)
284
+
285
+ needs_sum, needs_trim, _ = should_summarize(
286
+ participant.model_id, api_messages, participant.summary,
287
+ )
288
+ if needs_trim:
289
+ api_messages = build_compressed_messages(
290
+ api_messages, participant.summary, needs_trim,
291
+ )
292
+
293
+ resolved = {
294
+ "model_id": participant.model_id,
295
+ "base_url": participant.base_url,
296
+ "api_key": participant.api_key,
297
+ "is_neon": participant.is_neon,
298
+ "hana_model_id": participant.hana_model_id,
299
+ "persona_name": participant.persona_name,
300
+ "neon_direct_vllm": participant.neon_direct_vllm,
301
+ "vllm_base_url": participant.vllm_base_url,
302
+ "vllm_api_key": participant.vllm_api_key,
303
+ }
304
+
305
  log_entry: dict[str, Any] = {
306
  "timestamp": time.time(),
307
+ "label": f"participant:{participant.participant_id}:{label}",
308
+ "model": participant.model_id,
309
+ "request": {"messages": api_messages, "max_tokens": max_tokens},
310
  }
311
 
312
+ try:
313
+ result = await chat_completion(
314
+ resolved=resolved,
315
+ messages=api_messages,
316
+ temperature=0.7,
317
+ max_tokens=max_tokens,
318
+ timeout=timeout,
319
+ )
320
+ except Exception as exc:
321
+ LOG.exception("Participant %s call failed: %s", participant.participant_id, exc)
322
+ log_entry["response"] = {"error": str(exc)}
323
+ session.api_log.append(log_entry)
324
+ participant.consecutive_failures += 1
325
+ return "", 0.0, False
 
 
 
 
326
 
327
  log_entry["response"] = result
328
  session.api_log.append(log_entry)
329
 
330
+ if result.get("error"):
331
+ participant.consecutive_failures += 1
332
+ return "", result.get("elapsed_seconds", 0), False
333
+
334
+ participant.consecutive_failures = 0
335
+ text = strip_thinking(result.get("response", ""))
336
+ elapsed = float(result.get("elapsed_seconds", 0) or 0)
337
+ return text, elapsed, True
338
 
339
 
340
+ def _add_participant_message(
 
341
  session: Session,
342
+ participant: Participant,
343
+ text: str,
344
+ *,
345
+ phase: Phase,
346
+ elapsed: float,
347
+ addressed_to: str | None = None,
348
+ ) -> dict[str, Any]:
349
+ msg = {
350
+ "speaker_id": participant.participant_id,
351
+ "speaker_name": participant.name,
352
+ "role": "participant",
353
+ "text": text,
354
+ "phase": phase.value,
 
 
 
 
 
 
 
 
 
 
 
355
  "timestamp": time.time(),
356
+ "elapsed_seconds": round(elapsed, 2),
357
+ "addressed_to": addressed_to,
358
+ "model_id": participant.model_id,
359
+ "model_display": participant.display_name,
360
  }
361
+ session.messages.append(msg)
362
+ session.total_participant_messages += 1
363
+ return msg
364
 
 
 
 
 
 
 
 
 
 
 
 
 
365
 
366
+ def _add_orchestrator_message(
367
+ session: Session,
368
+ text: str,
369
+ *,
370
+ kind: str,
371
+ extra: dict[str, Any] | None = None,
372
+ ) -> dict[str, Any]:
373
+ msg = {
374
+ "speaker_id": "orchestrator",
375
+ "speaker_name": "Orchestrator",
376
+ "role": "orchestrator",
377
+ "kind": kind, # "status" | "factor" | "majority_report" | "no_consensus_report"
378
+ "text": text,
379
+ "phase": session.phase.value,
380
+ "timestamp": time.time(),
381
+ }
382
+ if extra:
383
+ msg.update(extra)
384
+ session.messages.append(msg)
385
+ return msg
386
 
387
 
388
+ def _msg_payload(msg: dict[str, Any]) -> dict[str, Any]:
389
+ """Public payload for a message event over SSE."""
390
+ return msg
 
 
 
 
 
 
391
 
392
 
393
  # ---------------------------------------------------------------------------
394
+ # Phase implementations
395
  # ---------------------------------------------------------------------------
396
 
397
+ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
398
+ session.phase = Phase.INITIAL_OPINIONS
399
+ yield _sse("status", {"message": "Phase 1: collecting independent first opinions..."})
400
+
401
+ actives = _active_participants(session)
402
+ for p in actives:
403
+ # Phase 1 deliberately uses a *bare* prompt (no transcript) so each
404
+ # participant's first opinion is independent of the others.
405
+ prompt = INITIAL_OPINION_PROMPT.format(question=session.question)
406
+ text, elapsed, ok = await _call_participant(
407
+ session=session, participant=p,
408
+ user_prompt=prompt,
409
+ label="initial_opinion",
410
+ max_tokens=700,
411
+ )
412
+ if not ok or not text.strip():
413
+ yield _sse("participant_error", {
414
+ "participant_id": p.participant_id,
415
+ "name": p.name,
416
+ "phase": session.phase.value,
417
+ })
418
+ if p.consecutive_failures >= 3:
419
+ p.enabled = False
420
+ yield _sse("status", {
421
+ "message": f"{p.name} auto-disabled after 3 failures.",
422
+ })
423
+ continue
424
+ msg = _add_participant_message(session, p, text, phase=session.phase, elapsed=elapsed)
425
+ session.initial_opinions[p.participant_id] = text
426
+ yield _sse("message", _msg_payload(msg))
427
+
428
+ if _participant_msg_cap_hit(session):
429
+ async for chunk in _wait_for_continue(session, "messages"):
430
+ yield chunk
431
+
432
+ yield _sse("status", {"message": "Building Credential Summary..."})
433
+ creds = await build_credential_summary(
434
+ orchestrator_model_id=_orchestrator_model_id(session),
435
+ question=session.question,
436
+ participants=_active_participants(session),
437
+ initial_opinions=session.initial_opinions,
438
+ api_log=session.api_log,
439
+ )
440
+ _bump_orchestrator_count(session)
441
+ session.credential_summary = creds
442
 
 
443
 
444
+ async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[str]:
445
+ session.phase = (
446
+ Phase.CRITIQUE_ROUND_1 if round_number == 1 else Phase.CRITIQUE_ROUND_2
447
+ )
448
+ yield _sse("status", {
449
+ "message": f"Phase 2: critique round {round_number} of 2...",
450
+ })
451
+ cred_block = credentials_to_block(session.credential_summary)
452
+ actives = _active_participants(session)
453
+ for p in actives:
454
+ transcript = _format_history(session.messages)
455
+ prompt = CRITIQUE_PROMPT.format(
456
+ round_number=round_number,
457
+ question=session.question,
458
+ credential_summary=cred_block,
459
+ transcript=transcript,
460
+ )
461
+ text, elapsed, ok = await _call_participant(
462
+ session=session, participant=p,
463
+ user_prompt=prompt,
464
+ label=f"critique_round_{round_number}",
465
+ max_tokens=700,
466
+ )
467
+ if not ok or not text.strip():
468
+ yield _sse("participant_error", {
469
+ "participant_id": p.participant_id, "name": p.name,
470
+ "phase": session.phase.value,
471
+ })
472
+ if p.consecutive_failures >= 3:
473
+ p.enabled = False
474
+ yield _sse("status", {"message": f"{p.name} auto-disabled after 3 failures."})
475
+ continue
476
+
477
+ # Detect addressed_to so the consensus phase's targeted-response
478
+ # logic can also reuse it - cheap classification call.
479
+ addressed = await classify_addressed_to(
480
+ orchestrator_model_id=_orchestrator_model_id(session),
481
+ participants=_active_participants(session),
482
+ speaker_name=p.name,
483
+ message=text,
484
+ api_log=session.api_log,
485
+ )
486
+ _bump_orchestrator_count(session)
487
 
488
+ msg = _add_participant_message(
489
+ session, p, text, phase=session.phase, elapsed=elapsed,
490
+ addressed_to=addressed,
 
491
  )
492
+ yield _sse("message", _msg_payload(msg))
493
+
494
+ if _participant_msg_cap_hit(session):
495
+ async for chunk in _wait_for_continue(session, "messages"):
496
+ yield chunk
497
+ if _orchestrator_cap_hit(session):
498
+ async for chunk in _wait_for_continue(session, "orchestrator"):
499
+ yield chunk
500
+
501
+
502
+ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
503
+ session.phase = Phase.STATUS_ASSESSMENT
504
+ yield _sse("status", {"message": "Phase 3: assessing whether more questions are needed..."})
505
+
506
+ cred_block = credentials_to_block(session.credential_summary)
507
+
508
+ # Refresh Credential Summary once after Phase 2 critique - participants
509
+ # have revealed a lot more about themselves through critique.
510
+ transcript = _format_history(session.messages)
511
+ refreshed = await refresh_credential_summary(
512
+ orchestrator_model_id=_orchestrator_model_id(session),
513
+ question=session.question,
514
+ participants=_active_participants(session),
515
+ existing=session.credential_summary,
516
+ critique_transcript=transcript,
517
+ api_log=session.api_log,
518
+ )
519
+ _bump_orchestrator_count(session)
520
+ session.credential_summary = refreshed
521
+ cred_block = credentials_to_block(session.credential_summary)
522
+
523
+ for iteration in range(3):
524
+ session.status_assessment_iterations = iteration + 1
525
+ prompt = STATUS_ASSESSMENT_PROMPT.format(
526
+ question=session.question,
527
+ credential_summary=cred_block,
528
+ transcript=_format_history(session.messages),
529
  )
530
+ _raw, parsed = await orchestrator_call(
531
+ orchestrator_model_id=_orchestrator_model_id(session),
532
+ user_prompt=prompt,
533
+ label=f"status_assessment_{iteration + 1}",
534
+ api_log=session.api_log,
535
+ max_tokens=512,
536
+ )
537
+ _bump_orchestrator_count(session)
538
 
539
+ opinions_solidified = bool(
540
+ isinstance(parsed, dict) and parsed.get("opinions_solidified")
 
 
541
  )
542
+ open_qs: list[dict[str, Any]] = []
543
+ if isinstance(parsed, dict):
544
+ open_qs = parsed.get("open_questions") or []
545
+
546
+ if opinions_solidified or not open_qs:
547
+ yield _sse("orchestrator", {
548
+ "kind": "status",
549
+ "text": "Opinions appear solidified - moving to finalization.",
550
+ })
551
+ return
552
+
553
+ # Otherwise run targeted follow-ups
554
+ active_ids = {p.participant_id for p in _active_participants(session)}
555
+ for oq in open_qs:
556
+ pid = oq.get("participant_id")
557
+ question_text = (oq.get("question") or "").strip()
558
+ if not pid or pid not in active_ids or not question_text:
559
+ continue
560
+ target = next(p for p in session.participants if p.participant_id == pid)
561
+ announce = (
562
+ f"The orchestrator has a follow-up for {target.name}: "
563
+ f"\"{question_text}\""
564
+ )
565
+ announce_msg = _add_orchestrator_message(session, announce, kind="status")
566
+ yield _sse("orchestrator", _msg_payload(announce_msg))
567
+
568
+ transcript = _format_history(session.messages)
569
+ prompt2 = TARGETED_FOLLOWUP_PROMPT.format(
570
+ transcript=transcript,
571
+ credential_summary=cred_block,
572
+ targeted_question=question_text,
573
+ )
574
+ text, elapsed, ok = await _call_participant(
575
+ session=session, participant=target,
576
+ user_prompt=prompt2,
577
+ label="targeted_followup",
578
+ max_tokens=600,
579
  )
580
+ if not ok or not text.strip():
581
+ yield _sse("participant_error", {
582
+ "participant_id": target.participant_id, "name": target.name,
583
+ "phase": session.phase.value,
584
+ })
585
+ continue
586
+ msg = _add_participant_message(
587
+ session, target, text, phase=session.phase, elapsed=elapsed,
 
 
 
 
 
 
 
 
 
588
  )
589
+ yield _sse("message", _msg_payload(msg))
590
+
591
+ if _participant_msg_cap_hit(session):
592
+ async for chunk in _wait_for_continue(session, "messages"):
593
+ yield chunk
594
+ if _orchestrator_cap_hit(session):
595
+ async for chunk in _wait_for_continue(session, "orchestrator"):
596
+ yield chunk
597
+
598
+ yield _sse("orchestrator", {
599
+ "kind": "status",
600
+ "text": "Status assessment limit reached - moving to finalization.",
601
+ })
602
+
603
+
604
+ async def _phase_finalization(session: Session) -> AsyncIterator[str]:
605
+ session.phase = Phase.FINALIZATION
606
+ yield _sse("status", {"message": "Phase 4: opinion finalization..."})
607
+
608
+ cred_block = credentials_to_block(session.credential_summary)
609
+ actives = _active_participants(session)
610
+ for p in actives:
611
+ transcript = _format_history(session.messages)
612
+ prompt = FINALIZATION_PROMPT.format(
613
+ question=session.question,
614
+ credential_summary=cred_block,
615
+ transcript=transcript,
616
+ )
617
+ text, elapsed, ok = await _call_participant(
618
+ session=session, participant=p,
619
+ user_prompt=prompt,
620
+ label="finalization",
621
+ max_tokens=600,
622
+ )
623
+ if not ok or not text.strip():
624
+ yield _sse("participant_error", {
625
+ "participant_id": p.participant_id, "name": p.name,
626
+ "phase": session.phase.value,
627
+ })
628
+ continue
629
+ session.final_opinions[p.participant_id] = text
630
+ msg = _add_participant_message(
631
+ session, p, text, phase=session.phase, elapsed=elapsed,
632
+ )
633
+ yield _sse("message", _msg_payload(msg))
634
+
635
+ if _participant_msg_cap_hit(session):
636
+ async for chunk in _wait_for_continue(session, "messages"):
637
+ yield chunk
638
+
639
+
640
+ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
641
+ session.phase = Phase.CONSENSUS
642
+ yield _sse("status", {"message": "Phase 5: consensus gathering..."})
643
+
644
+ cred_block = credentials_to_block(session.credential_summary)
645
+ actives = _active_participants(session)
646
+
647
+ # Initial alliance detection from the finalization-phase opinions
648
+ groups = await detect_alliances(
649
+ orchestrator_model_id=_orchestrator_model_id(session),
650
+ question=session.question,
651
+ participants=actives,
652
+ final_opinions=session.final_opinions,
653
+ api_log=session.api_log,
654
+ )
655
+ _bump_orchestrator_count(session)
656
+ session.alliance_groups = groups
657
+
658
+ announce = "Alliance groups detected: " + "; ".join(
659
+ f"\"{g.get('stance', '')}\" -> [{', '.join(g.get('members') or [])}]"
660
+ for g in groups
661
+ )
662
+ msg = _add_orchestrator_message(session, announce, kind="status")
663
+ yield _sse("orchestrator", _msg_payload(msg))
664
+
665
+ # Round-robin among active participants, but yield to the addressed-to
666
+ # target whenever the previous message named one explicitly.
667
+ queue: list[Participant] = list(actives)
668
+ last_addressed: str | None = None
669
+
670
+ # Hard backstop on this phase: if we make a lot of consensus turns
671
+ # without resolving, exit and let closure handle it. The orchestrator-
672
+ # call cap will hit before this, but it's a clean upper bound.
673
+ max_consensus_turns = 6 * len(actives)
674
+ consensus_turns = 0
675
+
676
+ while consensus_turns < max_consensus_turns:
677
+ consensus_turns += 1
678
+
679
+ # Pick speaker
680
+ if last_addressed:
681
+ speaker = next(
682
+ (p for p in actives if p.participant_id == last_addressed),
683
+ None,
684
  )
685
+ if speaker is None:
686
+ speaker = queue[0] if queue else actives[0]
687
+ else:
688
+ queue = [p for p in queue if p.participant_id != speaker.participant_id]
689
+ last_addressed = None
690
+ else:
691
+ if not queue:
692
+ queue = list(actives)
693
+ speaker = queue.pop(0)
694
+
695
+ # Decide allied vs solo prompt
696
+ speaker_group, other_groups = _find_speaker_group(speaker, session.alliance_groups)
697
+ prompt = _build_consensus_prompt(
698
+ session, speaker, speaker_group, other_groups,
699
+ actives, cred_block,
700
  )
 
 
701
 
702
+ text, elapsed, ok = await _call_participant(
703
+ session=session, participant=speaker,
704
+ user_prompt=prompt,
705
+ label="consensus",
706
+ max_tokens=700,
707
+ )
708
+ if not ok or not text.strip():
709
+ yield _sse("participant_error", {
710
+ "participant_id": speaker.participant_id, "name": speaker.name,
711
+ "phase": session.phase.value,
712
+ })
713
+ continue
714
+
715
+ addressed = await classify_addressed_to(
716
+ orchestrator_model_id=_orchestrator_model_id(session),
717
+ participants=actives,
718
+ speaker_name=speaker.name,
719
+ message=text,
720
+ api_log=session.api_log,
721
+ )
722
+ _bump_orchestrator_count(session)
723
+ last_addressed = addressed
724
+
725
+ msg = _add_participant_message(
726
+ session, speaker, text, phase=session.phase, elapsed=elapsed,
727
+ addressed_to=addressed,
728
+ )
729
+ yield _sse("message", _msg_payload(msg))
730
+
731
+ if _participant_msg_cap_hit(session):
732
+ async for chunk in _wait_for_continue(session, "messages"):
733
+ yield chunk
734
+ if _orchestrator_cap_hit(session):
735
+ async for chunk in _wait_for_continue(session, "orchestrator"):
736
+ yield chunk
737
+
738
+ # Status check every full round (every len(actives) turns)
739
+ if consensus_turns % max(1, len(actives)) == 0:
740
+ transcript = _format_history(session.messages)
741
+ status = await assess_consensus_status(
742
+ orchestrator_model_id=_orchestrator_model_id(session),
743
+ question=session.question,
744
+ transcript=transcript,
745
+ alliance_groups=session.alliance_groups,
746
+ api_log=session.api_log,
747
+ )
748
+ _bump_orchestrator_count(session)
749
+ if status.get("status") == "majority":
750
+ session.alliance_groups = await _refresh_alliance_groups(session, actives)
751
+ yield _sse("orchestrator", {
752
+ "kind": "status",
753
+ "text": f"Majority reached. {status.get('rationale', '')}".strip(),
754
+ })
755
+ return
756
+ if status.get("status") == "unproductive":
757
+ yield _sse("orchestrator", {
758
+ "kind": "status",
759
+ "text": f"Conversation no longer productive. {status.get('rationale', '')}".strip(),
760
+ })
761
+ return
762
+ # else: productive - keep going
763
+
764
+
765
+ async def _refresh_alliance_groups(
766
+ session: Session,
767
+ actives: list[Participant],
768
+ ) -> list[dict[str, Any]]:
769
+ """Re-cluster after the consensus phase, treating the latest round of
770
+ consensus statements as each participant's current stance."""
771
+ latest_by_id: dict[str, str] = {}
772
+ for m in session.messages:
773
+ if m.get("role") != "participant":
774
+ continue
775
+ if m.get("phase") != Phase.CONSENSUS.value:
776
+ continue
777
+ latest_by_id[m["speaker_id"]] = m["text"]
778
+ # Fall back to finalization opinions for any participant who didn't
779
+ # speak in the consensus phase yet.
780
+ merged: dict[str, str] = dict(session.final_opinions)
781
+ merged.update(latest_by_id)
782
+ groups = await detect_alliances(
783
+ orchestrator_model_id=_orchestrator_model_id(session),
784
+ question=session.question,
785
+ participants=actives,
786
+ final_opinions=merged,
787
+ api_log=session.api_log,
788
+ )
789
+ _bump_orchestrator_count(session)
790
+ return groups
791
+
792
+
793
+ def _find_speaker_group(
794
+ speaker: Participant,
795
+ groups: list[dict[str, Any]],
796
+ ) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
797
+ speaker_group: dict[str, Any] | None = None
798
+ others: list[dict[str, Any]] = []
799
+ for g in groups:
800
+ if speaker.participant_id in (g.get("members") or []):
801
+ speaker_group = g
802
+ else:
803
+ others.append(g)
804
+ return speaker_group, others
805
 
806
+
807
+ def _build_consensus_prompt(
808
+ session: Session,
809
+ speaker: Participant,
810
+ speaker_group: dict[str, Any] | None,
811
+ other_groups: list[dict[str, Any]],
812
+ actives: list[Participant],
813
+ cred_block: str,
814
+ ) -> str:
815
+ transcript = _format_history(session.messages)
816
+
817
+ # If the previous message addressed this speaker by id, route a
818
+ # targeted-response prompt instead of the standard allied/solo flow.
819
+ if session.messages:
820
+ last = session.messages[-1]
821
+ if (
822
+ last.get("role") == "participant"
823
+ and last.get("addressed_to") == speaker.participant_id
824
+ ):
825
+ return CONSENSUS_TARGETED_RESPONSE_PROMPT.format(
826
+ addressed_by_name=last.get("speaker_name", "another participant"),
827
+ addressed_message=last.get("text", ""),
828
+ question=session.question,
829
+ credential_summary=cred_block,
830
+ transcript=transcript,
831
+ )
832
+
833
+ if speaker_group and len(speaker_group.get("members") or []) > 1:
834
+ members = ", ".join(
835
+ p.name for p in actives
836
+ if p.participant_id in (speaker_group.get("members") or [])
837
+ and p.participant_id != speaker.participant_id
838
+ ) or "(no co-allies named)"
839
+ return CONSENSUS_ALLIED_PROMPT.format(
840
+ alliance_members=members,
841
+ alliance_stance=speaker_group.get("stance", "(unspecified)"),
842
+ question=session.question,
843
+ credential_summary=cred_block,
844
+ transcript=transcript,
845
+ )
846
+
847
+ other_groups_block = "\n".join(
848
+ f" - \"{g.get('stance', '')}\" supported by " + ", ".join(
849
+ p.name for p in actives if p.participant_id in (g.get("members") or [])
850
+ )
851
+ for g in other_groups
852
+ ) or "(no other groups)"
853
+ return CONSENSUS_SOLO_PROMPT.format(
854
+ your_stance=(speaker_group or {}).get("stance", "(unspecified)"),
855
+ other_groups_block=other_groups_block,
856
+ question=session.question,
857
+ credential_summary=cred_block,
858
+ transcript=transcript,
859
+ )
860
 
861
 
862
  # ---------------------------------------------------------------------------
863
+ # Closure
864
  # ---------------------------------------------------------------------------
865
 
866
+ async def _phase_closure(session: Session) -> AsyncIterator[str]:
867
+ session.phase = Phase.CLOSURE
868
+ yield _sse("status", {"message": "Phase 6: closure..."})
 
 
 
 
 
 
 
 
 
 
 
869
 
870
+ cred_block = credentials_to_block(session.credential_summary)
871
+ transcript = _format_history(session.messages)
872
 
873
+ status = await assess_consensus_status(
874
+ orchestrator_model_id=_orchestrator_model_id(session),
875
+ question=session.question,
876
+ transcript=transcript,
877
+ alliance_groups=session.alliance_groups,
878
+ api_log=session.api_log,
879
+ )
880
+ _bump_orchestrator_count(session)
881
+
882
+ actives = _active_participants(session)
883
+ if status.get("status") == "majority":
884
+ idx = status.get("majority_group_index")
885
+ majority_group = None
886
+ if isinstance(idx, int) and 0 <= idx < len(session.alliance_groups):
887
+ majority_group = session.alliance_groups[idx]
888
+ else:
889
+ # Fallback: largest group wins
890
+ if session.alliance_groups:
891
+ majority_group = max(
892
+ session.alliance_groups,
893
+ key=lambda g: len(g.get("members") or []),
894
+ )
895
+ if majority_group:
896
+ members_names = [
897
+ p.name for p in actives
898
+ if p.participant_id in (majority_group.get("members") or [])
899
+ ]
900
+ stance = majority_group.get("stance", "")
901
+ prompt = MAJORITY_REPORT_PROMPT.format(
902
+ question=session.question,
903
+ credential_summary=cred_block,
904
+ majority_members=", ".join(members_names),
905
+ majority_stance=stance,
906
+ transcript=transcript,
907
+ )
908
+ raw, _ = await orchestrator_call(
909
+ orchestrator_model_id=_orchestrator_model_id(session),
910
+ user_prompt=prompt,
911
+ label="majority_report",
912
+ api_log=session.api_log,
913
+ expect_json=False,
914
+ max_tokens=900,
915
+ temperature=0.3,
916
+ )
917
+ _bump_orchestrator_count(session)
918
+ session.final_report = {
919
+ "kind": "majority",
920
+ "text": raw,
921
+ "majority_members": members_names,
922
+ "majority_stance": stance,
923
+ "alliance_groups": session.alliance_groups,
924
+ }
925
+ msg = _add_orchestrator_message(
926
+ session, raw, kind="majority_report",
927
+ extra={"majority_members": members_names, "majority_stance": stance},
928
+ )
929
+ yield _sse("orchestrator", _msg_payload(msg))
930
+ return
931
+
932
+ # Not productive / no majority. First time -> surface unaddressed factor.
933
+ if session.consensus_attempts < 1:
934
+ session.consensus_attempts += 1
935
+ factor = await find_unaddressed_factor(
936
+ orchestrator_model_id=_orchestrator_model_id(session),
937
+ question=session.question,
938
+ credential_summary_block=cred_block,
939
+ transcript=transcript,
940
+ api_log=session.api_log,
941
+ )
942
+ _bump_orchestrator_count(session)
943
+ if factor and factor.get("factor"):
944
+ announce = (
945
+ f"The discussion has stalled. The orchestrator surfaces a new "
946
+ f"factor for the group to consider: {factor['factor']}"
947
+ )
948
+ msg = _add_orchestrator_message(
949
+ session, announce, kind="factor",
950
+ extra={"expected_to_shift": factor.get("expected_to_shift") or []},
951
+ )
952
+ yield _sse("orchestrator", _msg_payload(msg))
953
+ # Re-run the consensus phase once more
954
+ async for chunk in _phase_consensus(session):
955
+ yield chunk
956
+ async for chunk in _phase_closure(session):
957
+ yield chunk
958
+ return
959
+
960
+ # Failed twice (or no factor surfaced) -> emit no-consensus report
961
+ prompt = NO_CONSENSUS_REPORT_PROMPT.format(
962
+ question=session.question,
963
+ credential_summary=cred_block,
964
+ alliance_block="\n".join(
965
+ f" - \"{g.get('stance', '')}\": "
966
+ + ", ".join(
967
+ p.name for p in actives
968
+ if p.participant_id in (g.get("members") or [])
969
+ )
970
+ for g in session.alliance_groups
971
+ ),
972
+ transcript=transcript,
973
+ )
974
+ raw, _ = await orchestrator_call(
975
+ orchestrator_model_id=_orchestrator_model_id(session),
976
+ user_prompt=prompt,
977
+ label="no_consensus_report",
978
+ api_log=session.api_log,
979
+ expect_json=False,
980
+ max_tokens=900,
981
+ temperature=0.3,
982
+ )
983
+ _bump_orchestrator_count(session)
984
+ session.final_report = {
985
+ "kind": "no_consensus",
986
+ "text": raw,
987
+ "alliance_groups": session.alliance_groups,
988
  }
989
+ msg = _add_orchestrator_message(session, raw, kind="no_consensus_report")
990
+ yield _sse("orchestrator", _msg_payload(msg))
991
 
992
 
993
+ # ---------------------------------------------------------------------------
994
+ # Public driver
995
+ # ---------------------------------------------------------------------------
996
+
997
+ async def run_conversation(session: Session) -> AsyncIterator[str]:
998
+ """Drive the full six-phase conversation, yielding SSE chunks."""
999
+ actives = _active_participants(session)
1000
+ if len(actives) < 2:
1001
+ yield _sse("error", {
1002
+ "message": "Need at least 2 active participants to start.",
1003
+ })
1004
+ yield _sse("done", {})
1005
+ return
1006
+ if len(actives) > session.max_participants:
1007
+ # Defense in depth - the API layer should have already enforced this.
1008
+ for extra in actives[session.max_participants:]:
1009
+ extra.enabled = False
1010
+
1011
+ try:
1012
+ async for chunk in _phase_initial_opinions(session):
1013
+ yield chunk
1014
+
1015
+ async for chunk in _phase_critique(session, 1):
1016
+ yield chunk
1017
+ async for chunk in _phase_critique(session, 2):
1018
+ yield chunk
1019
+
1020
+ async for chunk in _phase_status_assessment(session):
1021
+ yield chunk
1022
+
1023
+ async for chunk in _phase_finalization(session):
1024
+ yield chunk
1025
+
1026
+ async for chunk in _phase_consensus(session):
1027
+ yield chunk
1028
+
1029
+ async for chunk in _phase_closure(session):
1030
+ yield chunk
1031
+ except Exception as exc:
1032
+ LOG.exception("Conversation crashed: %s", exc)
1033
+ yield _sse("error", {"message": f"Internal error: {exc}"})
1034
+ finally:
1035
+ session.finished = True
1036
+ session.phase = Phase.FINISHED
1037
+
1038
+ # Build per-participant contribution summaries for the table view.
1039
+ try:
1040
+ await _build_contribution_summaries(session)
1041
+ except Exception as exc:
1042
+ LOG.warning("Failed to build contribution summaries: %s", exc)
1043
+
1044
+ yield _sse("system", {"text": "End of Chat", "phase": session.phase.value})
1045
+ yield _sse("done", {})
1046
+
1047
+
1048
+ async def _build_contribution_summaries(session: Session) -> None:
1049
+ actives = _active_participants(session)
1050
+ roster = "\n".join(
1051
+ f"- id: {p.participant_id} | name: {p.name}" for p in actives
1052
+ )
1053
+ transcript = _format_history(session.messages)
1054
+ prompt = CONTRIBUTION_SUMMARY_PROMPT.format(
1055
+ roster_block=roster,
1056
+ transcript=transcript,
1057
+ )
1058
+ _raw, parsed = await orchestrator_call(
1059
+ orchestrator_model_id=_orchestrator_model_id(session),
1060
+ user_prompt=prompt,
1061
+ label="contribution_summaries",
1062
+ api_log=session.api_log,
1063
+ max_tokens=900,
1064
+ )
1065
+ session.orchestrator_call_count += 1
1066
+ if isinstance(parsed, dict) and isinstance(parsed.get("contributions"), list):
1067
+ for c in parsed["contributions"]:
1068
+ pid = c.get("participant_id")
1069
+ summary = (c.get("summary") or "").strip()
1070
+ if pid and summary:
1071
+ session.contribution_summaries[pid] = summary
backend/app/services/prompts/__init__.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-phase prompt templates for the CCAI orchestrator.
2
+
3
+ Each phase's templates live in their own file so prompt iteration doesn't
4
+ churn the state machine in `orchestrator.py`. All templates here are pure
5
+ strings; they're formatted and combined in `orchestrator.py`.
6
+ """
7
+
8
+ from app.services.prompts.directives import (
9
+ PARTICIPANT_BASE_DIRECTIVE,
10
+ NO_REASONING_DIRECTIVE,
11
+ ORCHESTRATOR_BASE_DIRECTIVE,
12
+ )
13
+ from app.services.prompts.initial_opinions import INITIAL_OPINION_PROMPT
14
+ from app.services.prompts.credential_summary import (
15
+ CREDENTIAL_BUILD_PROMPT,
16
+ CREDENTIAL_REFRESH_PROMPT,
17
+ )
18
+ from app.services.prompts.critique import CRITIQUE_PROMPT
19
+ from app.services.prompts.status_assessment import (
20
+ STATUS_ASSESSMENT_PROMPT,
21
+ TARGETED_FOLLOWUP_PROMPT,
22
+ )
23
+ from app.services.prompts.finalization import FINALIZATION_PROMPT
24
+ from app.services.prompts.consensus import (
25
+ ALLIANCE_DETECTION_PROMPT,
26
+ ADDRESSED_TO_PROMPT,
27
+ CONSENSUS_ALLIED_PROMPT,
28
+ CONSENSUS_SOLO_PROMPT,
29
+ CONSENSUS_TARGETED_RESPONSE_PROMPT,
30
+ CONSENSUS_STATUS_PROMPT,
31
+ )
32
+ from app.services.prompts.closure import (
33
+ UNADDRESSED_FACTOR_PROMPT,
34
+ MAJORITY_REPORT_PROMPT,
35
+ NO_CONSENSUS_REPORT_PROMPT,
36
+ CONTRIBUTION_SUMMARY_PROMPT,
37
+ )
38
+
39
+ __all__ = [
40
+ "PARTICIPANT_BASE_DIRECTIVE",
41
+ "NO_REASONING_DIRECTIVE",
42
+ "ORCHESTRATOR_BASE_DIRECTIVE",
43
+ "INITIAL_OPINION_PROMPT",
44
+ "CREDENTIAL_BUILD_PROMPT",
45
+ "CREDENTIAL_REFRESH_PROMPT",
46
+ "CRITIQUE_PROMPT",
47
+ "STATUS_ASSESSMENT_PROMPT",
48
+ "TARGETED_FOLLOWUP_PROMPT",
49
+ "FINALIZATION_PROMPT",
50
+ "ALLIANCE_DETECTION_PROMPT",
51
+ "ADDRESSED_TO_PROMPT",
52
+ "CONSENSUS_ALLIED_PROMPT",
53
+ "CONSENSUS_SOLO_PROMPT",
54
+ "CONSENSUS_TARGETED_RESPONSE_PROMPT",
55
+ "CONSENSUS_STATUS_PROMPT",
56
+ "UNADDRESSED_FACTOR_PROMPT",
57
+ "MAJORITY_REPORT_PROMPT",
58
+ "NO_CONSENSUS_REPORT_PROMPT",
59
+ "CONTRIBUTION_SUMMARY_PROMPT",
60
+ ]
backend/app/services/prompts/closure.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 6: closure prompts (majority report, unaddressed-factor probe,
2
+ no-consensus failure report, and per-participant contribution summaries
3
+ used by the table view).
4
+ """
5
+
6
+ UNADDRESSED_FACTOR_PROMPT = (
7
+ "The discussion has stalled in the consensus-gathering phase. As the "
8
+ "neutral orchestrator, review the conversation and the Credential "
9
+ "Summary. Identify ONE important factor that has not been adequately "
10
+ "discussed and that is likely to shift the opinion of at least one "
11
+ "current participant if surfaced.\n\n"
12
+ "Question:\n<<<\n{question}\n>>>\n\n"
13
+ "Credential Summary:\n{credential_summary}\n\n"
14
+ "Conversation so far:\n{transcript}\n\n"
15
+ "Return JSON ONLY in this exact shape:\n"
16
+ "{{\n"
17
+ ' "factor": "<one short paragraph framing the factor as a question or consideration the group should now address>",\n'
18
+ ' "expected_to_shift": ["<participant_id>", "..."]\n'
19
+ "}}\n\n"
20
+ "The factor must be a real consideration that genuinely hasn't been "
21
+ "raised - not a rephrasing of what was already said. Output JSON only."
22
+ )
23
+
24
+ MAJORITY_REPORT_PROMPT = (
25
+ "The group has reached majority agreement in the discussion. As the "
26
+ "neutral orchestrator, produce a clear final report for the user who "
27
+ "asked the original question.\n\n"
28
+ "Question:\n<<<\n{question}\n>>>\n\n"
29
+ "Credential Summary (with credibility_for_question scores):\n{credential_summary}\n\n"
30
+ "Majority alliance: members={majority_members}, stance=\"{majority_stance}\".\n\n"
31
+ "Full conversation:\n{transcript}\n\n"
32
+ "Write the report as plain prose (no markdown headers required) covering:\n"
33
+ " 1. The decision the group reached, in one or two clear sentences.\n"
34
+ " 2. The strongest reasons the majority gave.\n"
35
+ " 3. Important dissenting points raised by participants whose "
36
+ "credibility_for_question >= 0.6 - quote or paraphrase them by name. "
37
+ "Skip dissent from participants with credibility below 0.6.\n"
38
+ " 4. Caveats or open questions worth flagging for the user.\n\n"
39
+ "Stay neutral. Do not editorialize beyond what the participants said. "
40
+ "Keep the whole report under ~250 words."
41
+ )
42
+
43
+ NO_CONSENSUS_REPORT_PROMPT = (
44
+ "The group has tried twice to reach consensus and has not succeeded. "
45
+ "As the neutral orchestrator, produce a final report for the user who "
46
+ "asked the original question.\n\n"
47
+ "Question:\n<<<\n{question}\n>>>\n\n"
48
+ "Credential Summary:\n{credential_summary}\n\n"
49
+ "Final alliance groups:\n{alliance_block}\n\n"
50
+ "Full conversation:\n{transcript}\n\n"
51
+ "Write the report as plain prose covering:\n"
52
+ " 1. A short statement that the group did not reach consensus.\n"
53
+ " 2. Each major opinion that emerged, who supported it (by name), "
54
+ "and the strongest reason given for it.\n"
55
+ " 3. Your own neutral recommendation for the most defensible position, "
56
+ "based purely on the strength of the arguments and the credibility_for_"
57
+ "question scores - not your own opinion. Make clear this is a "
58
+ "recommendation, not a decision.\n"
59
+ " 4. A brief suggestion that the user weigh these and make their own "
60
+ "decision.\n\n"
61
+ "Stay neutral. Keep the report under ~300 words."
62
+ )
63
+
64
+ CONTRIBUTION_SUMMARY_PROMPT = (
65
+ "Below is the full transcript of a multi-participant discussion. For "
66
+ "each listed participant, write a 2-3 sentence neutral summary of "
67
+ "their overall contribution: the position they took, how it evolved, "
68
+ "and the strongest argument they made. Do not editorialize. Do not "
69
+ "rank or grade them.\n\n"
70
+ "Participants:\n{roster_block}\n\n"
71
+ "Transcript:\n{transcript}\n\n"
72
+ "Return JSON ONLY in this exact shape:\n"
73
+ "{{\n"
74
+ ' "contributions": [\n'
75
+ ' {{ "participant_id": "<id>", "summary": "<2-3 sentences>" }}\n'
76
+ " ]\n"
77
+ "}}\n\n"
78
+ "Output JSON only."
79
+ )
backend/app/services/prompts/consensus.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 5: consensus-gathering prompts.
2
+
3
+ The orchestrator detects "alliance groups" of participants with similar
4
+ revised opinions, then nudges them toward a group decision. Allied
5
+ participants are prompted to argue and recruit; solo participants are
6
+ prompted to seek allies, switch, or propose compromises.
7
+ """
8
+
9
+ ALLIANCE_DETECTION_PROMPT = (
10
+ "Below are each participant's revised opinions from the finalization "
11
+ "phase. Your job, as the orchestrator, is to cluster them into "
12
+ "alliance groups: sets of participants whose opinions are similar "
13
+ "enough that they would naturally team up in a real meeting.\n\n"
14
+ "Question:\n<<<\n{question}\n>>>\n\n"
15
+ "Revised opinions:\n{finalization_block}\n\n"
16
+ "Return JSON ONLY in this exact shape:\n"
17
+ "{{\n"
18
+ ' "groups": [\n'
19
+ " {{\n"
20
+ ' "stance": "<one short sentence describing the shared position>",\n'
21
+ ' "members": ["<participant_id>", "..."]\n'
22
+ " }}\n"
23
+ " ]\n"
24
+ "}}\n\n"
25
+ "Every participant must appear in exactly one group. Solo participants "
26
+ "(no allies) get their own single-member group. Output JSON only."
27
+ )
28
+
29
+ ADDRESSED_TO_PROMPT = (
30
+ "Below is the most recent message in a multi-participant discussion. "
31
+ "Decide whether it is primarily aimed at one specific other participant "
32
+ "(e.g. challenging them, asking them a question, calling on them to "
33
+ "respond), or whether it is a broadcast to the whole group.\n\n"
34
+ "Available participants and their ids:\n{roster_block}\n\n"
35
+ "Speaker: {speaker}\n"
36
+ "Message: {message}\n\n"
37
+ "Return JSON ONLY: {{\"addressed_to\": \"<participant_id or null>\"}}. "
38
+ "Use the literal JSON null (no quotes) when no specific addressee is "
39
+ "obvious. Output JSON only."
40
+ )
41
+
42
+ CONSENSUS_ALLIED_PROMPT = (
43
+ "Phase 5 of the discussion: Consensus Gathering.\n\n"
44
+ "The orchestrator has clustered the group into alliances based on the "
45
+ "revised opinions. You are part of an alliance with: {alliance_members}. "
46
+ "Your shared stance: \"{alliance_stance}\".\n\n"
47
+ "Other groups currently disagree. Your job, in 4-8 sentences, is to "
48
+ "advocate for your alliance's position and try to win over participants "
49
+ "from other groups. Strategies that work in real human meetings:\n"
50
+ " - Counter the main points of contrasting opinions with concrete facts "
51
+ "or arguments (cite specific things others said).\n"
52
+ " - Reinforce your own side's main points with additional supporting "
53
+ "facts or by emphasizing the credibility of an ally on this topic.\n"
54
+ " - Address specific participants by name when challenging or inviting "
55
+ "them.\n\n"
56
+ "Question:\n<<<\n{question}\n>>>\n\n"
57
+ "Credential Summary:\n{credential_summary}\n\n"
58
+ "Conversation so far:\n{transcript}"
59
+ )
60
+
61
+ CONSENSUS_SOLO_PROMPT = (
62
+ "Phase 5 of the discussion: Consensus Gathering.\n\n"
63
+ "Right now you are the sole holder of your stance: \"{your_stance}\". "
64
+ "The other groups are: {other_groups_block}. In a real human meeting, "
65
+ "someone in your position has three good options - pick whichever fits "
66
+ "your character and what's been said:\n"
67
+ " 1. Pick the existing group whose stance is closest to yours and try "
68
+ "to get them to shift toward your view.\n"
69
+ " 2. Switch your support to whichever group's stance you can honestly "
70
+ "live with, naming them and explaining why.\n"
71
+ " 3. Propose a compromise position that both you and at least one "
72
+ "other group might find acceptable.\n\n"
73
+ "Respond in 4-8 sentences. Address other participants by name when "
74
+ "doing so makes sense. Stay in character.\n\n"
75
+ "Question:\n<<<\n{question}\n>>>\n\n"
76
+ "Credential Summary:\n{credential_summary}\n\n"
77
+ "Conversation so far:\n{transcript}"
78
+ )
79
+
80
+ CONSENSUS_TARGETED_RESPONSE_PROMPT = (
81
+ "Phase 5 of the discussion: Consensus Gathering.\n\n"
82
+ "{addressed_by_name} just spoke and aimed their message at you "
83
+ "specifically. The orchestrator is giving you the floor to respond.\n\n"
84
+ "Their message: \"{addressed_message}\"\n\n"
85
+ "Question:\n<<<\n{question}\n>>>\n\n"
86
+ "Credential Summary:\n{credential_summary}\n\n"
87
+ "Conversation so far:\n{transcript}\n\n"
88
+ "Respond directly to {addressed_by_name} in 3-7 sentences. You may "
89
+ "concede a point, push back with a counter-argument, ask a clarifying "
90
+ "question, or propose a compromise. Stay in character."
91
+ )
92
+
93
+ CONSENSUS_STATUS_PROMPT = (
94
+ "Below is the discussion through the consensus-gathering phase so far. "
95
+ "As the orchestrator, decide whether (a) a majority has reached "
96
+ "agreement, (b) opinions are still actively shifting in a productive "
97
+ "direction, or (c) opinions have stopped shifting and the conversation "
98
+ "is no longer productive.\n\n"
99
+ "Question:\n<<<\n{question}\n>>>\n\n"
100
+ "Latest alliance groups:\n{alliance_block}\n\n"
101
+ "Conversation so far:\n{transcript}\n\n"
102
+ "Return JSON ONLY in this exact shape:\n"
103
+ "{{\n"
104
+ ' "status": "<majority|productive|unproductive>",\n'
105
+ ' "majority_group_index": <integer index into alliance_groups, or null>,\n'
106
+ ' "rationale": "<one short sentence>"\n'
107
+ "}}\n\n"
108
+ "Use \"majority\" only if more than half of all participants now share a "
109
+ "single stance. Use \"unproductive\" only if the last few exchanges "
110
+ "have been repetitive or the participants are clearly entrenched. Output "
111
+ "JSON only."
112
+ )
backend/app/services/prompts/credential_summary.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Credential Summary: the orchestrator's neutral assessment of each
2
+ participant's expertise, personality, and credibility on the question.
3
+
4
+ Built once after Phase 1 from each participant's role prompt + first
5
+ opinion. Refreshed once after Phase 2 critique because participants
6
+ reveal a lot more about themselves through critique than through their
7
+ opening pitch.
8
+ """
9
+
10
+ CREDENTIAL_BUILD_PROMPT = (
11
+ "Below is the question being discussed and, for each participant, "
12
+ "their role prompt and their first opinion (Phase 1). Build a Credential "
13
+ "Summary: a neutral, third-person assessment of each participant that "
14
+ "any other participant could use to weight their statements.\n\n"
15
+ "Question:\n<<<\n{question}\n>>>\n\n"
16
+ "Participants:\n{participants_block}\n\n"
17
+ "Return JSON ONLY in this exact shape:\n"
18
+ "{{\n"
19
+ ' "credentials": [\n'
20
+ " {{\n"
21
+ ' "participant_id": "<participant_id>",\n'
22
+ ' "name": "<participant_name>",\n'
23
+ ' "expertise": "<1-2 sentences on what they know about and don\'t know about>",\n'
24
+ ' "personality": "<1 sentence on debating style / temperament>",\n'
25
+ ' "credibility_for_question": <number 0.0 to 1.0>,\n'
26
+ ' "bias_to_watch": "<1 sentence on biases or blind spots>"\n'
27
+ " }}\n"
28
+ " ]\n"
29
+ "}}\n\n"
30
+ "credibility_for_question is YOUR neutral estimate of how much weight "
31
+ "their voice should carry on THIS specific question, given their stated "
32
+ "background and how they framed their first opinion. Use the full 0-1 "
33
+ "scale; do not bunch everyone near the top. Do NOT favor or disfavor "
34
+ "any participant. Output JSON only - no commentary, no markdown."
35
+ )
36
+
37
+ CREDENTIAL_REFRESH_PROMPT = (
38
+ "Below is the original Credential Summary you produced after Phase 1. "
39
+ "After two rounds of critique, the participants have revealed more "
40
+ "about themselves. Update the Credential Summary if anything material "
41
+ "changed: shifts in apparent expertise, observed reasoning quality, "
42
+ "newly visible biases, or revised credibility for THIS question. Keep "
43
+ "anything that's still accurate. Return JSON in the same shape as the "
44
+ "input. JSON only.\n\n"
45
+ "Question:\n<<<\n{question}\n>>>\n\n"
46
+ "Original Credential Summary:\n{credential_summary_json}\n\n"
47
+ "Critique-round transcript:\n{critique_transcript}"
48
+ )
backend/app/services/prompts/critique.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 2: each participant gets two turns of critique."""
2
+
3
+ CRITIQUE_PROMPT = (
4
+ "Phase 2 of the discussion: Critique (round {round_number} of 2).\n\n"
5
+ "The group has been asked the following question:\n\n"
6
+ "<<<\n{question}\n>>>\n\n"
7
+ "Here is a Credential Summary the orchestrator built about each "
8
+ "participant. Use it the way a thoughtful person in a real meeting would: "
9
+ "weight statements appropriately, but don't let it shut down good ideas "
10
+ "from less-credentialed voices.\n\n"
11
+ "Credential Summary:\n{credential_summary}\n\n"
12
+ "Conversation so far:\n{transcript}\n\n"
13
+ "It is now your turn. In a focused 5-10 sentence response:\n"
14
+ " 1. Offer constructive criticism of one or more other participants' "
15
+ "opinions, naming them directly. Cite specific points, not vibes.\n"
16
+ " 2. Ask any follow-up questions of specific participants where you "
17
+ "want a clearer answer.\n"
18
+ " 3. Revise your own opinion if (and only if) the discussion has "
19
+ "given you reason to. If you've revised, say so explicitly.\n\n"
20
+ "Stay in character. Do not try to wrap up the discussion - we are not "
21
+ "near the end yet."
22
+ )
backend/app/services/prompts/directives.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared directive blocks injected into every participant / orchestrator call."""
2
+
3
+ # Always appended to a participant's role_prompt before any phase template.
4
+ # Establishes the CCAI ground rules: you are one of N participants, the
5
+ # orchestrator runs the conversation, you don't speak for anyone else and
6
+ # you don't speak out of turn.
7
+ PARTICIPANT_BASE_DIRECTIVE = (
8
+ "You are one of {n_participants} participants in a structured group "
9
+ "discussion facilitated by a neutral orchestrator. The other "
10
+ "participants are: {other_participants}. The orchestrator will tell "
11
+ "you when it is your turn, what phase of the discussion you are in, "
12
+ "and exactly what is being asked of you. Do NOT simulate the other "
13
+ "participants, do NOT speak out of turn, and do NOT address the "
14
+ "orchestrator as if it were one of the participants - it has no "
15
+ "opinion and is not part of the decision."
16
+ )
17
+
18
+ # Same hard "no reasoning, no meta-commentary" guard the upstream LLMChats3
19
+ # orchestrator used. Always appended to the system message of any
20
+ # participant call. The sanitizer in app.utils.sanitize is the actual
21
+ # guarantee, but this directive makes a lot of models cooperate.
22
+ NO_REASONING_DIRECTIVE = (
23
+ "IMPORTANT: Respond ONLY with your in-character contribution. Do NOT "
24
+ "include your reasoning, thought process, analysis of the prompt, "
25
+ "meta-commentary, internal monologue, scratchpad, draft notes, or any "
26
+ "tags such as <think>, <reasoning>, or <scratchpad>. Output ONLY the "
27
+ "words your character would actually say to the group."
28
+ )
29
+
30
+ # Used as the system prompt for every orchestrator-side LLM call (status
31
+ # checks, alliance detection, addressed-to classification, summaries).
32
+ ORCHESTRATOR_BASE_DIRECTIVE = (
33
+ "You are the neutral orchestrator of a structured group discussion. "
34
+ "You do NOT have an opinion on the question being discussed, you do "
35
+ "NOT pick a side, and you do NOT decide any issue. Your only job is "
36
+ "to assess the conversation and produce the exact output format the "
37
+ "instruction asks for. When the instruction asks for JSON, return ONLY "
38
+ "valid JSON with no surrounding prose, markdown fences, or commentary."
39
+ )
backend/app/services/prompts/finalization.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 4: each participant either states a revised post-discussion
2
+ opinion or endorses another participant's revised opinion (with optional
3
+ added comment).
4
+ """
5
+
6
+ FINALIZATION_PROMPT = (
7
+ "Phase 4 of the discussion: Opinion Finalization.\n\n"
8
+ "The question:\n<<<\n{question}\n>>>\n\n"
9
+ "Credential Summary:\n{credential_summary}\n\n"
10
+ "Full conversation so far:\n{transcript}\n\n"
11
+ "Now, considering everything that has been said, state your post-"
12
+ "discussion opinion. You have two options:\n\n"
13
+ " Option A - State your own revised opinion. If you have moved at all "
14
+ "from your first opinion, say what changed and why.\n\n"
15
+ " Option B - Endorse another participant's revised opinion. Name the "
16
+ "participant. Optionally add a sentence or two of your own (a caveat, "
17
+ "an additional argument, a slight tweak).\n\n"
18
+ "Begin your response with one of:\n"
19
+ " - \"My revised opinion:\" (if Option A)\n"
20
+ " - \"I agree with <participant name>:\" (if Option B)\n\n"
21
+ "Keep the whole response under 10 sentences."
22
+ )
backend/app/services/prompts/initial_opinions.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 1: each participant offers an *independent* first opinion.
2
+
3
+ The orchestrator hides every other participant's response until the round
4
+ is finished, so first opinions are genuinely independent. That is what
5
+ makes the Credential Summary in the next step meaningful.
6
+ """
7
+
8
+ INITIAL_OPINION_PROMPT = (
9
+ "Phase 1 of the discussion: First Opinions.\n\n"
10
+ "The group has been asked the following question:\n\n"
11
+ "<<<\n{question}\n>>>\n\n"
12
+ "You are speaking before any other participant has shared their view. "
13
+ "Read the question carefully, consider it through the lens of who you "
14
+ "are (your background, expertise, values, and personality), and offer "
15
+ "your initial opinion.\n\n"
16
+ "Your first opinion should:\n"
17
+ " 1. Take a clear, specific position on the question.\n"
18
+ " 2. Explain the 1-3 most important reasons behind your position, "
19
+ "drawing on your particular background or expertise.\n"
20
+ " 3. Acknowledge any uncertainty or trade-offs you see.\n\n"
21
+ "Speak in the first person. Keep it focused: 4-8 sentences. Do not "
22
+ "address other participants by name yet - you have not heard them "
23
+ "speak."
24
+ )
backend/app/services/prompts/status_assessment.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 3: orchestrator decides whether the conversation needs more
2
+ targeted follow-ups before moving to finalization.
3
+ """
4
+
5
+ STATUS_ASSESSMENT_PROMPT = (
6
+ "Below is the question being discussed, the current Credential Summary, "
7
+ "and the conversation transcript through the critique rounds. Your job, "
8
+ "as the orchestrator, is to decide whether the participants have "
9
+ "solidified their opinions or whether there are still important open "
10
+ "questions that warrant a targeted follow-up to specific participants "
11
+ "before we move on to opinion finalization.\n\n"
12
+ "Question:\n<<<\n{question}\n>>>\n\n"
13
+ "Credential Summary:\n{credential_summary}\n\n"
14
+ "Conversation so far:\n{transcript}\n\n"
15
+ "Return JSON ONLY in this exact shape:\n"
16
+ "{{\n"
17
+ ' "opinions_solidified": <true|false>,\n'
18
+ ' "open_questions": [\n'
19
+ ' {{ "participant_id": "<participant_id>", "question": "<one direct question>" }}\n'
20
+ " ],\n"
21
+ ' "notes": "<one short sentence on the discussion state>"\n'
22
+ "}}\n\n"
23
+ "If opinions are clearly solidified, return an empty open_questions list "
24
+ "and opinions_solidified=true. Otherwise list 1-3 high-leverage "
25
+ "questions, each aimed at one specific participant by participant_id. "
26
+ "Each follow-up should target a real ambiguity or unresolved disagreement "
27
+ "in the transcript - never invent topics that haven't come up. Output "
28
+ "JSON only."
29
+ )
30
+
31
+ TARGETED_FOLLOWUP_PROMPT = (
32
+ "The orchestrator has a follow-up question for you specifically.\n\n"
33
+ "Conversation so far:\n{transcript}\n\n"
34
+ "Credential Summary of the group:\n{credential_summary}\n\n"
35
+ "Follow-up question for you: {targeted_question}\n\n"
36
+ "Answer the question directly, in 3-6 sentences. You may reference "
37
+ "other participants' statements by name. Stay in character."
38
+ )
backend/app/utils/__init__.py ADDED
File without changes
backend/app/utils/sanitize.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralized sanitizer for LLM responses.
2
+
3
+ CCAI conversations don't work well when thinking traces leak into chat or
4
+ into orchestrator/summarizer/Credential-Summary inputs, so every LLM
5
+ response funnels through `strip_thinking` before being stored, displayed,
6
+ or forwarded to another LLM.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ # Top-level reasoning blocks emitted as XML-ish tags. DOTALL so we catch
13
+ # multi-line reasoning blocks; non-greedy so adjacent blocks don't merge.
14
+ _THINK_TAG_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
15
+ _REASONING_BLOCK_RE = re.compile(
16
+ r"<(reasoning|reflection|inner_thoughts|scratchpad|analysis|plan)>.*?</\1>",
17
+ re.DOTALL | re.IGNORECASE,
18
+ )
19
+
20
+ # Bare "thought:" / "reasoning:" prologues some models emit before content
21
+ # (only at the very start of the response, otherwise we'd nuke the body).
22
+ _PROLOGUE_RE = re.compile(
23
+ r"^\s*(thought|thinking|reasoning|analysis|scratchpad)\s*:\s*"
24
+ r".*?(?=\n\n|\Z)",
25
+ re.DOTALL | re.IGNORECASE,
26
+ )
27
+
28
+ # Some providers wrap thinking in special framing tokens. We try to strip
29
+ # the *paired* form (open ... close) first so the body in between is
30
+ # removed, and fall back to stripping any leftover bare markers.
31
+ _PAIRED_FRAMING_RES = [
32
+ re.compile(r"<\|reasoning\|>.*?<\|/reasoning\|>", re.DOTALL | re.IGNORECASE),
33
+ re.compile(r"<\|think\|>.*?<\|/think\|>", re.DOTALL | re.IGNORECASE),
34
+ ]
35
+ _FRAMING_TOKENS = ["<|reasoning|>", "<|/reasoning|>", "<|think|>", "<|/think|>"]
36
+
37
+
38
+ def strip_thinking(text: str | None) -> str:
39
+ """Return `text` with all reasoning artifacts removed.
40
+
41
+ Safe to call on empty, whitespace-only, or None inputs (returns empty
42
+ string in those cases). Idempotent: calling twice yields the same result.
43
+ """
44
+ if not text:
45
+ return ""
46
+
47
+ out = _THINK_TAG_RE.sub("", text)
48
+ out = _REASONING_BLOCK_RE.sub("", out)
49
+
50
+ for paired in _PAIRED_FRAMING_RES:
51
+ out = paired.sub("", out)
52
+ for tok in _FRAMING_TOKENS:
53
+ out = out.replace(tok, "")
54
+
55
+ out = _PROLOGUE_RE.sub("", out)
56
+
57
+ return out.strip()
58
+
59
+
60
+ def response_has_thinking(text: str | None, msg: dict | None = None) -> bool:
61
+ """Return True if the raw response had any thinking artifact.
62
+
63
+ Checks both the textual content and any `reasoning_content` /
64
+ `reasoning` fields the OpenAI-compat client may have surfaced.
65
+ """
66
+ if msg is not None:
67
+ if msg.get("reasoning_content") or msg.get("reasoning"):
68
+ return True
69
+
70
+ if not text:
71
+ return False
72
+
73
+ if _THINK_TAG_RE.search(text):
74
+ return True
75
+ if _REASONING_BLOCK_RE.search(text):
76
+ return True
77
+ if any(tok in text for tok in _FRAMING_TOKENS):
78
+ return True
79
+ if _PROLOGUE_RE.match(text):
80
+ return True
81
+ return False
backend/requirements.txt CHANGED
@@ -5,4 +5,5 @@ pydantic-settings>=2.6.0
5
  python-multipart>=0.0.12
6
  python-dotenv>=1.0.0
7
  huggingface_hub>=0.25.0
8
- itsdangerous>=2.2.0
 
 
5
  python-multipart>=0.0.12
6
  python-dotenv>=1.0.0
7
  huggingface_hub>=0.25.0
8
+ itsdangerous>=2.2.0
9
+ pytest>=8.0.0
backend/tests/__init__.py ADDED
File without changes
backend/tests/test_context_budget.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.services.context_budget import (
2
+ ContextSummary,
3
+ DEFAULT_CONTEXT,
4
+ DEFAULT_REPLY_BUDGET,
5
+ SUMMARIZE_THRESHOLD,
6
+ TRIM_THRESHOLD,
7
+ build_compressed_messages,
8
+ context_window_for,
9
+ estimate_messages_tokens,
10
+ select_summarizer_model_id,
11
+ should_summarize,
12
+ )
13
+
14
+
15
+ def test_context_window_known_model():
16
+ assert context_window_for("gpt-4.1-mini") == 128_000
17
+
18
+
19
+ def test_context_window_unknown_falls_back():
20
+ assert context_window_for("totally-unknown") == DEFAULT_CONTEXT
21
+
22
+
23
+ def test_context_window_neon_falls_back():
24
+ assert context_window_for("neon:Foo/Bar@2025.10.01:Researcher") == DEFAULT_CONTEXT
25
+
26
+
27
+ def test_estimate_tokens_grows_with_text():
28
+ a = estimate_messages_tokens([{"role": "user", "content": "hi"}])
29
+ b = estimate_messages_tokens([{"role": "user", "content": "hi" * 1000}])
30
+ assert b > a
31
+
32
+
33
+ def test_should_summarize_below_threshold():
34
+ """A small ~10-token prompt against a 1M-token gemini window: never
35
+ should we trigger summarize.
36
+ """
37
+ summary = ContextSummary()
38
+ api = [{"role": "user", "content": "hello"}]
39
+ needs_sum, needs_trim, _ = should_summarize("gemini-2.5-flash", api, summary)
40
+ assert needs_sum is False
41
+ assert needs_trim is False
42
+
43
+
44
+ def test_should_summarize_above_threshold_triggers_summarize():
45
+ summary = ContextSummary()
46
+ big = "x" * 10_000 # ~2500 estimated tokens
47
+ api = [
48
+ {"role": "system", "content": big},
49
+ {"role": "user", "content": big},
50
+ ]
51
+ # Small 8K-window model -> 6K input budget -> 55% = 3300; we're way over.
52
+ needs_sum, _, budget = should_summarize("totally-unknown", api, summary)
53
+ assert needs_sum is True
54
+ assert budget >= 2_048
55
+
56
+
57
+ def test_should_trim_only_when_summary_exists():
58
+ """Even at 70%, trim should only happen once we already have a
59
+ running summary - otherwise we'd drop history with no replacement.
60
+ """
61
+ summary = ContextSummary()
62
+ big = "x" * 30_000
63
+ api = [
64
+ {"role": "system", "content": big},
65
+ {"role": "user", "content": big},
66
+ ]
67
+ _, needs_trim_no_sum, _ = should_summarize("totally-unknown", api, summary)
68
+ assert needs_trim_no_sum is False
69
+
70
+ summary.summary_text = "Previously, the group discussed X."
71
+ _, needs_trim_yes_sum, _ = should_summarize("totally-unknown", api, summary)
72
+ assert needs_trim_yes_sum is True
73
+
74
+
75
+ def test_build_compressed_keeps_system_and_recent():
76
+ summary = ContextSummary(summary_text="condensed history")
77
+ msgs = [
78
+ {"role": "system", "content": "you are X"},
79
+ {"role": "user", "content": "old1"},
80
+ {"role": "assistant", "content": "oldA"},
81
+ {"role": "user", "content": "old2"},
82
+ {"role": "assistant", "content": "oldB"},
83
+ {"role": "user", "content": "recent"},
84
+ ]
85
+ out = build_compressed_messages(msgs, summary, needs_trim=True)
86
+ # head + summary + last KEEP_RECENT_MESSAGES (=6) <= len(msgs) so we
87
+ # might end up with everything; the contract is just that the
88
+ # original system and the running summary are preserved.
89
+ assert out[0] == msgs[0]
90
+ assert any("Summary of earlier" in m["content"] for m in out)
91
+
92
+
93
+ def test_select_summarizer_falls_back():
94
+ # Override wins
95
+ assert select_summarizer_model_id("custom", "orch") == "custom"
96
+ # Falls back to orchestrator if no override
97
+ assert select_summarizer_model_id(None, "orch") == "orch"
backend/tests/test_csv_export.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.api.chat import _export_csv_table
2
+ from app.services.models import Phase, Session
3
+ from app.services.models import Participant
4
+
5
+
6
+ def _mk_session():
7
+ s = Session()
8
+ s.question = "Will \"AI\" change, education? Yes, no, maybe.\nNew lines too."
9
+ p1 = Participant(
10
+ participant_id="extra_a",
11
+ name="Alice",
12
+ role_prompt="rp",
13
+ model_id="model-a",
14
+ kind="extra",
15
+ display_name="Provider/Model A",
16
+ )
17
+ p2 = Participant(
18
+ participant_id="expert_b",
19
+ name="Bob, Ph.D.",
20
+ role_prompt="rp",
21
+ model_id="model-b",
22
+ kind="expert",
23
+ display_name="Provider/Model B",
24
+ )
25
+ s.participants = [p1, p2]
26
+ s.initial_opinions = {
27
+ "extra_a": "Alice's, opinion has commas, and \"quotes\".",
28
+ "expert_b": "Bob's\nmulti-line\nopinion.",
29
+ }
30
+ s.contribution_summaries = {
31
+ "extra_a": "Stayed firm.",
32
+ "expert_b": "Pushed hard.",
33
+ }
34
+ s.final_opinions = {
35
+ "extra_a": "Final A",
36
+ "expert_b": "Final B",
37
+ }
38
+ s.messages = [
39
+ {
40
+ "speaker_id": "extra_a", "speaker_name": "Alice",
41
+ "role": "participant", "phase": Phase.CONSENSUS.value,
42
+ "text": "Final consensus statement A",
43
+ },
44
+ {
45
+ "speaker_id": "expert_b", "speaker_name": "Bob, Ph.D.",
46
+ "role": "participant", "phase": Phase.CONSENSUS.value,
47
+ "text": "Final consensus statement B",
48
+ },
49
+ ]
50
+ s.final_report = {"kind": "majority", "text": "Group decided X."}
51
+ return s
52
+
53
+
54
+ def test_csv_export_roundtrips_through_csv_module():
55
+ """Ensure values containing commas, quotes, and newlines get quoted
56
+ correctly per RFC 4180."""
57
+ import csv
58
+ import io
59
+
60
+ s = _mk_session()
61
+ out = _export_csv_table(s)
62
+ assert out["filename"] == "ccai_chat_table.csv"
63
+ parsed = list(csv.reader(io.StringIO(out["content"])))
64
+ # Header is question, then final, then blank, then column row.
65
+ assert parsed[0][0] == "Question"
66
+ assert "AI" in parsed[0][1] and "education" in parsed[0][1]
67
+ assert parsed[1][0] == "Final Group Opinion"
68
+ assert "Group decided X." in parsed[1][1]
69
+ # blank row
70
+ assert parsed[2] == []
71
+ # column header row
72
+ assert parsed[3] == [
73
+ "Participant",
74
+ "First opinion",
75
+ "Conversation contribution",
76
+ "Revised opinion",
77
+ "Final opinion",
78
+ ]
79
+ alice_row = parsed[4]
80
+ assert alice_row[0] == "Alice"
81
+ assert "\"quotes\"" in alice_row[1] # csv module preserved the quotes
82
+ bob_row = parsed[5]
83
+ assert bob_row[0] == "Bob, Ph.D."
84
+ assert "multi-line" in bob_row[1]
85
+
86
+
87
+ def test_csv_export_no_field_count_drift():
88
+ """Every row after the header should have exactly 5 columns even when
89
+ payload contains pathological characters."""
90
+ import csv
91
+ import io
92
+
93
+ s = _mk_session()
94
+ out = _export_csv_table(s)
95
+ rows = list(csv.reader(io.StringIO(out["content"])))
96
+ data_rows = rows[4:]
97
+ for row in data_rows:
98
+ assert len(row) == 5
backend/tests/test_json_calls.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.services.json_calls import parse_json_response
2
+
3
+
4
+ def test_plain_json_object():
5
+ assert parse_json_response('{"a": 1}') == {"a": 1}
6
+
7
+
8
+ def test_plain_json_array():
9
+ assert parse_json_response('[1, 2, 3]') == [1, 2, 3]
10
+
11
+
12
+ def test_markdown_fence():
13
+ raw = "```json\n{\"a\": 1}\n```"
14
+ assert parse_json_response(raw) == {"a": 1}
15
+
16
+
17
+ def test_markdown_fence_no_lang():
18
+ raw = "```\n{\"a\": 1}\n```"
19
+ assert parse_json_response(raw) == {"a": 1}
20
+
21
+
22
+ def test_prose_then_json():
23
+ raw = "Sure, here's the result:\n\n{\"a\": 1, \"b\": [2,3]}"
24
+ assert parse_json_response(raw) == {"a": 1, "b": [2, 3]}
25
+
26
+
27
+ def test_json_then_prose():
28
+ raw = "{\"a\": 1}\nThe end."
29
+ assert parse_json_response(raw) == {"a": 1}
30
+
31
+
32
+ def test_nested_braces_ok():
33
+ raw = "Look:\n{\"outer\": {\"inner\": [1, {\"k\": \"v\"}]}}"
34
+ assert parse_json_response(raw) == {"outer": {"inner": [1, {"k": "v"}]}}
35
+
36
+
37
+ def test_string_with_braces_doesnt_confuse_balancer():
38
+ raw = '{"text": "this has } and { in it", "ok": true}'
39
+ assert parse_json_response(raw) == {"text": "this has } and { in it", "ok": True}
40
+
41
+
42
+ def test_unparseable_returns_none():
43
+ assert parse_json_response("not json at all") is None
44
+
45
+
46
+ def test_empty_returns_none():
47
+ assert parse_json_response("") is None
48
+ assert parse_json_response(None) is None
backend/tests/test_sanitize.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.utils.sanitize import strip_thinking, response_has_thinking
2
+
3
+
4
+ def test_strip_simple_think_tag():
5
+ assert strip_thinking("<think>plan</think>final") == "final"
6
+
7
+
8
+ def test_strip_multiline_think_tag():
9
+ raw = "<think>line1\nline2\n</think>actual reply"
10
+ assert strip_thinking(raw) == "actual reply"
11
+
12
+
13
+ def test_strip_nested_reasoning_blocks():
14
+ raw = (
15
+ "<reasoning>step 1\nstep 2</reasoning>"
16
+ "<analysis>more thinking</analysis>"
17
+ "real text"
18
+ )
19
+ assert strip_thinking(raw) == "real text"
20
+
21
+
22
+ def test_strip_multiple_think_blocks():
23
+ raw = "<think>a</think>middle<think>b</think>end"
24
+ assert strip_thinking(raw) == "middleend"
25
+
26
+
27
+ def test_strip_uppercase_tag():
28
+ assert strip_thinking("<THINK>plan</THINK>final") == "final"
29
+
30
+
31
+ def test_idempotent():
32
+ cleaned = strip_thinking("<think>foo</think>bar")
33
+ assert strip_thinking(cleaned) == cleaned
34
+
35
+
36
+ def test_empty_inputs_safe():
37
+ assert strip_thinking(None) == ""
38
+ assert strip_thinking("") == ""
39
+ assert strip_thinking(" \n\t ") == ""
40
+
41
+
42
+ def test_thought_prologue():
43
+ raw = "Thought: I should probably mention X.\n\nReal response here."
44
+ assert strip_thinking(raw) == "Real response here."
45
+
46
+
47
+ def test_response_has_thinking_via_text():
48
+ assert response_has_thinking("<think>plan</think>x")
49
+ assert not response_has_thinking("just plain text")
50
+
51
+
52
+ def test_response_has_thinking_via_msg_field():
53
+ assert response_has_thinking("plain text", {"reasoning_content": "stuff"})
54
+ assert response_has_thinking("plain text", {"reasoning": "stuff"})
55
+ assert not response_has_thinking("plain text", {"content": "x"})
56
+
57
+
58
+ def test_strip_framing_tokens():
59
+ raw = "<|reasoning|>plan<|/reasoning|>final"
60
+ assert strip_thinking(raw) == "final"
docker-compose.yml CHANGED
@@ -2,9 +2,12 @@ services:
2
  app:
3
  build: .
4
  ports:
5
- - "8000:8000"
 
 
6
  env_file:
7
  - .env
8
  environment:
9
- CORS_ORIGINS: "http://localhost:8000,http://localhost:3000"
 
10
  restart: unless-stopped
 
2
  app:
3
  build: .
4
  ports:
5
+ # Match the HuggingFace Space app_port so docker compose and HF
6
+ # behave identically. The container always listens on 7860.
7
+ - "7860:7860"
8
  env_file:
9
  - .env
10
  environment:
11
+ CORS_ORIGINS: "http://localhost:7860,http://localhost:3000"
12
+ HF_RATE_LIMIT_DAILY: "30"
13
  restart: unless-stopped
frontend/package-lock.json CHANGED
@@ -16725,23 +16725,6 @@
16725
  }
16726
  }
16727
  },
16728
- "node_modules/tailwindcss/node_modules/yaml": {
16729
- "version": "2.8.3",
16730
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
16731
- "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
16732
- "license": "ISC",
16733
- "optional": true,
16734
- "peer": true,
16735
- "bin": {
16736
- "yaml": "bin.mjs"
16737
- },
16738
- "engines": {
16739
- "node": ">= 14.6"
16740
- },
16741
- "funding": {
16742
- "url": "https://github.com/sponsors/eemeli"
16743
- }
16744
- },
16745
  "node_modules/tapable": {
16746
  "version": "2.3.0",
16747
  "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
 
16725
  }
16726
  }
16727
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16728
  "node_modules/tapable": {
16729
  "version": "2.3.0",
16730
  "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
frontend/src/App.js CHANGED
@@ -1,81 +1,102 @@
1
  import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
2
- import { Sun, Moon } from 'lucide-react';
3
- import LLMSelector from './components/LLMSelector';
4
- import PersonaAccordion from './components/PersonaAccordion';
5
  import ChatControls from './components/ChatControls';
6
  import ChatArea from './components/ChatArea';
7
- import DevMenu from './components/DevMenu';
8
- import AuthBadge from './components/AuthBadge';
9
- import { fetchModels, generateRole, generateRoleFreeform, startChat, getOrchestrator, setOrchestrator, getSpeedPriority, setSpeedPriority, exportChat, exportApiLog, getAuthStatus } from './utils/api';
 
 
 
 
 
 
10
  import './styles/variables.css';
11
  import './styles/layout.css';
12
  import './styles/components.css';
 
13
 
14
- const EMPTY_PERSONA = { name: '', profile: '', identity: '', samples: '' };
15
-
16
- function getDisplayName(modelId, providers, neonModels) {
17
- if (!modelId) return '';
18
- if (modelId.startsWith('neon:')) {
19
- return modelId.split(':')[2] || modelId;
20
- }
21
- for (const p of (providers || [])) {
22
- for (const m of p.models) {
23
- if (m.id === modelId) return m.name;
24
- }
25
- }
26
- return modelId;
27
  }
28
 
29
  export default function App() {
30
- const [theme, setTheme] = useState(() =>
31
- window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
 
 
32
  );
 
 
 
 
 
 
 
 
 
33
  const [providers, setProviders] = useState([]);
34
  const [neonModels, setNeonModels] = useState([]);
35
- const [selections, setSelections] = useState([]);
36
- const [personaA, setPersonaA] = useState({ ...EMPTY_PERSONA });
37
- const [personaB, setPersonaB] = useState({ ...EMPTY_PERSONA });
38
- const [accordionOpen, setAccordionOpen] = useState(true);
 
 
 
 
 
 
 
 
 
39
  const [messages, setMessages] = useState([]);
40
  const [systemMessages, setSystemMessages] = useState([]);
41
  const [isRunning, setIsRunning] = useState(false);
42
  const [statusText, setStatusText] = useState('');
43
  const [sessionId, setSessionId] = useState(null);
44
- const [chatFinished, setChatFinished] = useState(false);
45
- const [orchestratorModel, setOrchestratorModel] = useState('');
46
- const [personaMode, setPersonaMode] = useState('freeform');
47
- const [roleStyle, setRoleStyle] = useState('ai_completed');
48
- const [speedPriority, setSpeedPriorityState] = useState(false);
49
- const [auth, setAuth] = useState(null);
50
- const [showResponseTime, setShowResponseTime] = useState(false);
51
- const [showChatStats, setShowChatStats] = useState(false);
52
- const [rolePrompts, setRolePrompts] = useState(null);
53
- const [rolePromptsOpen, setRolePromptsOpen] = useState(false);
54
  const abortRef = useRef(null);
55
- const lastRoleConfigRef = useRef(null);
56
 
 
57
  useEffect(() => {
58
  document.documentElement.setAttribute('data-theme', theme);
 
59
  }, [theme]);
60
 
61
-
62
-
63
  useEffect(() => {
64
- fetchModels()
65
- .then(data => {
66
- setProviders(data.providers || []);
67
- setNeonModels(data.neon_models || []);
68
- })
69
- .catch(err => console.error('Failed to load models:', err));
70
- getOrchestrator()
71
- .then(data => setOrchestratorModel(data.model_id || ''))
72
- .catch(() => {});
73
- getSpeedPriority()
74
- .then(data => setSpeedPriorityState(!!data.enabled))
75
- .catch(() => {});
 
 
76
  getAuthStatus().then(setAuth).catch(() => {});
77
- }, []);
 
 
 
78
 
 
79
  const allModelsFlat = useMemo(() => {
80
  const list = [];
81
  for (const p of providers) {
@@ -96,31 +117,127 @@ export default function App() {
96
  return list;
97
  }, [providers, neonModels]);
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  const handleOrchestratorChange = useCallback(async (modelId) => {
100
  try {
101
  await setOrchestrator(modelId || '');
102
- setOrchestratorModel(modelId || '');
103
  } catch (err) {
104
  console.error('Failed to set orchestrator:', err);
105
  }
106
  }, []);
107
-
108
- const handlePersonaModeChange = useCallback((mode) => {
109
- setPersonaMode(mode);
110
- setRoleStyle(mode === 'freeform' ? 'ai_completed' : 'exact');
111
  }, []);
112
-
113
  const handleSpeedPriorityChange = useCallback(async (enabled) => {
114
  try {
115
  await setSpeedPriority(enabled);
116
  setSpeedPriorityState(enabled);
117
- } catch (err) {
118
- console.error('Failed to set speed priority:', err);
 
 
 
 
 
119
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  }, []);
121
 
122
- const downloadFile = useCallback((filename, content) => {
123
- const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  const url = URL.createObjectURL(blob);
125
  const a = document.createElement('a');
126
  a.href = url;
@@ -128,125 +245,152 @@ export default function App() {
128
  a.click();
129
  URL.revokeObjectURL(url);
130
  }, []);
131
-
132
  const handleDownloadTxt = useCallback(async () => {
133
  if (!sessionId) return;
134
  try {
135
- const result = await exportChat(sessionId, 'txt');
136
- downloadFile(result.filename, result.content);
137
  } catch (err) { console.error('Export failed:', err); }
138
  }, [sessionId, downloadFile]);
139
-
140
  const handleDownloadMd = useCallback(async () => {
141
  if (!sessionId) return;
142
  try {
143
- const result = await exportChat(sessionId, 'md');
144
- downloadFile(result.filename, result.content);
145
  } catch (err) { console.error('Export failed:', err); }
146
  }, [sessionId, downloadFile]);
147
-
 
 
 
 
 
 
148
  const handleDownloadApiLog = useCallback(async () => {
149
  if (!sessionId) return;
150
  try {
151
- const result = await exportApiLog(sessionId);
152
- downloadFile('api_log.json', JSON.stringify(result, null, 2));
153
  } catch (err) { console.error('API log export failed:', err); }
154
  }, [sessionId, downloadFile]);
155
 
156
- const selectedNameA = selections[0] ? getDisplayName(selections[0], providers, neonModels) : '';
157
- const selectedNameB = selections[1] ? getDisplayName(selections[1], providers, neonModels) : '';
158
-
159
- const canStart = selections.length === 2 && !isRunning;
160
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  const handleStop = useCallback(() => {
162
- if (abortRef.current) {
163
- abortRef.current.abort();
164
- abortRef.current = null;
165
- }
166
  setIsRunning(false);
167
- setChatFinished(true);
168
  setStatusText('');
 
169
  setSystemMessages(prev => [...prev, { text: 'Chat stopped by user.' }]);
170
  }, []);
 
 
 
 
 
 
 
171
 
172
- const handleStart = useCallback(async (starterText) => {
173
- if (selections.length < 2) return;
 
 
174
 
175
  const controller = new AbortController();
176
  abortRef.current = controller;
177
-
178
  setIsRunning(true);
179
- setAccordionOpen(false);
180
  setMessages([]);
181
  setSystemMessages([]);
182
- setChatFinished(false);
 
 
 
183
 
184
  try {
185
- const currentConfig = JSON.stringify({
186
- selections, personaMode, roleStyle,
187
- a: personaMode === 'freeform'
188
- ? { name: personaA.name, freeform: personaA.freeform || '' }
189
- : { name: personaA.name, profile: personaA.profile, identity: personaA.identity, samples: personaA.samples },
190
- b: personaMode === 'freeform'
191
- ? { name: personaB.name, freeform: personaB.freeform || '' }
192
- : { name: personaB.name, profile: personaB.profile, identity: personaB.identity, samples: personaB.samples },
193
- });
194
-
195
- let cachedPrompts = rolePrompts;
196
- const configChanged = currentConfig !== lastRoleConfigRef.current;
197
-
198
- if (configChanged || !cachedPrompts) {
199
- setStatusText('Generating expert persona roles...');
200
-
201
- const genA = personaMode === 'freeform'
202
- ? generateRoleFreeform({ model_id: selections[0], name: personaA.name, text: personaA.freeform || '', role_style: roleStyle })
203
- : generateRole({ model_id: selections[0], name: personaA.name, profile: personaA.profile, identity: personaA.identity, samples: personaA.samples, role_style: roleStyle });
204
- const genB = personaMode === 'freeform'
205
- ? generateRoleFreeform({ model_id: selections[1], name: personaB.name, text: personaB.freeform || '', role_style: roleStyle })
206
- : generateRole({ model_id: selections[1], name: personaB.name, profile: personaB.profile, identity: personaB.identity, samples: personaB.samples, role_style: roleStyle });
207
-
208
- const [roleA, roleB] = await Promise.all([genA, genB]);
209
-
210
- if (controller.signal.aborted) return;
211
-
212
- cachedPrompts = {
213
- a: { name: personaA.name || 'Expert Persona A', model: getDisplayName(selections[0], providers, neonModels), prompt: roleA.role_prompt },
214
- b: { name: personaB.name || 'Expert Persona B', model: getDisplayName(selections[1], providers, neonModels), prompt: roleB.role_prompt },
215
- };
216
- setRolePrompts(cachedPrompts);
217
- lastRoleConfigRef.current = currentConfig;
218
- }
219
-
220
- setStatusText('Starting conversation...');
221
-
222
  await startChat(
 
223
  {
224
- persona_a_model_id: selections[0],
225
- persona_a_name: cachedPrompts.a.name,
226
- persona_a_role: cachedPrompts.a.prompt,
227
- persona_b_model_id: selections[1],
228
- persona_b_name: cachedPrompts.b.name,
229
- persona_b_role: cachedPrompts.b.prompt,
230
- starter_text: starterText,
231
- },
232
- {
233
- onSession: (data) => setSessionId(data.session_id),
234
  onMessage: (data) => {
235
  setMessages(prev => [...prev, data]);
236
  setStatusText('Conversation in progress...');
237
  },
 
 
 
 
 
 
 
 
 
 
 
238
  onSystem: (data) => {
239
  setSystemMessages(prev => [...prev, data]);
240
  if (data.text === 'End of Chat') {
241
- setChatFinished(true);
242
  setStatusText('');
243
  }
244
  },
245
- onStatus: (data) => setStatusText(data.message || ''),
246
  onError: (data) => {
247
  setStatusText('');
248
  setSystemMessages(prev => [...prev, { text: `Error: ${data.message}` }]);
249
  },
 
 
 
 
 
 
 
 
 
 
 
250
  onDone: () => {
251
  setIsRunning(false);
252
  setStatusText('');
@@ -260,7 +404,7 @@ export default function App() {
260
  const isRateLimit = err.message && err.message.includes('Daily conversation limit');
261
  setSystemMessages(prev => [...prev, {
262
  text: isRateLimit
263
- ? 'Daily conversation limit reached (20/day). Sign in with HuggingFace for unlimited access.'
264
  : `Error: ${err.message}`,
265
  }]);
266
  } finally {
@@ -268,84 +412,85 @@ export default function App() {
268
  abortRef.current = null;
269
  getAuthStatus().then(setAuth).catch(() => {});
270
  }
271
- }, [selections, personaA, personaB, personaMode, roleStyle, rolePrompts]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
  return (
274
  <div className="app">
275
- <header className="app-header">
276
- <div className="header-left">
277
- <a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="header-brand-link">
278
- <img src="/neon-logo.png" alt="Neon.ai" className="app-logo" />
279
- </a>
280
- <h1 className="app-title"><a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="app-title-link">Neon.ai</a> - AI to AI Conversations</h1>
281
- </div>
282
- <div className="header-right">
283
- <AuthBadge auth={auth} />
284
- <button
285
- className="icon-btn"
286
- onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
287
- title="Toggle theme"
288
- >
289
- {theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
290
- </button>
291
- <DevMenu
292
- allModels={allModelsFlat}
293
- orchestratorModel={orchestratorModel}
294
- onOrchestratorChange={handleOrchestratorChange}
295
- personaMode={personaMode}
296
- onPersonaModeChange={handlePersonaModeChange}
297
- roleStyle={roleStyle}
298
- onRoleStyleChange={setRoleStyle}
299
- speedPriority={speedPriority}
300
- onSpeedPriorityChange={handleSpeedPriorityChange}
301
- showResponseTime={showResponseTime}
302
- onShowResponseTimeChange={setShowResponseTime}
303
- showChatStats={showChatStats}
304
- onShowChatStatsChange={setShowChatStats}
305
- rolePrompts={rolePrompts}
306
- onShowRolePrompts={() => setRolePromptsOpen(true)}
307
- onDownloadChatTxt={handleDownloadTxt}
308
- onDownloadChatMd={handleDownloadMd}
309
- onDownloadApiLog={handleDownloadApiLog}
310
- hasChat={messages.length > 0}
311
- hasApiLog={!!sessionId}
312
- />
313
- </div>
314
- </header>
315
 
316
  <main className="app-main">
317
- <LLMSelector
318
- providers={providers}
319
- neonModels={neonModels}
320
- selections={selections}
321
- onSelectionsChange={setSelections}
 
322
  />
323
-
324
  <div className="content">
325
- <PersonaAccordion
326
- isOpen={accordionOpen}
327
- onToggle={() => setAccordionOpen(o => !o)}
328
- personaA={personaA}
329
- personaB={personaB}
330
- onChangeA={setPersonaA}
331
- onChangeB={setPersonaB}
332
- selectedNameA={selectedNameA}
333
- selectedNameB={selectedNameB}
334
- mode={personaMode}
335
- />
336
-
337
  <ChatControls
338
- onStart={handleStart}
 
339
  onStop={handleStop}
340
- disabled={!canStart}
341
  isRunning={isRunning}
 
342
  />
343
-
344
  <ChatArea
345
  messages={messages}
346
  systemMessages={systemMessages}
347
  isRunning={isRunning}
348
  statusText={statusText}
 
 
 
349
  showResponseTime={showResponseTime}
350
  showChatStats={showChatStats}
351
  />
@@ -356,25 +501,21 @@ export default function App() {
356
  <a href="https://www.neon.ai/contact" target="_blank" rel="noopener noreferrer">Patents and licensing</a>
357
  </footer>
358
 
359
- {rolePromptsOpen && rolePrompts && (
360
- <div className="modal-overlay" onClick={() => setRolePromptsOpen(false)}>
361
- <div className="modal-content" onClick={e => e.stopPropagation()}>
362
- <div className="modal-header">
363
- <h2>Generated Role Prompts</h2>
364
- <button className="modal-close" onClick={() => setRolePromptsOpen(false)}>&times;</button>
365
- </div>
366
- <div className="modal-body">
367
- <div className="role-prompt-section">
368
- <h3>{rolePrompts.a.name} <span className="role-prompt-model">({rolePrompts.a.model})</span></h3>
369
- <pre className="role-prompt-text">{rolePrompts.a.prompt}</pre>
370
- </div>
371
- <div className="role-prompt-section">
372
- <h3>{rolePrompts.b.name} <span className="role-prompt-model">({rolePrompts.b.model})</span></h3>
373
- <pre className="role-prompt-text">{rolePrompts.b.prompt}</pre>
374
- </div>
375
- </div>
376
- </div>
377
- </div>
378
  )}
379
  </div>
380
  );
 
1
  import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
2
+ import Header from './components/Header';
3
+ import ParticipantSidebar from './components/ParticipantSidebar';
 
4
  import ChatControls from './components/ChatControls';
5
  import ChatArea from './components/ChatArea';
6
+ import ExpertPersonaModal from './components/ExpertPersonaModal';
7
+ import ChatTableView from './components/ChatTableView';
8
+ import {
9
+ fetchModels, fetchPersonas, fetchDemoQuestions,
10
+ startChat, continueChat, getOrchestrator, setOrchestrator,
11
+ getSpeedPriority, setSpeedPriority, getAuthStatus,
12
+ exportChat, exportApiLog, fetchTableView, getRateLimitStatus,
13
+ } from './utils/api';
14
+ import * as storage from './utils/storage';
15
  import './styles/variables.css';
16
  import './styles/layout.css';
17
  import './styles/components.css';
18
+ import './styles/ccai.css';
19
 
20
+ function pickRandom(list) {
21
+ if (!list || list.length === 0) return null;
22
+ return list[Math.floor(Math.random() * list.length)];
 
 
 
 
 
 
 
 
 
 
23
  }
24
 
25
  export default function App() {
26
+ // Persistent state
27
+ const persisted = useMemo(() => storage.loadState(), []);
28
+ const [theme, setTheme] = useState(() => persisted.theme
29
+ || (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
30
  );
31
+ const [expertPersonas, setExpertPersonas] = useState(persisted.expert_personas || []);
32
+ const [selectedIds, setSelectedIds] = useState(persisted.participants_selected || []);
33
+ const [enabledMap, setEnabledMap] = useState(persisted.participants_enabled || {});
34
+ const [modelAssignments, setModelAssignments] = useState(persisted.model_assignments || {});
35
+ const [orchestratorModel, setOrchestratorModelState] = useState(persisted.orchestrator_model_id);
36
+ const [summarizerModel, setSummarizerModelState] = useState(persisted.summarizer_model_id);
37
+ const [maxParticipants, setMaxParticipants] = useState(persisted.max_participants || 5);
38
+
39
+ // Backend catalog
40
  const [providers, setProviders] = useState([]);
41
  const [neonModels, setNeonModels] = useState([]);
42
+ const [catalog, setCatalog] = useState({ neon: [], extra: [] });
43
+ const [demoQuestions, setDemoQuestions] = useState([]);
44
+
45
+ // Display options
46
+ const [speedPriority, setSpeedPriorityState] = useState(false);
47
+ const [showResponseTime, setShowResponseTime] = useState(false);
48
+ const [showChatStats, setShowChatStats] = useState(false);
49
+
50
+ // Auth + rate limit
51
+ const [auth, setAuth] = useState(null);
52
+ const [dailyLimit, setDailyLimit] = useState(30);
53
+
54
+ // Conversation state
55
  const [messages, setMessages] = useState([]);
56
  const [systemMessages, setSystemMessages] = useState([]);
57
  const [isRunning, setIsRunning] = useState(false);
58
  const [statusText, setStatusText] = useState('');
59
  const [sessionId, setSessionId] = useState(null);
60
+ const [sessionParticipants, setSessionParticipants] = useState([]);
61
+ const [pause, setPause] = useState(null);
62
+
63
+ // Modals
64
+ const [expertModalOpen, setExpertModalOpen] = useState(false);
65
+ const [expertEditing, setExpertEditing] = useState(null);
66
+ const [tableData, setTableData] = useState(null);
67
+ const [tableOpen, setTableOpen] = useState(false);
68
+
 
69
  const abortRef = useRef(null);
 
70
 
71
+ // ─── Apply theme ────────────────────────────────────────────────
72
  useEffect(() => {
73
  document.documentElement.setAttribute('data-theme', theme);
74
+ storage.setTheme(theme);
75
  }, [theme]);
76
 
77
+ // ─── Load catalogs ──────────────────────────────────────────────
 
78
  useEffect(() => {
79
+ fetchModels().then(d => {
80
+ setProviders(d.providers || []);
81
+ setNeonModels(d.neon_models || []);
82
+ }).catch(err => console.error('Failed to load models:', err));
83
+ fetchPersonas().then(setCatalog).catch(err => console.error('Failed to load personas:', err));
84
+ fetchDemoQuestions().then(d => setDemoQuestions(d.questions || []))
85
+ .catch(err => console.error('Failed to load demo questions:', err));
86
+ getOrchestrator().then(d => {
87
+ // Only sync if user hasn't explicitly chosen one (localStorage wins)
88
+ if (!persisted.orchestrator_model_id && d?.model_id) {
89
+ setOrchestratorModelState(d.model_id);
90
+ }
91
+ }).catch(() => {});
92
+ getSpeedPriority().then(d => setSpeedPriorityState(!!d.enabled)).catch(() => {});
93
  getAuthStatus().then(setAuth).catch(() => {});
94
+ getRateLimitStatus().then(d => {
95
+ if (d?.daily_limit) setDailyLimit(d.daily_limit);
96
+ }).catch(() => {});
97
+ }, [persisted.orchestrator_model_id]);
98
 
99
+ // ─── Build a flat list of all models for pickers ────────────────
100
  const allModelsFlat = useMemo(() => {
101
  const list = [];
102
  for (const p of providers) {
 
117
  return list;
118
  }, [providers, neonModels]);
119
 
120
+ // ─── Active participants resolved from selectedIds ──────────────
121
+ const allCatalogParticipants = useMemo(() => {
122
+ const map = {};
123
+ for (const p of (catalog.neon || [])) map[p.participant_id] = p;
124
+ for (const p of (catalog.extra || [])) map[p.participant_id] = p;
125
+ for (const p of (expertPersonas || [])) map[p.participant_id] = p;
126
+ return map;
127
+ }, [catalog, expertPersonas]);
128
+
129
+ const selectedParticipants = useMemo(() => {
130
+ return selectedIds
131
+ .map(id => allCatalogParticipants[id])
132
+ .filter(Boolean);
133
+ }, [selectedIds, allCatalogParticipants]);
134
+
135
+ const enabledSelectedCount = useMemo(() => {
136
+ return selectedParticipants.filter(p => enabledMap[p.participant_id] !== false).length;
137
+ }, [selectedParticipants, enabledMap]);
138
+
139
+ // ─── Persistence ────────────────────────────────────────────────
140
+ useEffect(() => { storage.setExpertPersonas(expertPersonas); }, [expertPersonas]);
141
+ useEffect(() => { storage.setParticipantsSelected(selectedIds); }, [selectedIds]);
142
+ useEffect(() => { storage.setParticipantsEnabled(enabledMap); }, [enabledMap]);
143
+ useEffect(() => { storage.setModelAssignments(modelAssignments); }, [modelAssignments]);
144
+ useEffect(() => { storage.setOrchestratorModelId(orchestratorModel); }, [orchestratorModel]);
145
+ useEffect(() => { storage.setSummarizerModelId(summarizerModel); }, [summarizerModel]);
146
+ useEffect(() => { storage.setMaxParticipants(maxParticipants); }, [maxParticipants]);
147
+
148
+ // ─── Settings handlers ──────────────────────────────────────────
149
  const handleOrchestratorChange = useCallback(async (modelId) => {
150
  try {
151
  await setOrchestrator(modelId || '');
152
+ setOrchestratorModelState(modelId || null);
153
  } catch (err) {
154
  console.error('Failed to set orchestrator:', err);
155
  }
156
  }, []);
157
+ const handleSummarizerChange = useCallback((modelId) => {
158
+ setSummarizerModelState(modelId || null);
 
 
159
  }, []);
 
160
  const handleSpeedPriorityChange = useCallback(async (enabled) => {
161
  try {
162
  await setSpeedPriority(enabled);
163
  setSpeedPriorityState(enabled);
164
+ } catch (err) { console.error('Failed to set speed priority:', err); }
165
+ }, []);
166
+ const handleMaxParticipantsChange = useCallback((n) => {
167
+ const clamped = Math.max(3, Math.min(9, n));
168
+ setMaxParticipants(clamped);
169
+ if (selectedIds.length > clamped) {
170
+ setSelectedIds(prev => prev.slice(0, clamped));
171
  }
172
+ }, [selectedIds]);
173
+ const handleModelAssignmentChange = useCallback((participantId, modelId) => {
174
+ setModelAssignments(prev => {
175
+ const next = { ...prev };
176
+ if (modelId) next[participantId] = modelId;
177
+ else delete next[participantId];
178
+ return next;
179
+ });
180
+ }, []);
181
+
182
+ // ─── Participant ops ────────────────────────────────────────────
183
+ const handleToggleParticipant = useCallback((participant, kind) => {
184
+ const id = participant.participant_id;
185
+ setSelectedIds(prev => {
186
+ if (prev.includes(id)) {
187
+ // Deselect entirely
188
+ setEnabledMap(em => {
189
+ const next = { ...em };
190
+ delete next[id];
191
+ return next;
192
+ });
193
+ return prev.filter(x => x !== id);
194
+ }
195
+ if (prev.length >= maxParticipants) return prev;
196
+ setEnabledMap(em => ({ ...em, [id]: true }));
197
+ return [...prev, id];
198
+ });
199
+ }, [maxParticipants]);
200
+
201
+ const handleSidebarToggleEnabled = useCallback((participantId, enabled) => {
202
+ setEnabledMap(em => ({ ...em, [participantId]: enabled }));
203
+ }, []);
204
+
205
+ const handleSidebarRemove = useCallback((participantId) => {
206
+ setSelectedIds(prev => prev.filter(x => x !== participantId));
207
+ setEnabledMap(em => {
208
+ const next = { ...em };
209
+ delete next[participantId];
210
+ return next;
211
+ });
212
  }, []);
213
 
214
+ // ─── Expert persona ops ─────────────────────────────────────────
215
+ const handleOpenExpertModal = useCallback((personaOrNull) => {
216
+ setExpertEditing(personaOrNull);
217
+ setExpertModalOpen(true);
218
+ }, []);
219
+ const handleSaveExpert = useCallback((persona) => {
220
+ setExpertPersonas(prev => {
221
+ const idx = prev.findIndex(p => p.participant_id === persona.participant_id);
222
+ if (idx === -1) return [...prev, persona];
223
+ const next = [...prev];
224
+ next[idx] = persona;
225
+ return next;
226
+ });
227
+ setExpertModalOpen(false);
228
+ setExpertEditing(null);
229
+ }, []);
230
+ const handleDeleteExpert = useCallback((id) => {
231
+ setExpertPersonas(prev => prev.filter(p => p.participant_id !== id));
232
+ setSelectedIds(prev => prev.filter(x => x !== id));
233
+ setEnabledMap(em => { const n = { ...em }; delete n[id]; return n; });
234
+ setExpertModalOpen(false);
235
+ setExpertEditing(null);
236
+ }, []);
237
+
238
+ // ─── Downloads ──────────────────────────────────────────────────
239
+ const downloadFile = useCallback((filename, content, mime = 'text/plain;charset=utf-8') => {
240
+ const blob = new Blob([content], { type: mime });
241
  const url = URL.createObjectURL(blob);
242
  const a = document.createElement('a');
243
  a.href = url;
 
245
  a.click();
246
  URL.revokeObjectURL(url);
247
  }, []);
 
248
  const handleDownloadTxt = useCallback(async () => {
249
  if (!sessionId) return;
250
  try {
251
+ const r = await exportChat(sessionId, 'txt');
252
+ downloadFile(r.filename, r.content);
253
  } catch (err) { console.error('Export failed:', err); }
254
  }, [sessionId, downloadFile]);
 
255
  const handleDownloadMd = useCallback(async () => {
256
  if (!sessionId) return;
257
  try {
258
+ const r = await exportChat(sessionId, 'md');
259
+ downloadFile(r.filename, r.content);
260
  } catch (err) { console.error('Export failed:', err); }
261
  }, [sessionId, downloadFile]);
262
+ const handleDownloadCsvTable = useCallback(async () => {
263
+ if (!sessionId) return;
264
+ try {
265
+ const r = await exportChat(sessionId, 'csv-table');
266
+ downloadFile(r.filename, r.content, 'text/csv;charset=utf-8');
267
+ } catch (err) { console.error('CSV export failed:', err); }
268
+ }, [sessionId, downloadFile]);
269
  const handleDownloadApiLog = useCallback(async () => {
270
  if (!sessionId) return;
271
  try {
272
+ const r = await exportApiLog(sessionId);
273
+ downloadFile('api_log.json', JSON.stringify(r, null, 2), 'application/json');
274
  } catch (err) { console.error('API log export failed:', err); }
275
  }, [sessionId, downloadFile]);
276
 
277
+ // ─── Table view ─────────────────────────────────────────────────
278
+ const handleShowTableView = useCallback(async () => {
279
+ if (!sessionId) return;
280
+ try {
281
+ const data = await fetchTableView(sessionId);
282
+ setTableData(data);
283
+ setTableOpen(true);
284
+ } catch (err) { console.error('Table fetch failed:', err); }
285
+ }, [sessionId]);
286
+
287
+ // ─── Build start payload ────────────────────────────────────────
288
+ const buildStartPayload = useCallback((theQuestion) => {
289
+ const enabledParticipants = selectedParticipants.filter(
290
+ p => enabledMap[p.participant_id] !== false,
291
+ );
292
+ const participants = enabledParticipants.map(p => ({
293
+ participant_id: p.participant_id,
294
+ kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon'
295
+ : (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')),
296
+ name: p.name,
297
+ role_prompt: p.role_prompt || null,
298
+ model_id_override: modelAssignments[p.participant_id] || null,
299
+ }));
300
+ const expert_payload = enabledParticipants
301
+ .filter(p => (p.kind || '').startsWith('expert'))
302
+ .map(p => ({
303
+ participant_id: p.participant_id,
304
+ name: p.name,
305
+ model_id: modelAssignments[p.participant_id] || p.model_id,
306
+ role_prompt: p.role_prompt,
307
+ }));
308
+ return {
309
+ question: theQuestion,
310
+ participants,
311
+ expert_personas: expert_payload,
312
+ model_assignments: modelAssignments,
313
+ orchestrator_model_id: orchestratorModel,
314
+ summarizer_model_id: summarizerModel,
315
+ max_participants: maxParticipants,
316
+ };
317
+ }, [selectedParticipants, enabledMap, modelAssignments, orchestratorModel, summarizerModel, maxParticipants]);
318
+
319
+ // ─── Stop / continue ────────────────────────────────────────────
320
  const handleStop = useCallback(() => {
321
+ if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; }
 
 
 
322
  setIsRunning(false);
 
323
  setStatusText('');
324
+ setPause(null);
325
  setSystemMessages(prev => [...prev, { text: 'Chat stopped by user.' }]);
326
  }, []);
327
+ const handleContinuePause = useCallback(async (reason) => {
328
+ if (!sessionId) return;
329
+ try {
330
+ await continueChat(sessionId, reason);
331
+ setPause(null);
332
+ } catch (err) { console.error('Continue failed:', err); }
333
+ }, [sessionId]);
334
 
335
+ // ─── Start chat ─────────────────────────────────────────────────
336
+ const handleStart = useCallback(async (theQuestion) => {
337
+ if (!theQuestion || !theQuestion.trim()) return;
338
+ if (enabledSelectedCount < 2) return;
339
 
340
  const controller = new AbortController();
341
  abortRef.current = controller;
 
342
  setIsRunning(true);
 
343
  setMessages([]);
344
  setSystemMessages([]);
345
+ setStatusText('Starting conversation...');
346
+ setSessionId(null);
347
+ setSessionParticipants([]);
348
+ setPause(null);
349
 
350
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  await startChat(
352
+ buildStartPayload(theQuestion),
353
  {
354
+ onSession: (data) => {
355
+ setSessionId(data.session_id);
356
+ setSessionParticipants(data.participants || []);
357
+ },
 
 
 
 
 
 
358
  onMessage: (data) => {
359
  setMessages(prev => [...prev, data]);
360
  setStatusText('Conversation in progress...');
361
  },
362
+ onOrchestrator: (data) => {
363
+ // Orchestrator events with kind == "status" but no text are
364
+ // status banners; bubble them into a message-style entry so
365
+ // they render with the orchestrator pill.
366
+ if (data && data.text) {
367
+ setMessages(prev => [...prev, { ...data, role: 'orchestrator' }]);
368
+ } else if (data?.message) {
369
+ setStatusText(data.message);
370
+ }
371
+ },
372
+ onStatus: (data) => setStatusText(data.message || ''),
373
  onSystem: (data) => {
374
  setSystemMessages(prev => [...prev, data]);
375
  if (data.text === 'End of Chat') {
 
376
  setStatusText('');
377
  }
378
  },
 
379
  onError: (data) => {
380
  setStatusText('');
381
  setSystemMessages(prev => [...prev, { text: `Error: ${data.message}` }]);
382
  },
383
+ onParticipantError: (data) => {
384
+ setSystemMessages(prev => [...prev, {
385
+ text: `${data.name || 'A participant'} couldn't respond this turn.`,
386
+ }]);
387
+ },
388
+ onFailsafePause: (data) => {
389
+ setPause({ reason: 'messages', ...data });
390
+ },
391
+ onOrchestratorCapPause: (data) => {
392
+ setPause({ reason: 'orchestrator', ...data });
393
+ },
394
  onDone: () => {
395
  setIsRunning(false);
396
  setStatusText('');
 
404
  const isRateLimit = err.message && err.message.includes('Daily conversation limit');
405
  setSystemMessages(prev => [...prev, {
406
  text: isRateLimit
407
+ ? `Daily conversation limit reached (${dailyLimit}/day). Sign in with HuggingFace for unlimited access.`
408
  : `Error: ${err.message}`,
409
  }]);
410
  } finally {
 
412
  abortRef.current = null;
413
  getAuthStatus().then(setAuth).catch(() => {});
414
  }
415
+ }, [buildStartPayload, enabledSelectedCount, dailyLimit]);
416
+
417
+ const handleStartRandom = useCallback(() => {
418
+ if (demoQuestions.length === 0) {
419
+ setSystemMessages(prev => [...prev, { text: 'No demo questions available.' }]);
420
+ return;
421
+ }
422
+ const q = pickRandom(demoQuestions);
423
+ handleStart(q.text);
424
+ }, [demoQuestions, handleStart]);
425
+
426
+ const startDisabled = isRunning || enabledSelectedCount < 2;
427
+ const startDisabledReason = enabledSelectedCount < 2
428
+ ? 'Add at least 2 active participants to start.'
429
+ : '';
430
 
431
  return (
432
  <div className="app">
433
+ <Header
434
+ theme={theme}
435
+ onToggleTheme={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
436
+ auth={auth}
437
+ dailyLimit={dailyLimit}
438
+ catalog={catalog}
439
+ expertPersonas={expertPersonas}
440
+ selectedIds={selectedIds}
441
+ maxParticipants={maxParticipants}
442
+ onToggleParticipant={handleToggleParticipant}
443
+ onOpenExpertModal={handleOpenExpertModal}
444
+
445
+ allModels={allModelsFlat}
446
+ orchestratorModel={orchestratorModel}
447
+ onOrchestratorChange={handleOrchestratorChange}
448
+ summarizerModel={summarizerModel}
449
+ onSummarizerChange={handleSummarizerChange}
450
+ speedPriority={speedPriority}
451
+ onSpeedPriorityChange={handleSpeedPriorityChange}
452
+ showResponseTime={showResponseTime}
453
+ onShowResponseTimeChange={setShowResponseTime}
454
+ showChatStats={showChatStats}
455
+ onShowChatStatsChange={setShowChatStats}
456
+ onMaxParticipantsChange={handleMaxParticipantsChange}
457
+ participants={selectedParticipants}
458
+ modelAssignments={modelAssignments}
459
+ onModelAssignmentChange={handleModelAssignmentChange}
460
+ onShowTableView={handleShowTableView}
461
+ onDownloadChatTxt={handleDownloadTxt}
462
+ onDownloadChatMd={handleDownloadMd}
463
+ onDownloadCsvTable={handleDownloadCsvTable}
464
+ onDownloadApiLog={handleDownloadApiLog}
465
+ hasApiLog={!!sessionId}
466
+ hasChat={messages.length > 0}
467
+ />
 
 
 
 
 
468
 
469
  <main className="app-main">
470
+ <ParticipantSidebar
471
+ participants={selectedParticipants}
472
+ enabledMap={enabledMap}
473
+ modelAssignments={modelAssignments}
474
+ onToggleEnabled={handleSidebarToggleEnabled}
475
+ onRemove={handleSidebarRemove}
476
  />
 
477
  <div className="content">
 
 
 
 
 
 
 
 
 
 
 
 
478
  <ChatControls
479
+ onStartRandom={handleStartRandom}
480
+ onStartTyped={handleStart}
481
  onStop={handleStop}
482
+ disabled={startDisabled}
483
  isRunning={isRunning}
484
+ disabledReason={startDisabledReason}
485
  />
 
486
  <ChatArea
487
  messages={messages}
488
  systemMessages={systemMessages}
489
  isRunning={isRunning}
490
  statusText={statusText}
491
+ pause={pause}
492
+ onContinuePause={handleContinuePause}
493
+ participants={sessionParticipants.length > 0 ? sessionParticipants : selectedParticipants}
494
  showResponseTime={showResponseTime}
495
  showChatStats={showChatStats}
496
  />
 
501
  <a href="https://www.neon.ai/contact" target="_blank" rel="noopener noreferrer">Patents and licensing</a>
502
  </footer>
503
 
504
+ <ExpertPersonaModal
505
+ isOpen={expertModalOpen}
506
+ initial={expertEditing}
507
+ onClose={() => { setExpertModalOpen(false); setExpertEditing(null); }}
508
+ onSave={handleSaveExpert}
509
+ onDelete={handleDeleteExpert}
510
+ allModels={allModelsFlat}
511
+ defaultModelId={orchestratorModel || ''}
512
+ />
513
+ {tableOpen && (
514
+ <ChatTableView
515
+ data={tableData}
516
+ onClose={() => setTableOpen(false)}
517
+ onExportCsv={handleDownloadCsvTable}
518
+ />
 
 
 
 
519
  )}
520
  </div>
521
  );
frontend/src/components/AuthBadge.js CHANGED
@@ -1,8 +1,9 @@
1
  import React from 'react';
2
  import { LogIn, LogOut, User } from 'lucide-react';
3
 
4
- export default function AuthBadge({ auth }) {
5
  if (!auth) return null;
 
6
 
7
  if (auth.logged_in) {
8
  return (
@@ -23,7 +24,7 @@ export default function AuthBadge({ auth }) {
23
  return (
24
  <div className="auth-badge">
25
  {auth.remaining_conversations >= 0 && (
26
- <span className="auth-remaining">{auth.remaining_conversations}/20 chats</span>
27
  )}
28
  <a href="/oauth/huggingface/login" className="auth-link auth-login">
29
  <LogIn size={13} /> Sign in
 
1
  import React from 'react';
2
  import { LogIn, LogOut, User } from 'lucide-react';
3
 
4
+ export default function AuthBadge({ auth, dailyLimit }) {
5
  if (!auth) return null;
6
+ const cap = dailyLimit || 30;
7
 
8
  if (auth.logged_in) {
9
  return (
 
24
  return (
25
  <div className="auth-badge">
26
  {auth.remaining_conversations >= 0 && (
27
+ <span className="auth-remaining">{auth.remaining_conversations}/{cap} chats</span>
28
  )}
29
  <a href="/oauth/huggingface/login" className="auth-link auth-login">
30
  <LogIn size={13} /> Sign in
frontend/src/components/ChatArea.js CHANGED
@@ -1,35 +1,73 @@
1
  import React, { useEffect, useRef, useMemo } from 'react';
2
  import MessageBubble from './MessageBubble';
 
 
3
 
4
- export default function ChatArea({ messages, systemMessages, isRunning, statusText, showResponseTime, showChatStats }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  const endRef = useRef(null);
6
 
7
  useEffect(() => {
8
  endRef.current?.scrollIntoView({ behavior: 'smooth' });
9
- }, [messages, systemMessages]);
10
 
11
- const hasContent = messages.length > 0 || systemMessages.length > 0;
12
- const chatEnded = systemMessages.some(s => s.text === 'End of Chat');
 
 
 
 
 
 
 
 
13
 
14
  const stats = useMemo(() => {
15
- if (!chatEnded || messages.length === 0) return null;
16
- const totalTime = messages.reduce((sum, m) => sum + (m.elapsed_seconds || 0), 0);
17
- return { count: messages.length, totalTime: totalTime.toFixed(1) };
 
 
 
18
  }, [chatEnded, messages]);
19
 
20
  return (
21
  <div className="chat-area">
22
  {!hasContent && !isRunning && (
23
  <div className="chat-empty">
24
- Select two LLMs, configure expert personas, and start a conversation.
25
  </div>
26
  )}
27
-
28
- {messages.map((msg, i) => (
29
- <MessageBubble key={i} message={msg} showResponseTime={showResponseTime} />
30
- ))}
31
-
32
- {systemMessages.map((sys, i) => (
 
 
 
 
 
 
 
 
 
33
  <div
34
  key={`sys-${i}`}
35
  className={`system-message ${sys.text === 'End of Chat' ? 'end-of-chat' : ''}`}
@@ -37,20 +75,18 @@ export default function ChatArea({ messages, systemMessages, isRunning, statusTe
37
  {sys.text}
38
  </div>
39
  ))}
40
-
41
  {showChatStats && stats && (
42
  <div className="chat-stats">
43
- {stats.count} messages &middot; {stats.totalTime}s total generation time
44
  </div>
45
  )}
46
-
47
  {isRunning && statusText && (
48
  <div className="status-bar">
49
  <div className="spinner" />
50
  <span>{statusText}</span>
51
  </div>
52
  )}
53
-
54
  <div ref={endRef} />
55
  </div>
56
  );
 
1
  import React, { useEffect, useRef, useMemo } from 'react';
2
  import MessageBubble from './MessageBubble';
3
+ import OrchestratorMessage from './OrchestratorMessage';
4
+ import FailsafePauseBanner from './FailsafePauseBanner';
5
 
6
+ /**
7
+ * Renders the conversation: a mix of participant bubbles, orchestrator
8
+ * status banners, and the failsafe-pause continue control. Participant
9
+ * coloring is derived from each participant's index in the active
10
+ * roster, so colors are stable per-participant for the whole chat.
11
+ */
12
+ export default function ChatArea({
13
+ messages,
14
+ systemMessages,
15
+ isRunning,
16
+ statusText,
17
+ pause,
18
+ onContinuePause,
19
+ participants,
20
+ showResponseTime,
21
+ showChatStats,
22
+ }) {
23
  const endRef = useRef(null);
24
 
25
  useEffect(() => {
26
  endRef.current?.scrollIntoView({ behavior: 'smooth' });
27
+ }, [messages, systemMessages, statusText, pause]);
28
 
29
+ const speakerIdxFor = useMemo(() => {
30
+ const map = {};
31
+ (participants || []).forEach((p, i) => {
32
+ map[p.participant_id] = i;
33
+ });
34
+ return map;
35
+ }, [participants]);
36
+
37
+ const hasContent = (messages?.length || 0) + (systemMessages?.length || 0) > 0;
38
+ const chatEnded = (systemMessages || []).some(s => s.text === 'End of Chat');
39
 
40
  const stats = useMemo(() => {
41
+ if (!chatEnded || !messages || messages.length === 0) return null;
42
+ const participantMsgs = messages.filter(m => m.role !== 'orchestrator');
43
+ const totalTime = participantMsgs.reduce(
44
+ (sum, m) => sum + (m.elapsed_seconds || 0), 0,
45
+ );
46
+ return { count: participantMsgs.length, totalTime: totalTime.toFixed(1) };
47
  }, [chatEnded, messages]);
48
 
49
  return (
50
  <div className="chat-area">
51
  {!hasContent && !isRunning && (
52
  <div className="chat-empty">
53
+ Add at least 2 participants from the header dropdown, then start a conversation.
54
  </div>
55
  )}
56
+ {(messages || []).map((msg, i) => {
57
+ if (msg.role === 'orchestrator') {
58
+ return <OrchestratorMessage key={i} message={msg} />;
59
+ }
60
+ const idx = speakerIdxFor[msg.speaker_id] ?? i;
61
+ return (
62
+ <MessageBubble
63
+ key={i}
64
+ message={msg}
65
+ idx={idx}
66
+ showResponseTime={showResponseTime}
67
+ />
68
+ );
69
+ })}
70
+ {(systemMessages || []).map((sys, i) => (
71
  <div
72
  key={`sys-${i}`}
73
  className={`system-message ${sys.text === 'End of Chat' ? 'end-of-chat' : ''}`}
 
75
  {sys.text}
76
  </div>
77
  ))}
 
78
  {showChatStats && stats && (
79
  <div className="chat-stats">
80
+ {stats.count} participant messages &middot; {stats.totalTime}s total generation time
81
  </div>
82
  )}
83
+ <FailsafePauseBanner pause={pause} onContinue={onContinuePause} />
84
  {isRunning && statusText && (
85
  <div className="status-bar">
86
  <div className="spinner" />
87
  <span>{statusText}</span>
88
  </div>
89
  )}
 
90
  <div ref={endRef} />
91
  </div>
92
  );
frontend/src/components/ChatControls.js CHANGED
@@ -1,21 +1,29 @@
1
  import React, { useState } from 'react';
2
  import { Play, Shuffle, Square } from 'lucide-react';
3
 
4
- export default function ChatControls({ onStart, onStop, disabled, isRunning }) {
5
- const [starterText, setStarterText] = useState('');
6
-
7
- const handleStartWithText = () => {
8
- onStart(starterText.trim() || null);
9
- };
10
-
11
- const handleAutoStart = () => {
12
- onStart(null);
13
- };
14
-
 
 
 
 
 
 
 
 
15
  return (
16
  <div className="chat-controls">
17
  {isRunning ? (
18
- <button className="btn-stop" onClick={onStop} title="Stop the conversation">
19
  <Square size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
20
  Stop Chat
21
  </button>
@@ -23,28 +31,29 @@ export default function ChatControls({ onStart, onStop, disabled, isRunning }) {
23
  <>
24
  <button
25
  className="btn-primary"
26
- onClick={handleAutoStart}
27
  disabled={disabled}
28
- title="Let the LLMs start on their own"
 
29
  >
30
  <Shuffle size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
31
  Let Them Start
32
  </button>
33
  <input
34
  type="text"
35
- placeholder="Let them start on their own, or enter a conversation starter here"
36
- value={starterText}
37
- onChange={e => setStarterText(e.target.value)}
38
  disabled={disabled}
 
39
  onKeyDown={e => {
40
- if (e.key === 'Enter' && !disabled) handleStartWithText();
 
 
41
  }}
42
  />
43
  <button
44
  className="btn-primary"
45
- onClick={handleStartWithText}
46
- disabled={disabled}
47
- title="Start with your message"
48
  >
49
  <Play size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
50
  Start Chat With My Prompt
 
1
  import React, { useState } from 'react';
2
  import { Play, Shuffle, Square } from 'lucide-react';
3
 
4
+ /**
5
+ * "Let Them Start" picks a random demo question from the bank.
6
+ * "Start Chat With My Prompt" uses the typed-in question.
7
+ *
8
+ * Both require >=2 enabled participants - that's enforced upstream and
9
+ * mirrored here as a disabled state.
10
+ */
11
+ export default function ChatControls({
12
+ onStartRandom,
13
+ onStartTyped,
14
+ onStop,
15
+ disabled,
16
+ isRunning,
17
+ disabledReason,
18
+ }) {
19
+ const [text, setText] = useState('');
20
+ const placeholder = disabled
21
+ ? (disabledReason || 'Add participants to start a conversation')
22
+ : 'Or type your own question for the group...';
23
  return (
24
  <div className="chat-controls">
25
  {isRunning ? (
26
+ <button className="btn-stop" onClick={onStop}>
27
  <Square size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
28
  Stop Chat
29
  </button>
 
31
  <>
32
  <button
33
  className="btn-primary"
 
34
  disabled={disabled}
35
+ onClick={() => onStartRandom()}
36
+ title="Pick a random demo question and start"
37
  >
38
  <Shuffle size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
39
  Let Them Start
40
  </button>
41
  <input
42
  type="text"
43
+ value={text}
44
+ placeholder={placeholder}
 
45
  disabled={disabled}
46
+ onChange={e => setText(e.target.value)}
47
  onKeyDown={e => {
48
+ if (e.key === 'Enter' && !disabled && text.trim()) {
49
+ onStartTyped(text.trim());
50
+ }
51
  }}
52
  />
53
  <button
54
  className="btn-primary"
55
+ disabled={disabled || !text.trim()}
56
+ onClick={() => onStartTyped(text.trim())}
 
57
  >
58
  <Play size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
59
  Start Chat With My Prompt
frontend/src/components/ChatTableView.js ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ /**
4
+ * Phase-by-phase summary of the conversation rendered as a table.
5
+ * Question on top, final group opinion under it, one row per
6
+ * participant with first / contribution / revised / final columns.
7
+ *
8
+ * Driven by the GET /api/chat/{id}/table endpoint - so this component
9
+ * just renders the JSON response.
10
+ */
11
+ export default function ChatTableView({ data, onClose, onExportCsv }) {
12
+ if (!data) return null;
13
+ return (
14
+ <div className="ccai-table-overlay">
15
+ <div className="ccai-table-card">
16
+ <div className="ccai-table-header">
17
+ <h2>Conversation Summary Table</h2>
18
+ <div className="ccai-tab-spacer" />
19
+ <button
20
+ className="btn-sm btn-outline"
21
+ onClick={onExportCsv}
22
+ title="Export this table as CSV"
23
+ >
24
+ Export CSV
25
+ </button>
26
+ <button className="modal-close" onClick={onClose}>&times;</button>
27
+ </div>
28
+ <div className="ccai-table-body">
29
+ <div className="ccai-table-question">
30
+ <strong>Question:</strong>
31
+ <div>{data.question}</div>
32
+ </div>
33
+ <div className="ccai-table-final">
34
+ <strong>Final group opinion:</strong>
35
+ <div>
36
+ {data.final_report ? data.final_report : (
37
+ <em>No final report yet.</em>
38
+ )}
39
+ </div>
40
+ </div>
41
+ <div className="ccai-table-scroll">
42
+ <table className="ccai-table">
43
+ <thead>
44
+ <tr>
45
+ <th>Participant</th>
46
+ <th>First opinion</th>
47
+ <th>Conversation contribution</th>
48
+ <th>Revised opinion</th>
49
+ <th>Final opinion</th>
50
+ </tr>
51
+ </thead>
52
+ <tbody>
53
+ {(data.rows || []).map(row => (
54
+ <tr key={row.participant_id}>
55
+ <td className="ccai-table-name">
56
+ <div>{row.name}</div>
57
+ <small>{row.model_display}</small>
58
+ </td>
59
+ <td>{row.first_opinion}</td>
60
+ <td>{row.contribution_summary || <em>(no summary)</em>}</td>
61
+ <td>{row.revised_opinion}</td>
62
+ <td>{row.final_opinion}</td>
63
+ </tr>
64
+ ))}
65
+ </tbody>
66
+ </table>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ );
72
+ }
frontend/src/components/DevMenu.js CHANGED
@@ -1,43 +1,60 @@
1
  import React, { useState, useMemo, useRef, useEffect } from 'react';
2
- import { ChevronRight, Download, Settings2, Search, Check, Eye, EyeOff, FileText, Square, CheckSquare } from 'lucide-react';
 
 
 
3
 
 
 
 
 
 
 
 
 
 
 
4
  export default function DevMenu({
5
  allModels,
6
  orchestratorModel,
7
  onOrchestratorChange,
8
- personaMode,
9
- onPersonaModeChange,
10
- roleStyle,
11
- onRoleStyleChange,
12
  speedPriority,
13
  onSpeedPriorityChange,
14
  showResponseTime,
15
  onShowResponseTimeChange,
16
  showChatStats,
17
  onShowChatStatsChange,
18
- rolePrompts,
19
- onShowRolePrompts,
20
- onDownloadApiLog,
 
 
 
 
21
  onDownloadChatTxt,
22
  onDownloadChatMd,
 
 
23
  hasApiLog,
24
  hasChat,
25
  }) {
26
  const [open, setOpen] = useState(false);
27
- const [orchOpen, setOrchOpen] = useState(false);
28
  const [q, setQ] = useState('');
29
  const wrapRef = useRef(null);
30
  const searchRef = useRef(null);
31
 
32
  useEffect(() => {
33
- if (orchOpen && searchRef.current) searchRef.current.focus();
34
- }, [orchOpen]);
35
 
36
  useEffect(() => {
37
  function handleClickOutside(e) {
38
  if (wrapRef.current && !wrapRef.current.contains(e.target)) {
39
  setOpen(false);
40
- setOrchOpen(false);
41
  setQ('');
42
  }
43
  }
@@ -54,11 +71,21 @@ export default function DevMenu({
54
  });
55
  }, [allModels, q]);
56
 
57
- const currentName = useMemo(() => {
58
- if (!orchestratorModel) return 'Default (backend)';
59
- const m = allModels.find(m => m.id === orchestratorModel);
60
- return m ? m.name : orchestratorModel;
61
- }, [orchestratorModel, allModels]);
 
 
 
 
 
 
 
 
 
 
62
 
63
  return (
64
  <div className="dev-wrap" ref={wrapRef}>
@@ -69,17 +96,84 @@ export default function DevMenu({
69
  <button className="btn-sm btn-outline" disabled={!hasChat} onClick={onDownloadChatMd}>
70
  <Download size={14} /> .md
71
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  </div>
73
 
74
  <div className="dev-dropdown-header">
75
- <button className="icon-btn" onClick={() => { setOpen(o => !o); setOrchOpen(false); setQ(''); }} title="Settings">
 
 
 
 
76
  <Settings2 size={16} />
77
  </button>
78
  {open && (
79
  <div className="dev-panel">
80
- <button onClick={() => { setOrchOpen(o => !o); setQ(''); }}>
81
- Orchestrator model… <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
 
 
 
 
 
82
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  <div className="dev-panel-divider" />
84
  <div className="dev-panel-label">Response priority</div>
85
  <button
@@ -96,38 +190,7 @@ export default function DevMenu({
96
  {speedPriority ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
97
  Prioritize conversation speed
98
  </button>
99
- <div className="dev-panel-divider" />
100
- <div className="dev-panel-label">Expert persona input</div>
101
- <button
102
- className={`dev-panel-choice ${personaMode === 'structured' ? 'dev-panel-choice-active' : ''}`}
103
- onClick={() => onPersonaModeChange('structured')}
104
- >
105
- {personaMode === 'structured' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
106
- Structured expert persona input
107
- </button>
108
- <button
109
- className={`dev-panel-choice ${personaMode === 'freeform' ? 'dev-panel-choice-active' : ''}`}
110
- onClick={() => onPersonaModeChange('freeform')}
111
- >
112
- {personaMode === 'freeform' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
113
- Freeform expert persona input
114
- </button>
115
- <div className="dev-panel-divider" />
116
- <div className="dev-panel-label">Role generation</div>
117
- <button
118
- className={`dev-panel-choice ${roleStyle === 'ai_completed' ? 'dev-panel-choice-active' : ''}`}
119
- onClick={() => onRoleStyleChange('ai_completed')}
120
- >
121
- {roleStyle === 'ai_completed' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
122
- AI completed roles
123
- </button>
124
- <button
125
- className={`dev-panel-choice ${roleStyle === 'exact' ? 'dev-panel-choice-active' : ''}`}
126
- onClick={() => onRoleStyleChange('exact')}
127
- >
128
- {roleStyle === 'exact' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
129
- Exact user roles
130
- </button>
131
  <div className="dev-panel-divider" />
132
  <div className="dev-panel-label">Display options</div>
133
  <button
@@ -144,10 +207,7 @@ export default function DevMenu({
144
  {showChatStats ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
145
  Chat stats after end
146
  </button>
147
- <button className="dev-panel-choice" disabled={!rolePrompts} onClick={() => { onShowRolePrompts(); setOpen(false); }}>
148
- <FileText size={14} className="dev-check-icon" />
149
- View role prompts
150
- </button>
151
  <div className="dev-panel-divider" />
152
  <button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatTxt(); setOpen(false); }}>
153
  Download chat as .txt
@@ -155,17 +215,32 @@ export default function DevMenu({
155
  <button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatMd(); setOpen(false); }}>
156
  Download chat as .md
157
  </button>
 
 
 
158
  <button disabled={!hasApiLog} onClick={() => { onDownloadApiLog(); setOpen(false); }}>
159
  Download full API history
160
  </button>
161
  </div>
162
  )}
163
 
164
- {open && orchOpen && (
165
  <div className="dev-sub-panel">
166
  <div className="dev-sub-header">
167
- <span className="dev-sub-title">Orchestrator</span>
168
- <span className="dev-sub-current">{currentName}</span>
 
 
 
 
 
 
 
 
 
 
 
 
169
  </div>
170
  <div className="dev-sub-search">
171
  <Search size={14} className="dev-sub-search-icon" />
@@ -178,26 +253,56 @@ export default function DevMenu({
178
  />
179
  </div>
180
  <ul className="dev-sub-list">
181
- <li>
182
- <button
183
- className={`dev-sub-item ${!orchestratorModel ? 'dev-sub-item-active' : ''}`}
184
- onClick={() => { onOrchestratorChange(null); setOrchOpen(false); setOpen(false); setQ(''); }}
185
- >
186
- <strong>Default (backend)</strong>
187
- <span className="dev-sub-provider">Use server default</span>
188
- </button>
189
- </li>
190
- {filtered.map(m => (
191
- <li key={m.id}>
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  <button
193
- className={`dev-sub-item ${orchestratorModel === m.id ? 'dev-sub-item-active' : ''}`}
194
- onClick={() => { onOrchestratorChange(m.id); setOrchOpen(false); setOpen(false); setQ(''); }}
195
  >
196
- <strong>{m.name}</strong>
197
- <span className="dev-sub-provider">{m.provider}</span>
198
  </button>
199
  </li>
200
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  </ul>
202
  </div>
203
  )}
 
1
  import React, { useState, useMemo, useRef, useEffect } from 'react';
2
+ import {
3
+ ChevronRight, Download, Settings2, Search,
4
+ Square, CheckSquare, UserPlus, Table2,
5
+ } from 'lucide-react';
6
 
7
+ /**
8
+ * Settings menu, structurally identical to LLMChats3 but populated with
9
+ * CCAI controls:
10
+ * - Orchestrator model (searchable)
11
+ * - Summarizer model (searchable, with "Same as Orchestrator" default)
12
+ * - Max participants (3-9, default 5)
13
+ * - Per-participant model assignments
14
+ * - "Create Expert Persona..." shortcut
15
+ * - Display options + downloads (txt / md / csv-table / api-log)
16
+ */
17
  export default function DevMenu({
18
  allModels,
19
  orchestratorModel,
20
  onOrchestratorChange,
21
+ summarizerModel,
22
+ onSummarizerChange,
 
 
23
  speedPriority,
24
  onSpeedPriorityChange,
25
  showResponseTime,
26
  onShowResponseTimeChange,
27
  showChatStats,
28
  onShowChatStatsChange,
29
+ maxParticipants,
30
+ onMaxParticipantsChange,
31
+ participants,
32
+ modelAssignments,
33
+ onModelAssignmentChange,
34
+ onOpenExpertModal,
35
+ onShowTableView,
36
  onDownloadChatTxt,
37
  onDownloadChatMd,
38
+ onDownloadCsvTable,
39
+ onDownloadApiLog,
40
  hasApiLog,
41
  hasChat,
42
  }) {
43
  const [open, setOpen] = useState(false);
44
+ const [activeSub, setActiveSub] = useState(null); // null | "orch" | "sum" | <participant_id>
45
  const [q, setQ] = useState('');
46
  const wrapRef = useRef(null);
47
  const searchRef = useRef(null);
48
 
49
  useEffect(() => {
50
+ if (activeSub && searchRef.current) searchRef.current.focus();
51
+ }, [activeSub]);
52
 
53
  useEffect(() => {
54
  function handleClickOutside(e) {
55
  if (wrapRef.current && !wrapRef.current.contains(e.target)) {
56
  setOpen(false);
57
+ setActiveSub(null);
58
  setQ('');
59
  }
60
  }
 
71
  });
72
  }, [allModels, q]);
73
 
74
+ const nameForModel = (id) => {
75
+ if (!id) return null;
76
+ const m = allModels.find(x => x.id === id);
77
+ return m ? m.name : id;
78
+ };
79
+ const orchName = nameForModel(orchestratorModel) || 'Default (backend)';
80
+ const sumName = summarizerModel
81
+ ? (nameForModel(summarizerModel) || summarizerModel)
82
+ : 'Same as Orchestrator';
83
+
84
+ const onPickForSubject = (id, subject) => {
85
+ if (subject === 'orch') onOrchestratorChange(id);
86
+ else if (subject === 'sum') onSummarizerChange(id);
87
+ else if (subject) onModelAssignmentChange(subject, id);
88
+ };
89
 
90
  return (
91
  <div className="dev-wrap" ref={wrapRef}>
 
96
  <button className="btn-sm btn-outline" disabled={!hasChat} onClick={onDownloadChatMd}>
97
  <Download size={14} /> .md
98
  </button>
99
+ <button
100
+ className="btn-sm btn-outline"
101
+ disabled={!hasChat}
102
+ onClick={onShowTableView}
103
+ title="Open the conversation summary table"
104
+ >
105
+ <Table2 size={14} /> Table
106
+ </button>
107
+ <button
108
+ className="btn-sm btn-outline"
109
+ disabled={!hasChat}
110
+ onClick={onDownloadCsvTable}
111
+ title="Download the table view as CSV"
112
+ >
113
+ <Download size={14} /> .csv
114
+ </button>
115
  </div>
116
 
117
  <div className="dev-dropdown-header">
118
+ <button
119
+ className="icon-btn"
120
+ onClick={() => { setOpen(o => !o); setActiveSub(null); setQ(''); }}
121
+ title="Settings"
122
+ >
123
  <Settings2 size={16} />
124
  </button>
125
  {open && (
126
  <div className="dev-panel">
127
+ <button onClick={() => { setActiveSub(s => s === 'orch' ? null : 'orch'); setQ(''); }}>
128
+ Orchestrator model… <span className="dev-panel-hint">{orchName}</span>
129
+ <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
130
+ </button>
131
+ <button onClick={() => { setActiveSub(s => s === 'sum' ? null : 'sum'); setQ(''); }}>
132
+ Summarizer model… <span className="dev-panel-hint">{sumName}</span>
133
+ <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
134
  </button>
135
+
136
+ <div className="dev-panel-divider" />
137
+ <div className="dev-panel-label">Max participants ({maxParticipants})</div>
138
+ <div className="ccai-stepper-row">
139
+ <button
140
+ className="btn-sm btn-outline ccai-stepper-btn"
141
+ disabled={maxParticipants <= 3}
142
+ onClick={() => onMaxParticipantsChange(Math.max(3, maxParticipants - 1))}
143
+ >−</button>
144
+ <div className="ccai-stepper-val">{maxParticipants}</div>
145
+ <button
146
+ className="btn-sm btn-outline ccai-stepper-btn"
147
+ disabled={maxParticipants >= 9}
148
+ onClick={() => onMaxParticipantsChange(Math.min(9, maxParticipants + 1))}
149
+ >+</button>
150
+ <span className="dev-panel-hint">3-9</span>
151
+ </div>
152
+
153
+ <div className="dev-panel-divider" />
154
+ <div className="dev-panel-label">Participants</div>
155
+ <button onClick={() => { onOpenExpertModal(null); setOpen(false); }}>
156
+ <UserPlus size={14} className="dev-check-icon" />
157
+ Create Expert Persona…
158
+ </button>
159
+ {(participants || []).length > 0 && (
160
+ <div className="dev-panel-label">Per-participant model</div>
161
+ )}
162
+ {(participants || []).map(p => {
163
+ const assigned = modelAssignments[p.participant_id];
164
+ const labelName = assigned ? nameForModel(assigned)
165
+ : (p.default_model_id ? nameForModel(p.default_model_id) : '(default)');
166
+ return (
167
+ <button
168
+ key={p.participant_id}
169
+ onClick={() => { setActiveSub(s => s === p.participant_id ? null : p.participant_id); setQ(''); }}
170
+ >
171
+ {p.name}<span className="dev-panel-hint"> {labelName}</span>
172
+ <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
173
+ </button>
174
+ );
175
+ })}
176
+
177
  <div className="dev-panel-divider" />
178
  <div className="dev-panel-label">Response priority</div>
179
  <button
 
190
  {speedPriority ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
191
  Prioritize conversation speed
192
  </button>
193
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  <div className="dev-panel-divider" />
195
  <div className="dev-panel-label">Display options</div>
196
  <button
 
207
  {showChatStats ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
208
  Chat stats after end
209
  </button>
210
+
 
 
 
211
  <div className="dev-panel-divider" />
212
  <button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatTxt(); setOpen(false); }}>
213
  Download chat as .txt
 
215
  <button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatMd(); setOpen(false); }}>
216
  Download chat as .md
217
  </button>
218
+ <button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadCsvTable(); setOpen(false); }}>
219
+ Download summary table as .csv
220
+ </button>
221
  <button disabled={!hasApiLog} onClick={() => { onDownloadApiLog(); setOpen(false); }}>
222
  Download full API history
223
  </button>
224
  </div>
225
  )}
226
 
227
+ {open && activeSub && (
228
  <div className="dev-sub-panel">
229
  <div className="dev-sub-header">
230
+ <span className="dev-sub-title">
231
+ {activeSub === 'orch' && 'Orchestrator model'}
232
+ {activeSub === 'sum' && 'Summarizer model'}
233
+ {activeSub !== 'orch' && activeSub !== 'sum' && (
234
+ <>Model for {participants.find(p => p.participant_id === activeSub)?.name || activeSub}</>
235
+ )}
236
+ </span>
237
+ <span className="dev-sub-current">
238
+ {activeSub === 'orch' && orchName}
239
+ {activeSub === 'sum' && sumName}
240
+ {activeSub !== 'orch' && activeSub !== 'sum' && (
241
+ nameForModel(modelAssignments[activeSub]) || '(default)'
242
+ )}
243
+ </span>
244
  </div>
245
  <div className="dev-sub-search">
246
  <Search size={14} className="dev-sub-search-icon" />
 
253
  />
254
  </div>
255
  <ul className="dev-sub-list">
256
+ {activeSub === 'sum' && (
257
+ <li>
258
+ <button
259
+ className={`dev-sub-item ${!summarizerModel ? 'dev-sub-item-active' : ''}`}
260
+ onClick={() => { onPickForSubject(null, 'sum'); setActiveSub(null); setQ(''); }}
261
+ >
262
+ <strong>Same as Orchestrator (default)</strong>
263
+ <span className="dev-sub-provider">Use whichever model is currently the orchestrator</span>
264
+ </button>
265
+ </li>
266
+ )}
267
+ {activeSub === 'orch' && (
268
+ <li>
269
+ <button
270
+ className={`dev-sub-item ${!orchestratorModel ? 'dev-sub-item-active' : ''}`}
271
+ onClick={() => { onPickForSubject(null, 'orch'); setActiveSub(null); setQ(''); }}
272
+ >
273
+ <strong>Default (backend)</strong>
274
+ <span className="dev-sub-provider">Use server default</span>
275
+ </button>
276
+ </li>
277
+ )}
278
+ {activeSub !== 'orch' && activeSub !== 'sum' && (
279
+ <li>
280
  <button
281
+ className={`dev-sub-item ${!modelAssignments[activeSub] ? 'dev-sub-item-active' : ''}`}
282
+ onClick={() => { onPickForSubject(null, activeSub); setActiveSub(null); setQ(''); }}
283
  >
284
+ <strong>(persona default)</strong>
285
+ <span className="dev-sub-provider">Use the persona's bundled or saved default</span>
286
  </button>
287
  </li>
288
+ )}
289
+ {filtered.map(m => {
290
+ const currentId =
291
+ activeSub === 'orch' ? orchestratorModel
292
+ : activeSub === 'sum' ? summarizerModel
293
+ : modelAssignments[activeSub];
294
+ return (
295
+ <li key={m.id}>
296
+ <button
297
+ className={`dev-sub-item ${currentId === m.id ? 'dev-sub-item-active' : ''}`}
298
+ onClick={() => { onPickForSubject(m.id, activeSub); setActiveSub(null); setQ(''); }}
299
+ >
300
+ <strong>{m.name}</strong>
301
+ <span className="dev-sub-provider">{m.provider}</span>
302
+ </button>
303
+ </li>
304
+ );
305
+ })}
306
  </ul>
307
  </div>
308
  )}
frontend/src/components/ExpertPersonaModal.js ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Upload, Save, Trash2 } from 'lucide-react';
3
+ import { generateRole, generateRoleFreeform } from '../utils/api';
4
+
5
+ /**
6
+ * Single source of truth for creating Expert Personas. Replaces the
7
+ * inline PersonaAccordion + DevMenu persona-mode/role-style settings
8
+ * from LLMChats3 - those choices now live inside this modal.
9
+ *
10
+ * Tabs: Structured | Freeform
11
+ * Role-style toggle: AI-completed | Exact (matches LLMChats3 semantics)
12
+ * Freeform tab supports a file upload for writing samples.
13
+ */
14
+ export default function ExpertPersonaModal({
15
+ isOpen,
16
+ initial, // existing persona to edit, or null for new
17
+ onClose,
18
+ onSave,
19
+ onDelete,
20
+ allModels, // [{ id, name, provider }]
21
+ defaultModelId,
22
+ }) {
23
+ const [activeTab, setActiveTab] = useState('freeform');
24
+ const [name, setName] = useState('');
25
+ const [profile, setProfile] = useState('');
26
+ const [identity, setIdentity] = useState('');
27
+ const [samples, setSamples] = useState('');
28
+ const [freeText, setFreeText] = useState('');
29
+ const [roleStyle, setRoleStyle] = useState('ai_completed');
30
+ const [modelId, setModelId] = useState(defaultModelId || '');
31
+ const [generatedPrompt, setGeneratedPrompt] = useState('');
32
+ const [busy, setBusy] = useState(false);
33
+ const [error, setError] = useState('');
34
+ const fileInputRef = useRef(null);
35
+
36
+ useEffect(() => {
37
+ if (!isOpen) return;
38
+ if (initial) {
39
+ setActiveTab(initial.input_mode || 'freeform');
40
+ setName(initial.name || '');
41
+ setProfile(initial.profile || '');
42
+ setIdentity(initial.identity || '');
43
+ setSamples(initial.samples || '');
44
+ setFreeText(initial.freeform || '');
45
+ setRoleStyle(initial.role_style || 'ai_completed');
46
+ setModelId(initial.model_id || defaultModelId || '');
47
+ setGeneratedPrompt(initial.role_prompt || '');
48
+ } else {
49
+ setActiveTab('freeform');
50
+ setName('');
51
+ setProfile('');
52
+ setIdentity('');
53
+ setSamples('');
54
+ setFreeText('');
55
+ setRoleStyle('ai_completed');
56
+ setModelId(defaultModelId || '');
57
+ setGeneratedPrompt('');
58
+ }
59
+ setError('');
60
+ }, [isOpen, initial, defaultModelId]);
61
+
62
+ if (!isOpen) return null;
63
+
64
+ const handleFileUpload = async (e) => {
65
+ const file = e.target.files?.[0];
66
+ if (!file) return;
67
+ try {
68
+ const text = await file.text();
69
+ setFreeText(prev => (prev ? prev + '\n\n' : '') + text);
70
+ } catch (err) {
71
+ setError(`File read failed: ${err.message}`);
72
+ }
73
+ e.target.value = '';
74
+ };
75
+
76
+ const handleGenerate = async () => {
77
+ setError('');
78
+ if (!modelId) {
79
+ setError('Pick a model to power this persona first.');
80
+ return;
81
+ }
82
+ if (!name.trim()) {
83
+ setError('Persona needs a name.');
84
+ return;
85
+ }
86
+ setBusy(true);
87
+ try {
88
+ const result = activeTab === 'freeform'
89
+ ? await generateRoleFreeform({
90
+ model_id: modelId,
91
+ name: name.trim(),
92
+ text: freeText,
93
+ role_style: roleStyle,
94
+ })
95
+ : await generateRole({
96
+ model_id: modelId,
97
+ name: name.trim(),
98
+ profile,
99
+ identity,
100
+ samples,
101
+ role_style: roleStyle,
102
+ });
103
+ setGeneratedPrompt(result.role_prompt || '');
104
+ } catch (err) {
105
+ setError(err.message || String(err));
106
+ } finally {
107
+ setBusy(false);
108
+ }
109
+ };
110
+
111
+ const canSave = name.trim() && modelId && generatedPrompt.trim();
112
+ const handleSave = () => {
113
+ if (!canSave) return;
114
+ onSave({
115
+ participant_id: initial?.participant_id || `expert_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
116
+ kind: 'expert',
117
+ name: name.trim(),
118
+ model_id: modelId,
119
+ role_prompt: generatedPrompt.trim(),
120
+ input_mode: activeTab,
121
+ role_style: roleStyle,
122
+ profile,
123
+ identity,
124
+ samples,
125
+ freeform: freeText,
126
+ });
127
+ };
128
+
129
+ return (
130
+ <div className="modal-overlay" onClick={onClose}>
131
+ <div
132
+ className="modal-content ccai-expert-modal"
133
+ onClick={e => e.stopPropagation()}
134
+ >
135
+ <div className="modal-header">
136
+ <h2>{initial ? `Edit Expert Persona: ${initial.name}` : 'Create Expert Persona'}</h2>
137
+ <button className="modal-close" onClick={onClose}>&times;</button>
138
+ </div>
139
+ <div className="modal-body">
140
+ <div className="ccai-expert-row">
141
+ <div className="ccai-expert-field">
142
+ <label>Name</label>
143
+ <input
144
+ type="text"
145
+ value={name}
146
+ placeholder="e.g. Dr. Patel - Pediatric Cardiologist"
147
+ onChange={e => setName(e.target.value)}
148
+ />
149
+ </div>
150
+ <div className="ccai-expert-field">
151
+ <label>Powered by LLM</label>
152
+ <select
153
+ value={modelId}
154
+ onChange={e => setModelId(e.target.value)}
155
+ >
156
+ <option value="">Pick a model...</option>
157
+ {(allModels || []).map(m => (
158
+ <option key={m.id} value={m.id}>
159
+ {m.name} {m.provider ? `(${m.provider})` : ''}
160
+ </option>
161
+ ))}
162
+ </select>
163
+ </div>
164
+ </div>
165
+
166
+ <div className="ccai-tab-row">
167
+ <button
168
+ className={'ccai-tab-btn' + (activeTab === 'freeform' ? ' ccai-tab-btn-active' : '')}
169
+ onClick={() => setActiveTab('freeform')}
170
+ >
171
+ Freeform
172
+ </button>
173
+ <button
174
+ className={'ccai-tab-btn' + (activeTab === 'structured' ? ' ccai-tab-btn-active' : '')}
175
+ onClick={() => setActiveTab('structured')}
176
+ >
177
+ Structured
178
+ </button>
179
+ <div className="ccai-tab-spacer" />
180
+ <label className="ccai-role-style">
181
+ <input
182
+ type="radio"
183
+ name="role-style"
184
+ checked={roleStyle === 'ai_completed'}
185
+ onChange={() => setRoleStyle('ai_completed')}
186
+ />
187
+ AI-completed
188
+ </label>
189
+ <label className="ccai-role-style">
190
+ <input
191
+ type="radio"
192
+ name="role-style"
193
+ checked={roleStyle === 'exact'}
194
+ onChange={() => setRoleStyle('exact')}
195
+ />
196
+ Exact (no inferring)
197
+ </label>
198
+ </div>
199
+
200
+ {activeTab === 'freeform' ? (
201
+ <div className="ccai-expert-freeform">
202
+ <div className="freeform-label-row">
203
+ <label>Persona description, writing samples, anything you want the LLM to know:</label>
204
+ <button
205
+ className="btn-sm btn-outline upload-btn"
206
+ onClick={() => fileInputRef.current?.click()}
207
+ >
208
+ <Upload size={12} /> Upload .txt
209
+ </button>
210
+ <input
211
+ type="file"
212
+ accept=".txt,.md"
213
+ ref={fileInputRef}
214
+ onChange={handleFileUpload}
215
+ style={{ display: 'none' }}
216
+ />
217
+ </div>
218
+ <textarea
219
+ className="freeform-textarea"
220
+ value={freeText}
221
+ placeholder="Drop in any background, transcript, writing samples, biography, etc. Sparse input is fine - the AI-completed mode will fill in plausible details."
222
+ onChange={e => setFreeText(e.target.value)}
223
+ />
224
+ </div>
225
+ ) : (
226
+ <div className="ccai-expert-structured">
227
+ <div className="ccai-expert-field">
228
+ <label>Identity statement</label>
229
+ <input
230
+ type="text"
231
+ value={identity}
232
+ onChange={e => setIdentity(e.target.value)}
233
+ placeholder="One-sentence 'who are you'"
234
+ />
235
+ </div>
236
+ <div className="ccai-expert-field">
237
+ <label>Profile / background</label>
238
+ <textarea
239
+ value={profile}
240
+ onChange={e => setProfile(e.target.value)}
241
+ rows={3}
242
+ />
243
+ </div>
244
+ <div className="ccai-expert-field">
245
+ <label>Writing / speech samples</label>
246
+ <textarea
247
+ value={samples}
248
+ onChange={e => setSamples(e.target.value)}
249
+ rows={3}
250
+ />
251
+ </div>
252
+ </div>
253
+ )}
254
+
255
+ <div className="ccai-expert-actions">
256
+ <button
257
+ className="btn-secondary"
258
+ onClick={handleGenerate}
259
+ disabled={busy || !modelId || !name.trim()}
260
+ >
261
+ {busy ? 'Generating role prompt...' : 'Generate role prompt'}
262
+ </button>
263
+ </div>
264
+
265
+ {generatedPrompt && (
266
+ <div className="ccai-expert-prompt">
267
+ <label>Generated role prompt (editable)</label>
268
+ <textarea
269
+ value={generatedPrompt}
270
+ onChange={e => setGeneratedPrompt(e.target.value)}
271
+ rows={6}
272
+ />
273
+ </div>
274
+ )}
275
+
276
+ {error && <div className="ccai-expert-error">{error}</div>}
277
+
278
+ <div className="ccai-expert-footer">
279
+ {initial && onDelete && (
280
+ <button
281
+ className="btn-sm ccai-remove-btn"
282
+ onClick={() => onDelete(initial.participant_id)}
283
+ >
284
+ <Trash2 size={12} /> Delete
285
+ </button>
286
+ )}
287
+ <div className="ccai-tab-spacer" />
288
+ <button className="btn-secondary" onClick={onClose}>Cancel</button>
289
+ <button
290
+ className="btn-primary"
291
+ disabled={!canSave}
292
+ onClick={handleSave}
293
+ >
294
+ <Save size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
295
+ {initial ? 'Save changes' : 'Save persona'}
296
+ </button>
297
+ </div>
298
+ </div>
299
+ </div>
300
+ </div>
301
+ );
302
+ }
frontend/src/components/ExportBar.js DELETED
@@ -1,79 +0,0 @@
1
- import React, { useState, useRef, useEffect } from 'react';
2
- import { Download, Settings } from 'lucide-react';
3
- import { exportChat, exportApiLog } from '../utils/api';
4
-
5
- export default function ExportBar({ sessionId }) {
6
- const [devOpen, setDevOpen] = useState(false);
7
- const dropdownRef = useRef(null);
8
-
9
- useEffect(() => {
10
- const handleClickOutside = (e) => {
11
- if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
12
- setDevOpen(false);
13
- }
14
- };
15
- document.addEventListener('mousedown', handleClickOutside);
16
- return () => document.removeEventListener('mousedown', handleClickOutside);
17
- }, []);
18
-
19
- const downloadFile = (filename, content) => {
20
- const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
21
- const url = URL.createObjectURL(blob);
22
- const a = document.createElement('a');
23
- a.href = url;
24
- a.download = filename;
25
- a.click();
26
- URL.revokeObjectURL(url);
27
- };
28
-
29
- const handleExport = async (fmt) => {
30
- try {
31
- const result = await exportChat(sessionId, fmt);
32
- downloadFile(result.filename, result.content);
33
- } catch (err) {
34
- console.error('Export failed:', err);
35
- }
36
- };
37
-
38
- const handleApiLogExport = async () => {
39
- try {
40
- const result = await exportApiLog(sessionId);
41
- downloadFile('api_log.json', JSON.stringify(result, null, 2));
42
- setDevOpen(false);
43
- } catch (err) {
44
- console.error('API log export failed:', err);
45
- }
46
- };
47
-
48
- if (!sessionId) return null;
49
-
50
- return (
51
- <div className="export-bar">
52
- <button className="btn-secondary" onClick={() => handleExport('txt')}>
53
- <Download size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
54
- Download .txt
55
- </button>
56
- <button className="btn-secondary" onClick={() => handleExport('md')}>
57
- <Download size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
58
- Download .md
59
- </button>
60
-
61
- <div className="dev-dropdown" ref={dropdownRef}>
62
- <button
63
- className="icon-btn"
64
- onClick={() => setDevOpen(o => !o)}
65
- title="Developer Options"
66
- >
67
- <Settings size={16} />
68
- </button>
69
- {devOpen && (
70
- <div className="dev-dropdown-menu">
71
- <button onClick={handleApiLogExport}>
72
- Download Full API Log
73
- </button>
74
- </div>
75
- )}
76
- </div>
77
- </div>
78
- );
79
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/FailsafePauseBanner.js ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Play } from 'lucide-react';
3
+
4
+ /**
5
+ * Inline banner shown when the orchestrator hits one of the two
6
+ * failsafes (60+20 messages, 100+50 orchestrator calls). User clicks
7
+ * Continue to grant another batch.
8
+ */
9
+ export default function FailsafePauseBanner({ pause, onContinue }) {
10
+ if (!pause) return null;
11
+ const incLabel = pause.reason === 'messages' ? '+20 messages' : '+50 orchestrator calls';
12
+ const titleLabel = pause.reason === 'messages'
13
+ ? 'Conversation paused (message cap)'
14
+ : 'Conversation paused (orchestrator call cap)';
15
+ return (
16
+ <div className="ccai-failsafe-banner">
17
+ <div>
18
+ <div className="ccai-failsafe-title">{titleLabel}</div>
19
+ <div className="ccai-failsafe-text">{pause.message}</div>
20
+ </div>
21
+ <button className="btn-primary" onClick={() => onContinue(pause.reason)}>
22
+ <Play size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
23
+ Continue conversation ({incLabel})
24
+ </button>
25
+ </div>
26
+ );
27
+ }
frontend/src/components/Header.js ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Sun, Moon } from 'lucide-react';
3
+ import AuthBadge from './AuthBadge';
4
+ import ParticipantDropdown from './ParticipantDropdown';
5
+ import DevMenu from './DevMenu';
6
+
7
+ /**
8
+ * Header bar: brand on the left; on the right, participant dropdown,
9
+ * settings, exports, table-view toggle, and the rate-limit-aware auth
10
+ * badge.
11
+ */
12
+ export default function Header({
13
+ theme,
14
+ onToggleTheme,
15
+ auth,
16
+ dailyLimit,
17
+
18
+ catalog,
19
+ expertPersonas,
20
+ selectedIds,
21
+ maxParticipants,
22
+ onToggleParticipant,
23
+ onOpenExpertModal,
24
+
25
+ // dev menu props passed straight through
26
+ ...devProps
27
+ }) {
28
+ return (
29
+ <header className="app-header">
30
+ <div className="header-left">
31
+ <a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="header-brand-link">
32
+ <img src="/neon-logo.png" alt="Neon.ai" className="app-logo" />
33
+ </a>
34
+ <h1 className="app-title">
35
+ <a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="app-title-link">
36
+ Neon.ai
37
+ </a> - CCAI Vibe Demo
38
+ </h1>
39
+ </div>
40
+ <div className="header-right">
41
+ <AuthBadge auth={auth} dailyLimit={dailyLimit} />
42
+ <ParticipantDropdown
43
+ catalog={catalog}
44
+ expertPersonas={expertPersonas}
45
+ selectedIds={selectedIds}
46
+ maxParticipants={maxParticipants}
47
+ onToggleParticipant={onToggleParticipant}
48
+ onOpenExpertModal={onOpenExpertModal}
49
+ />
50
+ <button
51
+ className="icon-btn"
52
+ onClick={onToggleTheme}
53
+ title="Toggle theme"
54
+ >
55
+ {theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
56
+ </button>
57
+ <DevMenu
58
+ {...devProps}
59
+ onOpenExpertModal={onOpenExpertModal}
60
+ />
61
+ </div>
62
+ </header>
63
+ );
64
+ }
frontend/src/components/LLMSelector.js DELETED
@@ -1,159 +0,0 @@
1
- import React, { useCallback, useState } from 'react';
2
- import { Cloud, ChevronDown, ChevronRight, User } from 'lucide-react';
3
-
4
- export default function LLMSelector({ providers, neonModels, selections, onSelectionsChange }) {
5
- const [openGroups, setOpenGroups] = useState({});
6
-
7
- const toggleGroup = (key) => {
8
- setOpenGroups(prev => ({ ...prev, [key]: !prev[key] }));
9
- };
10
-
11
- const handleClick = useCallback((modelId) => {
12
- onSelectionsChange(prev => {
13
- const isSelected = prev.includes(modelId);
14
- const isBoth = prev.length === 2 && prev[0] === modelId && prev[1] === modelId;
15
-
16
- if (isBoth) return [];
17
-
18
- if (isSelected) return [modelId, modelId];
19
-
20
- if (prev.length < 2) return [...prev, modelId];
21
-
22
- return [prev[1], modelId];
23
- });
24
- }, [onSelectionsChange]);
25
-
26
- const getIndicatorClass = (modelId) => {
27
- const [a, b] = selections;
28
- if (a === modelId && b === modelId) return 'select-indicator double-selected';
29
- if (a === modelId) return 'select-indicator selected-a';
30
- if (b === modelId) return 'select-indicator selected-b';
31
- return 'select-indicator';
32
- };
33
-
34
- const getLabel = (modelId) => {
35
- const [a, b] = selections;
36
- if (a === modelId && b === modelId) return 'AB';
37
- if (a === modelId) return 'A';
38
- if (b === modelId) return 'B';
39
- return '';
40
- };
41
-
42
- const shortName = (name) => name.split('/').pop() || name;
43
-
44
- const renderModel = (model) => (
45
- <button
46
- key={model.id}
47
- className="model-btn"
48
- onClick={() => handleClick(model.id)}
49
- >
50
- <div className={getIndicatorClass(model.id)}>
51
- {getLabel(model.id) && <span className="selection-label">{getLabel(model.id)}</span>}
52
- </div>
53
- <span className="model-name">{model.name}</span>
54
- {model.params && <span className="model-params">{model.params}</span>}
55
- </button>
56
- );
57
-
58
- const renderNeonPersona = (persona) => (
59
- <button
60
- key={persona.id}
61
- className="neon-persona-item"
62
- onClick={() => handleClick(persona.id)}
63
- >
64
- <div className={getIndicatorClass(persona.id)}>
65
- {getLabel(persona.id) && <span className="selection-label">{getLabel(persona.id)}</span>}
66
- </div>
67
- <div className="persona-details">
68
- <div className="persona-name-row">
69
- <User size={12} />
70
- {persona.name}
71
- </div>
72
- {persona.systemPrompt && (
73
- <div className="persona-prompt-preview">
74
- {persona.systemPrompt.slice(0, 120)}
75
- {persona.systemPrompt.length > 120 ? '…' : ''}
76
- </div>
77
- )}
78
- {!persona.systemPrompt && (
79
- <div className="persona-prompt-preview">No system prompt (vanilla)</div>
80
- )}
81
- </div>
82
- </button>
83
- );
84
-
85
- return (
86
- <div className="sidebar">
87
- <h2 className="sidebar-title">AI Models</h2>
88
-
89
- {(neonModels || []).length > 0 && (
90
- <div className="sidebar-section">
91
- <h3 className="selector-title">
92
- <img src="/neon-logo.png" alt="" className="selector-title-icon" />
93
- Neon.ai Models
94
- </h3>
95
- <div className="neon-model-list">
96
- {[...(neonModels || [])].sort((a, b) => shortName(a.name).localeCompare(shortName(b.name))).map(model => {
97
- const key = `neon-${model.model_id}`;
98
- const isOpen = !!openGroups[key];
99
- const activePersonas = (model.personas || []).filter(p => p.enabled !== false);
100
- return (
101
- <div key={key} className="neon-model-card">
102
- <button
103
- className="neon-model-header"
104
- onClick={() => toggleGroup(key)}
105
- >
106
- <div className="neon-model-info">
107
- <span className="neon-model-name">{shortName(model.name)}</span>
108
- {model.version && <span className="neon-model-version">v{model.version}</span>}
109
- </div>
110
- <div className="neon-model-meta">
111
- {isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
112
- </div>
113
- </button>
114
- {isOpen && (
115
- <div className="neon-persona-list">
116
- {activePersonas.map(persona => renderNeonPersona({
117
- id: `neon:${model.model_id}:${persona.persona_name}`,
118
- name: persona.persona_name,
119
- systemPrompt: persona.system_prompt || '',
120
- }))}
121
- </div>
122
- )}
123
- </div>
124
- );
125
- })}
126
- </div>
127
- </div>
128
- )}
129
-
130
- {(providers || []).length > 0 && (
131
- <div className="sidebar-section">
132
- <h3 className="selector-title">
133
- <Cloud size={16} />
134
- Other Models
135
- </h3>
136
- {[...(providers || [])].sort((a, b) => a.name.localeCompare(b.name)).map(provider => {
137
- const key = `prov-${provider.id}`;
138
- const isOpen = !!openGroups[key];
139
- return (
140
- <div key={key} className="provider-group comp-group">
141
- <button className="provider-accordion-header" onClick={() => toggleGroup(key)}>
142
- <span className="provider-accordion-title">{provider.name}</span>
143
- <span className="provider-accordion-meta">
144
- {isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
145
- </span>
146
- </button>
147
- {isOpen && (
148
- <div className="model-list">
149
- {provider.models.map(renderModel)}
150
- </div>
151
- )}
152
- </div>
153
- );
154
- })}
155
- </div>
156
- )}
157
- </div>
158
- );
159
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/MessageBubble.js CHANGED
@@ -2,19 +2,52 @@ import React from 'react';
2
  import ReactMarkdown from 'react-markdown';
3
  import remarkGfm from 'remark-gfm';
4
 
5
- export default function MessageBubble({ message, showResponseTime }) {
6
- const isA = message.speaker_idx === 0;
7
- const side = isA ? 'a' : 'b';
8
- const initial = message.speaker ? message.speaker.charAt(0).toUpperCase() : (isA ? 'A' : 'B');
9
- const elapsed = message.elapsed_seconds;
 
 
 
 
 
 
 
 
 
 
10
 
 
 
 
 
 
 
 
 
 
11
  return (
12
- <div className={`message-row speaker-${side}`}>
13
- <div className={`avatar avatar-${side}`}>
 
 
 
14
  {initial}
15
  </div>
16
- <div className={`message-bubble bubble-${side}`}>
17
- <div className="message-speaker">{message.speaker}</div>
 
 
 
 
 
 
 
 
 
 
 
18
  <ReactMarkdown remarkPlugins={[remarkGfm]}>
19
  {message.text}
20
  </ReactMarkdown>
 
2
  import ReactMarkdown from 'react-markdown';
3
  import remarkGfm from 'remark-gfm';
4
 
5
+ const PALETTE = [
6
+ { color: '#6366F1', bg: '#EEF2FF' }, // indigo
7
+ { color: '#059669', bg: '#ECFDF5' }, // emerald
8
+ { color: '#D97706', bg: '#FFFBEB' }, // amber
9
+ { color: '#DC2626', bg: '#FEE2E2' }, // red
10
+ { color: '#0891B2', bg: '#ECFEFF' }, // cyan
11
+ { color: '#7C3AED', bg: '#F5F3FF' }, // violet
12
+ { color: '#0D9488', bg: '#F0FDFA' }, // teal
13
+ { color: '#DB2777', bg: '#FDF2F8' }, // pink
14
+ { color: '#65A30D', bg: '#F7FEE7' }, // lime
15
+ ];
16
+
17
+ function colorForIdx(idx) {
18
+ return PALETTE[idx % PALETTE.length];
19
+ }
20
 
21
+ /**
22
+ * Generic participant bubble. The CCAI demo can have up to 9 active
23
+ * participants, so we colorize by their index in the active roster
24
+ * rather than the original A/B scheme.
25
+ */
26
+ export default function MessageBubble({ message, idx, showResponseTime }) {
27
+ const tone = colorForIdx(idx);
28
+ const initial = (message.speaker_name || '?').charAt(0).toUpperCase();
29
+ const elapsed = message.elapsed_seconds;
30
  return (
31
+ <div className="message-row ccai-message-row">
32
+ <div
33
+ className="avatar"
34
+ style={{ background: tone.color, borderRadius: '50%' }}
35
+ >
36
  {initial}
37
  </div>
38
+ <div
39
+ className="message-bubble ccai-bubble"
40
+ style={{
41
+ background: tone.bg,
42
+ border: `1px solid ${tone.color}33`,
43
+ }}
44
+ >
45
+ <div className="message-speaker" style={{ color: tone.color }}>
46
+ {message.speaker_name}
47
+ {message.model_display && (
48
+ <span className="ccai-bubble-model"> &middot; {message.model_display}</span>
49
+ )}
50
+ </div>
51
  <ReactMarkdown remarkPlugins={[remarkGfm]}>
52
  {message.text}
53
  </ReactMarkdown>
frontend/src/components/OrchestratorMessage.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+
5
+ /**
6
+ * Distinct rendering for orchestrator messages. Centered, italic, and
7
+ * a different color than participant bubbles so users always know who's
8
+ * speaking. Used for status updates, follow-up announcements, factor
9
+ * surfacing, and the final majority/no-consensus reports.
10
+ */
11
+ export default function OrchestratorMessage({ message }) {
12
+ const isReport = message.kind === 'majority_report' || message.kind === 'no_consensus_report';
13
+ const className = (
14
+ 'ccai-orchestrator-msg' +
15
+ (isReport ? ' ccai-orchestrator-msg-report' : '')
16
+ );
17
+ return (
18
+ <div className={className}>
19
+ <div className="ccai-orchestrator-msg-label">
20
+ Orchestrator{message.kind === 'majority_report' ? ' - Majority Report'
21
+ : message.kind === 'no_consensus_report' ? ' - No-Consensus Report'
22
+ : message.kind === 'factor' ? ' - New Consideration'
23
+ : ''}
24
+ </div>
25
+ <div className="ccai-orchestrator-msg-body">
26
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
27
+ {message.text || ''}
28
+ </ReactMarkdown>
29
+ </div>
30
+ </div>
31
+ );
32
+ }
frontend/src/components/ParticipantDropdown.js ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { Users, Plus, ChevronDown } from 'lucide-react';
3
+
4
+ /**
5
+ * Header dropdown that lists every available participant the user can
6
+ * pull into the conversation. Three sections:
7
+ * - Neon (HANA personas, vanilla/RAG already filtered server-side)
8
+ * - Extra (the four bundled non-Neon-LLM personas)
9
+ * - Expert (user-created, stored in localStorage)
10
+ *
11
+ * Selecting a participant adds them to the active conversation list. The
12
+ * "Create Expert Persona..." entry opens the modal.
13
+ */
14
+ export default function ParticipantDropdown({
15
+ catalog,
16
+ expertPersonas,
17
+ selectedIds,
18
+ maxParticipants,
19
+ onToggleParticipant,
20
+ onOpenExpertModal,
21
+ }) {
22
+ const [open, setOpen] = useState(false);
23
+ const ref = useRef(null);
24
+
25
+ useEffect(() => {
26
+ function handleClickOutside(e) {
27
+ if (open && ref.current && !ref.current.contains(e.target)) {
28
+ setOpen(false);
29
+ }
30
+ }
31
+ document.addEventListener('mousedown', handleClickOutside);
32
+ return () => document.removeEventListener('mousedown', handleClickOutside);
33
+ }, [open]);
34
+
35
+ const isSelected = (id) => selectedIds.includes(id);
36
+ const atCap = selectedIds.length >= maxParticipants;
37
+
38
+ return (
39
+ <div className="ccai-dropdown-wrap" ref={ref}>
40
+ <button
41
+ className="btn-sm btn-outline ccai-dropdown-trigger"
42
+ onClick={() => setOpen(o => !o)}
43
+ title="Add or remove participants"
44
+ >
45
+ <Users size={14} />
46
+ <span>Participants ({selectedIds.length}/{maxParticipants})</span>
47
+ <ChevronDown size={12} />
48
+ </button>
49
+ {open && (
50
+ <div className="ccai-dropdown-panel">
51
+ <div className="ccai-dropdown-section">
52
+ <div className="ccai-dropdown-section-title">Neon.ai Personas</div>
53
+ {(catalog?.neon || []).length === 0 && (
54
+ <div className="ccai-dropdown-empty">
55
+ Neon personas unavailable - check HANA auth.
56
+ </div>
57
+ )}
58
+ {(catalog?.neon || []).map((p) => (
59
+ <DropdownItem
60
+ key={p.participant_id}
61
+ participant={p}
62
+ checked={isSelected(p.participant_id)}
63
+ disabledForAdd={atCap && !isSelected(p.participant_id)}
64
+ onToggle={() => onToggleParticipant(p, 'neon')}
65
+ />
66
+ ))}
67
+ </div>
68
+ <div className="ccai-dropdown-divider" />
69
+ <div className="ccai-dropdown-section">
70
+ <div className="ccai-dropdown-section-title">Extra Personas</div>
71
+ {(catalog?.extra || []).map((p) => (
72
+ <DropdownItem
73
+ key={p.participant_id}
74
+ participant={p}
75
+ checked={isSelected(p.participant_id)}
76
+ disabledForAdd={atCap && !isSelected(p.participant_id)}
77
+ onToggle={() => onToggleParticipant(p, 'extra')}
78
+ />
79
+ ))}
80
+ </div>
81
+ <div className="ccai-dropdown-divider" />
82
+ <div className="ccai-dropdown-section">
83
+ <div className="ccai-dropdown-section-title">Expert Personas</div>
84
+ {(expertPersonas || []).length === 0 && (
85
+ <div className="ccai-dropdown-empty">
86
+ You haven't created any expert personas yet.
87
+ </div>
88
+ )}
89
+ {(expertPersonas || []).map((p) => (
90
+ <DropdownItem
91
+ key={p.participant_id}
92
+ participant={p}
93
+ checked={isSelected(p.participant_id)}
94
+ disabledForAdd={atCap && !isSelected(p.participant_id)}
95
+ onToggle={() => onToggleParticipant(p, 'expert')}
96
+ />
97
+ ))}
98
+ <button
99
+ className="ccai-dropdown-create-btn"
100
+ onClick={() => { setOpen(false); onOpenExpertModal(null); }}
101
+ >
102
+ <Plus size={12} />
103
+ Create Expert Persona...
104
+ </button>
105
+ </div>
106
+ </div>
107
+ )}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ function DropdownItem({ participant, checked, disabledForAdd, onToggle }) {
113
+ return (
114
+ <label
115
+ className={
116
+ 'ccai-dropdown-item' +
117
+ (checked ? ' ccai-dropdown-item-checked' : '') +
118
+ (disabledForAdd ? ' ccai-dropdown-item-disabled' : '')
119
+ }
120
+ title={disabledForAdd ? 'Participant cap reached' : ''}
121
+ >
122
+ <input
123
+ type="checkbox"
124
+ checked={checked}
125
+ disabled={disabledForAdd}
126
+ onChange={onToggle}
127
+ />
128
+ <div className="ccai-dropdown-item-text">
129
+ <div className="ccai-dropdown-item-name">{participant.name}</div>
130
+ <div className="ccai-dropdown-item-sub">
131
+ {participant.model_display || participant.default_model_id || ''}
132
+ </div>
133
+ </div>
134
+ </label>
135
+ );
136
+ }