NeonClary commited on
Commit
1c62c48
·
unverified ·
2 Parent(s): 05146ffa2c8495

Merge pull request #5 from NeonClary/dev-cursor

Browse files
backend/app/api/chat.py CHANGED
@@ -23,7 +23,10 @@ from typing import Any
23
 
24
  from app.clients.hana_client import hana_client
25
  from app.services import human_io
26
- from app.services.credential import normalize_one_credential
 
 
 
27
  from app.services.extra_personas import EXTRA_PERSONAS, get_extra_persona
28
  from app.services.json_calls import orchestrator_call
29
  from app.services.resilience import build_substitution_chain
@@ -132,8 +135,9 @@ class AutoSelectRequest(BaseModel):
132
 
133
 
134
  class HumanCredentialPayload(BaseModel):
135
- """User-authored credential summary for the in-the-loop human.
136
 
 
137
  The orchestrator prepends this entry to the LLM-built credential
138
  summary so the human always appears first in the modal / exports.
139
  """
@@ -354,6 +358,17 @@ async def api_demo_questions():
354
  # Start chat
355
  # ---------------------------------------------------------------------------
356
 
 
 
 
 
 
 
 
 
 
 
 
357
  def _build_participant(
358
  sel: ParticipantSelectionPayload,
359
  expert_lookup: dict[str, ExpertPersonaPayload],
@@ -410,6 +425,8 @@ def _build_participant(
410
  )
411
  if not model_id:
412
  model_id = pid
 
 
413
  if not role_prompt:
414
  role_prompt = (
415
  f"You are {name}, a Neon.ai persona. Speak naturally in your "
@@ -435,6 +452,11 @@ def _build_participant(
435
  else:
436
  raise HTTPException(400, f"Unknown participant kind: {kind}")
437
 
 
 
 
 
 
438
  resolved = settings.resolve_model(model_id)
439
  if not resolved:
440
  raise HTTPException(400, f"Unknown model: {model_id}")
@@ -480,6 +502,13 @@ async def api_start_chat(req: StartChatRequest, request: Request):
480
 
481
  expert_lookup = {ep.participant_id: ep for ep in req.expert_personas}
482
 
 
 
 
 
 
 
 
483
  max_p = max(MIN_MAX_PARTICIPANTS, min(MAX_MAX_PARTICIPANTS, req.max_participants))
484
  if len(req.participants) < 2:
485
  raise HTTPException(400, "Need at least 2 participants")
@@ -745,6 +774,40 @@ async def api_edit_human_credential(
745
  return {"ok": True, "credential": updated}
746
 
747
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
  # Module-level registry of in-flight credential drafts. Each draft is a
749
  # tiny piece of state: the question being discussed, the human's name,
750
  # the question/answer history, and the configured cap. Drafts are
 
23
 
24
  from app.clients.hana_client import hana_client
25
  from app.services import human_io
26
+ from app.services.credential import (
27
+ build_human_credential_from_profile,
28
+ normalize_one_credential,
29
+ )
30
  from app.services.extra_personas import EXTRA_PERSONAS, get_extra_persona
31
  from app.services.json_calls import orchestrator_call
32
  from app.services.resilience import build_substitution_chain
 
135
 
136
 
137
  class HumanCredentialPayload(BaseModel):
138
+ """Structured credential summary for the in-the-loop human.
139
 
140
+ Generated from the user's profile text via /credentials/from-profile.
141
  The orchestrator prepends this entry to the LLM-built credential
142
  summary so the human always appears first in the modal / exports.
143
  """
 
358
  # Start chat
359
  # ---------------------------------------------------------------------------
360
 
361
+ def _neon_role_prompt_from_model_id(model_id: str) -> str:
362
+ """Look up a Neon persona's HANA system_prompt from the client cache."""
363
+ if not model_id.startswith("neon:"):
364
+ return ""
365
+ parts = model_id.split(":", 2)
366
+ if len(parts) != 3:
367
+ return ""
368
+ sp = hana_client.get_persona_system_prompt(parts[1], parts[2])
369
+ return (sp or "").strip()
370
+
371
+
372
  def _build_participant(
373
  sel: ParticipantSelectionPayload,
374
  expert_lookup: dict[str, ExpertPersonaPayload],
 
425
  )
426
  if not model_id:
427
  model_id = pid
428
+ if not role_prompt:
429
+ role_prompt = _neon_role_prompt_from_model_id(model_id)
430
  if not role_prompt:
431
  role_prompt = (
432
  f"You are {name}, a Neon.ai persona. Speak naturally in your "
 
452
  else:
453
  raise HTTPException(400, f"Unknown participant kind: {kind}")
454
 
455
+ # When the user assigns a Neon model to a non-Neon persona, surface
456
+ # that model's HANA persona prompt for inference context.
457
+ if not role_prompt and model_id.startswith("neon:"):
458
+ role_prompt = _neon_role_prompt_from_model_id(model_id)
459
+
460
  resolved = settings.resolve_model(model_id)
461
  if not resolved:
462
  raise HTTPException(400, f"Unknown model: {model_id}")
 
502
 
503
  expert_lookup = {ep.participant_id: ep for ep in req.expert_personas}
504
 
505
+ # Populate the HANA persona prompt cache so Neon role_prompt
506
+ # resolution works even if /api/models hasn't been called yet.
507
+ try:
508
+ await hana_client.get_models()
509
+ except Exception as exc:
510
+ LOG.warning("HANA get_models during chat start failed: %s", exc)
511
+
512
  max_p = max(MIN_MAX_PARTICIPANTS, min(MAX_MAX_PARTICIPANTS, req.max_participants))
513
  if len(req.participants) < 2:
514
  raise HTTPException(400, "Need at least 2 participants")
 
774
  return {"ok": True, "credential": updated}
775
 
776
 
777
+ class HumanCredentialFromProfileRequest(BaseModel):
778
+ """Body for POST /api/chat/credentials/from-profile."""
779
+
780
+ name: str
781
+ question: str = ""
782
+ profile_text: str
783
+ participant_id: str = ""
784
+ orchestrator_model_id: str | None = None
785
+
786
+
787
+ @router.post("/chat/credentials/from-profile")
788
+ async def api_human_credential_from_profile(req: HumanCredentialFromProfileRequest):
789
+ """Generate a structured credential summary from a human's profile text.
790
+
791
+ The orchestrator assesses the self-description the same way it would
792
+ a participant role prompt when building the Phase-1 Credential
793
+ Summary (expertise, style, credibility on this question, biases).
794
+ """
795
+ if not req.name.strip():
796
+ raise HTTPException(400, "name is required")
797
+ if not req.profile_text.strip():
798
+ raise HTTPException(400, "profile_text is required")
799
+
800
+ orchestrator_id = req.orchestrator_model_id or settings.orchestrator_model
801
+ credential = await build_human_credential_from_profile(
802
+ orchestrator_model_id=orchestrator_id,
803
+ question=(req.question or "").strip(),
804
+ name=req.name.strip(),
805
+ profile_text=req.profile_text.strip(),
806
+ participant_id=req.participant_id.strip(),
807
+ )
808
+ return {"credential": credential}
809
+
810
+
811
  # Module-level registry of in-flight credential drafts. Each draft is a
812
  # tiny piece of state: the question being discussed, the human's name,
813
  # the question/answer history, and the configured cap. Drafts are
backend/app/api/personas.py CHANGED
@@ -46,11 +46,13 @@ async def get_personas():
46
  persona_name = p.get("persona_name") or ""
47
  if _is_vanilla_or_rag(persona_name):
48
  continue
 
49
  raw_neon.append({
50
  "model_id": nm["model_id"],
51
  "model_short": model_short,
52
  "persona_name": persona_name,
53
  "description": p.get("description") or "",
 
54
  })
55
 
56
  # Pass 2: batch-reformat display names via the orchestrator LLM
@@ -72,6 +74,7 @@ async def get_personas():
72
  "model_display": r["model_short"],
73
  "default_model_id": participant_id,
74
  "description": r["description"],
 
75
  })
76
 
77
  extras = list_extra_personas()
 
46
  persona_name = p.get("persona_name") or ""
47
  if _is_vanilla_or_rag(persona_name):
48
  continue
49
+ system_prompt = (p.get("system_prompt") or "").strip()
50
  raw_neon.append({
51
  "model_id": nm["model_id"],
52
  "model_short": model_short,
53
  "persona_name": persona_name,
54
  "description": p.get("description") or "",
55
+ "role_prompt": system_prompt,
56
  })
57
 
58
  # Pass 2: batch-reformat display names via the orchestrator LLM
 
74
  "model_display": r["model_short"],
75
  "default_model_id": participant_id,
76
  "description": r["description"],
77
+ "role_prompt": r.get("role_prompt") or "",
78
  })
79
 
80
  extras = list_extra_personas()
backend/app/clients/llm_router.py CHANGED
@@ -180,6 +180,9 @@ async def _call_neon_direct_vllm(
180
  "response": strip_thinking(result.get("response", "")),
181
  "elapsed_seconds": result.get("elapsed_seconds", 0),
182
  "model": resolved["model_id"],
 
 
 
183
  }
184
 
185
 
 
180
  "response": strip_thinking(result.get("response", "")),
181
  "elapsed_seconds": result.get("elapsed_seconds", 0),
182
  "model": resolved["model_id"],
183
+ "error": result.get("error", False),
184
+ "error_kind": result.get("error_kind", ""),
185
+ "error_status": result.get("error_status"),
186
  }
187
 
188
 
