NeonClary Cursor commited on
Commit
d4017c8
·
1 Parent(s): 9a51d6a

Build credentials during Phase 1 and streamline human participant setup.

Browse files

Generate per-participant credentials concurrently with initial opinions, rebuild only on model changes, and let humans join from profile text with background credential generation—no question required upfront. Also refresh chat-start controls, rate-limit notices, and header layout.

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

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
  """
@@ -770,6 +774,40 @@ async def api_edit_human_credential(
770
  return {"ok": True, "credential": updated}
771
 
772
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
773
  # Module-level registry of in-flight credential drafts. Each draft is a
774
  # tiny piece of state: the question being discussed, the human's name,
775
  # 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
  """
 
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/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/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
@@ -53,9 +53,9 @@ from app.services.context_budget import (
53
  should_summarize,
54
  )
55
  from app.services.credential import (
56
- build_credential_summary,
 
57
  credentials_to_block,
58
- refresh_credential_summary,
59
  )
60
  from app.services.json_calls import orchestrator_call
61
  from app.services.orchestrator_speed import (
@@ -765,6 +765,107 @@ def _participant_turn_failure_sse(
765
  return out
766
 
767
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
768
  # ---------------------------------------------------------------------------
769
  # Phase implementations
770
  # ---------------------------------------------------------------------------
@@ -790,7 +891,11 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
790
  yield chunk
791
 
792
  async def _post_initial(result: _AiTurnResult) -> dict[str, Any]:
793
- session.initial_opinions[result.participant.participant_id] = result.turn.text
 
 
 
 
794
  return {}
795
 
796
  def _build_initial_spec(p: Participant) -> _AiTurnSpec | None:
@@ -813,21 +918,9 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
813
  ):
814
  yield chunk
815
 
816
- for chunk in _orchestrator_banner_sse(session, "Building Credential Summary..."):
817
- yield chunk
818
- creds = await build_credential_summary(
819
- orchestrator_model_id=_orchestrator_model_id(session),
820
- question=session.question,
821
- participants=_active_participants(session),
822
- initial_opinions=session.initial_opinions,
823
- api_log=session.api_log,
824
- human_credential=session.human_credential,
825
- )
826
- _bump_orchestrator_count(session)
827
- session.credential_summary = creds
828
- # Surface the freshly-built summary so the frontend can enable the
829
- # "View Credential Summary" menu item and cache its snapshot. The
830
- # full list also stays available via GET /api/chat/{id}/credentials.
831
  yield _sse("credentials_updated", {
832
  "stage": "built",
833
  "credentials": session.credential_summary,
@@ -936,7 +1029,6 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
936
  yield chunk
937
 
938
  cred_block = credentials_to_block(session.credential_summary)
939
- cred_refreshed = False
940
 
941
  for iteration in range(session.limits.status_assessment_max):
942
  session.status_assessment_iterations = iteration + 1
@@ -974,27 +1066,6 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
974
  yield _sse("orchestrator", _msg_payload(msg))
975
  return
976
 
977
- # Refresh credentials only when follow-ups are actually needed.
978
- if not cred_refreshed:
979
- cred_refreshed = True
980
- full_transcript = _format_history(session.messages)
981
- refreshed = await refresh_credential_summary(
982
- orchestrator_model_id=_orchestrator_model_id(session),
983
- question=session.question,
984
- participants=_active_participants(session),
985
- existing=session.credential_summary,
986
- critique_transcript=full_transcript,
987
- api_log=session.api_log,
988
- )
989
- _bump_orchestrator_count(session)
990
- if refreshed != session.credential_summary:
991
- session.credential_summary = refreshed
992
- yield _sse("credentials_updated", {
993
- "stage": "refreshed",
994
- "credentials": session.credential_summary,
995
- })
996
- cred_block = credentials_to_block(session.credential_summary)
997
-
998
  # Otherwise run targeted follow-ups
999
  active_ids = {p.participant_id for p in _active_participants(session)}
1000
  for oq in open_qs:
 
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
 
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 (
 
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
  # ---------------------------------------------------------------------------
 
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:
 
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,
 
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:
backend/app/services/orchestrator_speed.py CHANGED
@@ -356,7 +356,10 @@ async def _emit_ai_turn_result(
356
 
357
  p = result.participant
358
  turn = result.turn
 
359
  for ev in turn.sse_events:
 
 
360
  yield ev
361
  if not turn.ok:
362
  for chunk in _participant_turn_failure_sse(session, p):
@@ -380,6 +383,18 @@ async def _emit_ai_turn_result(
380
  )
381
  yield _sse("message", _msg_payload(msg))
382
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  if _orchestrator_cap_hit(session):
384
  async for chunk in _wait_for_continue(session, "orchestrator"):
385
  yield chunk
 
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):
 
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/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,9 @@ 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
  /** Append a system note at the current point in the chat timeline (not end-of-feed). */
@@ -159,6 +161,10 @@ export default function App() {
159
  const [humanSubmitting, setHumanSubmitting] = useState(false);
160
 
161
  const abortRef = useRef(null);
 
 
 
 
162
 
163
  // ─── Apply theme ────────────────────────────────────────────────
164
  useEffect(() => {
@@ -196,6 +202,15 @@ export default function App() {
196
  }).catch(() => {});
197
  }, [persisted.orchestrator_model_id]);
198
 
 
 
 
 
 
 
 
 
 
199
  // ─── Build a flat list of all models for pickers ────────────────
200
  const allModelsFlat = useMemo(() => {
201
  const list = [];
@@ -364,13 +379,67 @@ export default function App() {
364
  setHumanModalOpen(true);
365
  }, [humanParticipant]);
366
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  const handleSaveHuman = useCallback((spec) => {
368
- setHumanParticipant(spec);
 
 
 
 
 
369
  setHumanModalOpen(false);
370
  setHumanEditing(null);
371
- }, []);
 
 
372
 
373
  const handleRemoveHuman = useCallback(() => {
 
374
  setHumanParticipant(null);
375
  setHumanModalOpen(false);
376
  setHumanEditing(null);
@@ -421,6 +490,7 @@ export default function App() {
421
  credibility_for_question: updated.credibility_for_question ?? 0.55,
422
  bias_to_watch: updated.bias_to_watch || '',
423
  },
 
424
  } : prev);
425
  // Refresh the credentials cache so the modal reflects the edit.
426
  const data = await fetchCredentials(sessionId);
@@ -586,7 +656,7 @@ export default function App() {
586
  // `participantsOverride`, if provided, replaces the
587
  // selectedParticipants-derived list (used by the auto-select flow
588
  // because the freshly-chosen list isn't in state yet when we need it).
589
- const buildStartPayload = useCallback((theQuestion, participantsOverride) => {
590
  // Always honor the sidebar enabled toggles, including when
591
  // auto-select supplies a participantsOverride list (which used to
592
  // bypass enabledMap and always prepend a disabled human).
@@ -616,14 +686,15 @@ export default function App() {
616
  // participants array. Backend rejects start if it sees a human in
617
  // participants but no human_credential, so this MUST be present
618
  // whenever the human is enabled.
 
619
  const humanInList = baseList.find(p => p.kind === 'human');
620
  let human_credential = null;
621
- if (humanInList && humanParticipant) {
622
- const cs = humanParticipant.credential_summary || {};
623
  human_credential = {
624
  participant_id: humanInList.participant_id,
625
  name: humanInList.name,
626
- expertise: cs.expertise || '',
627
  personality: cs.personality || '',
628
  credibility_for_question: typeof cs.credibility_for_question === 'number'
629
  ? cs.credibility_for_question
@@ -673,6 +744,10 @@ export default function App() {
673
  // ─── Start chat ─────────────────────────────────────────────────
674
  const handleStart = useCallback(async (theQuestion) => {
675
  if (!theQuestion || !theQuestion.trim()) return;
 
 
 
 
676
  // In auto-select mode the dropdown has no manual picks - skip the
677
  // pre-flight count check and validate the chosen pool below instead.
678
  if (!autoSelectMode && enabledSelectedCount < 2) return;
@@ -768,9 +843,41 @@ export default function App() {
768
  }
769
  }
770
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771
  try {
772
  await startChat(
773
- buildStartPayload(theQuestion, resolvedParticipants),
774
  {
775
  onSession: (data) => {
776
  setSessionId(data.session_id);
@@ -929,21 +1036,12 @@ export default function App() {
929
  getAuthStatus().then(setAuth).catch(() => {});
930
  }
931
  }, [
932
- buildStartPayload, enabledSelectedCount, dailyLimit,
933
  autoSelectMode, allCatalogParticipants, modelAssignments,
934
  maxParticipants, orchestratorModel, enabledMap,
935
- humanParticipant, humanCatalogEntry,
936
  ]);
937
 
938
- const handleStartRandom = useCallback(() => {
939
- if (demoQuestions.length === 0) {
940
- setSystemMessages(prev => [...prev, { text: 'No demo questions available.' }]);
941
- return;
942
- }
943
- const q = pickRandom(demoQuestions);
944
- handleStart(q.text);
945
- }, [demoQuestions, handleStart]);
946
-
947
  // In auto-select mode we don't require manual picks - the orchestrator
948
  // will choose them at /chat/start time, so just need 2+ candidates
949
  // available in the catalog.
@@ -964,7 +1062,6 @@ export default function App() {
964
  theme={theme}
965
  onToggleTheme={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
966
  auth={auth}
967
- dailyLimit={dailyLimit}
968
  catalog={catalog}
969
  expertPersonas={expertPersonas}
970
  selectedIds={selectedIds}
@@ -1023,8 +1120,9 @@ export default function App() {
1023
  />
1024
  <div className="content">
1025
  <ChatControls
1026
- onStartRandom={handleStartRandom}
1027
- onStartTyped={handleStart}
 
1028
  onStop={handleStop}
1029
  disabled={startDisabled}
1030
  isRunning={isRunning}
@@ -1086,8 +1184,6 @@ export default function App() {
1086
  <HumanParticipantModal
1087
  isOpen={humanModalOpen}
1088
  initial={humanEditing}
1089
- question={activeQuestion}
1090
- orchestratorModel={orchestratorModel}
1091
  onClose={() => { setHumanModalOpen(false); setHumanEditing(null); }}
1092
  onSave={handleSaveHuman}
1093
  onRemove={humanEditing ? handleRemoveHuman : null}
@@ -1105,6 +1201,10 @@ export default function App() {
1105
  catalog={promptCatalog}
1106
  onClose={() => setPromptCatalogOpen(false)}
1107
  />
 
 
 
 
1108
  </div>
1109
  );
1110
  }
 
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). */
 
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 = [];
 
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);
 
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}
 
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/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/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
@@ -569,7 +569,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 +617,33 @@
617
  max-width: 560px;
618
  }
619
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  .ccai-credentials-question {
621
  margin: 0 20px;
622
  padding: 10px 12px;
 
569
 
570
  /* ── Credential Summary modal ──────────────────────────────────────
571
  Surfaces the orchestrator's per-participant assessment built after
572
+ Phase 1 (concurrent per participant). Reuses the table
573
  modal's overlay/card chrome for visual consistency.
574
  ──────────────────────────────────────────────────────────────── */
575
 
 
617
  max-width: 560px;
618
  }
619
 
620
+ .ccai-rate-limit-card {
621
+ width: min(480px, 92vw);
622
+ }
623
+
624
+ .ccai-rate-limit-body {
625
+ padding: 16px 20px;
626
+ font-size: 14px;
627
+ line-height: 1.5;
628
+ color: var(--text-secondary);
629
+ }
630
+
631
+ .ccai-rate-limit-body a {
632
+ color: var(--accent-primary);
633
+ text-decoration: none;
634
+ }
635
+
636
+ .ccai-rate-limit-body a:hover {
637
+ text-decoration: underline;
638
+ }
639
+
640
+ .ccai-rate-limit-actions {
641
+ display: flex;
642
+ justify-content: flex-end;
643
+ padding: 12px 20px 16px;
644
+ border-top: 1px solid var(--border-primary);
645
+ }
646
+
647
  .ccai-credentials-question {
648
  margin: 0 20px;
649
  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
  // } }