backend/app/data/demo_questions.json CHANGED
@@ -1,6 +1,12 @@
1
  {
2
  "version": 1,
3
  "questions": [
 
 
 
 
 
 
4
  {
5
  "id": "undergrad_majors",
6
  "category": "education",
 
1
  {
2
  "version": 1,
3
  "questions": [
4
+ {
5
+ "id": "best_pet",
6
+ "category": "lifestyle",
7
+ "title": "Which is the best pet, cats or dogs?",
8
+ "text": "Which is the best pet, cats or dogs?"
9
+ },
10
  {
11
  "id": "undergrad_majors",
12
  "category": "education",
backend/app/services/context_budget.py CHANGED
@@ -175,6 +175,87 @@ def build_compressed_messages(
175
  return head + [summary_msg] + tail
176
 
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  # ---------------------------------------------------------------------------
179
  # Run a summarize call against the configured summarizer model
180
  # ---------------------------------------------------------------------------
 
175
  return head + [summary_msg] + tail
176
 
177
 
178
+ # ---------------------------------------------------------------------------
179
+ # Compress transcript embedded inside a single user prompt (CCAI pattern)
180
+ # ---------------------------------------------------------------------------
181
+ #
182
+ # Phase prompts bake the full transcript into one user message, e.g.
183
+ # "Conversation so far:\n{transcript}\n\nIn 4-8 sentences:…". The AskJerry
184
+ # multi-message trim path never fires because api_messages only has
185
+ # [system, user]. These helpers swap the transcript body for summary+tail.
186
+
187
+ _TRANSCRIPT_HEADERS: tuple[str, ...] = (
188
+ "Conversation so far:\n",
189
+ "Full conversation so far:\n",
190
+ "Full transcript:\n",
191
+ "Full conversation:\n",
192
+ )
193
+
194
+ # Section headers that typically follow the transcript block in phase prompts.
195
+ _TRANSCRIPT_FOOTERS: tuple[str, ...] = (
196
+ "\n\nOpen threads",
197
+ "\n\nIn ",
198
+ "\n\nFIRST",
199
+ "\n\nThe orchestrator",
200
+ "\n\nRight now",
201
+ "\n\nPhase ",
202
+ "\n\nQuestion:\n",
203
+ "\n\nCredential Summary:\n",
204
+ "\n\nBelow is",
205
+ "\n\nTargeted question:\n",
206
+ )
207
+
208
+
209
+ def replace_embedded_transcript(user_prompt: str, new_transcript: str) -> str:
210
+ """Replace the transcript body inside a phase prompt, if a known header exists."""
211
+ for header in _TRANSCRIPT_HEADERS:
212
+ idx = user_prompt.find(header)
213
+ if idx < 0:
214
+ continue
215
+ start = idx + len(header)
216
+ rest = user_prompt[start:]
217
+ end = len(rest)
218
+ for footer in _TRANSCRIPT_FOOTERS:
219
+ pos = rest.find(footer)
220
+ if pos >= 0:
221
+ end = min(end, pos)
222
+ return user_prompt[:start] + new_transcript + rest[end:]
223
+ return user_prompt
224
+
225
+
226
+ def build_compressed_transcript_block(
227
+ summary: ContextSummary,
228
+ recent_transcript: str,
229
+ ) -> str:
230
+ """AskJerry-style block: running summary + recent tail."""
231
+ recent = (recent_transcript or "").strip()
232
+ if summary.is_active():
233
+ body = (
234
+ "[Earlier discussion summary]\n"
235
+ + summary.summary_text.strip()
236
+ )
237
+ if recent:
238
+ body += "\n\n[Recent messages]\n" + recent
239
+ return body
240
+ if recent:
241
+ return "[Recent messages — auto-trimmed for context]\n" + recent
242
+ return ""
243
+
244
+
245
+ def cap_max_tokens_for_window(
246
+ model_id: str,
247
+ api_messages: list[dict[str, Any]],
248
+ requested_max_tokens: int,
249
+ ) -> int:
250
+ """Shrink reply budget so input + output fits the model window (AskJerry)."""
251
+ window = context_window_for(model_id)
252
+ est = estimate_messages_tokens(api_messages)
253
+ headroom = window - est - 64
254
+ if headroom < 256:
255
+ return max(64, min(requested_max_tokens, headroom))
256
+ return min(requested_max_tokens, headroom)
257
+
258
+
259
  # ---------------------------------------------------------------------------
260
  # Run a summarize call against the configured summarizer model
261
  # ---------------------------------------------------------------------------
backend/app/services/credential.py CHANGED
@@ -1,8 +1,9 @@
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
 
@@ -14,6 +15,8 @@ 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
 
@@ -68,8 +71,8 @@ async def build_credential_summary(
68
  ) -> list[dict[str, Any]]:
69
  """Build the Credential Summary list. Returns an empty list on parse failure.
70
 
71
- Human participants (kind == "human") are NOT sent to the LLM - the
72
- user already authored their own credential summary in the
73
  HumanParticipantModal. We prepend that entry to the front of the
74
  returned list so the human always appears first in the modal /
75
  export, and we exclude them from the LLM input so the orchestrator
@@ -101,6 +104,139 @@ async def build_credential_summary(
101
  return creds
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  async def refresh_credential_summary(
105
  *,
106
  orchestrator_model_id: str,
@@ -160,7 +296,7 @@ def normalize_one_credential(c: dict[str, Any]) -> dict[str, Any]:
160
  "personality": c.get("personality", ""),
161
  "credibility_for_question": max(0.0, min(1.0, score)),
162
  "bias_to_watch": c.get("bias_to_watch", ""),
163
- "is_human": bool(c.get("is_human", True)),
164
  }
165
 
166
 
 
1
  """Credential Summary builder + refresher.
2
 
3
  The Credential Summary is a JSON dict (participant_id -> assessment)
4
+ threaded into every later participant turn. Each LLM participant's entry
5
+ is built concurrently during Phase 1 (as their initial opinion lands)
6
+ and is only rebuilt later if their backing model changes.
7
  """
8
  from __future__ import annotations
9
 
 
15
  from app.services.prompts import (
16
  CREDENTIAL_BUILD_PROMPT,
17
  CREDENTIAL_REFRESH_PROMPT,
18
+ HUMAN_CREDENTIAL_FROM_PROFILE_PROMPT,
19
+ SINGLE_PARTICIPANT_CREDENTIAL_BUILD_PROMPT,
20
  )
21
  from app.utils.sanitize import strip_thinking
22
 
 
71
  ) -> list[dict[str, Any]]:
72
  """Build the Credential Summary list. Returns an empty list on parse failure.
73
 
74
+ Human participants (kind == "human") are NOT sent to the LLM -
75
+ their credential was generated from the user's profile text in the
76
  HumanParticipantModal. We prepend that entry to the front of the
77
  returned list so the human always appears first in the modal /
78
  export, and we exclude them from the LLM input so the orchestrator
 
104
  return creds
105
 
106
 
107
+ async def build_credential_for_participant(
108
+ *,
109
+ orchestrator_model_id: str,
110
+ question: str,
111
+ participant: Any,
112
+ initial_opinion: str,
113
+ api_log: list[dict[str, Any]] | None = None,
114
+ ) -> dict[str, Any]:
115
+ """Build one credential entry from a role prompt + Phase-1 opinion."""
116
+ opinion = strip_thinking(initial_opinion or "")
117
+ prompt = SINGLE_PARTICIPANT_CREDENTIAL_BUILD_PROMPT.format(
118
+ question=question,
119
+ participant_id=participant.participant_id,
120
+ name=participant.name,
121
+ role_prompt=(participant.role_prompt or "").strip() or "(none)",
122
+ first_opinion=opinion or "(no opinion recorded)",
123
+ )
124
+ _raw, parsed = await orchestrator_call(
125
+ orchestrator_model_id=orchestrator_model_id,
126
+ user_prompt=prompt,
127
+ label=f"build_credential:{participant.participant_id}",
128
+ api_log=api_log,
129
+ max_tokens=512,
130
+ )
131
+ cred: dict[str, Any] = {}
132
+ if isinstance(parsed, dict):
133
+ if isinstance(parsed.get("credential"), dict):
134
+ cred = parsed["credential"]
135
+ elif isinstance(parsed.get("credentials"), list) and parsed["credentials"]:
136
+ cred = parsed["credentials"][0]
137
+
138
+ merged = {
139
+ "participant_id": participant.participant_id,
140
+ "name": participant.name,
141
+ "expertise": cred.get("expertise", ""),
142
+ "personality": cred.get("personality", ""),
143
+ "credibility_for_question": cred.get("credibility_for_question", 0.5),
144
+ "bias_to_watch": cred.get("bias_to_watch", ""),
145
+ "is_human": False,
146
+ }
147
+ if not merged["expertise"]:
148
+ merged["expertise"] = "(no credential available)"
149
+ return normalize_one_credential(merged)
150
+
151
+
152
+ def assemble_credential_summary_list(
153
+ *,
154
+ participants: list[Any],
155
+ credential_entries_by_pid: dict[str, dict[str, Any]],
156
+ human_credential: dict[str, Any] | None = None,
157
+ ) -> list[dict[str, Any]]:
158
+ """Merge per-participant credential rows in roster order (human first)."""
159
+ creds: list[dict[str, Any]] = []
160
+ if human_credential:
161
+ creds.append(normalize_one_credential(human_credential))
162
+
163
+ for p in participants:
164
+ if getattr(p, "kind", "") == "human":
165
+ continue
166
+ row = credential_entries_by_pid.get(p.participant_id)
167
+ if row:
168
+ creds.append(row)
169
+ else:
170
+ creds.append(normalize_one_credential({
171
+ "participant_id": p.participant_id,
172
+ "name": p.name,
173
+ "expertise": "(no credential available)",
174
+ "personality": "",
175
+ "credibility_for_question": 0.5,
176
+ "bias_to_watch": "",
177
+ "is_human": False,
178
+ }))
179
+ return creds
180
+
181
+
182
+ async def build_human_credential_from_profile(
183
+ *,
184
+ orchestrator_model_id: str,
185
+ question: str,
186
+ name: str,
187
+ profile_text: str,
188
+ participant_id: str = "",
189
+ api_log: list[dict[str, Any]] | None = None,
190
+ ) -> dict[str, Any]:
191
+ """Turn a human's freeform self-description into a structured credential.
192
+
193
+ Uses the same assessment rubric as Phase-1 credential building for
194
+ LLM participants (expertise, style, credibility, bias) but sources
195
+ only the profile text — equivalent to a persona role prompt.
196
+ """
197
+ q = (question or "").strip()
198
+ if q:
199
+ question_block = f"Question:\n<<<\n{q}\n>>>\n\n"
200
+ else:
201
+ question_block = (
202
+ "Discussion question: (not specified yet). Assess the "
203
+ "participant in general terms; credibility_for_question "
204
+ "should reflect their likely relevance once a topic is "
205
+ "chosen.\n\n"
206
+ )
207
+ prompt = HUMAN_CREDENTIAL_FROM_PROFILE_PROMPT.format(
208
+ question_block=question_block,
209
+ name=name.strip(),
210
+ profile_text=profile_text.strip(),
211
+ )
212
+ _raw, parsed = await orchestrator_call(
213
+ orchestrator_model_id=orchestrator_model_id,
214
+ user_prompt=prompt,
215
+ label="human_credential_from_profile",
216
+ api_log=api_log,
217
+ max_tokens=768,
218
+ )
219
+ cred: dict[str, Any] = {}
220
+ if isinstance(parsed, dict):
221
+ if isinstance(parsed.get("credential"), dict):
222
+ cred = parsed["credential"]
223
+ elif isinstance(parsed.get("credentials"), list) and parsed["credentials"]:
224
+ cred = parsed["credentials"][0]
225
+
226
+ merged = {
227
+ "participant_id": participant_id,
228
+ "name": name.strip(),
229
+ "expertise": cred.get("expertise", ""),
230
+ "personality": cred.get("personality", ""),
231
+ "credibility_for_question": cred.get("credibility_for_question", 0.55),
232
+ "bias_to_watch": cred.get("bias_to_watch", ""),
233
+ "is_human": True,
234
+ }
235
+ if not merged["expertise"] and profile_text.strip():
236
+ merged["expertise"] = profile_text.strip()[:500]
237
+ return normalize_one_credential(merged)
238
+
239
+
240
  async def refresh_credential_summary(
241
  *,
242
  orchestrator_model_id: str,
 
296
  "personality": c.get("personality", ""),
297
  "credibility_for_question": max(0.0, min(1.0, score)),
298
  "bias_to_watch": c.get("bias_to_watch", ""),
299
+ "is_human": bool(c.get("is_human", False)),
300
  }
301
 
302
 
backend/app/services/models.py CHANGED
@@ -310,6 +310,15 @@ class Session:
310
  # Phase 1 outputs
311
  initial_opinions: dict[str, str] = field(default_factory=dict)
312
  credential_summary: list[dict[str, Any]] = field(default_factory=list)
 
 
 
 
 
 
 
 
 
313
 
314
  # Phase 2 / 3 / 4 / 5 message store. Each entry:
315
  # { speaker_id, speaker_name, role: "participant"|"orchestrator",
 
310
  # Phase 1 outputs
311
  initial_opinions: dict[str, str] = field(default_factory=dict)
312
  credential_summary: list[dict[str, Any]] = field(default_factory=list)
313
+ # Per-participant credential builds kicked off as each initial
314
+ # opinion completes during Phase 1 (concurrent with remaining turns).
315
+ credential_build_tasks: dict[str, Any] = field(default_factory=dict)
316
+ credential_entries_by_pid: dict[str, dict[str, Any]] = field(
317
+ default_factory=dict,
318
+ )
319
+ # model_id each credential row was built for; used to rebuild only
320
+ # when the backing LLM behind a participant changes.
321
+ credential_model_by_pid: dict[str, str] = field(default_factory=dict)
322
 
323
  # Phase 2 / 3 / 4 / 5 message store. Each entry:
324
  # { speaker_id, speaker_name, role: "participant"|"orchestrator",
backend/app/services/orchestrator.py CHANGED
@@ -4,7 +4,7 @@ group discussion to a consensus (or to a documented failure-to-consense).
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
@@ -43,17 +43,19 @@ from app.services.context_budget import (
43
  ContextSummary,
44
  DEFAULT_REPLY_BUDGET,
45
  KEEP_RECENT_MESSAGES,
46
- build_compressed_messages,
 
47
  context_window_for,
48
  estimate_messages_tokens,
 
49
  run_summarize,
50
  select_summarizer_model_id,
51
  should_summarize,
52
  )
53
  from app.services.credential import (
54
- build_credential_summary,
 
55
  credentials_to_block,
56
- refresh_credential_summary,
57
  )
58
  from app.services.json_calls import orchestrator_call
59
  from app.services.orchestrator_speed import (
@@ -61,6 +63,7 @@ from app.services.orchestrator_speed import (
61
  _AiTurnSpec,
62
  compact_transcript_for_orchestrator,
63
  orchestrator_fast_model_id,
 
64
  run_roster_ai_turns_parallel,
65
  )
66
  from app.services.resilience import run_resilient_turn
@@ -298,7 +301,8 @@ async def _wait_for_continue(
298
  else:
299
  session.orchestrator_call_cap += bump_inc
300
  session.pause_reason = None
301
- yield _sse("status", {"message": "Resuming conversation..."})
 
302
 
303
 
304
  # ---------------------------------------------------------------------------
@@ -546,13 +550,38 @@ async def _call_participant(
546
 
547
  await _maybe_summarize_for_participant(session, participant, api_messages)
548
 
549
- needs_sum, needs_trim, _ = should_summarize(
550
  participant.model_id, api_messages, participant.summary,
551
  )
552
- if needs_trim:
553
- api_messages = build_compressed_messages(
554
- api_messages, participant.summary, needs_trim,
 
 
 
 
 
 
 
 
555
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
 
557
  resolved = {
558
  "model_id": participant.model_id,
@@ -699,13 +728,154 @@ def _msg_payload(msg: dict[str, Any]) -> dict[str, Any]:
699
  return msg
700
 
701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  # ---------------------------------------------------------------------------
703
  # Phase implementations
704
  # ---------------------------------------------------------------------------
705
 
706
  async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
707
  session.phase = Phase.INITIAL_OPINIONS
708
- yield _sse("status", {"message": "Phase 1: collecting independent first opinions..."})
 
 
 
709
 
710
  actives = _active_participants(session)
711
 
@@ -721,7 +891,11 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
721
  yield chunk
722
 
723
  async def _post_initial(result: _AiTurnResult) -> dict[str, Any]:
724
- session.initial_opinions[result.participant.participant_id] = result.turn.text
 
 
 
 
725
  return {}
726
 
727
  def _build_initial_spec(p: Participant) -> _AiTurnSpec | None:
@@ -734,10 +908,9 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
734
  max_tokens=700,
735
  )
736
 
737
- async for chunk in run_roster_ai_turns_parallel(
738
  session,
739
  actives,
740
- phase=session.phase,
741
  build_spec=_build_initial_spec,
742
  call_participant=_call_participant,
743
  on_human_turn=_human_initial,
@@ -745,20 +918,9 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
745
  ):
746
  yield chunk
747
 
748
- yield _sse("status", {"message": "Building Credential Summary..."})
749
- creds = await build_credential_summary(
750
- orchestrator_model_id=_orchestrator_model_id(session),
751
- question=session.question,
752
- participants=_active_participants(session),
753
- initial_opinions=session.initial_opinions,
754
- api_log=session.api_log,
755
- human_credential=session.human_credential,
756
- )
757
- _bump_orchestrator_count(session)
758
- session.credential_summary = creds
759
- # Surface the freshly-built summary so the frontend can enable the
760
- # "View Credential Summary" menu item and cache its snapshot. The
761
- # full list also stays available via GET /api/chat/{id}/credentials.
762
  yield _sse("credentials_updated", {
763
  "stage": "built",
764
  "credentials": session.credential_summary,
@@ -784,9 +946,11 @@ def _critique_phase_for(round_number: int) -> Phase:
784
  async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[str]:
785
  session.phase = _critique_phase_for(round_number)
786
  round_total = session.limits.critique_rounds
787
- yield _sse("status", {
788
- "message": f"Phase 2: critique round {round_number} of {round_total}...",
789
- })
 
 
790
  cred_block = credentials_to_block(session.credential_summary)
791
  actives = _active_participants(session)
792
  # Freeze transcript + pending threads at round start so parallel
@@ -859,10 +1023,12 @@ async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[
859
 
860
  async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
861
  session.phase = Phase.STATUS_ASSESSMENT
862
- yield _sse("status", {"message": "Phase 3: assessing whether more questions are needed..."})
 
 
 
863
 
864
  cred_block = credentials_to_block(session.credential_summary)
865
- cred_refreshed = False
866
 
867
  for iteration in range(session.limits.status_assessment_max):
868
  session.status_assessment_iterations = iteration + 1
@@ -900,27 +1066,6 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
900
  yield _sse("orchestrator", _msg_payload(msg))
901
  return
902
 
903
- # Refresh credentials only when follow-ups are actually needed.
904
- if not cred_refreshed:
905
- cred_refreshed = True
906
- full_transcript = _format_history(session.messages)
907
- refreshed = await refresh_credential_summary(
908
- orchestrator_model_id=_orchestrator_model_id(session),
909
- question=session.question,
910
- participants=_active_participants(session),
911
- existing=session.credential_summary,
912
- critique_transcript=full_transcript,
913
- api_log=session.api_log,
914
- )
915
- _bump_orchestrator_count(session)
916
- if refreshed != session.credential_summary:
917
- session.credential_summary = refreshed
918
- yield _sse("credentials_updated", {
919
- "stage": "refreshed",
920
- "credentials": session.credential_summary,
921
- })
922
- cred_block = credentials_to_block(session.credential_summary)
923
-
924
  # Otherwise run targeted follow-ups
925
  active_ids = {p.participant_id for p in _active_participants(session)}
926
  for oq in open_qs:
@@ -996,10 +1141,8 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
996
  for ev in turn.sse_events:
997
  yield ev
998
  if not turn.ok:
999
- yield _sse("participant_error", {
1000
- "participant_id": target.participant_id, "name": target.name,
1001
- "phase": session.phase.value,
1002
- })
1003
  continue
1004
  speaker = turn.speaker
1005
  text, elapsed = turn.text, turn.elapsed
@@ -1031,7 +1174,8 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
1031
 
1032
  async def _phase_finalization(session: Session) -> AsyncIterator[str]:
1033
  session.phase = Phase.FINALIZATION
1034
- yield _sse("status", {"message": "Phase 4: opinion finalization..."})
 
1035
 
1036
  cred_block = credentials_to_block(session.credential_summary)
1037
  actives = _active_participants(session)
@@ -1089,7 +1233,8 @@ async def _phase_finalization(session: Session) -> AsyncIterator[str]:
1089
 
1090
  async def _phase_consensus(session: Session) -> AsyncIterator[str]:
1091
  session.phase = Phase.CONSENSUS
1092
- yield _sse("status", {"message": "Phase 5: consensus gathering..."})
 
1093
 
1094
  cred_block = credentials_to_block(session.credential_summary)
1095
  actives = _active_participants(session)
@@ -1108,7 +1253,12 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
1108
  # Render alliance group members using the same display names shown
1109
  # in the sidebar (Participant.name), not raw participant_ids.
1110
  id_to_name = {p.participant_id: p.name for p in actives}
1111
- announce = "Alliance groups detected: " + "; ".join(
 
 
 
 
 
1112
  f"\"{g.get('stance', '')}\" -> ["
1113
  + ", ".join(
1114
  id_to_name.get(m, m) for m in (g.get("members") or [])
@@ -1139,6 +1289,10 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
1139
 
1140
  while consensus_turns < max_consensus_turns:
1141
  consensus_turns += 1
 
 
 
 
1142
 
1143
  # Pick speaker. Prefer the addressed-to target (dyadic exchange)
1144
  # only while we're under the consecutive-routing cap. Once the
@@ -1216,10 +1370,8 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
1216
  for ev in turn.sse_events:
1217
  yield ev
1218
  if not turn.ok:
1219
- yield _sse("participant_error", {
1220
- "participant_id": speaker.participant_id, "name": speaker.name,
1221
- "phase": session.phase.value,
1222
- })
1223
  continue
1224
  # Consensus phase doesn't swap participants, only LLMs behind
1225
  # them, so turn.speaker is the same instance as `speaker`. Use
@@ -1394,7 +1546,8 @@ def _build_consensus_prompt(
1394
 
1395
  async def _phase_closure(session: Session) -> AsyncIterator[str]:
1396
  session.phase = Phase.CLOSURE
1397
- yield _sse("status", {"message": "Phase 6: closure..."})
 
1398
 
1399
  cred_block = credentials_to_block(session.credential_summary)
1400
  transcript = await compact_transcript_for_orchestrator(
 
4
  Phase outline (matches the build plan):
5
 
6
  1. Initial Opinions (independent, no peeking)
7
+ 1.5. Credential Summary (built concurrently during Phase 1)
8
  2. Critique x 2 rounds (full history visible)
9
  3. Status Assessment (max 3 iterations of targeted follow-ups)
10
  4. Opinion Finalization
 
43
  ContextSummary,
44
  DEFAULT_REPLY_BUDGET,
45
  KEEP_RECENT_MESSAGES,
46
+ build_compressed_transcript_block,
47
+ cap_max_tokens_for_window,
48
  context_window_for,
49
  estimate_messages_tokens,
50
+ replace_embedded_transcript,
51
  run_summarize,
52
  select_summarizer_model_id,
53
  should_summarize,
54
  )
55
  from app.services.credential import (
56
+ assemble_credential_summary_list,
57
+ build_credential_for_participant,
58
  credentials_to_block,
 
59
  )
60
  from app.services.json_calls import orchestrator_call
61
  from app.services.orchestrator_speed import (
 
63
  _AiTurnSpec,
64
  compact_transcript_for_orchestrator,
65
  orchestrator_fast_model_id,
66
+ run_initial_opinions_roster,
67
  run_roster_ai_turns_parallel,
68
  )
69
  from app.services.resilience import run_resilient_turn
 
301
  else:
302
  session.orchestrator_call_cap += bump_inc
303
  session.pause_reason = None
304
+ for chunk in _orchestrator_banner_sse(session, "Resuming conversation..."):
305
+ yield chunk
306
 
307
 
308
  # ---------------------------------------------------------------------------
 
550
 
551
  await _maybe_summarize_for_participant(session, participant, api_messages)
552
 
553
+ needs_sum, needs_trim, input_budget = should_summarize(
554
  participant.model_id, api_messages, participant.summary,
555
  )
556
+
557
+ # CCAI embeds the transcript inside the user prompt. When over budget,
558
+ # swap that block for summary + recent tail (AskJerry pattern).
559
+ if needs_sum or needs_trim:
560
+ recent_transcript = _format_history(
561
+ session.messages[-KEEP_RECENT_MESSAGES:],
562
+ include_orchestrator=False,
563
+ )
564
+ compressed_block = build_compressed_transcript_block(
565
+ participant.summary,
566
+ recent_transcript,
567
  )
568
+ if compressed_block:
569
+ new_prompt = replace_embedded_transcript(user_prompt, compressed_block)
570
+ user_prompt = new_prompt
571
+ api_messages[1] = {"role": "user", "content": user_prompt}
572
+ elif len(user_prompt) > input_budget * 4:
573
+ # Last-resort: keep prompt head + tail if no transcript header matched.
574
+ keep = max(512, input_budget * 2)
575
+ user_prompt = (
576
+ user_prompt[:keep]
577
+ + "\n\n[…middle truncated for context…]\n\n"
578
+ + user_prompt[-keep:]
579
+ )
580
+ api_messages[1] = {"role": "user", "content": user_prompt}
581
+
582
+ max_tokens = cap_max_tokens_for_window(
583
+ participant.model_id, api_messages, max_tokens,
584
+ )
585
 
586
  resolved = {
587
  "model_id": participant.model_id,
 
728
  return msg
729
 
730
 
731
+ def _orchestrator_banner_sse(
732
+ session: Session,
733
+ text: str,
734
+ *,
735
+ kind: str = "status",
736
+ extra: dict[str, Any] | None = None,
737
+ ) -> list[str]:
738
+ """Append an orchestrator line to the transcript and emit chat + status SSE."""
739
+ msg = _add_orchestrator_message(session, text, kind=kind, extra=extra)
740
+ return [
741
+ _sse("orchestrator", _msg_payload(msg)),
742
+ _sse("status", {"message": text}),
743
+ ]
744
+
745
+
746
+ def _participant_turn_failure_sse(
747
+ session: Session,
748
+ participant: Participant,
749
+ ) -> list[str]:
750
+ """Emit participant_error and auto-disable banner when threshold hit."""
751
+ out = [
752
+ _sse("participant_error", {
753
+ "participant_id": participant.participant_id,
754
+ "name": participant.name,
755
+ "phase": session.phase.value,
756
+ }),
757
+ ]
758
+ if participant.consecutive_failures >= session.limits.auto_disable_failures:
759
+ participant.enabled = False
760
+ out.extend(_orchestrator_banner_sse(
761
+ session,
762
+ f"{participant.name} auto-disabled after "
763
+ f"{session.limits.auto_disable_failures} consecutive failures.",
764
+ ))
765
+ return out
766
+
767
+
768
+ # ---------------------------------------------------------------------------
769
+ # Credential summary (concurrent with Phase 1)
770
+ # ---------------------------------------------------------------------------
771
+
772
+ async def _credential_build_runner(
773
+ session: Session,
774
+ participant: Participant,
775
+ initial_opinion: str,
776
+ ) -> None:
777
+ """Background task: one participant's credential row."""
778
+ try:
779
+ cred = await build_credential_for_participant(
780
+ orchestrator_model_id=_orchestrator_model_id(session),
781
+ question=session.question,
782
+ participant=participant,
783
+ initial_opinion=initial_opinion,
784
+ api_log=session.api_log,
785
+ )
786
+ session.credential_entries_by_pid[participant.participant_id] = cred
787
+ session.credential_model_by_pid[participant.participant_id] = (
788
+ participant.model_id
789
+ )
790
+ _bump_orchestrator_count(session)
791
+ except Exception as exc:
792
+ LOG.exception(
793
+ "Credential build failed for %s: %s",
794
+ participant.participant_id,
795
+ exc,
796
+ )
797
+
798
+
799
+ def _schedule_phase1_credential_build(
800
+ session: Session,
801
+ participant: Participant,
802
+ initial_opinion: str,
803
+ ) -> None:
804
+ """Start (or restart) a background credential build for one AI participant."""
805
+ if participant.kind == "human":
806
+ return
807
+ if not (initial_opinion or "").strip():
808
+ return
809
+
810
+ pid = participant.participant_id
811
+ existing = session.credential_build_tasks.get(pid)
812
+ if existing is not None and not existing.done():
813
+ existing.cancel()
814
+
815
+ session.credential_build_tasks[pid] = asyncio.create_task(
816
+ _credential_build_runner(session, participant, initial_opinion),
817
+ name=f"credential:{pid}",
818
+ )
819
+
820
+
821
+ def _sync_credential_summary_from_entries(session: Session) -> None:
822
+ session.credential_summary = assemble_credential_summary_list(
823
+ participants=_active_participants(session),
824
+ credential_entries_by_pid=session.credential_entries_by_pid,
825
+ human_credential=session.human_credential,
826
+ )
827
+
828
+
829
+ async def _await_phase1_credential_tasks(session: Session) -> None:
830
+ """Wait for any in-flight per-participant credential builds."""
831
+ tasks = [
832
+ t for t in session.credential_build_tasks.values()
833
+ if t is not None and not t.done()
834
+ ]
835
+ if tasks:
836
+ await asyncio.gather(*tasks, return_exceptions=True)
837
+ _sync_credential_summary_from_entries(session)
838
+
839
+
840
+ async def _rebuild_participant_credential_on_model_change(
841
+ session: Session,
842
+ participant: Participant,
843
+ ) -> bool:
844
+ """Rebuild one credential row when the backing LLM model changes."""
845
+ if participant.kind == "human":
846
+ return False
847
+ pid = participant.participant_id
848
+ opinion = (session.initial_opinions or {}).get(pid, "")
849
+ if not opinion.strip():
850
+ return False
851
+ prior_model = session.credential_model_by_pid.get(pid)
852
+ if not prior_model or prior_model == participant.model_id:
853
+ return False
854
+
855
+ cred = await build_credential_for_participant(
856
+ orchestrator_model_id=_orchestrator_model_id(session),
857
+ question=session.question,
858
+ participant=participant,
859
+ initial_opinion=opinion,
860
+ api_log=session.api_log,
861
+ )
862
+ _bump_orchestrator_count(session)
863
+ session.credential_entries_by_pid[pid] = cred
864
+ session.credential_model_by_pid[pid] = participant.model_id
865
+ _sync_credential_summary_from_entries(session)
866
+ return True
867
+
868
+
869
  # ---------------------------------------------------------------------------
870
  # Phase implementations
871
  # ---------------------------------------------------------------------------
872
 
873
  async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
874
  session.phase = Phase.INITIAL_OPINIONS
875
+ for chunk in _orchestrator_banner_sse(
876
+ session, "Phase 1: collecting independent first opinions...",
877
+ ):
878
+ yield chunk
879
 
880
  actives = _active_participants(session)
881
 
 
891
  yield chunk
892
 
893
  async def _post_initial(result: _AiTurnResult) -> dict[str, Any]:
894
+ speaker = result.turn.speaker
895
+ session.initial_opinions[speaker.participant_id] = result.turn.text
896
+ _schedule_phase1_credential_build(
897
+ session, speaker, result.turn.text,
898
+ )
899
  return {}
900
 
901
  def _build_initial_spec(p: Participant) -> _AiTurnSpec | None:
 
908
  max_tokens=700,
909
  )
910
 
911
+ async for chunk in run_initial_opinions_roster(
912
  session,
913
  actives,
 
914
  build_spec=_build_initial_spec,
915
  call_participant=_call_participant,
916
  on_human_turn=_human_initial,
 
918
  ):
919
  yield chunk
920
 
921
+ # Credential rows were built in parallel as each opinion landed;
922
+ # only wait here if any background task is still finishing.
923
+ await _await_phase1_credential_tasks(session)
 
 
 
 
 
 
 
 
 
 
 
924
  yield _sse("credentials_updated", {
925
  "stage": "built",
926
  "credentials": session.credential_summary,
 
946
  async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[str]:
947
  session.phase = _critique_phase_for(round_number)
948
  round_total = session.limits.critique_rounds
949
+ for chunk in _orchestrator_banner_sse(
950
+ session,
951
+ f"Phase 2: critique round {round_number} of {round_total}...",
952
+ ):
953
+ yield chunk
954
  cred_block = credentials_to_block(session.credential_summary)
955
  actives = _active_participants(session)
956
  # Freeze transcript + pending threads at round start so parallel
 
1023
 
1024
  async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
1025
  session.phase = Phase.STATUS_ASSESSMENT
1026
+ for chunk in _orchestrator_banner_sse(
1027
+ session, "Phase 3: assessing whether more questions are needed...",
1028
+ ):
1029
+ yield chunk
1030
 
1031
  cred_block = credentials_to_block(session.credential_summary)
 
1032
 
1033
  for iteration in range(session.limits.status_assessment_max):
1034
  session.status_assessment_iterations = iteration + 1
 
1066
  yield _sse("orchestrator", _msg_payload(msg))
1067
  return
1068
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1069
  # Otherwise run targeted follow-ups
1070
  active_ids = {p.participant_id for p in _active_participants(session)}
1071
  for oq in open_qs:
 
1141
  for ev in turn.sse_events:
1142
  yield ev
1143
  if not turn.ok:
1144
+ for chunk in _participant_turn_failure_sse(session, target):
1145
+ yield chunk
 
 
1146
  continue
1147
  speaker = turn.speaker
1148
  text, elapsed = turn.text, turn.elapsed
 
1174
 
1175
  async def _phase_finalization(session: Session) -> AsyncIterator[str]:
1176
  session.phase = Phase.FINALIZATION
1177
+ for chunk in _orchestrator_banner_sse(session, "Phase 4: opinion finalization..."):
1178
+ yield chunk
1179
 
1180
  cred_block = credentials_to_block(session.credential_summary)
1181
  actives = _active_participants(session)
 
1233
 
1234
  async def _phase_consensus(session: Session) -> AsyncIterator[str]:
1235
  session.phase = Phase.CONSENSUS
1236
+ for chunk in _orchestrator_banner_sse(session, "Phase 5: consensus gathering..."):
1237
+ yield chunk
1238
 
1239
  cred_block = credentials_to_block(session.credential_summary)
1240
  actives = _active_participants(session)
 
1253
  # Render alliance group members using the same display names shown
1254
  # in the sidebar (Participant.name), not raw participant_ids.
1255
  id_to_name = {p.participant_id: p.name for p in actives}
1256
+ alliance_prefix = (
1257
+ "Updated alliance groups detected: "
1258
+ if session.consensus_attempts > 0
1259
+ else "Alliance groups detected: "
1260
+ )
1261
+ announce = alliance_prefix + "; ".join(
1262
  f"\"{g.get('stance', '')}\" -> ["
1263
  + ", ".join(
1264
  id_to_name.get(m, m) for m in (g.get("members") or [])
 
1289
 
1290
  while consensus_turns < max_consensus_turns:
1291
  consensus_turns += 1
1292
+ actives = _active_participants(session)
1293
+ if len(actives) < 2:
1294
+ break
1295
+ queue = [p for p in queue if p.enabled]
1296
 
1297
  # Pick speaker. Prefer the addressed-to target (dyadic exchange)
1298
  # only while we're under the consecutive-routing cap. Once the
 
1370
  for ev in turn.sse_events:
1371
  yield ev
1372
  if not turn.ok:
1373
+ for chunk in _participant_turn_failure_sse(session, speaker):
1374
+ yield chunk
 
 
1375
  continue
1376
  # Consensus phase doesn't swap participants, only LLMs behind
1377
  # them, so turn.speaker is the same instance as `speaker`. Use
 
1546
 
1547
  async def _phase_closure(session: Session) -> AsyncIterator[str]:
1548
  session.phase = Phase.CLOSURE
1549
+ for chunk in _orchestrator_banner_sse(session, "Phase 6: closure..."):
1550
+ yield chunk
1551
 
1552
  cred_block = credentials_to_block(session.credential_summary)
1553
  transcript = await compact_transcript_for_orchestrator(
backend/app/services/orchestrator_speed.py CHANGED
@@ -177,6 +177,7 @@ async def run_roster_ai_turns_parallel(
177
  from app.services.orchestrator import (
178
  _msg_payload,
179
  _participant_msg_cap_hit,
 
180
  _sse,
181
  _wait_for_continue,
182
  )
@@ -237,6 +238,104 @@ async def run_roster_ai_turns_parallel(
237
  yield chunk
238
 
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  async def _emit_ai_turn_result(
241
  session: "Session",
242
  result: _AiTurnResult,
@@ -249,6 +348,7 @@ async def _emit_ai_turn_result(
249
  _add_participant_message,
250
  _msg_payload,
251
  _orchestrator_cap_hit,
 
252
  _replying_to_ids,
253
  _sse,
254
  _wait_for_continue,
@@ -256,22 +356,14 @@ async def _emit_ai_turn_result(
256
 
257
  p = result.participant
258
  turn = result.turn
 
259
  for ev in turn.sse_events:
 
 
260
  yield ev
261
  if not turn.ok:
262
- yield _sse("participant_error", {
263
- "participant_id": p.participant_id,
264
- "name": p.name,
265
- "phase": phase.value,
266
- })
267
- if p.consecutive_failures >= session.limits.auto_disable_failures:
268
- p.enabled = False
269
- yield _sse("status", {
270
- "message": (
271
- f"{p.name} auto-disabled after "
272
- f"{session.limits.auto_disable_failures} failures."
273
- ),
274
- })
275
  return
276
 
277
  speaker = turn.speaker
@@ -291,6 +383,18 @@ async def _emit_ai_turn_result(
291
  )
292
  yield _sse("message", _msg_payload(msg))
293
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  if _orchestrator_cap_hit(session):
295
  async for chunk in _wait_for_continue(session, "orchestrator"):
296
  yield chunk
 
177
  from app.services.orchestrator import (
178
  _msg_payload,
179
  _participant_msg_cap_hit,
180
+ _participant_turn_failure_sse,
181
  _sse,
182
  _wait_for_continue,
183
  )
 
238
  yield chunk
239
 
240
 
241
+ async def run_initial_opinions_roster(
242
+ session: "Session",
243
+ actives: list["Participant"],
244
+ *,
245
+ build_spec: Callable[["Participant"], _AiTurnSpec | None],
246
+ call_participant: CallParticipantFn,
247
+ on_human_turn: Callable[
248
+ ["Participant"],
249
+ AsyncIterator[str],
250
+ ],
251
+ post_process: PostProcessFn | None = None,
252
+ ) -> AsyncIterator[str]:
253
+ """Phase-1 roster walk with human-aware AI prefetch.
254
+
255
+ When a human is in the roster, every AI participant's initial-
256
+ opinion call is fired immediately (in parallel) so answers are ready
257
+ while the human types. SSE ``message`` events are still emitted in
258
+ roster order: any LLMs listed before the human appear as soon as
259
+ their prefetch completes, and LLMs after the human stay hidden until
260
+ the human submits (or skips).
261
+ """
262
+ from app.services.models import Phase
263
+ from app.services.orchestrator import (
264
+ _participant_msg_cap_hit,
265
+ _sse,
266
+ _wait_for_continue,
267
+ )
268
+
269
+ phase = Phase.INITIAL_OPINIONS
270
+ has_human = any(p.kind == "human" for p in actives)
271
+ if not has_human:
272
+ async for chunk in run_roster_ai_turns_parallel(
273
+ session,
274
+ actives,
275
+ phase=phase,
276
+ build_spec=build_spec,
277
+ call_participant=call_participant,
278
+ on_human_turn=on_human_turn,
279
+ post_process=post_process,
280
+ ):
281
+ yield chunk
282
+ return
283
+
284
+ pending: dict[str, asyncio.Task[_AiTurnResult]] = {}
285
+ for p in actives:
286
+ if p.kind == "human":
287
+ continue
288
+ spec = build_spec(p)
289
+ if spec is None:
290
+ continue
291
+ pending[p.participant_id] = asyncio.create_task(
292
+ _execute_ai_turn(session, spec, call_participant),
293
+ name=f"prefetch_initial:{p.participant_id}",
294
+ )
295
+
296
+ for p in actives:
297
+ if p.kind == "human":
298
+ async for chunk in on_human_turn(p):
299
+ yield chunk
300
+ if _participant_msg_cap_hit(session):
301
+ async for chunk in _wait_for_continue(session, "messages"):
302
+ yield chunk
303
+ continue
304
+
305
+ task = pending.pop(p.participant_id, None)
306
+ if task is None:
307
+ continue
308
+ try:
309
+ result = await task
310
+ except BaseException as exc:
311
+ LOG.exception(
312
+ "Prefetched initial opinion failed for %s: %s",
313
+ p.participant_id,
314
+ exc,
315
+ )
316
+ yield _sse("participant_error", {
317
+ "participant_id": p.participant_id,
318
+ "name": p.name,
319
+ "phase": phase.value,
320
+ })
321
+ continue
322
+
323
+ extra: dict[str, Any] | None = None
324
+ if post_process is not None:
325
+ extra = await post_process(result) or {}
326
+ async for chunk in _emit_ai_turn_result(
327
+ session, result, phase=phase, extra=extra,
328
+ ):
329
+ yield chunk
330
+ if _participant_msg_cap_hit(session):
331
+ async for chunk in _wait_for_continue(session, "messages"):
332
+ yield chunk
333
+
334
+ for pid, task in pending.items():
335
+ if not task.done():
336
+ task.cancel()
337
+
338
+
339
  async def _emit_ai_turn_result(
340
  session: "Session",
341
  result: _AiTurnResult,
 
348
  _add_participant_message,
349
  _msg_payload,
350
  _orchestrator_cap_hit,
351
+ _participant_turn_failure_sse,
352
  _replying_to_ids,
353
  _sse,
354
  _wait_for_continue,
 
356
 
357
  p = result.participant
358
  turn = result.turn
359
+ substituted = False
360
  for ev in turn.sse_events:
361
+ if "participant_substituted" in ev:
362
+ substituted = True
363
  yield ev
364
  if not turn.ok:
365
+ for chunk in _participant_turn_failure_sse(session, p):
366
+ yield chunk
 
 
 
 
 
 
 
 
 
 
 
367
  return
368
 
369
  speaker = turn.speaker
 
383
  )
384
  yield _sse("message", _msg_payload(msg))
385
 
386
+ if substituted:
387
+ from app.services.orchestrator import (
388
+ _rebuild_participant_credential_on_model_change,
389
+ )
390
+ if await _rebuild_participant_credential_on_model_change(
391
+ session, speaker,
392
+ ):
393
+ yield _sse("credentials_updated", {
394
+ "stage": "model_changed",
395
+ "credentials": session.credential_summary,
396
+ })
397
+
398
  if _orchestrator_cap_hit(session):
399
  async for chunk in _wait_for_continue(session, "orchestrator"):
400
  yield chunk
backend/app/services/prompts/__init__.py CHANGED
@@ -14,6 +14,8 @@ 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 (
@@ -48,6 +50,8 @@ __all__ = [
48
  "INITIAL_OPINION_PROMPT",
49
  "CREDENTIAL_BUILD_PROMPT",
50
  "CREDENTIAL_REFRESH_PROMPT",
 
 
51
  "CRITIQUE_PROMPT",
52
  "STATUS_ASSESSMENT_PROMPT",
53
  "TARGETED_FOLLOWUP_PROMPT",
 
14
  from app.services.prompts.credential_summary import (
15
  CREDENTIAL_BUILD_PROMPT,
16
  CREDENTIAL_REFRESH_PROMPT,
17
+ HUMAN_CREDENTIAL_FROM_PROFILE_PROMPT,
18
+ SINGLE_PARTICIPANT_CREDENTIAL_BUILD_PROMPT,
19
  )
20
  from app.services.prompts.critique import CRITIQUE_PROMPT
21
  from app.services.prompts.status_assessment import (
 
50
  "INITIAL_OPINION_PROMPT",
51
  "CREDENTIAL_BUILD_PROMPT",
52
  "CREDENTIAL_REFRESH_PROMPT",
53
+ "HUMAN_CREDENTIAL_FROM_PROFILE_PROMPT",
54
+ "SINGLE_PARTICIPANT_CREDENTIAL_BUILD_PROMPT",
55
  "CRITIQUE_PROMPT",
56
  "STATUS_ASSESSMENT_PROMPT",
57
  "TARGETED_FOLLOWUP_PROMPT",
backend/app/services/prompts/credential_summary.py CHANGED
@@ -1,10 +1,9 @@
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 = (
@@ -34,6 +33,59 @@ CREDENTIAL_BUILD_PROMPT = (
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 "
 
1
  """Credential Summary: the orchestrator's neutral assessment of each
2
  participant's expertise, personality, and credibility on the question.
3
 
4
+ Each LLM participant's entry is built concurrently during Phase 1 (as
5
+ their initial opinion lands). Entries are only rebuilt if the backing
6
+ LLM model behind that participant changes.
 
7
  """
8
 
9
  CREDENTIAL_BUILD_PROMPT = (
 
33
  "any participant. Output JSON only - no commentary, no markdown."
34
  )
35
 
36
+ HUMAN_CREDENTIAL_FROM_PROFILE_PROMPT = (
37
+ "A human participant is joining a group discussion. Their "
38
+ "self-description below is equivalent to an LLM participant's role "
39
+ "prompt — use it as the sole source of background (they have not "
40
+ "spoken in the discussion yet). Build a Credential Summary entry: "
41
+ "a neutral, third-person assessment that any other participant could "
42
+ "use to weight their statements.\n\n"
43
+ "{question_block}"
44
+ "Name: {name}\n"
45
+ "Self-description:\n<<<\n{profile_text}\n>>>\n\n"
46
+ "Return JSON ONLY in this exact shape:\n"
47
+ "{{\n"
48
+ ' "credential": {{\n'
49
+ ' "name": "{name}",\n'
50
+ ' "expertise": "<1-2 sentences on what they know about and don\'t know about>",\n'
51
+ ' "personality": "<1 sentence on debating style / temperament>",\n'
52
+ ' "credibility_for_question": <number 0.0 to 1.0>,\n'
53
+ ' "bias_to_watch": "<1 sentence on biases or blind spots>"\n'
54
+ " }}\n"
55
+ "}}\n\n"
56
+ "credibility_for_question is YOUR neutral estimate of how much weight "
57
+ "their voice should carry on THIS specific question, given their "
58
+ "self-description and the question topic. Use the full 0-1 scale. "
59
+ "Output JSON only — no commentary, no markdown."
60
+ )
61
+
62
+ SINGLE_PARTICIPANT_CREDENTIAL_BUILD_PROMPT = (
63
+ "Below is the question being discussed and, for one participant, "
64
+ "their role prompt and their first opinion (Phase 1). Build ONE "
65
+ "Credential Summary entry: a neutral, third-person assessment that "
66
+ "any other participant could use to weight their statements.\n\n"
67
+ "Question:\n<<<\n{question}\n>>>\n\n"
68
+ "Participant id: {participant_id}\n"
69
+ "Name: {name}\n"
70
+ "Role prompt:\n<<<\n{role_prompt}\n>>>\n"
71
+ "First opinion:\n<<<\n{first_opinion}\n>>>\n\n"
72
+ "Return JSON ONLY in this exact shape:\n"
73
+ "{{\n"
74
+ ' "credential": {{\n'
75
+ ' "participant_id": "{participant_id}",\n'
76
+ ' "name": "{name}",\n'
77
+ ' "expertise": "<1-2 sentences on what they know about and don\'t know about>",\n'
78
+ ' "personality": "<1 sentence on debating style / temperament>",\n'
79
+ ' "credibility_for_question": <number 0.0 to 1.0>,\n'
80
+ ' "bias_to_watch": "<1 sentence on biases or blind spots>"\n'
81
+ " }}\n"
82
+ "}}\n\n"
83
+ "credibility_for_question is YOUR neutral estimate of how much weight "
84
+ "their voice should carry on THIS specific question, given their stated "
85
+ "background and how they framed their first opinion. Use the full 0-1 "
86
+ "scale. Output JSON only — no commentary, no markdown."
87
+ )
88
+
89
  CREDENTIAL_REFRESH_PROMPT = (
90
  "Below is the original Credential Summary you produced after Phase 1. "
91
  "After two rounds of critique, the participants have revealed more "
frontend/public/index.html CHANGED
@@ -5,8 +5,8 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <link rel="icon" type="image/png" href="/neon-logo.png" />
7
  <meta name="theme-color" content="#6366F1" />
8
- <meta name="description" content="Neon.ai - AI to AI Conversations" />
9
- <title>Neon.ai - AI to AI Conversations</title>
10
  </head>
11
  <body>
12
  <div id="root"></div>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <link rel="icon" type="image/png" href="/neon-logo.png" />
7
  <meta name="theme-color" content="#6366F1" />
8
+ <meta name="description" content="CCAI Demo" />
9
+ <title>CCAI Demo</title>
10
  </head>
11
  <body>
12
  <div id="root"></div>
frontend/src/App.js CHANGED
@@ -9,6 +9,7 @@ import CredentialSummaryModal from './components/CredentialSummaryModal';
9
  import ConversationLimitsModal from './components/ConversationLimitsModal';
10
  import PromptCatalogModal from './components/PromptCatalogModal';
11
  import HumanParticipantModal from './components/HumanParticipantModal';
 
12
  import {
13
  fetchModels, fetchPersonas, fetchDemoQuestions,
14
  startChat, continueChat, getOrchestrator, setOrchestrator,
@@ -21,6 +22,7 @@ import {
21
  fetchPromptCatalog,
22
  getRateLimitStatus,
23
  submitHumanResponse, patchHumanCredential,
 
24
  } from './utils/api';
25
  import * as storage from './utils/storage';
26
  import './styles/variables.css';
@@ -28,9 +30,19 @@ import './styles/layout.css';
28
  import './styles/components.css';
29
  import './styles/ccai.css';
30
 
31
- function pickRandom(list) {
32
- if (!list || list.length === 0) return null;
33
- return list[Math.floor(Math.random() * list.length)];
 
 
 
 
 
 
 
 
 
 
34
  }
35
 
36
  export default function App() {
@@ -149,6 +161,10 @@ export default function App() {
149
  const [humanSubmitting, setHumanSubmitting] = useState(false);
150
 
151
  const abortRef = useRef(null);
 
 
 
 
152
 
153
  // ─── Apply theme ────────────────────────────────────────────────
154
  useEffect(() => {
@@ -186,6 +202,15 @@ export default function App() {
186
  }).catch(() => {});
187
  }, [persisted.orchestrator_model_id]);
188
 
 
 
 
 
 
 
 
 
 
189
  // ─── Build a flat list of all models for pickers ────────────────
190
  const allModelsFlat = useMemo(() => {
191
  const list = [];
@@ -207,6 +232,20 @@ export default function App() {
207
  return list;
208
  }, [providers, neonModels]);
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  // ─── Active participants resolved from selectedIds ──────────────
211
  const allCatalogParticipants = useMemo(() => {
212
  const map = {};
@@ -340,13 +379,67 @@ export default function App() {
340
  setHumanModalOpen(true);
341
  }, [humanParticipant]);
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  const handleSaveHuman = useCallback((spec) => {
344
- setHumanParticipant(spec);
 
 
 
 
 
345
  setHumanModalOpen(false);
346
  setHumanEditing(null);
347
- }, []);
 
 
348
 
349
  const handleRemoveHuman = useCallback(() => {
 
350
  setHumanParticipant(null);
351
  setHumanModalOpen(false);
352
  setHumanEditing(null);
@@ -397,6 +490,7 @@ export default function App() {
397
  credibility_for_question: updated.credibility_for_question ?? 0.55,
398
  bias_to_watch: updated.bias_to_watch || '',
399
  },
 
400
  } : prev);
401
  // Refresh the credentials cache so the modal reflects the edit.
402
  const data = await fetchCredentials(sessionId);
@@ -562,7 +656,7 @@ export default function App() {
562
  // `participantsOverride`, if provided, replaces the
563
  // selectedParticipants-derived list (used by the auto-select flow
564
  // because the freshly-chosen list isn't in state yet when we need it).
565
- const buildStartPayload = useCallback((theQuestion, participantsOverride) => {
566
  // Always honor the sidebar enabled toggles, including when
567
  // auto-select supplies a participantsOverride list (which used to
568
  // bypass enabledMap and always prepend a disabled human).
@@ -592,14 +686,15 @@ export default function App() {
592
  // participants array. Backend rejects start if it sees a human in
593
  // participants but no human_credential, so this MUST be present
594
  // whenever the human is enabled.
 
595
  const humanInList = baseList.find(p => p.kind === 'human');
596
  let human_credential = null;
597
- if (humanInList && humanParticipant) {
598
- const cs = humanParticipant.credential_summary || {};
599
  human_credential = {
600
  participant_id: humanInList.participant_id,
601
  name: humanInList.name,
602
- expertise: cs.expertise || '',
603
  personality: cs.personality || '',
604
  credibility_for_question: typeof cs.credibility_for_question === 'number'
605
  ? cs.credibility_for_question
@@ -649,6 +744,10 @@ export default function App() {
649
  // ─── Start chat ─────────────────────────────────────────────────
650
  const handleStart = useCallback(async (theQuestion) => {
651
  if (!theQuestion || !theQuestion.trim()) return;
 
 
 
 
652
  // In auto-select mode the dropdown has no manual picks - skip the
653
  // pre-flight count check and validate the chosen pool below instead.
654
  if (!autoSelectMode && enabledSelectedCount < 2) return;
@@ -744,9 +843,41 @@ export default function App() {
744
  }
745
  }
746
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  try {
748
  await startChat(
749
- buildStartPayload(theQuestion, resolvedParticipants),
750
  {
751
  onSession: (data) => {
752
  setSessionId(data.session_id);
@@ -786,9 +917,6 @@ export default function App() {
786
  )));
787
  },
788
  onOrchestrator: (data) => {
789
- // Orchestrator events with kind == "status" but no text are
790
- // status banners; bubble them into a message-style entry so
791
- // they render with the orchestrator pill.
792
  if (data && data.text) {
793
  setMessages(prev => [...prev, { ...data, role: 'orchestrator' }]);
794
  } else if (data?.message) {
@@ -807,39 +935,35 @@ export default function App() {
807
  setSystemMessages(prev => [...prev, { text: `Error: ${data.message}` }]);
808
  },
809
  onParticipantError: (data) => {
810
- setSystemMessages(prev => [...prev, {
811
- text: `${data.name || 'A participant'} couldn't respond this turn.`,
812
- }]);
 
 
 
813
  },
814
  onParticipantSubstituted: (data) => {
815
- // Resilience layer swapped the backing LLM behind a
816
- // persona's prompt+name. Surface it as a system note so
817
- // the user can reconcile any change in voice / latency
818
- // with the chat metadata.
819
  const name = data.name || 'A participant';
820
  const toDisplay = data.to_model_display || data.to_model_id || 'a substitute model';
821
- setSystemMessages(prev => [...prev, {
822
- text: `${name}'s primary model didn't respond; continuing with ${toDisplay}.`,
823
- }]);
 
 
824
  },
825
  onParticipantReplaced: (data) => {
826
- // Phase 1 alternate kicked in. Replace the session
827
- // roster snapshot so the sidebar re-renders with the
828
- // new participant; also note the change in chat.
829
  if (Array.isArray(data?.roster)) {
830
  setSessionParticipants(data.roster);
831
  }
832
  const origName = data.original_name || 'A participant';
833
  const altName = data.new_name || 'an alternate';
834
- setSystemMessages(prev => [...prev, {
835
- text: `${origName} couldn't give an initial opinion; ${altName} is taking their place.`,
836
- }]);
 
 
837
  },
838
  onVoteCast: (data) => {
839
- // One per-ballot from a vote-based decision method. We
840
- // surface these as system notes so the user can follow
841
- // the tally as it happens; the final report message will
842
- // contain the canonical summary.
843
  const voter = data?.voter_name || 'A voter';
844
  let line;
845
  if (data?.vote) {
@@ -851,16 +975,15 @@ export default function App() {
851
  } else {
852
  line = `${voter} abstained or returned an invalid ballot.`;
853
  }
854
- setSystemMessages(prev => [...prev, { text: line }]);
855
  },
856
  onVoteTally: (data) => {
857
- // Final tally summary. The orchestrator message that
858
- // follows contains the rendered report, so we just log
859
- // a one-liner here for the system feed.
860
  const kind = data?.kind || 'vote';
861
- setSystemMessages(prev => [...prev, {
862
- text: `Vote complete (${kind}); see report below.`,
863
- }]);
 
 
864
  },
865
  onFailsafePause: (data) => {
866
  setPause({ reason: 'messages', ...data });
@@ -913,21 +1036,12 @@ export default function App() {
913
  getAuthStatus().then(setAuth).catch(() => {});
914
  }
915
  }, [
916
- buildStartPayload, enabledSelectedCount, dailyLimit,
917
  autoSelectMode, allCatalogParticipants, modelAssignments,
918
  maxParticipants, orchestratorModel, enabledMap,
919
- humanParticipant, humanCatalogEntry,
920
  ]);
921
 
922
- const handleStartRandom = useCallback(() => {
923
- if (demoQuestions.length === 0) {
924
- setSystemMessages(prev => [...prev, { text: 'No demo questions available.' }]);
925
- return;
926
- }
927
- const q = pickRandom(demoQuestions);
928
- handleStart(q.text);
929
- }, [demoQuestions, handleStart]);
930
-
931
  // In auto-select mode we don't require manual picks - the orchestrator
932
  // will choose them at /chat/start time, so just need 2+ candidates
933
  // available in the catalog.
@@ -948,7 +1062,6 @@ export default function App() {
948
  theme={theme}
949
  onToggleTheme={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
950
  auth={auth}
951
- dailyLimit={dailyLimit}
952
  catalog={catalog}
953
  expertPersonas={expertPersonas}
954
  selectedIds={selectedIds}
@@ -999,6 +1112,7 @@ export default function App() {
999
  participants={selectedParticipants}
1000
  enabledMap={enabledMap}
1001
  modelAssignments={modelAssignments}
 
1002
  onToggleEnabled={handleSidebarToggleEnabled}
1003
  onRemove={handleSidebarRemove}
1004
  autoSelectMode={autoSelectMode}
@@ -1006,8 +1120,9 @@ export default function App() {
1006
  />
1007
  <div className="content">
1008
  <ChatControls
1009
- onStartRandom={handleStartRandom}
1010
- onStartTyped={handleStart}
 
1011
  onStop={handleStop}
1012
  disabled={startDisabled}
1013
  isRunning={isRunning}
@@ -1069,8 +1184,6 @@ export default function App() {
1069
  <HumanParticipantModal
1070
  isOpen={humanModalOpen}
1071
  initial={humanEditing}
1072
- question={activeQuestion}
1073
- orchestratorModel={orchestratorModel}
1074
  onClose={() => { setHumanModalOpen(false); setHumanEditing(null); }}
1075
  onSave={handleSaveHuman}
1076
  onRemove={humanEditing ? handleRemoveHuman : null}
@@ -1088,6 +1201,10 @@ export default function App() {
1088
  catalog={promptCatalog}
1089
  onClose={() => setPromptCatalogOpen(false)}
1090
  />
 
 
 
 
1091
  </div>
1092
  );
1093
  }
 
9
  import ConversationLimitsModal from './components/ConversationLimitsModal';
10
  import PromptCatalogModal from './components/PromptCatalogModal';
11
  import HumanParticipantModal from './components/HumanParticipantModal';
12
+ import RateLimitNotice from './components/RateLimitNotice';
13
  import {
14
  fetchModels, fetchPersonas, fetchDemoQuestions,
15
  startChat, continueChat, getOrchestrator, setOrchestrator,
 
22
  fetchPromptCatalog,
23
  getRateLimitStatus,
24
  submitHumanResponse, patchHumanCredential,
25
+ generateHumanCredentialFromProfile,
26
  } from './utils/api';
27
  import * as storage from './utils/storage';
28
  import './styles/variables.css';
 
30
  import './styles/components.css';
31
  import './styles/ccai.css';
32
 
33
+ /** True when the user is subject to the per-IP daily chat cap. */
34
+ function isRateLimitedUser(auth) {
35
+ return auth && !auth.is_org_member && auth.remaining_conversations >= 0;
36
+ }
37
+
38
+ /** Append a system note at the current point in the chat timeline (not end-of-feed). */
39
+ function appendInlineChatNote(setMessages, text, extra = {}) {
40
+ setMessages(prev => [...prev, {
41
+ role: 'system',
42
+ text,
43
+ timestamp: Date.now() / 1000,
44
+ ...extra,
45
+ }]);
46
  }
47
 
48
  export default function App() {
 
161
  const [humanSubmitting, setHumanSubmitting] = useState(false);
162
 
163
  const abortRef = useRef(null);
164
+ const chatControlsRef = useRef(null);
165
+ const humanCredentialGenRef = useRef(null);
166
+ const oneLeftNoticeShownRef = useRef(false);
167
+ const [rateLimitNotice, setRateLimitNotice] = useState(null);
168
 
169
  // ─── Apply theme ────────────────────────────────────────────────
170
  useEffect(() => {
 
202
  }).catch(() => {});
203
  }, [persisted.orchestrator_model_id]);
204
 
205
+ // Pop up once per session when the daily cap leaves exactly one chat.
206
+ useEffect(() => {
207
+ if (!isRateLimitedUser(auth)) return;
208
+ if (auth.remaining_conversations === 1 && !oneLeftNoticeShownRef.current) {
209
+ oneLeftNoticeShownRef.current = true;
210
+ setRateLimitNotice('one_left');
211
+ }
212
+ }, [auth]);
213
+
214
  // ─── Build a flat list of all models for pickers ────────────────
215
  const allModelsFlat = useMemo(() => {
216
  const list = [];
 
232
  return list;
233
  }, [providers, neonModels]);
234
 
235
+ // Map neon:model@ver:persona ids -> HANA system_prompt (from /api/models).
236
+ const neonPromptByModelId = useMemo(() => {
237
+ const map = {};
238
+ for (const nm of neonModels) {
239
+ for (const p of (nm.personas || [])) {
240
+ if (p.enabled === false) continue;
241
+ const id = `neon:${nm.model_id}:${p.persona_name}`;
242
+ const sp = (p.system_prompt || '').trim();
243
+ if (sp) map[id] = sp;
244
+ }
245
+ }
246
+ return map;
247
+ }, [neonModels]);
248
+
249
  // ─── Active participants resolved from selectedIds ──────────────
250
  const allCatalogParticipants = useMemo(() => {
251
  const map = {};
 
379
  setHumanModalOpen(true);
380
  }, [humanParticipant]);
381
 
382
+ const runHumanCredentialGeneration = useCallback(async (spec, question) => {
383
+ const result = await generateHumanCredentialFromProfile({
384
+ name: spec.name,
385
+ question: (question || '').trim(),
386
+ profile_text: spec.profile_text,
387
+ participant_id: spec.participant_id,
388
+ orchestrator_model_id: orchestratorModel || null,
389
+ });
390
+ const cred = result.credential || {};
391
+ return {
392
+ ...spec,
393
+ credential_pending: false,
394
+ credential_built_for_question: (question || '').trim(),
395
+ credential_summary: {
396
+ name: cred.name || spec.name,
397
+ expertise: cred.expertise || '',
398
+ personality: cred.personality || '',
399
+ credibility_for_question: typeof cred.credibility_for_question === 'number'
400
+ ? cred.credibility_for_question
401
+ : 0.55,
402
+ bias_to_watch: cred.bias_to_watch || '',
403
+ },
404
+ };
405
+ }, [orchestratorModel]);
406
+
407
+ const startHumanCredentialGeneration = useCallback((spec, question) => {
408
+ const promise = runHumanCredentialGeneration(spec, question)
409
+ .then((updated) => {
410
+ setHumanParticipant(prev => (
411
+ prev && prev.participant_id === spec.participant_id ? updated : prev
412
+ ));
413
+ return updated;
414
+ })
415
+ .catch((err) => {
416
+ console.error('Human credential generation failed:', err);
417
+ setHumanParticipant(prev => (
418
+ prev && prev.participant_id === spec.participant_id
419
+ ? { ...prev, credential_pending: false, credential_error: err.message }
420
+ : prev
421
+ ));
422
+ throw err;
423
+ });
424
+ humanCredentialGenRef.current = promise;
425
+ return promise;
426
+ }, [runHumanCredentialGeneration]);
427
+
428
  const handleSaveHuman = useCallback((spec) => {
429
+ const pending = {
430
+ ...spec,
431
+ credential_pending: true,
432
+ credential_summary: null,
433
+ };
434
+ setHumanParticipant(pending);
435
  setHumanModalOpen(false);
436
  setHumanEditing(null);
437
+ const question = chatControlsRef.current?.getDraftQuestion?.() || '';
438
+ startHumanCredentialGeneration(pending, question);
439
+ }, [startHumanCredentialGeneration]);
440
 
441
  const handleRemoveHuman = useCallback(() => {
442
+ humanCredentialGenRef.current = null;
443
  setHumanParticipant(null);
444
  setHumanModalOpen(false);
445
  setHumanEditing(null);
 
490
  credibility_for_question: updated.credibility_for_question ?? 0.55,
491
  bias_to_watch: updated.bias_to_watch || '',
492
  },
493
+ // profile_text unchanged — edits in the modal only adjust the summary.
494
  } : prev);
495
  // Refresh the credentials cache so the modal reflects the edit.
496
  const data = await fetchCredentials(sessionId);
 
656
  // `participantsOverride`, if provided, replaces the
657
  // selectedParticipants-derived list (used by the auto-select flow
658
  // because the freshly-chosen list isn't in state yet when we need it).
659
+ const buildStartPayload = useCallback((theQuestion, participantsOverride, humanOverride) => {
660
  // Always honor the sidebar enabled toggles, including when
661
  // auto-select supplies a participantsOverride list (which used to
662
  // bypass enabledMap and always prepend a disabled human).
 
686
  // participants array. Backend rejects start if it sees a human in
687
  // participants but no human_credential, so this MUST be present
688
  // whenever the human is enabled.
689
+ const hp = humanOverride ?? humanParticipant;
690
  const humanInList = baseList.find(p => p.kind === 'human');
691
  let human_credential = null;
692
+ if (humanInList && hp) {
693
+ const cs = hp.credential_summary || {};
694
  human_credential = {
695
  participant_id: humanInList.participant_id,
696
  name: humanInList.name,
697
+ expertise: cs.expertise || hp.profile_text?.slice(0, 500) || '',
698
  personality: cs.personality || '',
699
  credibility_for_question: typeof cs.credibility_for_question === 'number'
700
  ? cs.credibility_for_question
 
744
  // ─── Start chat ─────────────────────────────────────────────────
745
  const handleStart = useCallback(async (theQuestion) => {
746
  if (!theQuestion || !theQuestion.trim()) return;
747
+ if (isRateLimitedUser(auth) && auth.remaining_conversations === 0) {
748
+ setRateLimitNotice('exhausted');
749
+ return;
750
+ }
751
  // In auto-select mode the dropdown has no manual picks - skip the
752
  // pre-flight count check and validate the chosen pool below instead.
753
  if (!autoSelectMode && enabledSelectedCount < 2) return;
 
843
  }
844
  }
845
 
846
+ let humanForStart = humanParticipant;
847
+ const humanEnabledForStart = humanParticipant
848
+ && enabledMap[humanParticipant.participant_id] !== false;
849
+ if (humanEnabledForStart) {
850
+ const q = theQuestion.trim();
851
+ const needsQuestionRefresh = q
852
+ && humanParticipant.credential_built_for_question !== q;
853
+ if (humanParticipant.credential_pending && humanCredentialGenRef.current) {
854
+ setStatusText('Finishing credential summary...');
855
+ try {
856
+ humanForStart = await humanCredentialGenRef.current;
857
+ } catch {
858
+ humanForStart = humanParticipant;
859
+ }
860
+ }
861
+ if (!humanForStart?.credential_summary?.expertise || needsQuestionRefresh) {
862
+ setStatusText('Preparing credential summary...');
863
+ try {
864
+ humanForStart = await runHumanCredentialGeneration(humanParticipant, q);
865
+ setHumanParticipant(humanForStart);
866
+ humanCredentialGenRef.current = Promise.resolve(humanForStart);
867
+ } catch (err) {
868
+ setIsRunning(false);
869
+ setStatusText('');
870
+ setSystemMessages(prev => [...prev, {
871
+ text: `Could not generate human credential: ${err.message}`,
872
+ }]);
873
+ return;
874
+ }
875
+ }
876
+ }
877
+
878
  try {
879
  await startChat(
880
+ buildStartPayload(theQuestion, resolvedParticipants, humanForStart),
881
  {
882
  onSession: (data) => {
883
  setSessionId(data.session_id);
 
917
  )));
918
  },
919
  onOrchestrator: (data) => {
 
 
 
920
  if (data && data.text) {
921
  setMessages(prev => [...prev, { ...data, role: 'orchestrator' }]);
922
  } else if (data?.message) {
 
935
  setSystemMessages(prev => [...prev, { text: `Error: ${data.message}` }]);
936
  },
937
  onParticipantError: (data) => {
938
+ const text = `${data.name || 'A participant'} couldn't respond this turn.`;
939
+ appendInlineChatNote(setMessages, text, {
940
+ kind: 'participant_error',
941
+ phase: data.phase,
942
+ participant_id: data.participant_id,
943
+ });
944
  },
945
  onParticipantSubstituted: (data) => {
 
 
 
 
946
  const name = data.name || 'A participant';
947
  const toDisplay = data.to_model_display || data.to_model_id || 'a substitute model';
948
+ appendInlineChatNote(
949
+ setMessages,
950
+ `${name}'s primary model didn't respond; continuing with ${toDisplay}.`,
951
+ { kind: 'participant_substituted', phase: data.phase },
952
+ );
953
  },
954
  onParticipantReplaced: (data) => {
 
 
 
955
  if (Array.isArray(data?.roster)) {
956
  setSessionParticipants(data.roster);
957
  }
958
  const origName = data.original_name || 'A participant';
959
  const altName = data.new_name || 'an alternate';
960
+ appendInlineChatNote(
961
+ setMessages,
962
+ `${origName} couldn't give an initial opinion; ${altName} is taking their place.`,
963
+ { kind: 'participant_replaced', phase: data.phase },
964
+ );
965
  },
966
  onVoteCast: (data) => {
 
 
 
 
967
  const voter = data?.voter_name || 'A voter';
968
  let line;
969
  if (data?.vote) {
 
975
  } else {
976
  line = `${voter} abstained or returned an invalid ballot.`;
977
  }
978
+ appendInlineChatNote(setMessages, line, { kind: 'vote_cast' });
979
  },
980
  onVoteTally: (data) => {
 
 
 
981
  const kind = data?.kind || 'vote';
982
+ appendInlineChatNote(
983
+ setMessages,
984
+ `Vote complete (${kind}); see report below.`,
985
+ { kind: 'vote_tally' },
986
+ );
987
  },
988
  onFailsafePause: (data) => {
989
  setPause({ reason: 'messages', ...data });
 
1036
  getAuthStatus().then(setAuth).catch(() => {});
1037
  }
1038
  }, [
1039
+ buildStartPayload, enabledSelectedCount, dailyLimit, auth,
1040
  autoSelectMode, allCatalogParticipants, modelAssignments,
1041
  maxParticipants, orchestratorModel, enabledMap,
1042
+ humanParticipant, humanCatalogEntry, runHumanCredentialGeneration,
1043
  ]);
1044
 
 
 
 
 
 
 
 
 
 
1045
  // In auto-select mode we don't require manual picks - the orchestrator
1046
  // will choose them at /chat/start time, so just need 2+ candidates
1047
  // available in the catalog.
 
1062
  theme={theme}
1063
  onToggleTheme={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
1064
  auth={auth}
 
1065
  catalog={catalog}
1066
  expertPersonas={expertPersonas}
1067
  selectedIds={selectedIds}
 
1112
  participants={selectedParticipants}
1113
  enabledMap={enabledMap}
1114
  modelAssignments={modelAssignments}
1115
+ neonPromptByModelId={neonPromptByModelId}
1116
  onToggleEnabled={handleSidebarToggleEnabled}
1117
  onRemove={handleSidebarRemove}
1118
  autoSelectMode={autoSelectMode}
 
1120
  />
1121
  <div className="content">
1122
  <ChatControls
1123
+ ref={chatControlsRef}
1124
+ demoQuestions={demoQuestions}
1125
+ onStart={handleStart}
1126
  onStop={handleStop}
1127
  disabled={startDisabled}
1128
  isRunning={isRunning}
 
1184
  <HumanParticipantModal
1185
  isOpen={humanModalOpen}
1186
  initial={humanEditing}
 
 
1187
  onClose={() => { setHumanModalOpen(false); setHumanEditing(null); }}
1188
  onSave={handleSaveHuman}
1189
  onRemove={humanEditing ? handleRemoveHuman : null}
 
1201
  catalog={promptCatalog}
1202
  onClose={() => setPromptCatalogOpen(false)}
1203
  />
1204
+ <RateLimitNotice
1205
+ kind={rateLimitNotice}
1206
+ onClose={() => setRateLimitNotice(null)}
1207
+ />
1208
  </div>
1209
  );
1210
  }
frontend/src/components/AuthBadge.js CHANGED
@@ -1,19 +1,15 @@
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 (
10
- <div className="auth-badge">
11
  <User size={14} />
12
  <span className="auth-username">{auth.username}</span>
13
  {auth.is_org_member && <span className="auth-org-tag">org</span>}
14
- {!auth.is_org_member && auth.remaining_conversations >= 0 && (
15
- <span className="auth-remaining">{auth.remaining_conversations} left</span>
16
- )}
17
  <a href="/oauth/huggingface/logout" className="auth-link" title="Sign out">
18
  <LogOut size={13} />
19
  </a>
@@ -22,12 +18,9 @@ export default function AuthBadge({ auth, dailyLimit }) {
22
  }
23
 
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
31
  </a>
32
  </div>
33
  );
 
1
  import React from 'react';
2
+ import { 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 (
9
+ <div className="auth-badge auth-badge-end">
10
  <User size={14} />
11
  <span className="auth-username">{auth.username}</span>
12
  {auth.is_org_member && <span className="auth-org-tag">org</span>}
 
 
 
13
  <a href="/oauth/huggingface/logout" className="auth-link" title="Sign out">
14
  <LogOut size={13} />
15
  </a>
 
18
  }
19
 
20
  return (
21
+ <div className="auth-badge auth-badge-end">
 
 
 
22
  <a href="/oauth/huggingface/login" className="auth-link auth-login">
23
+ Sign in
24
  </a>
25
  </div>
26
  );
frontend/src/components/ChatArea.js CHANGED
@@ -74,8 +74,15 @@ export default function ChatArea({
74
  </div>
75
  )}
76
  {(messages || []).map((msg, i) => {
 
 
 
 
 
 
 
77
  if (msg.role === 'orchestrator') {
78
- return <OrchestratorMessage key={i} message={msg} messageIdx={i} />;
79
  }
80
  const idx = speakerIdxFor[msg.speaker_id] ?? i;
81
  const prev = i > 0 ? messages[i - 1] : null;
 
74
  </div>
75
  )}
76
  {(messages || []).map((msg, i) => {
77
+ if (msg.role === 'system') {
78
+ return (
79
+ <div key={msg.message_id || `sys-inline-${i}`} className="system-message">
80
+ {msg.text}
81
+ </div>
82
+ );
83
+ }
84
  if (msg.role === 'orchestrator') {
85
+ return <OrchestratorMessage key={msg.message_id || i} message={msg} messageIdx={i} />;
86
  }
87
  const idx = speakerIdxFor[msg.speaker_id] ?? i;
88
  const prev = i > 0 ? messages[i - 1] : null;
frontend/src/components/ChatControls.js CHANGED
@@ -1,26 +1,96 @@
1
- import React, { useState } from 'react';
2
- import { Play, Shuffle } 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
  activeQuestion,
19
- }) {
20
- const [text, setText] = useState('');
21
- const placeholder = disabled
22
- ? (disabledReason || 'Add participants to start a conversation')
23
- : 'Or type your own question for the group...';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  return (
25
  <div className="chat-controls">
26
  {isRunning ? (
@@ -40,37 +110,35 @@ export default function ChatControls({
40
  </>
41
  ) : (
42
  <>
43
- <button
44
- className="btn-primary"
45
- disabled={disabled}
46
- onClick={() => onStartRandom()}
47
- title="Pick a random demo question and start"
48
- >
49
- <Shuffle size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
50
- Let Them Start
51
- </button>
52
  <input
53
  type="text"
54
- value={text}
55
- placeholder={placeholder}
 
 
 
 
56
  disabled={disabled}
57
- onChange={e => setText(e.target.value)}
58
- onKeyDown={e => {
59
- if (e.key === 'Enter' && !disabled && text.trim()) {
60
- onStartTyped(text.trim());
 
61
  }
62
  }}
63
  />
64
  <button
65
  className="btn-primary"
66
- disabled={disabled || !text.trim()}
67
- onClick={() => onStartTyped(text.trim())}
68
  >
69
  <Play size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
70
- Start Chat With My Prompt
71
  </button>
72
  </>
73
  )}
74
  </div>
75
  );
76
- }
 
 
 
1
+ import React, { useState, useRef, useCallback, forwardRef, useImperativeHandle } from 'react';
2
+ import { Play } from 'lucide-react';
3
+
4
+ const DEMO_SUFFIX = ' [Or type your own question]';
5
+
6
+ function formatDemoDisplay(question) {
7
+ if (!question) return '';
8
+ const label = (question.title || question.text || '').trim();
9
+ return `Demo Question: ${label}${DEMO_SUFFIX}`;
10
+ }
11
 
12
  /**
13
+ * Question field pre-filled with a cycling demo prompt; "Start Chat"
14
+ * uses the demo question text or whatever the user typed.
 
 
 
15
  */
16
+ const ChatControls = forwardRef(function ChatControls({
17
+ demoQuestions = [],
18
+ onStart,
19
  onStop,
20
  disabled,
21
  isRunning,
22
  disabledReason,
23
  activeQuestion,
24
+ }, ref) {
25
+ const demoIndexRef = useRef(0);
26
+ const [demoIndex, setDemoIndex] = useState(0);
27
+ const [mode, setMode] = useState('demo');
28
+ const [userText, setUserText] = useState('');
29
+
30
+ const currentDemo = demoQuestions.length > 0
31
+ ? demoQuestions[demoIndex % demoQuestions.length]
32
+ : null;
33
+ const demoDisplay = formatDemoDisplay(currentDemo);
34
+ const inputValue = mode === 'demo' ? demoDisplay : userText;
35
+
36
+ const resolveQuestion = useCallback(() => {
37
+ if (mode === 'custom') return userText.trim();
38
+ return (currentDemo?.text || '').trim();
39
+ }, [mode, userText, currentDemo]);
40
+
41
+ useImperativeHandle(ref, () => ({
42
+ getDraftQuestion: resolveQuestion,
43
+ }), [resolveQuestion]);
44
+
45
+ const advanceDemo = useCallback(() => {
46
+ if (demoQuestions.length === 0) return;
47
+ const next = (demoIndexRef.current + 1) % demoQuestions.length;
48
+ demoIndexRef.current = next;
49
+ setDemoIndex(next);
50
+ setMode('demo');
51
+ setUserText('');
52
+ }, [demoQuestions.length]);
53
+
54
+ const handleChange = (e) => {
55
+ const v = e.target.value;
56
+ if (mode === 'demo') {
57
+ if (v === '' || v === demoDisplay) return;
58
+ setMode('custom');
59
+ setUserText(v);
60
+ return;
61
+ }
62
+ if (v === '') {
63
+ setMode('demo');
64
+ setUserText('');
65
+ return;
66
+ }
67
+ setUserText(v);
68
+ };
69
+
70
+ const handleKeyDown = (e) => {
71
+ if (disabled || mode !== 'demo') return;
72
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
73
+ setMode('custom');
74
+ setUserText(e.key);
75
+ e.preventDefault();
76
+ return;
77
+ }
78
+ if (e.key === 'Backspace' || e.key === 'Delete') {
79
+ setMode('custom');
80
+ setUserText('');
81
+ e.preventDefault();
82
+ }
83
+ };
84
+
85
+ const handleStart = () => {
86
+ const question = resolveQuestion();
87
+ if (!question || disabled) return;
88
+ onStart(question);
89
+ advanceDemo();
90
+ };
91
+
92
+ const canStart = !disabled && !!resolveQuestion();
93
+
94
  return (
95
  <div className="chat-controls">
96
  {isRunning ? (
 
110
  </>
111
  ) : (
112
  <>
 
 
 
 
 
 
 
 
 
113
  <input
114
  type="text"
115
+ value={inputValue}
116
+ placeholder={
117
+ disabled
118
+ ? (disabledReason || 'Add participants to start a conversation')
119
+ : (demoQuestions.length === 0 ? 'Loading demo questions…' : '')
120
+ }
121
  disabled={disabled}
122
+ onChange={handleChange}
123
+ onKeyDown={(e) => {
124
+ handleKeyDown(e);
125
+ if (e.key === 'Enter' && canStart) {
126
+ handleStart();
127
  }
128
  }}
129
  />
130
  <button
131
  className="btn-primary"
132
+ disabled={!canStart}
133
+ onClick={handleStart}
134
  >
135
  <Play size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
136
+ Start Chat
137
  </button>
138
  </>
139
  )}
140
  </div>
141
  );
142
+ });
143
+
144
+ export default ChatControls;
frontend/src/components/CredentialSummaryModal.js CHANGED
@@ -6,8 +6,9 @@ import { Download, Edit2, Check, X, User } from 'lucide-react';
6
  * Summary - the per-participant assessment of expertise, debating
7
  * style, credibility on this question, and biases to watch.
8
  *
9
- * Built once after Phase 1 (initial opinions) and refreshed once after
10
- * Phase 2 (critique). The modal pulls a fresh snapshot via GET
 
11
  * /api/chat/{id}/credentials each time it's opened, so the user sees
12
  * the latest version regardless of when they peek.
13
  *
@@ -62,7 +63,7 @@ export default function CredentialSummaryModal({
62
  <h2>Credential Summary</h2>
63
  <div className="ccai-credentials-subtitle">
64
  The orchestrator's neutral assessment of each participant.
65
- Built after Phase 1 and refreshed once after Phase 2 critique.
66
  </div>
67
  </div>
68
  <div className="ccai-tab-spacer" />
 
6
  * Summary - the per-participant assessment of expertise, debating
7
  * style, credibility on this question, and biases to watch.
8
  *
9
+ * Built concurrently during Phase 1 (as each initial opinion lands).
10
+ * Rebuilt only if a participant's backing LLM model changes. The modal
11
+ * pulls a fresh snapshot via GET
12
  * /api/chat/{id}/credentials each time it's opened, so the user sees
13
  * the latest version regardless of when they peek.
14
  *
 
63
  <h2>Credential Summary</h2>
64
  <div className="ccai-credentials-subtitle">
65
  The orchestrator's neutral assessment of each participant.
66
+ Built during Phase 1; updated only if a participant&apos;s model changes.
67
  </div>
68
  </div>
69
  <div className="ccai-tab-spacer" />
frontend/src/components/Header.js CHANGED
@@ -23,7 +23,6 @@ export default function Header({
23
  theme,
24
  onToggleTheme,
25
  auth,
26
- dailyLimit,
27
 
28
  catalog,
29
  expertPersonas,
@@ -88,7 +87,6 @@ export default function Header({
88
  </h1>
89
  </div>
90
  <div className="header-right">
91
- <AuthBadge auth={auth} dailyLimit={dailyLimit} />
92
  <ParticipantDropdown
93
  catalog={catalog}
94
  expertPersonas={expertPersonas}
@@ -174,6 +172,7 @@ export default function Header({
174
  onShowConversationLimits={onShowConversationLimits}
175
  conversationLimitsOverridden={conversationLimitsOverridden}
176
  />
 
177
  </div>
178
  </header>
179
  );
 
23
  theme,
24
  onToggleTheme,
25
  auth,
 
26
 
27
  catalog,
28
  expertPersonas,
 
87
  </h1>
88
  </div>
89
  <div className="header-right">
 
90
  <ParticipantDropdown
91
  catalog={catalog}
92
  expertPersonas={expertPersonas}
 
172
  onShowConversationLimits={onShowConversationLimits}
173
  conversationLimitsOverridden={conversationLimitsOverridden}
174
  />
175
+ <AuthBadge auth={auth} />
176
  </div>
177
  </header>
178
  );
frontend/src/components/HumanParticipantModal.js CHANGED
@@ -1,196 +1,42 @@
1
- import React, { useState, useCallback, useRef, useEffect } from 'react';
2
- import { Download, Sparkles, X } from 'lucide-react';
3
- import {
4
- startCredentialDraft,
5
- answerCredentialDraft,
6
- cancelCredentialDraft,
7
- } from '../utils/api';
8
 
9
  /**
10
  * Modal for adding (or editing) the in-the-loop human participant.
11
  *
12
- * Two flows:
13
- * - Manual: the user types their own credential summary into the
14
- * text area. The textarea pre-fills with a sample so they always
15
- * have somewhere to start.
16
- * - AI-assisted: clicking "Use AI to make a Credential Summary"
17
- * opens a small chat with the orchestrator LLM. The LLM asks 3-6
18
- * adaptive questions and emits a final structured summary, which
19
- * replaces the textarea content.
20
- *
21
- * On Approve, the modal hands the finalized
22
- * { participant_id, name, credential_summary: {...} }
23
- * shape back to App.js via onSave. App.js persists it via storage and
24
- * adds the human to the active participant set.
25
- *
26
- * "Download as .txt" lets the user keep a copy of their summary
27
- * outside the demo (useful if they want to reuse it elsewhere).
28
  */
29
  export default function HumanParticipantModal({
30
  isOpen,
31
  initial,
32
- question,
33
- orchestratorModel,
34
  onClose,
35
  onSave,
36
  onRemove,
37
  }) {
38
  const [name, setName] = useState('');
39
- const [summary, setSummary] = useState('');
40
- // AI Q&A state
41
- const [aiDraftId, setAiDraftId] = useState(null);
42
- const [aiHistory, setAiHistory] = useState([]); // [{q, a}, ...]
43
- const [aiCurrentQuestion, setAiCurrentQuestion] = useState('');
44
- const [aiAnswer, setAiAnswer] = useState('');
45
- const [aiBusy, setAiBusy] = useState(false);
46
- const [aiError, setAiError] = useState('');
47
- const [aiCounts, setAiCounts] = useState({ asked: 0, max: 6 });
48
-
49
- const sampleRef = useRef('');
50
 
51
- // Reset on open / when the initial payload changes.
52
  useEffect(() => {
53
  if (!isOpen) return;
54
- const initialName = initial?.name || 'Pat';
55
- setName(initialName);
56
- const sample = initial?.credential_summary
57
- ? renderSummaryToText(initial.credential_summary)
58
- : sampleSummaryText(initialName);
59
- sampleRef.current = sample;
60
- setSummary(sample);
61
- setAiDraftId(null);
62
- setAiHistory([]);
63
- setAiCurrentQuestion('');
64
- setAiAnswer('');
65
- setAiBusy(false);
66
- setAiError('');
67
- setAiCounts({ asked: 0, max: 6 });
68
  }, [isOpen, initial]);
69
 
70
- // Abandon the AI Q&A if the modal is closed mid-flow.
71
- useEffect(() => {
72
- if (!isOpen && aiDraftId) {
73
- cancelCredentialDraft(aiDraftId);
74
- }
75
- }, [isOpen, aiDraftId]);
76
-
77
- const handleStartAi = useCallback(async () => {
78
- if (!name.trim()) {
79
- setAiError('Please enter a name first.');
80
- return;
81
- }
82
- if (!question || !question.trim()) {
83
- setAiError('Enter your discussion question before using AI assist.');
84
- return;
85
- }
86
- setAiBusy(true);
87
- setAiError('');
88
- try {
89
- const result = await startCredentialDraft({
90
- name: name.trim(),
91
- question: question.trim(),
92
- max_questions: 6,
93
- orchestrator_model_id: orchestratorModel || null,
94
- });
95
- setAiDraftId(result.draft_id);
96
- setAiCounts({
97
- asked: result.questions_asked || 1,
98
- max: result.max_questions || 6,
99
- });
100
- if (result.kind === 'summary') {
101
- setSummary(renderSummaryToText({
102
- ...(result.summary || {}),
103
- name: result.summary?.name || name.trim(),
104
- }));
105
- setAiCurrentQuestion('');
106
- } else {
107
- setAiCurrentQuestion(result.question || '');
108
- }
109
- } catch (err) {
110
- setAiError(err.message || 'AI assist failed to start.');
111
- } finally {
112
- setAiBusy(false);
113
- }
114
- }, [name, question, orchestratorModel]);
115
-
116
- const handleSubmitAnswer = useCallback(async () => {
117
- if (!aiDraftId) return;
118
- if (!aiAnswer.trim()) {
119
- setAiError('Please type an answer first.');
120
- return;
121
- }
122
- setAiBusy(true);
123
- setAiError('');
124
- const lastQ = aiCurrentQuestion;
125
- const lastA = aiAnswer.trim();
126
- try {
127
- const result = await answerCredentialDraft(aiDraftId, lastA);
128
- setAiHistory(prev => [...prev, { q: lastQ, a: lastA }]);
129
- setAiAnswer('');
130
- setAiCounts({
131
- asked: result.questions_asked || aiCounts.asked,
132
- max: result.max_questions || aiCounts.max,
133
- });
134
- if (result.kind === 'summary') {
135
- setSummary(renderSummaryToText({
136
- ...(result.summary || {}),
137
- name: result.summary?.name || name.trim(),
138
- }));
139
- setAiCurrentQuestion('');
140
- setAiDraftId(null);
141
- } else {
142
- setAiCurrentQuestion(result.question || '');
143
- }
144
- } catch (err) {
145
- setAiError(err.message || 'AI assist failed to continue.');
146
- } finally {
147
- setAiBusy(false);
148
- }
149
- }, [aiDraftId, aiAnswer, aiCurrentQuestion, aiCounts.asked, aiCounts.max, name]);
150
-
151
- const handleStopAi = useCallback(async () => {
152
- if (aiDraftId) {
153
- await cancelCredentialDraft(aiDraftId);
154
- }
155
- setAiDraftId(null);
156
- setAiCurrentQuestion('');
157
- setAiAnswer('');
158
- setAiError('');
159
- }, [aiDraftId]);
160
-
161
  const handleApprove = useCallback(() => {
162
- if (!name.trim()) {
163
- setAiError('Please enter a name before approving.');
164
- return;
165
- }
166
- if (!summary.trim()) {
167
- setAiError('Credential summary cannot be empty.');
168
- return;
169
- }
170
- const parsed = parseSummaryText(summary, name.trim());
171
  const pid = initial?.participant_id || `human_${Date.now()}`;
172
  onSave({
173
  participant_id: pid,
174
  name: name.trim(),
175
- credential_summary: parsed,
176
  });
177
- }, [name, summary, initial, onSave]);
178
-
179
- const handleDownload = useCallback(() => {
180
- const text = `Name: ${name}\n\n${summary}\n`;
181
- const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
182
- const url = URL.createObjectURL(blob);
183
- const a = document.createElement('a');
184
- a.href = url;
185
- a.download = `${(name || 'human').replace(/\s+/g, '_')}-credential.txt`;
186
- a.click();
187
- URL.revokeObjectURL(url);
188
- }, [name, summary]);
189
 
190
  if (!isOpen) return null;
191
 
192
- const aiInProgress = !!aiDraftId && !!aiCurrentQuestion;
193
-
194
  return (
195
  <div className="ccai-credentials-overlay">
196
  <div className="ccai-credentials-card ccai-human-modal-card">
@@ -199,7 +45,7 @@ export default function HumanParticipantModal({
199
  <h2>Add a Human Participant</h2>
200
  <div className="ccai-credentials-subtitle">
201
  Give yourself (or another human) a seat at the table.
202
- The orchestrator will pause for your input when it's
203
  your turn.
204
  </div>
205
  </div>
@@ -219,82 +65,27 @@ export default function HumanParticipantModal({
219
  />
220
  </label>
221
 
222
- <div className="ccai-human-field">
223
- <div className="ccai-human-summary-header">
224
- <span className="ccai-human-field-label">
225
- Credential summary
226
- </span>
227
- <button
228
- type="button"
229
- className="btn-sm btn-outline ccai-human-ai-btn"
230
- onClick={handleStartAi}
231
- disabled={aiBusy || aiInProgress}
232
- title="Have the AI ask a few questions and draft a summary for you"
233
- >
234
- <Sparkles size={14} style={{ marginRight: 4 }} />
235
- Use AI to make a Credential Summary
236
- </button>
237
- </div>
238
  <textarea
239
  className="ccai-human-summary"
240
- value={summary}
241
- onChange={e => setSummary(e.target.value)}
242
- rows={10}
243
  spellCheck
 
 
 
 
244
  />
245
  <div className="ccai-human-summary-help">
246
- This is what the orchestrator and other participants will
247
- see about you. Edit it to your taste — or click the AI
248
- assist button above and answer a few questions.
249
  </div>
250
- </div>
251
-
252
- {aiInProgress && (
253
- <div className="ccai-human-ai-panel">
254
- <div className="ccai-human-ai-counter">
255
- AI assist · question {aiCounts.asked} of {aiCounts.max}
256
- <button
257
- type="button"
258
- className="ccai-human-ai-stop"
259
- onClick={handleStopAi}
260
- title="Stop the AI Q&A and keep what's in the textarea"
261
- >
262
- <X size={12} /> Stop
263
- </button>
264
- </div>
265
- {aiHistory.map((qa, i) => (
266
- <div key={i} className="ccai-human-ai-turn">
267
- <div className="ccai-human-ai-q">Q: {qa.q}</div>
268
- <div className="ccai-human-ai-a">A: {qa.a}</div>
269
- </div>
270
- ))}
271
- <div className="ccai-human-ai-current-q">
272
- <strong>Q:</strong> {aiCurrentQuestion}
273
- </div>
274
- <textarea
275
- className="ccai-human-ai-answer"
276
- value={aiAnswer}
277
- onChange={e => setAiAnswer(e.target.value)}
278
- rows={3}
279
- placeholder="Your answer..."
280
- disabled={aiBusy}
281
- />
282
- <div className="ccai-human-ai-actions">
283
- <button
284
- type="button"
285
- className="btn btn-primary btn-sm"
286
- onClick={handleSubmitAnswer}
287
- disabled={aiBusy || !aiAnswer.trim()}
288
- >
289
- {aiBusy ? 'Thinking…' : 'Send answer'}
290
- </button>
291
- </div>
292
- </div>
293
- )}
294
-
295
- {aiError && (
296
- <div className="ccai-human-error">{aiError}</div>
297
- )}
298
  </div>
299
 
300
  <div className="ccai-human-modal-footer">
@@ -315,20 +106,15 @@ export default function HumanParticipantModal({
315
  <button
316
  type="button"
317
  className="btn-sm btn-outline"
318
- onClick={handleDownload}
319
- disabled={!summary.trim()}
320
  >
321
- <Download size={14} style={{ marginRight: 4 }} />
322
- Download as .txt
323
- </button>
324
- <button type="button" className="btn-sm btn-outline" onClick={onClose}>
325
  Cancel
326
  </button>
327
  <button
328
  type="button"
329
  className="btn btn-primary btn-sm"
330
  onClick={handleApprove}
331
- disabled={!name.trim() || !summary.trim()}
332
  >
333
  Approve
334
  </button>
@@ -338,96 +124,3 @@ export default function HumanParticipantModal({
338
  </div>
339
  );
340
  }
341
-
342
- // ─── Helpers ─────────────────────────────────────────────────────
343
-
344
- function sampleSummaryText(name) {
345
- // The pre-fill is intentionally generic-but-plausible: the user can
346
- // edit a sentence or two and approve, or wipe it and start fresh.
347
- return [
348
- `Expertise: ${name} is a curious generalist with hands-on `
349
- + 'experience across several professional domains, comfortable '
350
- + 'asking pointed questions in unfamiliar territory.',
351
- '',
352
- `Style: Conversational and pragmatic; ${name} weighs trade-offs `
353
- + 'aloud and is happy to change their mind when shown new '
354
- + 'evidence.',
355
- '',
356
- 'Credibility on this question: 0.55',
357
- '',
358
- 'Bias to watch: Tendency to favor concrete, near-term solutions '
359
- + 'over abstract long-horizon ones.',
360
- ].join('\n');
361
- }
362
-
363
- function renderSummaryToText(cred) {
364
- if (!cred) return '';
365
- const lines = [];
366
- if (cred.expertise) lines.push(`Expertise: ${cred.expertise}`);
367
- if (cred.personality) lines.push('', `Style: ${cred.personality}`);
368
- if (cred.credibility_for_question !== undefined
369
- && cred.credibility_for_question !== null) {
370
- const v = Number(cred.credibility_for_question);
371
- if (!Number.isNaN(v)) {
372
- lines.push('', `Credibility on this question: ${v.toFixed(2)}`);
373
- }
374
- }
375
- if (cred.bias_to_watch) lines.push('', `Bias to watch: ${cred.bias_to_watch}`);
376
- return lines.join('\n');
377
- }
378
-
379
- /**
380
- * Parse the textarea content back into the structured shape the
381
- * backend expects. Looks for lines starting with the field labels;
382
- * anything else is appended to whichever field is current.
383
- *
384
- * This is tolerant: if no labels are found, the whole blob becomes
385
- * the `expertise` field (so naive users typing freeform still get a
386
- * usable credential summary on Approve).
387
- */
388
- function parseSummaryText(text, name) {
389
- const result = {
390
- name,
391
- expertise: '',
392
- personality: '',
393
- credibility_for_question: 0.55,
394
- bias_to_watch: '',
395
- };
396
- let current = 'expertise';
397
- for (const rawLine of text.split('\n')) {
398
- const line = rawLine.trimEnd();
399
- if (!line.trim()) continue;
400
- const lower = line.toLowerCase();
401
- if (lower.startsWith('expertise:')) {
402
- current = 'expertise';
403
- result.expertise = line.slice(line.indexOf(':') + 1).trim();
404
- } else if (lower.startsWith('style:') || lower.startsWith('personality:')) {
405
- current = 'personality';
406
- result.personality = line.slice(line.indexOf(':') + 1).trim();
407
- } else if (lower.startsWith('credibility')) {
408
- current = 'credibility';
409
- const num = parseFloat(line.replace(/[^0-9.]/g, ''));
410
- if (!Number.isNaN(num)) {
411
- // Heuristic: numbers > 1 are probably 0..100; coerce to 0..1.
412
- result.credibility_for_question = num > 1 ? num / 100 : num;
413
- }
414
- } else if (lower.startsWith('bias')) {
415
- current = 'bias_to_watch';
416
- result.bias_to_watch = line.slice(line.indexOf(':') + 1).trim();
417
- } else if (current === 'expertise') {
418
- result.expertise += (result.expertise ? '\n' : '') + line;
419
- } else if (current === 'personality') {
420
- result.personality += (result.personality ? '\n' : '') + line;
421
- } else if (current === 'bias_to_watch') {
422
- result.bias_to_watch += (result.bias_to_watch ? '\n' : '') + line;
423
- }
424
- }
425
- // Clamp credibility into the valid range.
426
- if (Number.isNaN(result.credibility_for_question)) {
427
- result.credibility_for_question = 0.55;
428
- }
429
- result.credibility_for_question = Math.max(
430
- 0, Math.min(1, result.credibility_for_question),
431
- );
432
- return result;
433
- }
 
1
+ import React, { useState, useCallback, useEffect } from 'react';
2
+ import { X } from 'lucide-react';
 
 
 
 
 
3
 
4
  /**
5
  * Modal for adding (or editing) the in-the-loop human participant.
6
  *
7
+ * The user enters a name and a freeform self-description (experience,
8
+ * personality, etc.). On Approve the parent saves immediately and
9
+ * generates the structured credential summary in the background.
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  */
11
  export default function HumanParticipantModal({
12
  isOpen,
13
  initial,
 
 
14
  onClose,
15
  onSave,
16
  onRemove,
17
  }) {
18
  const [name, setName] = useState('');
19
+ const [profileText, setProfileText] = useState('');
 
 
 
 
 
 
 
 
 
 
20
 
 
21
  useEffect(() => {
22
  if (!isOpen) return;
23
+ setName(initial?.name || 'Pat');
24
+ setProfileText(initial?.profile_text || '');
 
 
 
 
 
 
 
 
 
 
 
 
25
  }, [isOpen, initial]);
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const handleApprove = useCallback(() => {
28
+ if (!name.trim()) return;
29
+ if (!profileText.trim()) return;
 
 
 
 
 
 
 
30
  const pid = initial?.participant_id || `human_${Date.now()}`;
31
  onSave({
32
  participant_id: pid,
33
  name: name.trim(),
34
+ profile_text: profileText.trim(),
35
  });
36
+ }, [name, profileText, initial, onSave]);
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  if (!isOpen) return null;
39
 
 
 
40
  return (
41
  <div className="ccai-credentials-overlay">
42
  <div className="ccai-credentials-card ccai-human-modal-card">
 
45
  <h2>Add a Human Participant</h2>
46
  <div className="ccai-credentials-subtitle">
47
  Give yourself (or another human) a seat at the table.
48
+ The orchestrator will pause for your input when it&apos;s
49
  your turn.
50
  </div>
51
  </div>
 
65
  />
66
  </label>
67
 
68
+ <label className="ccai-human-field">
69
+ <span className="ccai-human-field-label">
70
+ Experience, personality, …
71
+ </span>
 
 
 
 
 
 
 
 
 
 
 
 
72
  <textarea
73
  className="ccai-human-summary"
74
+ value={profileText}
75
+ onChange={e => setProfileText(e.target.value)}
76
+ rows={8}
77
  spellCheck
78
+ placeholder={
79
+ 'Describe your background, how you tend to argue, '
80
+ + 'and anything the group should know about your perspective…'
81
+ }
82
  />
83
  <div className="ccai-human-summary-help">
84
+ The orchestrator will turn this into a credential summary
85
+ for the group — the same way it assesses each LLM
86
+ participant&apos;s persona prompt.
87
  </div>
88
+ </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  </div>
90
 
91
  <div className="ccai-human-modal-footer">
 
106
  <button
107
  type="button"
108
  className="btn-sm btn-outline"
109
+ onClick={onClose}
 
110
  >
 
 
 
 
111
  Cancel
112
  </button>
113
  <button
114
  type="button"
115
  className="btn btn-primary btn-sm"
116
  onClick={handleApprove}
117
+ disabled={!name.trim() || !profileText.trim()}
118
  >
119
  Approve
120
  </button>
 
124
  </div>
125
  );
126
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/ParticipantSidebar.js CHANGED
@@ -12,6 +12,7 @@ export default function ParticipantSidebar({
12
  participants,
13
  enabledMap,
14
  modelAssignments,
 
15
  onToggleEnabled,
16
  onRemove,
17
  autoSelectMode,
@@ -56,6 +57,7 @@ export default function ParticipantSidebar({
56
  participant={p}
57
  enabled={enabled}
58
  modelOverride={modelOverride}
 
59
  onToggleEnabled={() => onToggleEnabled(p.participant_id, !enabled)}
60
  onRemove={() => onRemove(p.participant_id)}
61
  />
@@ -65,10 +67,18 @@ export default function ParticipantSidebar({
65
  );
66
  }
67
 
68
- function ParticipantCard({ participant, enabled, modelOverride, onToggleEnabled, onRemove }) {
69
  const [open, setOpen] = useState(false);
70
  const isHuman = participant.kind === 'human';
71
 
 
 
 
 
 
 
 
 
72
  return (
73
  <div
74
  className={
@@ -152,7 +162,7 @@ function ParticipantCard({ participant, enabled, modelOverride, onToggleEnabled,
152
  <div className="ccai-participant-field">
153
  <div className="ccai-participant-field-label">Persona prompt</div>
154
  <pre className="ccai-participant-prompt">
155
- {participant.role_prompt || '(no prompt set)'}
156
  </pre>
157
  </div>
158
  </>
 
12
  participants,
13
  enabledMap,
14
  modelAssignments,
15
+ neonPromptByModelId = {},
16
  onToggleEnabled,
17
  onRemove,
18
  autoSelectMode,
 
57
  participant={p}
58
  enabled={enabled}
59
  modelOverride={modelOverride}
60
+ neonPromptByModelId={neonPromptByModelId}
61
  onToggleEnabled={() => onToggleEnabled(p.participant_id, !enabled)}
62
  onRemove={() => onRemove(p.participant_id)}
63
  />
 
67
  );
68
  }
69
 
70
+ function ParticipantCard({ participant, enabled, modelOverride, neonPromptByModelId, onToggleEnabled, onRemove }) {
71
  const [open, setOpen] = useState(false);
72
  const isHuman = participant.kind === 'human';
73
 
74
+ const effectiveModelId = modelOverride
75
+ || participant.default_model_id
76
+ || (participant.kind === 'neon' ? participant.participant_id : '');
77
+ const personaPrompt = (effectiveModelId.startsWith('neon:')
78
+ && neonPromptByModelId[effectiveModelId])
79
+ || participant.role_prompt
80
+ || '';
81
+
82
  return (
83
  <div
84
  className={
 
162
  <div className="ccai-participant-field">
163
  <div className="ccai-participant-field-label">Persona prompt</div>
164
  <pre className="ccai-participant-prompt">
165
+ {personaPrompt || '(no prompt set)'}
166
  </pre>
167
  </div>
168
  </>
frontend/src/components/RateLimitNotice.js ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const COPY = {
4
+ one_left: {
5
+ title: 'One chat remaining today',
6
+ body: (
7
+ <>
8
+ You have one chat left for today. Contact us at{' '}
9
+ <a href="mailto:info@neon.ai">info@neon.ai</a> if you would like to do
10
+ more with CCAI.
11
+ </>
12
+ ),
13
+ },
14
+ exhausted: {
15
+ title: 'Daily chat limit reached',
16
+ body: (
17
+ <>
18
+ You have used all of your chats for today. Contact us at{' '}
19
+ <a href="mailto:info@neon.ai">info@neon.ai</a> if you would like to do
20
+ more with CCAI.
21
+ </>
22
+ ),
23
+ },
24
+ };
25
+
26
+ /**
27
+ * Modal notice for anonymous / rate-limited users at 1 chat left or
28
+ * when they try to start after the daily cap is exhausted.
29
+ */
30
+ export default function RateLimitNotice({ kind, onClose }) {
31
+ if (!kind || !COPY[kind]) return null;
32
+ const { title, body } = COPY[kind];
33
+
34
+ return (
35
+ <div
36
+ className="ccai-credentials-overlay"
37
+ role="dialog"
38
+ aria-modal="true"
39
+ aria-labelledby="ccai-rate-limit-title"
40
+ onClick={onClose}
41
+ >
42
+ <div
43
+ className="ccai-credentials-card ccai-rate-limit-card"
44
+ onClick={(e) => e.stopPropagation()}
45
+ >
46
+ <div className="ccai-credentials-header">
47
+ <div>
48
+ <h2 id="ccai-rate-limit-title">{title}</h2>
49
+ </div>
50
+ <div className="ccai-tab-spacer" />
51
+ <button type="button" className="modal-close" onClick={onClose} aria-label="Close">
52
+ &times;
53
+ </button>
54
+ </div>
55
+ <div className="ccai-rate-limit-body">{body}</div>
56
+ <div className="ccai-rate-limit-actions">
57
+ <button type="button" className="btn-primary" onClick={onClose}>
58
+ OK
59
+ </button>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ );
64
+ }
frontend/src/styles/ccai.css CHANGED
@@ -533,6 +533,7 @@
533
  border-collapse: collapse;
534
  font-size: 12px;
535
  color: var(--text-primary);
 
536
  }
537
 
538
  .ccai-table th,
@@ -554,6 +555,18 @@
554
  top: 0;
555
  }
556
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  .ccai-table-name {
558
  white-space: nowrap;
559
  font-weight: 600;
@@ -569,7 +582,7 @@
569
 
570
  /* ── Credential Summary modal ──────────────────────────────────────
571
  Surfaces the orchestrator's per-participant assessment built after
572
- Phase 1 and refreshed after Phase 2 critique. Reuses the table
573
  modal's overlay/card chrome for visual consistency.
574
  ──────────────────────────────────────────────────────────────── */
575
 
@@ -617,6 +630,33 @@
617
  max-width: 560px;
618
  }
619
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  .ccai-credentials-question {
621
  margin: 0 20px;
622
  padding: 10px 12px;
 
533
  border-collapse: collapse;
534
  font-size: 12px;
535
  color: var(--text-primary);
536
+ table-layout: fixed;
537
  }
538
 
539
  .ccai-table th,
 
555
  top: 0;
556
  }
557
 
558
+ .ccai-table th:first-child,
559
+ .ccai-table td:first-child {
560
+ width: 15%;
561
+ }
562
+
563
+ .ccai-table th:nth-child(n + 2),
564
+ .ccai-table td:nth-child(n + 2) {
565
+ width: 21.25%;
566
+ overflow-wrap: break-word;
567
+ word-wrap: break-word;
568
+ }
569
+
570
  .ccai-table-name {
571
  white-space: nowrap;
572
  font-weight: 600;
 
582
 
583
  /* ── Credential Summary modal ──────────────────────────────────────
584
  Surfaces the orchestrator's per-participant assessment built after
585
+ Phase 1 (concurrent per participant). Reuses the table
586
  modal's overlay/card chrome for visual consistency.
587
  ──────────────────────────────────────────────────────────────── */
588
 
 
630
  max-width: 560px;
631
  }
632
 
633
+ .ccai-rate-limit-card {
634
+ width: min(480px, 92vw);
635
+ }
636
+
637
+ .ccai-rate-limit-body {
638
+ padding: 16px 20px;
639
+ font-size: 14px;
640
+ line-height: 1.5;
641
+ color: var(--text-secondary);
642
+ }
643
+
644
+ .ccai-rate-limit-body a {
645
+ color: var(--accent-primary);
646
+ text-decoration: none;
647
+ }
648
+
649
+ .ccai-rate-limit-body a:hover {
650
+ text-decoration: underline;
651
+ }
652
+
653
+ .ccai-rate-limit-actions {
654
+ display: flex;
655
+ justify-content: flex-end;
656
+ padding: 12px 20px 16px;
657
+ border-top: 1px solid var(--border-primary);
658
+ }
659
+
660
  .ccai-credentials-question {
661
  margin: 0 20px;
662
  padding: 10px 12px;
frontend/src/styles/components.css CHANGED
@@ -1144,6 +1144,10 @@
1144
  color: var(--text-primary);
1145
  }
1146
 
 
 
 
 
1147
  /* ── Footer ─────────────────────────────────────────────────────── */
1148
 
1149
  .app-footer {
 
1144
  color: var(--text-primary);
1145
  }
1146
 
1147
+ .auth-badge-end {
1148
+ margin-left: 4px;
1149
+ }
1150
+
1151
  /* ── Footer ─────────────────────────────────────────────────────── */
1152
 
1153
  .app-footer {
frontend/src/utils/api.js CHANGED
@@ -310,6 +310,32 @@ export async function patchHumanCredential(sessionId, patch) {
310
  * first question or (rarely) a final summary if the LLM bails. The
311
  * draft_id is needed for subsequent /answer calls.
312
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  export async function startCredentialDraft({
314
  name, question, max_questions = 6, orchestrator_model_id = null,
315
  }) {
 
310
  * first question or (rarely) a final summary if the LLM bails. The
311
  * draft_id is needed for subsequent /answer calls.
312
  */
313
+ /**
314
+ * Generate a structured credential summary from a human's freeform
315
+ * profile text (experience, personality, etc.). Uses the orchestrator
316
+ * the same way it assesses an LLM participant's role prompt.
317
+ */
318
+ export async function generateHumanCredentialFromProfile({
319
+ name, question, profile_text, participant_id, orchestrator_model_id,
320
+ }) {
321
+ const resp = await fetch(`${API_BASE}/api/chat/credentials/from-profile`, {
322
+ method: 'POST',
323
+ headers: { 'Content-Type': 'application/json' },
324
+ body: JSON.stringify({
325
+ name,
326
+ question,
327
+ profile_text,
328
+ participant_id: participant_id || '',
329
+ orchestrator_model_id: orchestrator_model_id || null,
330
+ }),
331
+ });
332
+ if (!resp.ok) {
333
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
334
+ throw new Error(err.detail || 'Credential generation failed');
335
+ }
336
+ return resp.json();
337
+ }
338
+
339
  export async function startCredentialDraft({
340
  name, question, max_questions = 6, orchestrator_model_id = null,
341
  }) {
frontend/src/utils/storage.js CHANGED
@@ -30,7 +30,8 @@ const DEFAULTS = {
30
  auto_select_mode: false,
31
  // In-the-loop human participant. null when no human is configured.
32
  // Shape when set:
33
- // { participant_id, name, credential_summary: {
 
34
  // name, expertise, personality,
35
  // credibility_for_question, bias_to_watch
36
  // } }
 
30
  auto_select_mode: false,
31
  // In-the-loop human participant. null when no human is configured.
32
  // Shape when set:
33
+ // { participant_id, name, profile_text,
34
+ // credential_summary: {
35
  // name, expertise, personality,
36
  // credibility_for_question, bias_to_watch
37
  // } }