NeonClary commited on
Commit
f932645
·
unverified ·
2 Parent(s): a763505bbab75f

Merge pull request #1 from NeonClary/dev-cursor

Browse files

Adding better chat formatting, menu items, title change & more

.cursor/rules/docker-build.mdc ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ description: Docker build cache conventions for CCAI-Vibe-Demo
3
+ globs: Dockerfile,docker-compose*.yml,.dockerignore,backend/requirements.txt,frontend/package*.json
4
+ alwaysApply: false
5
+ ---
6
+
7
+ # Docker build cache conventions
8
+
9
+ This project uses **BuildKit cache mounts** so `npm ci` and `pip install`
10
+ skip the network when only source files change. Don't replace them
11
+ with plain `RUN` commands - that reintroduces full re-downloads on every
12
+ dependency change.
13
+
14
+ ## Required Dockerfile pattern
15
+
16
+ The Dockerfile MUST start with the BuildKit syntax directive:
17
+
18
+ ```dockerfile
19
+ # syntax=docker/dockerfile:1.7
20
+ ```
21
+
22
+ ### Frontend stage (root user, node:22-alpine)
23
+
24
+ ```dockerfile
25
+ COPY frontend/package.json frontend/package-lock.json ./
26
+ RUN --mount=type=cache,target=/root/.npm \
27
+ npm ci
28
+ ```
29
+
30
+ ### Backend stage (USER user, uid 1000, python:3.12-slim)
31
+
32
+ ```dockerfile
33
+ COPY --chown=user backend/requirements.txt ./
34
+ RUN --mount=type=cache,target=/home/user/.cache/pip,uid=1000,gid=1000 \
35
+ pip install --user -r requirements.txt
36
+ ```
37
+
38
+ The `uid=1000,gid=1000` flags are mandatory because the cache target
39
+ sits inside the unprivileged user's home dir. Without them the cache
40
+ is created root-owned and pip cannot write to it.
41
+
42
+ ## Don'ts
43
+
44
+ - **Don't** pass `--no-cache-dir` to `pip install`. It defeats the
45
+ cache mount. The mount lives outside the image, so wheels never
46
+ bloat the final layer either way.
47
+ - **Don't** `COPY .` blindly. Layers must be copy-deps-first,
48
+ copy-source-second so a source edit doesn't bust the deps layer.
49
+ - **Don't** add new files outside `backend/`, `frontend/` or the
50
+ lockfiles to a `COPY` line without first checking `.dockerignore`.
51
+
52
+ ## Build context (`.dockerignore`)
53
+
54
+ The ignore list excludes `.git/`, `node_modules/`, `frontend/build/`,
55
+ `agent-transcripts/`, `__pycache__`, secrets (`.env*`),
56
+ `docker-compose.override.yml`, `backend/tests/`, and editor metadata.
57
+ If you need a previously-ignored path inside the image, add a narrow
58
+ `!path/to/keep` exception rather than removing the broad ignore.
59
+
60
+ ## Verifying the cache works
61
+
62
+ After a Dockerfile or deps-file change, run two builds back to back:
63
+
64
+ ```powershell
65
+ docker compose build
66
+ docker compose build # second time
67
+ ```
68
+
69
+ The second build's `npm ci` / `pip install` step should print a
70
+ `CACHED` line (when nothing in the deps file changed) or a
71
+ near-instant `=> [internal] load build context` finish (when only
72
+ source files changed). If it re-downloads, the cache mount is
73
+ mis-configured.
74
+
75
+ If a build ever errors with `the --mount option requires BuildKit`,
76
+ opt in explicitly:
77
+
78
+ ```powershell
79
+ $env:DOCKER_BUILDKIT=1; docker compose build
80
+ ```
81
+
82
+ ## HuggingFace Spaces compatibility
83
+
84
+ HuggingFace's Docker Space builder honors the `# syntax=` directive
85
+ and BuildKit cache mounts. Older builders silently treat the mounts
86
+ as no-ops, so this Dockerfile remains forward-compatible with any
87
+ plain-Docker environment.
.dockerignore ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build context exclusions for `docker build` / `docker compose build`.
2
+ # Trimming the context speeds the daemon transfer and keeps cache hashes
3
+ # stable: changes to ignored paths do NOT invalidate Dockerfile layers.
4
+
5
+ # ── Git / VCS metadata ───────────────────────────────────────────────
6
+ .git
7
+ .gitignore
8
+ .gitattributes
9
+ .github
10
+
11
+ # ── Cursor / agent transcripts (local IDE state, never needed in image)
12
+ .cursor
13
+ agent-transcripts
14
+ .commit-msg.txt
15
+
16
+ # ── Editor / OS junk ─────────────────────────────────────────────────
17
+ .vscode
18
+ .idea
19
+ .DS_Store
20
+ Thumbs.db
21
+ *.swp
22
+
23
+ # ── Secrets / env (image gets env at runtime via docker-compose)
24
+ .env
25
+ .env.*
26
+ *.env
27
+ !**/.env.example
28
+
29
+ # ── Python local caches ──────────────────────────────────────────────
30
+ **/__pycache__
31
+ *.pyc
32
+ *.pyo
33
+ *.pyd
34
+ *.egg-info
35
+ .pytest_cache
36
+ .mypy_cache
37
+ .ruff_cache
38
+ .venv
39
+ venv
40
+
41
+ # ── Node local caches & build outputs ────────────────────────────────
42
+ # Lockfiles ARE included on purpose so `npm ci` is reproducible.
43
+ **/node_modules
44
+ frontend/build
45
+ frontend/.next
46
+ frontend/.cache
47
+ **/.cache
48
+ npm-debug.log*
49
+ yarn-debug.log*
50
+ yarn-error.log*
51
+
52
+ # ── Local-only docker compose overrides ──────────────────────────────
53
+ # The base docker-compose.yml is needed for `docker compose build` from
54
+ # the build dir, but the local override (which references absolute host
55
+ # paths to a secrets file outside this repo) shouldn't end up in the
56
+ # image build context.
57
+ docker-compose.override.yml
58
+
59
+ # ── Tests aren't shipped in the runtime image ────────────────────────
60
+ # (Re-enable if you want to run pytest inside the deployed container.)
61
+ backend/tests
Dockerfile CHANGED
@@ -1,11 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  FROM node:22-alpine AS frontend-build
2
  WORKDIR /app/frontend
 
 
 
3
  COPY frontend/package.json frontend/package-lock.json ./
4
- RUN npm ci
 
 
5
  COPY frontend/ ./
6
  ENV REACT_APP_API_URL=
7
  RUN npm run build
8
 
 
9
  FROM python:3.12-slim
10
 
11
  RUN useradd -m -u 1000 user
@@ -16,8 +34,11 @@ ENV HOME=/home/user \
16
 
17
  WORKDIR $HOME/app
18
 
 
 
19
  COPY --chown=user backend/requirements.txt ./
20
- RUN pip install --no-cache-dir -r requirements.txt
 
21
 
22
  COPY --chown=user backend/ ./
23
  COPY --chown=user --from=frontend-build /app/frontend/build ./static
 
1
+ # syntax=docker/dockerfile:1.7
2
+ #
3
+ # BuildKit cache mounts speed up rebuilds by persisting npm and pip
4
+ # package caches across builds (outside the image - they don't bloat
5
+ # the final layer). The first `docker compose build` is unchanged;
6
+ # subsequent rebuilds skip the network for any unchanged dependency.
7
+ #
8
+ # The `# syntax=` directive auto-opts into BuildKit on Docker Desktop
9
+ # 20.10+; HuggingFace Spaces also supports this directive. Older
10
+ # builders silently treat the cache mounts as no-ops, so this file
11
+ # remains forward-compatible.
12
+
13
  FROM node:22-alpine AS frontend-build
14
  WORKDIR /app/frontend
15
+
16
+ # Copy lockfile + manifest first so the layer hash only changes when
17
+ # deps actually change, not on every source edit.
18
  COPY frontend/package.json frontend/package-lock.json ./
19
+ RUN --mount=type=cache,target=/root/.npm \
20
+ npm ci
21
+
22
  COPY frontend/ ./
23
  ENV REACT_APP_API_URL=
24
  RUN npm run build
25
 
26
+
27
  FROM python:3.12-slim
28
 
29
  RUN useradd -m -u 1000 user
 
34
 
35
  WORKDIR $HOME/app
36
 
37
+ # Lockfile-equivalent first; cache mount targets the unprivileged
38
+ # user's pip cache (uid/gid 1000 matches the `user` account).
39
  COPY --chown=user backend/requirements.txt ./
40
+ RUN --mount=type=cache,target=/home/user/.cache/pip,uid=1000,gid=1000 \
41
+ pip install --user -r requirements.txt
42
 
43
  COPY --chown=user backend/ ./
44
  COPY --chown=user --from=frontend-build /app/frontend/build ./static
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: CCAI Vibe Demo
3
  emoji: 🤝
4
  colorFrom: blue
5
  colorTo: purple
@@ -11,7 +11,7 @@ hf_oauth_scopes:
11
  pinned: false
12
  ---
13
 
14
- # CCAI Vibe Demo
15
 
16
  A demo of **Collaborative Conversational AI (CCAI)** - Neon.ai's patented
17
  group-discussion technology. Up to 9 participants (any mix of AI personas,
 
1
  ---
2
+ title: Collaborative Conversational AI (CCAI) Demo
3
  emoji: 🤝
4
  colorFrom: blue
5
  colorTo: purple
 
11
  pinned: false
12
  ---
13
 
14
+ # Collaborative Conversational AI (CCAI) Demo
15
 
16
  A demo of **Collaborative Conversational AI (CCAI)** - Neon.ai's patented
17
  group-discussion technology. Up to 9 participants (any mix of AI personas,
backend/app/api/chat.py CHANGED
@@ -21,13 +21,19 @@ from app.middleware.rate_limit import (
21
  )
22
  from app.services.extra_personas import get_extra_persona
23
  from app.services.models import (
 
 
 
24
  DEFAULT_MAX_PARTICIPANTS,
25
  MAX_MAX_PARTICIPANTS,
26
  MIN_MAX_PARTICIPANTS,
27
  Participant,
28
  Phase,
29
  Session,
 
30
  )
 
 
31
  from app.services.orchestrator import (
32
  create_session,
33
  get_session,
@@ -93,6 +99,27 @@ class ParticipantSelectionPayload(BaseModel):
93
  model_id_override: str | None = None
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  class StartChatRequest(BaseModel):
97
  question: str | None = None
98
 
@@ -103,6 +130,11 @@ class StartChatRequest(BaseModel):
103
  orchestrator_model_id: str | None = None
104
  summarizer_model_id: str | None = None
105
  max_participants: int = DEFAULT_MAX_PARTICIPANTS
 
 
 
 
 
106
 
107
 
108
  # ---------------------------------------------------------------------------
@@ -163,6 +195,87 @@ async def api_generate_role_freeform(req: GenerateRoleFreeformRequest):
163
  return result
164
 
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  # ---------------------------------------------------------------------------
167
  # Demo questions
168
  # ---------------------------------------------------------------------------
@@ -310,6 +423,12 @@ async def api_start_chat(req: StartChatRequest, request: Request):
310
  session.orchestrator_model_id = req.orchestrator_model_id
311
  session.summarizer_model_id = req.summarizer_model_id
312
  session.max_participants = max_p
 
 
 
 
 
 
313
 
314
  async def event_stream():
315
  yield (
@@ -365,6 +484,23 @@ async def api_export_chat(session_id: str, fmt: str = "txt"):
365
  return _export_txt(session)
366
 
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  @router.get("/chat/{session_id}/api-log")
369
  async def api_export_log(session_id: str):
370
  session = get_session(session_id)
@@ -426,6 +562,88 @@ def _format_participants_block(session: Session) -> list[str]:
426
  ]
427
 
428
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  def _export_txt(session: Session) -> dict:
430
  lines = ["CCAI Conversation Log", "=" * 40, ""]
431
  lines.append("Question:")
@@ -434,6 +652,9 @@ def _export_txt(session: Session) -> dict:
434
  lines.append("Participants:")
435
  lines.extend(_format_participants_block(session))
436
  lines.append("")
 
 
 
437
  for m in session.messages:
438
  speaker = m.get("speaker_name") or "(anon)"
439
  if m.get("role") == "orchestrator":
@@ -457,7 +678,12 @@ def _export_md(session: Session) -> dict:
457
  lines.append("")
458
  for p in session.participants:
459
  lines.append(f"- **{p.name}** (*{p.display_name}*)")
460
- lines.append("\n---\n")
 
 
 
 
 
461
  for m in session.messages:
462
  speaker = m.get("speaker_name") or "(anon)"
463
  is_orch = m.get("role") == "orchestrator"
@@ -478,7 +704,12 @@ def _export_md(session: Session) -> dict:
478
 
479
 
480
  def _export_csv_table(session: Session) -> dict:
481
- """RFC-4180 compliant CSV. csv.writer handles quoting/escaping."""
 
 
 
 
 
482
  buf = io.StringIO()
483
  writer = csv.writer(buf, quoting=csv.QUOTE_MINIMAL, lineterminator="\n")
484
 
@@ -488,14 +719,23 @@ def _export_csv_table(session: Session) -> dict:
488
  writer.writerow([])
489
  writer.writerow([
490
  "Participant",
 
 
 
 
491
  "First opinion",
492
  "Conversation contribution",
493
  "Revised opinion",
494
  "Final opinion",
495
  ])
496
  for p in session.participants:
 
497
  writer.writerow([
498
  p.name,
 
 
 
 
499
  (session.initial_opinions or {}).get(p.participant_id, ""),
500
  (session.contribution_summaries or {}).get(p.participant_id, ""),
501
  (session.final_opinions or {}).get(p.participant_id, ""),
 
21
  )
22
  from app.services.extra_personas import get_extra_persona
23
  from app.services.models import (
24
+ CONVERSATION_LIMIT_BOUNDS,
25
+ CONVERSATION_LIMIT_DESCRIPTIONS,
26
+ ConversationLimits,
27
  DEFAULT_MAX_PARTICIPANTS,
28
  MAX_MAX_PARTICIPANTS,
29
  MIN_MAX_PARTICIPANTS,
30
  Participant,
31
  Phase,
32
  Session,
33
+ clamp_conversation_limits,
34
  )
35
+ from app.services.auto_select import auto_select_participants
36
+ from app.services.prompts.catalog import build_prompt_catalog
37
  from app.services.orchestrator import (
38
  create_session,
39
  get_session,
 
99
  model_id_override: str | None = None
100
 
101
 
102
+ class AutoSelectCandidate(BaseModel):
103
+ """One row of the candidate pool sent to the auto-select endpoint."""
104
+
105
+ participant_id: str
106
+ name: str
107
+ role_prompt: str = ""
108
+ kind: str = ""
109
+ model_id: str = ""
110
+
111
+
112
+ class AutoSelectRequest(BaseModel):
113
+ """Body of POST /api/chat/auto-select-participants."""
114
+
115
+ question: str
116
+ count: int = 5
117
+ candidates: list[AutoSelectCandidate]
118
+ # Optional: pin the orchestrator model used for ranking. Defaults
119
+ # to the configured global orchestrator model.
120
+ orchestrator_model_id: str | None = None
121
+
122
+
123
  class StartChatRequest(BaseModel):
124
  question: str | None = None
125
 
 
130
  orchestrator_model_id: str | None = None
131
  summarizer_model_id: str | None = None
132
  max_participants: int = DEFAULT_MAX_PARTICIPANTS
133
+ # User-supplied overrides for the conversation's repetition /
134
+ # failsafe limits. Any field that is missing or out of range is
135
+ # silently clamped to the server-side default; see
136
+ # `clamp_conversation_limits` in services.models.
137
+ limits: dict[str, int] | None = None
138
 
139
 
140
  # ---------------------------------------------------------------------------
 
195
  return result
196
 
197
 
198
+ # ---------------------------------------------------------------------------
199
+ # Prompt catalog (Transparency: "View current chat prompts")
200
+ # ---------------------------------------------------------------------------
201
+
202
+ @router.get("/chat/prompts/catalog")
203
+ async def api_chat_prompts_catalog():
204
+ """Return every prompt template the orchestrator and participants
205
+ use during a chat, grouped by phase, each with a short purpose and
206
+ a list of runtime template variables. Used by the
207
+ PromptCatalogModal in the settings menu's Transparency section.
208
+
209
+ Shape:
210
+ {"groups": [{"title": "...", "items": [{"name", "purpose",
211
+ "variables", "template"}]}, ...]}
212
+ """
213
+ return build_prompt_catalog()
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Auto-select participants (LLM-based ranking for "Select N Automatically")
218
+ # ---------------------------------------------------------------------------
219
+
220
+ @router.post("/chat/auto-select-participants")
221
+ async def api_auto_select_participants(req: AutoSelectRequest):
222
+ """Rank the candidate pool by relevance to the question and return
223
+ the top `count` participant_ids. The frontend calls this just
224
+ before /chat/start when the user has the auto-select toggle on.
225
+
226
+ Returns: {"selected": [id, ...], "rationale": "short string"}.
227
+ `selected` is exactly `count` long unless the candidate pool is
228
+ smaller. Invalid / hallucinated ids are silently dropped and
229
+ padded with the next unused candidates.
230
+ """
231
+ if not req.question or not req.question.strip():
232
+ raise HTTPException(400, "Question is required")
233
+ if not req.candidates:
234
+ raise HTTPException(400, "At least one candidate is required")
235
+ if req.count < 1:
236
+ raise HTTPException(400, "count must be >= 1")
237
+
238
+ candidates_payload = [c.dict() for c in req.candidates]
239
+ orchestrator_id = req.orchestrator_model_id or settings.orchestrator_model
240
+ result = await auto_select_participants(
241
+ orchestrator_model_id=orchestrator_id,
242
+ question=req.question,
243
+ candidates=candidates_payload,
244
+ count=req.count,
245
+ )
246
+ return result
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Conversation limits (steppers in the settings menu)
251
+ # ---------------------------------------------------------------------------
252
+
253
+ @router.get("/chat/limits/defaults")
254
+ async def api_chat_limits_defaults():
255
+ """Return defaults, bounds, and human-readable descriptions for the
256
+ `ConversationLimits` knobs the user can tune in the settings menu.
257
+
258
+ The frontend uses the `defaults` to initialize the steppers, the
259
+ `bounds` to set min/max and clamp on input, and the `descriptions`
260
+ to render the section headers and per-field help text. Keeping
261
+ this server-driven means we add a knob in one place
262
+ (services.models) and the UI picks it up without a frontend
263
+ change beyond rendering.
264
+ """
265
+ defaults = ConversationLimits()
266
+ return {
267
+ "defaults": {
268
+ field_name: getattr(defaults, field_name)
269
+ for field_name in CONVERSATION_LIMIT_BOUNDS.keys()
270
+ },
271
+ "bounds": {
272
+ field_name: {"min": lo, "max": hi}
273
+ for field_name, (lo, hi) in CONVERSATION_LIMIT_BOUNDS.items()
274
+ },
275
+ "descriptions": CONVERSATION_LIMIT_DESCRIPTIONS,
276
+ }
277
+
278
+
279
  # ---------------------------------------------------------------------------
280
  # Demo questions
281
  # ---------------------------------------------------------------------------
 
423
  session.orchestrator_model_id = req.orchestrator_model_id
424
  session.summarizer_model_id = req.summarizer_model_id
425
  session.max_participants = max_p
426
+ # Attach the user-tunable limits and seed the runtime failsafe
427
+ # caps from them. clamp_conversation_limits silently coerces any
428
+ # missing or out-of-range values back to the defaults / bounds.
429
+ session.limits = clamp_conversation_limits(req.limits)
430
+ session.participant_message_cap = session.limits.participant_message_pause_at
431
+ session.orchestrator_call_cap = session.limits.orchestrator_call_pause_at
432
 
433
  async def event_stream():
434
  yield (
 
484
  return _export_txt(session)
485
 
486
 
487
+ @router.get("/chat/{session_id}/credentials")
488
+ async def api_credentials(session_id: str):
489
+ """Return the orchestrator-generated Credential Summary for the
490
+ current session. Built after Phase 1 and refreshed once after Phase 2
491
+ critique - so the response can be empty if the user opens the modal
492
+ before Phase 1 finishes.
493
+ """
494
+ session = get_session(session_id)
495
+ if not session:
496
+ raise HTTPException(404, "Session not found")
497
+ return {
498
+ "session_id": session_id,
499
+ "question": session.question,
500
+ "credentials": session.credential_summary or [],
501
+ }
502
+
503
+
504
  @router.get("/chat/{session_id}/api-log")
505
  async def api_export_log(session_id: str):
506
  session = get_session(session_id)
 
562
  ]
563
 
564
 
565
+ def _credentials_intro_line() -> str:
566
+ """One-liner that explains where this block came from. Repeated in TXT
567
+ and MD exports so the file is self-explanatory without the UI."""
568
+ return (
569
+ "Orchestrator-generated assessments of each participant's "
570
+ "expertise, debating style, credibility on this question, and "
571
+ "biases to watch. Built after Phase 1 (initial opinions) and "
572
+ "refreshed once after Phase 2 (critique)."
573
+ )
574
+
575
+
576
+ def _format_credential_block_txt(session: Session) -> list[str]:
577
+ """Plain-text Credential Summary section. Returns [] if no summary
578
+ has been built yet (e.g. user exports mid-Phase-1)."""
579
+ creds = session.credential_summary or []
580
+ if not creds:
581
+ return []
582
+ lines = ["Credential Summary", "-" * 40, _credentials_intro_line(), ""]
583
+ for c in creds:
584
+ name = c.get("name") or c.get("participant_id") or "(unknown)"
585
+ lines.append(f"{name}")
586
+ if c.get("expertise"):
587
+ lines.append(f" Expertise: {c['expertise']}")
588
+ if c.get("personality"):
589
+ lines.append(f" Style: {c['personality']}")
590
+ if c.get("credibility_for_question") is not None:
591
+ try:
592
+ score = float(c["credibility_for_question"])
593
+ lines.append(f" Credibility: {score:.2f} (0-1)")
594
+ except (TypeError, ValueError):
595
+ pass
596
+ if c.get("bias_to_watch"):
597
+ lines.append(f" Bias: {c['bias_to_watch']}")
598
+ lines.append("")
599
+ return lines
600
+
601
+
602
+ def _format_credential_block_md(session: Session) -> list[str]:
603
+ """Markdown Credential Summary section. Returns [] when empty."""
604
+ creds = session.credential_summary or []
605
+ if not creds:
606
+ return []
607
+ lines = ["## Credential Summary", "", f"_{_credentials_intro_line()}_", ""]
608
+ for c in creds:
609
+ name = c.get("name") or c.get("participant_id") or "(unknown)"
610
+ lines.append(f"### {name}")
611
+ lines.append("")
612
+ if c.get("expertise"):
613
+ lines.append(f"- **Expertise:** {c['expertise']}")
614
+ if c.get("personality"):
615
+ lines.append(f"- **Style:** {c['personality']}")
616
+ if c.get("credibility_for_question") is not None:
617
+ try:
618
+ score = float(c["credibility_for_question"])
619
+ lines.append(f"- **Credibility on this question:** {score:.2f} (0-1)")
620
+ except (TypeError, ValueError):
621
+ pass
622
+ if c.get("bias_to_watch"):
623
+ lines.append(f"- **Bias to watch:** {c['bias_to_watch']}")
624
+ lines.append("")
625
+ return lines
626
+
627
+
628
+ def _credential_for(session: Session, participant_id: str) -> dict:
629
+ """Lookup helper used by the CSV writer. Empty dict if not built yet."""
630
+ for c in session.credential_summary or []:
631
+ if c.get("participant_id") == participant_id:
632
+ return c
633
+ return {}
634
+
635
+
636
+ def _format_credibility_score(value: object) -> str:
637
+ """CSV-safe formatting for the credibility number (rounded float).
638
+ Returns "" when the value is missing or not numeric."""
639
+ if value is None:
640
+ return ""
641
+ try:
642
+ return f"{float(value):.2f}"
643
+ except (TypeError, ValueError):
644
+ return ""
645
+
646
+
647
  def _export_txt(session: Session) -> dict:
648
  lines = ["CCAI Conversation Log", "=" * 40, ""]
649
  lines.append("Question:")
 
652
  lines.append("Participants:")
653
  lines.extend(_format_participants_block(session))
654
  lines.append("")
655
+ cred_lines = _format_credential_block_txt(session)
656
+ if cred_lines:
657
+ lines.extend(cred_lines)
658
  for m in session.messages:
659
  speaker = m.get("speaker_name") or "(anon)"
660
  if m.get("role") == "orchestrator":
 
678
  lines.append("")
679
  for p in session.participants:
680
  lines.append(f"- **{p.name}** (*{p.display_name}*)")
681
+ lines.append("")
682
+ cred_lines = _format_credential_block_md(session)
683
+ if cred_lines:
684
+ lines.extend(cred_lines)
685
+ lines.append("---")
686
+ lines.append("")
687
  for m in session.messages:
688
  speaker = m.get("speaker_name") or "(anon)"
689
  is_orch = m.get("role") == "orchestrator"
 
704
 
705
 
706
  def _export_csv_table(session: Session) -> dict:
707
+ """RFC-4180 compliant CSV. csv.writer handles quoting/escaping.
708
+
709
+ Columns include the orchestrator-generated Credential Summary so
710
+ the table is self-contained: who each participant is (per the
711
+ orchestrator's read), then what they said and how it evolved.
712
+ """
713
  buf = io.StringIO()
714
  writer = csv.writer(buf, quoting=csv.QUOTE_MINIMAL, lineterminator="\n")
715
 
 
719
  writer.writerow([])
720
  writer.writerow([
721
  "Participant",
722
+ "Expertise (orchestrator's read)",
723
+ "Style",
724
+ "Credibility on this question (0-1)",
725
+ "Bias to watch",
726
  "First opinion",
727
  "Conversation contribution",
728
  "Revised opinion",
729
  "Final opinion",
730
  ])
731
  for p in session.participants:
732
+ cred = _credential_for(session, p.participant_id)
733
  writer.writerow([
734
  p.name,
735
+ cred.get("expertise", ""),
736
+ cred.get("personality", ""),
737
+ _format_credibility_score(cred.get("credibility_for_question")),
738
+ cred.get("bias_to_watch", ""),
739
  (session.initial_opinions or {}).get(p.participant_id, ""),
740
  (session.contribution_summaries or {}).get(p.participant_id, ""),
741
  (session.final_opinions or {}).get(p.participant_id, ""),
backend/app/data/demo_questions.json CHANGED
@@ -50,10 +50,10 @@
50
  "text": "I'm CTO of a 12-person AI startup that's about to release our first real product: a domain-specific reasoning agent for legal-contract review. We've raised a Series A. The founding team is split on whether to release our core inference stack as open source under a permissive license. The arguments for: faster developer adoption, recruiting, brand-building, the ecosystem moves fast and we'll be left behind otherwise. The arguments against: a well-funded competitor could fork us, our investors are nervous, and our actual moat is the domain training data, not the inference code. I'd like to land on a defensible decision in the next six weeks. What framework should we use, and what's the right answer for a company in roughly our position?"
51
  },
52
  {
53
- "id": "personal_health_diet",
54
- "category": "health",
55
- "title": "Diet overhaul with conflicting advice",
56
- "text": "I'm 41, somewhat overweight, with borderline-high blood pressure and slightly elevated LDL cholesterol. My doctor wants me to lose roughly 30 pounds and lower my blood pressure without yet starting medication. I have done short stints with several diets over the years - low carb, intermittent fasting, Mediterranean, and a vegan stretch - and lost weight on each before regaining it. I have a desk job, two school-age kids, and I cook most of our meals. I'm not interested in supplements with weak evidence, but I am willing to commit to a structured plan if it's actually likely to work for someone like me long-term. What should the plan look like, and what trade-offs am I implicitly making by picking one?"
57
  },
58
  {
59
  "id": "community_park_grant",
 
50
  "text": "I'm CTO of a 12-person AI startup that's about to release our first real product: a domain-specific reasoning agent for legal-contract review. We've raised a Series A. The founding team is split on whether to release our core inference stack as open source under a permissive license. The arguments for: faster developer adoption, recruiting, brand-building, the ecosystem moves fast and we'll be left behind otherwise. The arguments against: a well-funded competitor could fork us, our investors are nervous, and our actual moat is the domain training data, not the inference code. I'd like to land on a defensible decision in the next six weeks. What framework should we use, and what's the right answer for a company in roughly our position?"
51
  },
52
  {
53
+ "id": "four_day_workweek",
54
+ "category": "workplace / management",
55
+ "title": "Whether a 220-person firm should adopt a permanent 4-day work week",
56
+ "text": "I'm the COO of a 220-person professional-services firm in the Midwest US (architecture and engineering consulting). Two of our regional competitors have piloted 4-day work weeks for the past year and are leaning on it heavily in their recruiting; we've lost three good candidates to them in the last six months. Our partners are split on whether to follow. The case for: better retention, a stronger applicant pipeline, well-publicized productivity gains in some studies, and our work is mostly project-based so client deliverables don't strictly need a Friday. The case against: about 30% of our work involves real-time coordination with construction crews and government agencies that operate Mon-Fri; some senior partners worry it signals we are unserious; to absorb the cost we'd need to either stretch hours into longer days, cut staffing slightly, or accept lower billable utilization for at least the first year; and we have no clean way to roll it back if it doesn't work. Roughly half our staff are early-career employees with young kids; the other half are senior people who built the firm working 60-hour weeks and have mixed feelings about offering juniors a shorter week. Should we do it, what structure makes sense if we do, and how would we know at the 12-month mark whether it's working?"
57
  },
58
  {
59
  "id": "community_park_grant",
backend/app/main.py CHANGED
@@ -41,7 +41,11 @@ async def lifespan(app: FastAPI):
41
  await close_shared_client()
42
 
43
 
44
- app = FastAPI(title="CCAI Vibe Demo", version="1.0.0", lifespan=lifespan)
 
 
 
 
45
 
46
  app.add_middleware(
47
  SessionMiddleware,
 
41
  await close_shared_client()
42
 
43
 
44
+ app = FastAPI(
45
+ title="Collaborative Conversational AI (CCAI) Demo",
46
+ version="1.0.0",
47
+ lifespan=lifespan,
48
+ )
49
 
50
  app.add_middleware(
51
  SessionMiddleware,
backend/app/services/auto_select.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Auto-select N participants for a question.
2
+
3
+ Backs the optional "Select N Automatically" toggle in the participants
4
+ dropdown. The orchestrator LLM ranks the full candidate pool for
5
+ relevance to the question and returns the top N. The service:
6
+
7
+ - formats a compact candidates block (id + name + role role-prompt
8
+ snippet) so the LLM can pick deliberately;
9
+ - runs the call through `orchestrator_call` (which strips think
10
+ traces and is JSON-tolerant);
11
+ - validates every returned id against the candidate pool, drops
12
+ invented ones, and pads with the next-best unused candidates if the
13
+ LLM under-delivered.
14
+
15
+ If the orchestrator call fails entirely, we fall back to the first N
16
+ candidates in the order received, so the user still gets a working
17
+ chat instead of a hard error.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from typing import Any
23
+
24
+ from app.services.json_calls import orchestrator_call
25
+ from app.services.prompts import AUTO_SELECT_PARTICIPANTS_PROMPT
26
+
27
+ LOG = logging.getLogger(__name__)
28
+
29
+ _ROLE_SNIPPET_CHARS = 320
30
+
31
+
32
+ def _candidate_block(candidates: list[dict[str, Any]]) -> str:
33
+ """Render one line per candidate: id, name, kind, model, role snippet.
34
+
35
+ Role prompts are truncated so a roster of ~30 candidates fits in
36
+ a single orchestrator call without crowding out the question.
37
+ """
38
+ lines: list[str] = []
39
+ for i, c in enumerate(candidates, start=1):
40
+ role = (c.get("role_prompt") or "").strip()
41
+ if len(role) > _ROLE_SNIPPET_CHARS:
42
+ role = role[:_ROLE_SNIPPET_CHARS].rstrip() + "..."
43
+ lines.append(
44
+ f"{i}. id={c.get('participant_id')} | name={c.get('name')} "
45
+ f"| kind={c.get('kind')} | model={c.get('model_id')}\n"
46
+ f" role: {role or '(no role description)'}"
47
+ )
48
+ return "\n".join(lines)
49
+
50
+
51
+ def _validate_and_pad(
52
+ selected_raw: list[str] | None,
53
+ candidates: list[dict[str, Any]],
54
+ count: int,
55
+ ) -> list[str]:
56
+ """Keep only LLM-returned ids that exist in the candidate pool,
57
+ de-dupe while preserving the LLM's ranking, and pad with the next
58
+ unused candidates (in input order) up to `count`.
59
+ """
60
+ valid_ids = {c.get("participant_id") for c in candidates if c.get("participant_id")}
61
+ chosen: list[str] = []
62
+ seen: set[str] = set()
63
+ for sid in selected_raw or []:
64
+ if not isinstance(sid, str):
65
+ continue
66
+ if sid in valid_ids and sid not in seen:
67
+ chosen.append(sid)
68
+ seen.add(sid)
69
+ if len(chosen) == count:
70
+ break
71
+
72
+ if len(chosen) < count:
73
+ # Pad with the first unused candidates in the order received.
74
+ for c in candidates:
75
+ pid = c.get("participant_id")
76
+ if not pid or pid in seen:
77
+ continue
78
+ chosen.append(pid)
79
+ seen.add(pid)
80
+ if len(chosen) == count:
81
+ break
82
+
83
+ return chosen[:count]
84
+
85
+
86
+ async def auto_select_participants(
87
+ *,
88
+ orchestrator_model_id: str,
89
+ question: str,
90
+ candidates: list[dict[str, Any]],
91
+ count: int,
92
+ api_log: list[dict[str, Any]] | None = None,
93
+ ) -> dict[str, Any]:
94
+ """Return {"selected": [participant_id, ...], "rationale": str}.
95
+
96
+ `selected` is always exactly `count` long (padded from the
97
+ candidate pool if the LLM under-delivers). Never raises on LLM
98
+ errors - those degrade to a first-N fallback so the caller can
99
+ proceed to /chat/start.
100
+ """
101
+ n_target = max(1, min(count, len(candidates)))
102
+
103
+ if not candidates:
104
+ return {"selected": [], "rationale": "No candidates provided."}
105
+
106
+ # Single-candidate / under-supplied pools have nothing to pick from.
107
+ if len(candidates) <= n_target:
108
+ return {
109
+ "selected": [c["participant_id"] for c in candidates if c.get("participant_id")],
110
+ "rationale": "Candidate pool was at or below the requested count; using all.",
111
+ }
112
+
113
+ prompt = AUTO_SELECT_PARTICIPANTS_PROMPT.format(
114
+ question=question.strip(),
115
+ candidates_block=_candidate_block(candidates),
116
+ count=n_target,
117
+ )
118
+ _raw, parsed = await orchestrator_call(
119
+ orchestrator_model_id=orchestrator_model_id,
120
+ user_prompt=prompt,
121
+ label="auto_select_participants",
122
+ api_log=api_log,
123
+ max_tokens=512,
124
+ temperature=0.2,
125
+ )
126
+
127
+ selected_raw: list[str] | None = None
128
+ rationale = ""
129
+ if isinstance(parsed, dict):
130
+ if isinstance(parsed.get("selected"), list):
131
+ selected_raw = [str(x) for x in parsed["selected"]]
132
+ if isinstance(parsed.get("rationale"), str):
133
+ rationale = parsed["rationale"].strip()
134
+
135
+ selected = _validate_and_pad(selected_raw, candidates, n_target)
136
+
137
+ if not rationale:
138
+ rationale = "Selected by relevance to the question."
139
+ if not selected_raw:
140
+ rationale = "Auto-select fell back to the first candidates (LLM unavailable)."
141
+
142
+ return {"selected": selected, "rationale": rationale}
backend/app/services/models.py CHANGED
@@ -16,8 +16,13 @@ from app.services.context_budget import ContextSummary
16
 
17
  class Phase(str, Enum):
18
  INITIAL_OPINIONS = "initial_opinions"
 
 
 
19
  CRITIQUE_ROUND_1 = "critique_round_1"
20
  CRITIQUE_ROUND_2 = "critique_round_2"
 
 
21
  STATUS_ASSESSMENT = "status_assessment"
22
  FINALIZATION = "finalization"
23
  CONSENSUS = "consensus"
@@ -35,12 +40,192 @@ MAX_MAX_PARTICIPANTS = 9
35
 
36
  # Failsafe defaults from the plan: pause every N=60 participant messages
37
  # (then every +20), and every M=100 orchestrator calls (then every +50).
 
 
38
  PARTICIPANT_MESSAGE_PAUSE_AT = 60
39
  PARTICIPANT_MESSAGE_PAUSE_INC = 20
40
  ORCHESTRATOR_CALL_PAUSE_AT = 100
41
  ORCHESTRATOR_CALL_PAUSE_INC = 50
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  @dataclass
45
  class Participant:
46
  """One member of the CCAI forum.
@@ -122,7 +307,16 @@ class Session:
122
  # Per-participant contribution summaries for the table view
123
  contribution_summaries: dict[str, str] = field(default_factory=dict)
124
 
125
- # Failsafes
 
 
 
 
 
 
 
 
 
126
  total_participant_messages: int = 0
127
  participant_message_cap: int = PARTICIPANT_MESSAGE_PAUSE_AT
128
  orchestrator_call_count: int = 0
 
16
 
17
  class Phase(str, Enum):
18
  INITIAL_OPINIONS = "initial_opinions"
19
+ # Critique-round phases: up to 4 rounds are supported (matches the
20
+ # max value of `ConversationLimits.critique_rounds`). The state
21
+ # machine looks the active phase up via `_critique_phase_for(n)`.
22
  CRITIQUE_ROUND_1 = "critique_round_1"
23
  CRITIQUE_ROUND_2 = "critique_round_2"
24
+ CRITIQUE_ROUND_3 = "critique_round_3"
25
+ CRITIQUE_ROUND_4 = "critique_round_4"
26
  STATUS_ASSESSMENT = "status_assessment"
27
  FINALIZATION = "finalization"
28
  CONSENSUS = "consensus"
 
40
 
41
  # Failsafe defaults from the plan: pause every N=60 participant messages
42
  # (then every +20), and every M=100 orchestrator calls (then every +50).
43
+ # These remain the *defaults* for ConversationLimits; the runtime values
44
+ # come from Session.limits so the user can tune them per-conversation.
45
  PARTICIPANT_MESSAGE_PAUSE_AT = 60
46
  PARTICIPANT_MESSAGE_PAUSE_INC = 20
47
  ORCHESTRATOR_CALL_PAUSE_AT = 100
48
  ORCHESTRATOR_CALL_PAUSE_INC = 50
49
 
50
 
51
+ @dataclass
52
+ class ConversationLimits:
53
+ """Tunable repetition and failsafe limits for one CCAI session.
54
+
55
+ Defaults match the historical hard-coded values; the API layer
56
+ clamps user-supplied overrides via `clamp_conversation_limits` so
57
+ out-of-range values don't break the orchestrator. The frontend
58
+ gets the defaults + bounds + descriptions from
59
+ GET /api/chat/limits/defaults so the settings UI is server-driven.
60
+ """
61
+
62
+ # ── Discussion structure ──────────────────────────────────────
63
+ # How many critique turns each participant gets in Phase 2.
64
+ critique_rounds: int = 2
65
+ # How many times Phase 3 will surface a follow-up question
66
+ # (orchestrator-synthesized or relayed from a participant) before
67
+ # moving on to finalization. 0 skips Phase 3 entirely.
68
+ status_assessment_max: int = 3
69
+ # Phase 5 turn budget = this number x active participants. Higher
70
+ # means more back-and-forth before the conversation auto-ends.
71
+ consensus_turns_per_participant: int = 6
72
+ # In Phase 5, how many consecutive "addressed-to" routings (one
73
+ # participant addresses another, then is answered, etc.) before
74
+ # we force a round-robin pick. Prevents two participants from
75
+ # monopolizing the floor.
76
+ dyad_cap: int = 2
77
+ # If consensus fails the first time, how many additional attempts
78
+ # the orchestrator makes by surfacing a new factor for the group
79
+ # to consider. 0 disables retries.
80
+ stall_recovery_attempts: int = 1
81
+
82
+ # ── Reliability ──────────────────────────────────────────────
83
+ # If a participant's LLM call fails this many times in a row, the
84
+ # orchestrator auto-disables them for the rest of the chat.
85
+ auto_disable_failures: int = 3
86
+
87
+ # ── Failsafes ────────────────────────────────────────────────
88
+ # First pause point for participant messages (then increments).
89
+ participant_message_pause_at: int = PARTICIPANT_MESSAGE_PAUSE_AT
90
+ participant_message_pause_inc: int = PARTICIPANT_MESSAGE_PAUSE_INC
91
+ # First pause point for orchestrator-side LLM calls (then increments).
92
+ orchestrator_call_pause_at: int = ORCHESTRATOR_CALL_PAUSE_AT
93
+ orchestrator_call_pause_inc: int = ORCHESTRATOR_CALL_PAUSE_INC
94
+
95
+
96
+ # (min, max) bounds for each limit field. Any user-supplied value is
97
+ # clamped to this range server-side. Keep these conservative enough
98
+ # to stop runaway conversations and tight enough to keep behavior
99
+ # recognizable; widening is fine if a real use case emerges.
100
+ CONVERSATION_LIMIT_BOUNDS: dict[str, tuple[int, int]] = {
101
+ "critique_rounds": (1, 4),
102
+ "status_assessment_max": (0, 5),
103
+ "consensus_turns_per_participant": (2, 12),
104
+ "dyad_cap": (1, 5),
105
+ "stall_recovery_attempts": (0, 3),
106
+ "auto_disable_failures": (1, 10),
107
+ "participant_message_pause_at": (10, 500),
108
+ "participant_message_pause_inc": (5, 100),
109
+ "orchestrator_call_pause_at": (20, 500),
110
+ "orchestrator_call_pause_inc": (10, 200),
111
+ }
112
+
113
+
114
+ # Human-readable descriptions surfaced in the settings UI alongside
115
+ # each stepper. Group key controls the section header.
116
+ CONVERSATION_LIMIT_DESCRIPTIONS: dict[str, dict[str, str]] = {
117
+ "critique_rounds": {
118
+ "group": "Discussion structure",
119
+ "label": "Critique rounds",
120
+ "help": (
121
+ "How many times each participant speaks in Phase 2 "
122
+ "(critique). More rounds give the group more chances to "
123
+ "challenge each other; fewer rounds wraps faster."
124
+ ),
125
+ },
126
+ "status_assessment_max": {
127
+ "group": "Discussion structure",
128
+ "label": "Status-assessment iterations",
129
+ "help": (
130
+ "Max number of follow-up questions the orchestrator may "
131
+ "surface in Phase 3 before moving to opinion finalization. "
132
+ "Set to 0 to skip the follow-up phase entirely."
133
+ ),
134
+ },
135
+ "consensus_turns_per_participant": {
136
+ "group": "Discussion structure",
137
+ "label": "Consensus turns per participant",
138
+ "help": (
139
+ "Multiplier on the Phase 5 (consensus) turn budget. The "
140
+ "actual cap is this number times the count of active "
141
+ "participants. Higher = more debate before timeout."
142
+ ),
143
+ },
144
+ "dyad_cap": {
145
+ "group": "Discussion structure",
146
+ "label": "Dyad cap",
147
+ "help": (
148
+ "In Phase 5, how many consecutive addressed-to replies "
149
+ "(A->B->A->...) are allowed before the orchestrator forces "
150
+ "a round-robin pick. Keeps two voices from monopolizing."
151
+ ),
152
+ },
153
+ "stall_recovery_attempts": {
154
+ "group": "Discussion structure",
155
+ "label": "Stall recovery attempts",
156
+ "help": (
157
+ "If the group can't reach majority and the conversation "
158
+ "stalls, how many extra attempts the orchestrator makes "
159
+ "by surfacing a new factor for the group to consider."
160
+ ),
161
+ },
162
+ "auto_disable_failures": {
163
+ "group": "Reliability",
164
+ "label": "Auto-disable after N failures",
165
+ "help": (
166
+ "If a participant's LLM fails this many times in a row, "
167
+ "the orchestrator removes them from the rest of the chat "
168
+ "rather than keep trying."
169
+ ),
170
+ },
171
+ "participant_message_pause_at": {
172
+ "group": "Failsafes",
173
+ "label": "First pause: participant messages",
174
+ "help": (
175
+ "Total participant messages allowed before the "
176
+ "conversation pauses for a 'Continue' confirmation."
177
+ ),
178
+ },
179
+ "participant_message_pause_inc": {
180
+ "group": "Failsafes",
181
+ "label": "Each subsequent pause: +N messages",
182
+ "help": (
183
+ "After the first pause, this many additional participant "
184
+ "messages are allowed before the next pause."
185
+ ),
186
+ },
187
+ "orchestrator_call_pause_at": {
188
+ "group": "Failsafes",
189
+ "label": "First pause: orchestrator calls",
190
+ "help": (
191
+ "Total orchestrator-side LLM calls (assessments, "
192
+ "summaries, classifications) allowed before the "
193
+ "conversation pauses for a 'Continue' confirmation."
194
+ ),
195
+ },
196
+ "orchestrator_call_pause_inc": {
197
+ "group": "Failsafes",
198
+ "label": "Each subsequent pause: +N orchestrator calls",
199
+ "help": (
200
+ "After the first orchestrator-call pause, this many "
201
+ "additional calls are allowed before the next pause."
202
+ ),
203
+ },
204
+ }
205
+
206
+
207
+ def clamp_conversation_limits(payload: dict[str, Any] | None) -> ConversationLimits:
208
+ """Build a ConversationLimits from a partial dict. Each field is
209
+ clamped to its declared (min, max) range; missing or non-int values
210
+ fall back to the dataclass default. Never raises - any failure
211
+ silently degrades to the default for that one field.
212
+ """
213
+ limits = ConversationLimits()
214
+ if not payload:
215
+ return limits
216
+ for field_name, (lo, hi) in CONVERSATION_LIMIT_BOUNDS.items():
217
+ if field_name not in payload:
218
+ continue
219
+ raw = payload.get(field_name)
220
+ try:
221
+ v = int(raw)
222
+ except (TypeError, ValueError):
223
+ continue
224
+ v = max(lo, min(hi, v))
225
+ setattr(limits, field_name, v)
226
+ return limits
227
+
228
+
229
  @dataclass
230
  class Participant:
231
  """One member of the CCAI forum.
 
307
  # Per-participant contribution summaries for the table view
308
  contribution_summaries: dict[str, str] = field(default_factory=dict)
309
 
310
+ # User-tunable limits for this conversation. Defaults match the
311
+ # legacy hard-coded values; the API layer overrides via
312
+ # `clamp_conversation_limits` from the request payload. The
313
+ # orchestrator reads only from `session.limits.*`, never the
314
+ # module-level constants, so behavior is fully driven by config.
315
+ limits: ConversationLimits = field(default_factory=ConversationLimits)
316
+
317
+ # Failsafes (runtime state). The *_cap fields start at the limits'
318
+ # configured first-pause point and grow by the configured increment
319
+ # each time the user clicks Continue.
320
  total_participant_messages: int = 0
321
  participant_message_cap: int = PARTICIPANT_MESSAGE_PAUSE_AT
322
  orchestrator_call_count: int = 0
backend/app/services/orchestrator.py CHANGED
@@ -59,8 +59,6 @@ from app.services.models import (
59
  DEFAULT_MAX_PARTICIPANTS,
60
  MAX_MAX_PARTICIPANTS,
61
  MIN_MAX_PARTICIPANTS,
62
- ORCHESTRATOR_CALL_PAUSE_INC,
63
- PARTICIPANT_MESSAGE_PAUSE_INC,
64
  Participant,
65
  Phase,
66
  Session,
@@ -249,25 +247,30 @@ async def _wait_for_continue(
249
  session: Session,
250
  reason: str,
251
  ) -> AsyncIterator[str]:
252
- """Pause the state machine until the user clicks Continue."""
 
 
 
 
 
253
  session.paused_for_continue = True
254
  session.pause_reason = reason
255
  if reason == "messages":
 
256
  msg = (
257
  f"Conversation paused after {session.total_participant_messages} "
258
  "participant messages. Click Continue to allow another "
259
- f"{PARTICIPANT_MESSAGE_PAUSE_INC} messages."
260
  )
261
  evt = "failsafe_pause"
262
- bump_inc = PARTICIPANT_MESSAGE_PAUSE_INC
263
  else:
 
264
  msg = (
265
  f"Conversation paused after {session.orchestrator_call_count} "
266
  "orchestrator calls. Click Continue to allow another "
267
- f"{ORCHESTRATOR_CALL_PAUSE_INC} orchestrator calls."
268
  )
269
  evt = "orchestrator_cap_pause"
270
- bump_inc = ORCHESTRATOR_CALL_PAUSE_INC
271
 
272
  yield _sse(evt, {
273
  "reason": reason,
@@ -498,10 +501,13 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
498
  "name": p.name,
499
  "phase": session.phase.value,
500
  })
501
- if p.consecutive_failures >= 3:
502
  p.enabled = False
503
  yield _sse("status", {
504
- "message": f"{p.name} auto-disabled after 3 failures.",
 
 
 
505
  })
506
  continue
507
  msg = _add_participant_message(session, p, text, phase=session.phase, elapsed=elapsed)
@@ -522,14 +528,36 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
522
  )
523
  _bump_orchestrator_count(session)
524
  session.credential_summary = creds
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
 
526
 
527
  async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[str]:
528
- session.phase = (
529
- Phase.CRITIQUE_ROUND_1 if round_number == 1 else Phase.CRITIQUE_ROUND_2
530
- )
531
  yield _sse("status", {
532
- "message": f"Phase 2: critique round {round_number} of 2...",
533
  })
534
  cred_block = credentials_to_block(session.credential_summary)
535
  actives = _active_participants(session)
@@ -542,6 +570,7 @@ async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[
542
  pending_block = _format_pending_block(pending)
543
  prompt = CRITIQUE_PROMPT.format(
544
  round_number=round_number,
 
545
  question=session.question,
546
  credential_summary=cred_block,
547
  transcript=transcript,
@@ -558,9 +587,14 @@ async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[
558
  "participant_id": p.participant_id, "name": p.name,
559
  "phase": session.phase.value,
560
  })
561
- if p.consecutive_failures >= 3:
562
  p.enabled = False
563
- yield _sse("status", {"message": f"{p.name} auto-disabled after 3 failures."})
 
 
 
 
 
564
  continue
565
 
566
  # Detect addressed_to so the consensus phase's targeted-response
@@ -607,10 +641,15 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
607
  api_log=session.api_log,
608
  )
609
  _bump_orchestrator_count(session)
610
- session.credential_summary = refreshed
 
 
 
 
 
611
  cred_block = credentials_to_block(session.credential_summary)
612
 
613
- for iteration in range(3):
614
  session.status_assessment_iterations = iteration + 1
615
  prompt = STATUS_ASSESSMENT_PROMPT.format(
616
  question=session.question,
@@ -802,17 +841,19 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
802
  # Round-robin among active participants, but yield to the addressed-to
803
  # target whenever the previous message named one explicitly. To keep
804
  # two participants from monopolizing the floor with an A->B->A->B
805
- # loop, we cap consecutive addressed-to routings at DYAD_CAP. After
806
- # that many in a row, we force a round-robin pick on the next turn.
807
  queue: list[Participant] = list(actives)
808
  last_addressed: str | None = None
809
  dyad_run: int = 0
810
- DYAD_CAP = 2 # one full A->B->A exchange, then rotate
811
 
812
  # Hard backstop on this phase: if we make a lot of consensus turns
813
  # without resolving, exit and let closure handle it. The orchestrator-
814
- # call cap will hit before this, but it's a clean upper bound.
815
- max_consensus_turns = 6 * len(actives)
 
 
816
  consensus_turns = 0
817
 
818
  while consensus_turns < max_consensus_turns:
@@ -821,7 +862,7 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
821
  # Pick speaker. Prefer the addressed-to target (dyadic exchange)
822
  # only while we're under the consecutive-routing cap. Once the
823
  # cap is hit, force a round-robin pick so a third voice can join.
824
- if last_addressed and dyad_run < DYAD_CAP:
825
  speaker = next(
826
  (p for p in actives if p.participant_id == last_addressed),
827
  None,
@@ -1081,8 +1122,10 @@ async def _phase_closure(session: Session) -> AsyncIterator[str]:
1081
  yield _sse("orchestrator", _msg_payload(msg))
1082
  return
1083
 
1084
- # Not productive / no majority. First time -> surface unaddressed factor.
1085
- if session.consensus_attempts < 1:
 
 
1086
  session.consensus_attempts += 1
1087
  factor = await find_unaddressed_factor(
1088
  orchestrator_model_id=_orchestrator_model_id(session),
@@ -1164,10 +1207,9 @@ async def run_conversation(session: Session) -> AsyncIterator[str]:
1164
  async for chunk in _phase_initial_opinions(session):
1165
  yield chunk
1166
 
1167
- async for chunk in _phase_critique(session, 1):
1168
- yield chunk
1169
- async for chunk in _phase_critique(session, 2):
1170
- yield chunk
1171
 
1172
  async for chunk in _phase_status_assessment(session):
1173
  yield chunk
 
59
  DEFAULT_MAX_PARTICIPANTS,
60
  MAX_MAX_PARTICIPANTS,
61
  MIN_MAX_PARTICIPANTS,
 
 
62
  Participant,
63
  Phase,
64
  Session,
 
247
  session: Session,
248
  reason: str,
249
  ) -> AsyncIterator[str]:
250
+ """Pause the state machine until the user clicks Continue.
251
+
252
+ Increment values come from `session.limits`, which the user can
253
+ tune via the settings menu. Defaults match the historical
254
+ PARTICIPANT_MESSAGE_PAUSE_INC / ORCHESTRATOR_CALL_PAUSE_INC.
255
+ """
256
  session.paused_for_continue = True
257
  session.pause_reason = reason
258
  if reason == "messages":
259
+ bump_inc = session.limits.participant_message_pause_inc
260
  msg = (
261
  f"Conversation paused after {session.total_participant_messages} "
262
  "participant messages. Click Continue to allow another "
263
+ f"{bump_inc} messages."
264
  )
265
  evt = "failsafe_pause"
 
266
  else:
267
+ bump_inc = session.limits.orchestrator_call_pause_inc
268
  msg = (
269
  f"Conversation paused after {session.orchestrator_call_count} "
270
  "orchestrator calls. Click Continue to allow another "
271
+ f"{bump_inc} orchestrator calls."
272
  )
273
  evt = "orchestrator_cap_pause"
 
274
 
275
  yield _sse(evt, {
276
  "reason": reason,
 
501
  "name": p.name,
502
  "phase": session.phase.value,
503
  })
504
+ if p.consecutive_failures >= session.limits.auto_disable_failures:
505
  p.enabled = False
506
  yield _sse("status", {
507
+ "message": (
508
+ f"{p.name} auto-disabled after "
509
+ f"{session.limits.auto_disable_failures} failures."
510
+ ),
511
  })
512
  continue
513
  msg = _add_participant_message(session, p, text, phase=session.phase, elapsed=elapsed)
 
528
  )
529
  _bump_orchestrator_count(session)
530
  session.credential_summary = creds
531
+ # Surface the freshly-built summary so the frontend can enable the
532
+ # "View Credential Summary" menu item and cache its snapshot. The
533
+ # full list also stays available via GET /api/chat/{id}/credentials.
534
+ yield _sse("credentials_updated", {
535
+ "stage": "built",
536
+ "credentials": session.credential_summary,
537
+ })
538
+
539
+
540
+ _CRITIQUE_PHASES = {
541
+ 1: Phase.CRITIQUE_ROUND_1,
542
+ 2: Phase.CRITIQUE_ROUND_2,
543
+ 3: Phase.CRITIQUE_ROUND_3,
544
+ 4: Phase.CRITIQUE_ROUND_4,
545
+ }
546
+
547
+
548
+ def _critique_phase_for(round_number: int) -> Phase:
549
+ """Map a critique round number to the matching Phase enum value.
550
+ Falls back to CRITIQUE_ROUND_2 for unknown numbers - the API layer
551
+ clamps `critique_rounds` to the bounds, so this fallback is purely
552
+ defensive."""
553
+ return _CRITIQUE_PHASES.get(round_number, Phase.CRITIQUE_ROUND_2)
554
 
555
 
556
  async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[str]:
557
+ session.phase = _critique_phase_for(round_number)
558
+ round_total = session.limits.critique_rounds
 
559
  yield _sse("status", {
560
+ "message": f"Phase 2: critique round {round_number} of {round_total}...",
561
  })
562
  cred_block = credentials_to_block(session.credential_summary)
563
  actives = _active_participants(session)
 
570
  pending_block = _format_pending_block(pending)
571
  prompt = CRITIQUE_PROMPT.format(
572
  round_number=round_number,
573
+ round_total=round_total,
574
  question=session.question,
575
  credential_summary=cred_block,
576
  transcript=transcript,
 
587
  "participant_id": p.participant_id, "name": p.name,
588
  "phase": session.phase.value,
589
  })
590
+ if p.consecutive_failures >= session.limits.auto_disable_failures:
591
  p.enabled = False
592
+ yield _sse("status", {
593
+ "message": (
594
+ f"{p.name} auto-disabled after "
595
+ f"{session.limits.auto_disable_failures} failures."
596
+ ),
597
+ })
598
  continue
599
 
600
  # Detect addressed_to so the consensus phase's targeted-response
 
641
  api_log=session.api_log,
642
  )
643
  _bump_orchestrator_count(session)
644
+ if refreshed != session.credential_summary:
645
+ session.credential_summary = refreshed
646
+ yield _sse("credentials_updated", {
647
+ "stage": "refreshed",
648
+ "credentials": session.credential_summary,
649
+ })
650
  cred_block = credentials_to_block(session.credential_summary)
651
 
652
+ for iteration in range(session.limits.status_assessment_max):
653
  session.status_assessment_iterations = iteration + 1
654
  prompt = STATUS_ASSESSMENT_PROMPT.format(
655
  question=session.question,
 
841
  # Round-robin among active participants, but yield to the addressed-to
842
  # target whenever the previous message named one explicitly. To keep
843
  # two participants from monopolizing the floor with an A->B->A->B
844
+ # loop, we cap consecutive addressed-to routings at the configured
845
+ # `dyad_cap`. After that many in a row, we force a round-robin pick.
846
  queue: list[Participant] = list(actives)
847
  last_addressed: str | None = None
848
  dyad_run: int = 0
849
+ dyad_cap = session.limits.dyad_cap
850
 
851
  # Hard backstop on this phase: if we make a lot of consensus turns
852
  # without resolving, exit and let closure handle it. The orchestrator-
853
+ # call cap will usually hit before this, but it's a clean upper bound.
854
+ max_consensus_turns = (
855
+ session.limits.consensus_turns_per_participant * len(actives)
856
+ )
857
  consensus_turns = 0
858
 
859
  while consensus_turns < max_consensus_turns:
 
862
  # Pick speaker. Prefer the addressed-to target (dyadic exchange)
863
  # only while we're under the consecutive-routing cap. Once the
864
  # cap is hit, force a round-robin pick so a third voice can join.
865
+ if last_addressed and dyad_run < dyad_cap:
866
  speaker = next(
867
  (p for p in actives if p.participant_id == last_addressed),
868
  None,
 
1122
  yield _sse("orchestrator", _msg_payload(msg))
1123
  return
1124
 
1125
+ # Not productive / no majority. We may surface an unaddressed
1126
+ # factor and re-run consensus up to `stall_recovery_attempts` times
1127
+ # before giving up and emitting the no-consensus report.
1128
+ if session.consensus_attempts < session.limits.stall_recovery_attempts:
1129
  session.consensus_attempts += 1
1130
  factor = await find_unaddressed_factor(
1131
  orchestrator_model_id=_orchestrator_model_id(session),
 
1207
  async for chunk in _phase_initial_opinions(session):
1208
  yield chunk
1209
 
1210
+ for round_n in range(1, session.limits.critique_rounds + 1):
1211
+ async for chunk in _phase_critique(session, round_n):
1212
+ yield chunk
 
1213
 
1214
  async for chunk in _phase_status_assessment(session):
1215
  yield chunk
backend/app/services/prompts/__init__.py CHANGED
@@ -35,6 +35,7 @@ from app.services.prompts.closure import (
35
  NO_CONSENSUS_REPORT_PROMPT,
36
  CONTRIBUTION_SUMMARY_PROMPT,
37
  )
 
38
 
39
  __all__ = [
40
  "PARTICIPANT_BASE_DIRECTIVE",
@@ -57,4 +58,5 @@ __all__ = [
57
  "MAJORITY_REPORT_PROMPT",
58
  "NO_CONSENSUS_REPORT_PROMPT",
59
  "CONTRIBUTION_SUMMARY_PROMPT",
 
60
  ]
 
35
  NO_CONSENSUS_REPORT_PROMPT,
36
  CONTRIBUTION_SUMMARY_PROMPT,
37
  )
38
+ from app.services.prompts.auto_select import AUTO_SELECT_PARTICIPANTS_PROMPT
39
 
40
  __all__ = [
41
  "PARTICIPANT_BASE_DIRECTIVE",
 
58
  "MAJORITY_REPORT_PROMPT",
59
  "NO_CONSENSUS_REPORT_PROMPT",
60
  "CONTRIBUTION_SUMMARY_PROMPT",
61
+ "AUTO_SELECT_PARTICIPANTS_PROMPT",
62
  ]
backend/app/services/prompts/auto_select.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt for the optional "Select N Automatically" mode.
2
+
3
+ The user can enable an auto-select toggle in the participants dropdown;
4
+ when they start a chat, the orchestrator LLM ranks every available
5
+ candidate persona for relevance to the question and returns the top N.
6
+ """
7
+
8
+ AUTO_SELECT_PARTICIPANTS_PROMPT = (
9
+ "You are helping pick the most relevant participants for a CCAI "
10
+ "group discussion. The user has asked the following question:\n\n"
11
+ "<<<\n{question}\n>>>\n\n"
12
+ "Here are the candidate participants. Each has a `participant_id`, "
13
+ "a display `name`, and a role description (`role_prompt`).\n\n"
14
+ "{candidates_block}\n\n"
15
+ "Pick the {count} participants whose expertise and perspective are "
16
+ "MOST relevant and complementary for this question. Aim for "
17
+ "diversity of viewpoint as well as topical fit - a group of five "
18
+ "experts who all think the same way is less useful than five who "
19
+ "will productively disagree.\n\n"
20
+ "Reply with ONLY a JSON object of the exact shape:\n"
21
+ "{{\n"
22
+ " \"selected\": [\"participant_id_1\", \"participant_id_2\", ...],\n"
23
+ " \"rationale\": \"one short sentence explaining the mix\"\n"
24
+ "}}\n\n"
25
+ "`selected` MUST contain exactly {count} ids drawn ONLY from the "
26
+ "candidate list above, in order of relevance (most relevant first). "
27
+ "Do not invent ids."
28
+ )
backend/app/services/prompts/catalog.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hand-curated catalog of every prompt template the CCAI orchestrator
2
+ and participants use, organized by the order they fire during a chat.
3
+
4
+ This backs GET /api/chat/prompts/catalog, which feeds the "View current
5
+ chat prompts" modal in the settings menu. The goal is user-facing
6
+ transparency: a non-technical user should be able to scroll the modal
7
+ and understand exactly what the orchestrator asks each participant to
8
+ do at each phase.
9
+
10
+ The catalog is intentionally explicit (rather than auto-discovered) so
11
+ the per-prompt `purpose` strings can stay readable and so the
12
+ ordering matches the live conversation flow even when prompts are
13
+ imported from different modules.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Any
19
+
20
+ from app.services.prompts import (
21
+ ADDRESSED_TO_PROMPT,
22
+ ALLIANCE_DETECTION_PROMPT,
23
+ AUTO_SELECT_PARTICIPANTS_PROMPT,
24
+ CONSENSUS_ALLIED_PROMPT,
25
+ CONSENSUS_SOLO_PROMPT,
26
+ CONSENSUS_STATUS_PROMPT,
27
+ CONTRIBUTION_SUMMARY_PROMPT,
28
+ CREDENTIAL_BUILD_PROMPT,
29
+ CREDENTIAL_REFRESH_PROMPT,
30
+ CRITIQUE_PROMPT,
31
+ FINALIZATION_PROMPT,
32
+ INITIAL_OPINION_PROMPT,
33
+ MAJORITY_REPORT_PROMPT,
34
+ NO_CONSENSUS_REPORT_PROMPT,
35
+ NO_REASONING_DIRECTIVE,
36
+ ORCHESTRATOR_BASE_DIRECTIVE,
37
+ PARTICIPANT_BASE_DIRECTIVE,
38
+ STATUS_ASSESSMENT_PROMPT,
39
+ TARGETED_FOLLOWUP_FROM_PARTICIPANT_PROMPT,
40
+ TARGETED_FOLLOWUP_PROMPT,
41
+ UNADDRESSED_FACTOR_PROMPT,
42
+ )
43
+
44
+
45
+ # Each entry: (constant_name, prompt_text, purpose). The frontend will
46
+ # also derive {var} placeholders from the prompt text directly, but
47
+ # `name` and `purpose` stay hand-curated for readability.
48
+ _PROMPTS_BY_NAME: dict[str, tuple[str, str]] = {
49
+ # ── Always-on directives ─────────────────────────────────────
50
+ "PARTICIPANT_BASE_DIRECTIVE": (
51
+ PARTICIPANT_BASE_DIRECTIVE,
52
+ "Appended to every participant's role prompt so they know they "
53
+ "are in a CCAI forum, who the other participants are, and that "
54
+ "the orchestrator (not them) controls the floor.",
55
+ ),
56
+ "NO_REASONING_DIRECTIVE": (
57
+ NO_REASONING_DIRECTIVE,
58
+ "Hard 'no reasoning, no meta-commentary' guard added to every "
59
+ "participant call so models don't leak chain-of-thought, "
60
+ "<think> tags, or scratchpad text into the chat.",
61
+ ),
62
+ "ORCHESTRATOR_BASE_DIRECTIVE": (
63
+ ORCHESTRATOR_BASE_DIRECTIVE,
64
+ "System message for every orchestrator-side LLM call. "
65
+ "Establishes neutrality and demands strict JSON for the "
66
+ "structured ones (status checks, alliance detection, etc.).",
67
+ ),
68
+
69
+ # ── Optional pre-chat: Select N Automatically ────────────────
70
+ "AUTO_SELECT_PARTICIPANTS_PROMPT": (
71
+ AUTO_SELECT_PARTICIPANTS_PROMPT,
72
+ "Used when the user enables 'Select N Automatically' in the "
73
+ "participants dropdown. The orchestrator LLM ranks every "
74
+ "available candidate persona by relevance to the question and "
75
+ "returns the top N just before /chat/start.",
76
+ ),
77
+
78
+ # ── Phase 1 ─────────────────────────────────────────────────
79
+ "INITIAL_OPINION_PROMPT": (
80
+ INITIAL_OPINION_PROMPT,
81
+ "Phase 1. Asks each participant for an independent first "
82
+ "opinion, with no awareness of what anyone else has said. "
83
+ "This independence is what makes the Credential Summary in "
84
+ "the next step meaningful.",
85
+ ),
86
+
87
+ # ── Credential Summary (build) ──────────────────────────────
88
+ "CREDENTIAL_BUILD_PROMPT": (
89
+ CREDENTIAL_BUILD_PROMPT,
90
+ "After Phase 1 the orchestrator builds the Credential Summary: "
91
+ "a neutral assessment of each participant's expertise, "
92
+ "personality, biases, and a 0-1 credibility score on this "
93
+ "specific question. Returned as strict JSON.",
94
+ ),
95
+
96
+ # ── Phase 2 ─────────────────────────────────────────────────
97
+ "CRITIQUE_PROMPT": (
98
+ CRITIQUE_PROMPT,
99
+ "Phase 2. Each participant gets one or more critique rounds "
100
+ "(count is tunable in Settings -> Conversation limits). They "
101
+ "address any open questions aimed at them first, then offer "
102
+ "constructive critique of others and may revise their opinion.",
103
+ ),
104
+
105
+ # ── Credential Summary (refresh) ────────────────────────────
106
+ "CREDENTIAL_REFRESH_PROMPT": (
107
+ CREDENTIAL_REFRESH_PROMPT,
108
+ "After the last critique round, the orchestrator refreshes "
109
+ "the Credential Summary because participants reveal a lot "
110
+ "more about themselves through critique than through their "
111
+ "opening pitch.",
112
+ ),
113
+
114
+ # ── Phase 3 ─────────────────────────────────────────────────
115
+ "STATUS_ASSESSMENT_PROMPT": (
116
+ STATUS_ASSESSMENT_PROMPT,
117
+ "Phase 3. The orchestrator decides whether the group needs "
118
+ "targeted follow-ups before finalization. It prefers to relay "
119
+ "unanswered participant-to-participant questions verbatim and "
120
+ "only synthesizes a new one if no real open thread exists.",
121
+ ),
122
+ "TARGETED_FOLLOWUP_PROMPT": (
123
+ TARGETED_FOLLOWUP_PROMPT,
124
+ "Used inside Phase 3 when the orchestrator is asking a "
125
+ "follow-up of its own initiative (no real open thread "
126
+ "existed). The participant sees this as 'The orchestrator "
127
+ "has a follow-up for you'.",
128
+ ),
129
+ "TARGETED_FOLLOWUP_FROM_PARTICIPANT_PROMPT": (
130
+ TARGETED_FOLLOWUP_FROM_PARTICIPANT_PROMPT,
131
+ "Used inside Phase 3 when the orchestrator is relaying a "
132
+ "previously-unanswered question verbatim from another "
133
+ "participant, attributed to the original asker.",
134
+ ),
135
+
136
+ # ── Phase 4 ─────────────────────────────────────────────────
137
+ "FINALIZATION_PROMPT": (
138
+ FINALIZATION_PROMPT,
139
+ "Phase 4. Each participant states a revised post-discussion "
140
+ "opinion OR endorses another participant's revised opinion "
141
+ "(with an optional caveat). Open threads aimed at them are "
142
+ "answered briefly at the top.",
143
+ ),
144
+
145
+ # ── Phase 5 ─────────────────────────────────────────────────
146
+ "ALLIANCE_DETECTION_PROMPT": (
147
+ ALLIANCE_DETECTION_PROMPT,
148
+ "Start of Phase 5. The orchestrator clusters participants "
149
+ "into 'alliance groups' based on their revised opinions, so "
150
+ "consensus prompts can address allies and solo voices "
151
+ "differently.",
152
+ ),
153
+ "ADDRESSED_TO_PROMPT": (
154
+ ADDRESSED_TO_PROMPT,
155
+ "Used after each Phase 5 message to classify whether it was "
156
+ "aimed at one specific other participant (a dyadic exchange) "
157
+ "or broadcast to the whole group. Drives the back-and-forth "
158
+ "routing and the 'A -> B' header on each chat bubble.",
159
+ ),
160
+ "CONSENSUS_ALLIED_PROMPT": (
161
+ CONSENSUS_ALLIED_PROMPT,
162
+ "Phase 5 prompt for participants who are part of an alliance. "
163
+ "They advocate for the group's shared stance and try to win "
164
+ "over participants from other groups, addressing ONE "
165
+ "specific participant when possible.",
166
+ ),
167
+ "CONSENSUS_SOLO_PROMPT": (
168
+ CONSENSUS_SOLO_PROMPT,
169
+ "Phase 5 prompt for participants who have no allies. They "
170
+ "pick one of three strategies: persuade the closest group, "
171
+ "switch sides, or propose a compromise.",
172
+ ),
173
+ "CONSENSUS_STATUS_PROMPT": (
174
+ CONSENSUS_STATUS_PROMPT,
175
+ "Used periodically during Phase 5. The orchestrator decides "
176
+ "whether the group has reached majority agreement, is still "
177
+ "shifting productively, or has stalled and needs intervention.",
178
+ ),
179
+
180
+ # ── Phase 6 (closure) ───────────────────────────────────────
181
+ "UNADDRESSED_FACTOR_PROMPT": (
182
+ UNADDRESSED_FACTOR_PROMPT,
183
+ "If Phase 5 stalls, the orchestrator looks for ONE important "
184
+ "factor the group hasn't adequately discussed and surfaces it "
185
+ "as a new consideration before re-running consensus.",
186
+ ),
187
+ "MAJORITY_REPORT_PROMPT": (
188
+ MAJORITY_REPORT_PROMPT,
189
+ "Final report when the group reaches majority agreement. The "
190
+ "orchestrator writes a neutral prose summary of the decision, "
191
+ "the strongest supporting reasons, and high-credibility "
192
+ "dissenting points.",
193
+ ),
194
+ "NO_CONSENSUS_REPORT_PROMPT": (
195
+ NO_CONSENSUS_REPORT_PROMPT,
196
+ "Final report when consensus retries are exhausted without "
197
+ "agreement. The orchestrator lays out the major opinions, "
198
+ "names supporters, and offers its own neutral recommendation "
199
+ "with a note that the user should ultimately decide.",
200
+ ),
201
+
202
+ # ── Utility / table view ────────────────────────────────────
203
+ "CONTRIBUTION_SUMMARY_PROMPT": (
204
+ CONTRIBUTION_SUMMARY_PROMPT,
205
+ "Run after the chat ends to populate the per-participant "
206
+ "summary column in the table view (and CSV export). Asks the "
207
+ "orchestrator to write a neutral 2-3 sentence recap of each "
208
+ "participant's overall contribution.",
209
+ ),
210
+ }
211
+
212
+
213
+ # Ordered grouping that mirrors the live phase flow. Names reference
214
+ # `_PROMPTS_BY_NAME` keys; the frontend renders groups in this order.
215
+ _GROUPS: list[tuple[str, list[str]]] = [
216
+ ("Always-on directives", [
217
+ "PARTICIPANT_BASE_DIRECTIVE",
218
+ "NO_REASONING_DIRECTIVE",
219
+ "ORCHESTRATOR_BASE_DIRECTIVE",
220
+ ]),
221
+ ("Optional pre-chat", [
222
+ "AUTO_SELECT_PARTICIPANTS_PROMPT",
223
+ ]),
224
+ ("Phase 1: Initial Opinions", [
225
+ "INITIAL_OPINION_PROMPT",
226
+ ]),
227
+ ("Credential Summary (build)", [
228
+ "CREDENTIAL_BUILD_PROMPT",
229
+ ]),
230
+ ("Phase 2: Critique", [
231
+ "CRITIQUE_PROMPT",
232
+ ]),
233
+ ("Credential Summary (refresh)", [
234
+ "CREDENTIAL_REFRESH_PROMPT",
235
+ ]),
236
+ ("Phase 3: Status assessment & targeted follow-ups", [
237
+ "STATUS_ASSESSMENT_PROMPT",
238
+ "TARGETED_FOLLOWUP_PROMPT",
239
+ "TARGETED_FOLLOWUP_FROM_PARTICIPANT_PROMPT",
240
+ ]),
241
+ ("Phase 4: Opinion Finalization", [
242
+ "FINALIZATION_PROMPT",
243
+ ]),
244
+ ("Phase 5: Consensus Gathering", [
245
+ "ALLIANCE_DETECTION_PROMPT",
246
+ "ADDRESSED_TO_PROMPT",
247
+ "CONSENSUS_ALLIED_PROMPT",
248
+ "CONSENSUS_SOLO_PROMPT",
249
+ "CONSENSUS_STATUS_PROMPT",
250
+ ]),
251
+ ("Phase 6: Closure", [
252
+ "UNADDRESSED_FACTOR_PROMPT",
253
+ "MAJORITY_REPORT_PROMPT",
254
+ "NO_CONSENSUS_REPORT_PROMPT",
255
+ ]),
256
+ ("Utility prompts", [
257
+ "CONTRIBUTION_SUMMARY_PROMPT",
258
+ ]),
259
+ ]
260
+
261
+
262
+ _VAR_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
263
+
264
+
265
+ def _extract_variables(template: str) -> list[str]:
266
+ """Pull single-brace `{var}` placeholders out of a template,
267
+ preserving the first-occurrence order. Double-brace literals
268
+ (`{{` and `}}` from the JSON examples) are correctly ignored
269
+ because the regex only matches a single brace pair.
270
+ """
271
+ seen: set[str] = set()
272
+ out: list[str] = []
273
+ for m in _VAR_RE.finditer(template):
274
+ name = m.group(1)
275
+ if name in seen:
276
+ continue
277
+ seen.add(name)
278
+ out.append(name)
279
+ return out
280
+
281
+
282
+ def build_prompt_catalog() -> dict[str, Any]:
283
+ """Return the catalog as a JSON-serializable dict.
284
+
285
+ Shape:
286
+ {
287
+ "groups": [
288
+ {"title": "...", "items": [{"name", "purpose", "variables", "template"}, ...]},
289
+ ...
290
+ ]
291
+ }
292
+ """
293
+ groups_out: list[dict[str, Any]] = []
294
+ for group_title, names in _GROUPS:
295
+ items: list[dict[str, Any]] = []
296
+ for name in names:
297
+ if name not in _PROMPTS_BY_NAME:
298
+ # Defensive: skip a name typo rather than 500.
299
+ continue
300
+ template, purpose = _PROMPTS_BY_NAME[name]
301
+ items.append({
302
+ "name": name,
303
+ "purpose": purpose,
304
+ "variables": _extract_variables(template),
305
+ "template": template,
306
+ })
307
+ groups_out.append({"title": group_title, "items": items})
308
+ return {"groups": groups_out}
backend/app/services/prompts/critique.py CHANGED
@@ -1,7 +1,7 @@
1
  """Phase 2: each participant gets two turns of critique."""
2
 
3
  CRITIQUE_PROMPT = (
4
- "Phase 2 of the discussion: Critique (round {round_number} of 2).\n\n"
5
  "The group has been asked the following question:\n\n"
6
  "<<<\n{question}\n>>>\n\n"
7
  "Here is a Credential Summary the orchestrator built about each "
 
1
  """Phase 2: each participant gets two turns of critique."""
2
 
3
  CRITIQUE_PROMPT = (
4
+ "Phase 2 of the discussion: Critique (round {round_number} of {round_total}).\n\n"
5
  "The group has been asked the following question:\n\n"
6
  "<<<\n{question}\n>>>\n\n"
7
  "Here is a Credential Summary the orchestrator built about each "
backend/tests/test_conversation_limits.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the user-tunable repetition / failsafe limits exposed via
2
+ `ConversationLimits` and the `/api/chat/limits/defaults` endpoint.
3
+
4
+ These guard:
5
+ - defaults match the historical hard-coded values (no behavior change
6
+ for anyone who doesn't touch the new settings);
7
+ - clamping silently coerces missing / out-of-range / non-int values
8
+ back to the per-field defaults rather than raising;
9
+ - the defaults endpoint returns parallel `defaults` / `bounds` /
10
+ `descriptions` maps with the same field names so the frontend can
11
+ zip them into UI rows.
12
+ """
13
+ from fastapi.testclient import TestClient
14
+
15
+ from app.main import app
16
+ from app.services.models import (
17
+ CONVERSATION_LIMIT_BOUNDS,
18
+ CONVERSATION_LIMIT_DESCRIPTIONS,
19
+ ConversationLimits,
20
+ clamp_conversation_limits,
21
+ )
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Defaults
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def test_defaults_match_historical_hardcoded_values():
29
+ """If anyone changes a default by accident, this test makes them
30
+ think twice. The values here are the ones the orchestrator used
31
+ before this knobs-as-settings refactor."""
32
+ limits = ConversationLimits()
33
+ assert limits.critique_rounds == 2
34
+ assert limits.status_assessment_max == 3
35
+ assert limits.consensus_turns_per_participant == 6
36
+ assert limits.dyad_cap == 2
37
+ assert limits.stall_recovery_attempts == 1
38
+ assert limits.auto_disable_failures == 3
39
+ assert limits.participant_message_pause_at == 60
40
+ assert limits.participant_message_pause_inc == 20
41
+ assert limits.orchestrator_call_pause_at == 100
42
+ assert limits.orchestrator_call_pause_inc == 50
43
+
44
+
45
+ def test_bounds_cover_every_dataclass_field():
46
+ """Every tunable field needs a (min, max) bound; otherwise the
47
+ clamp helper would silently leave it unprotected."""
48
+ field_names = set(ConversationLimits().__dict__.keys())
49
+ bound_names = set(CONVERSATION_LIMIT_BOUNDS.keys())
50
+ assert field_names == bound_names
51
+
52
+
53
+ def test_descriptions_cover_every_dataclass_field():
54
+ """The settings UI is server-driven; if a field has no description
55
+ block, the modal would render an empty row for it."""
56
+ field_names = set(ConversationLimits().__dict__.keys())
57
+ described = set(CONVERSATION_LIMIT_DESCRIPTIONS.keys())
58
+ assert field_names == described
59
+ for entry in CONVERSATION_LIMIT_DESCRIPTIONS.values():
60
+ assert "group" in entry
61
+ assert "label" in entry
62
+ assert "help" in entry
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # clamp_conversation_limits
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def test_clamp_returns_defaults_for_none_or_empty():
70
+ assert clamp_conversation_limits(None) == ConversationLimits()
71
+ assert clamp_conversation_limits({}) == ConversationLimits()
72
+
73
+
74
+ def test_clamp_clamps_too_large_values():
75
+ """A value above the field's upper bound is coerced to the upper
76
+ bound, not rejected. This keeps the API permissive."""
77
+ out = clamp_conversation_limits({"critique_rounds": 99})
78
+ assert out.critique_rounds == CONVERSATION_LIMIT_BOUNDS["critique_rounds"][1]
79
+
80
+
81
+ def test_clamp_clamps_too_small_values():
82
+ out = clamp_conversation_limits({"critique_rounds": -10})
83
+ assert out.critique_rounds == CONVERSATION_LIMIT_BOUNDS["critique_rounds"][0]
84
+
85
+
86
+ def test_clamp_ignores_unknown_fields():
87
+ out = clamp_conversation_limits({"there_is_no_such_field": 7})
88
+ assert out == ConversationLimits()
89
+
90
+
91
+ def test_clamp_silently_drops_non_int_values():
92
+ """Stringy garbage that can't be coerced should fall back to the
93
+ default for that one field, not raise."""
94
+ out = clamp_conversation_limits({"critique_rounds": "not-a-number"})
95
+ assert out.critique_rounds == ConversationLimits().critique_rounds
96
+
97
+
98
+ def test_clamp_preserves_partial_overrides():
99
+ """Only-some-fields-supplied is the common case (UI sends just
100
+ the overridden ones)."""
101
+ out = clamp_conversation_limits({"dyad_cap": 4})
102
+ assert out.dyad_cap == 4
103
+ # All other fields untouched.
104
+ base = ConversationLimits()
105
+ for field_name in CONVERSATION_LIMIT_BOUNDS.keys():
106
+ if field_name == "dyad_cap":
107
+ continue
108
+ assert getattr(out, field_name) == getattr(base, field_name)
109
+
110
+
111
+ def test_clamp_accepts_string_ints():
112
+ """JSON sometimes serializes numbers as strings - we should not
113
+ refuse a perfectly valid int just because it arrived as '4'."""
114
+ out = clamp_conversation_limits({"dyad_cap": "4"})
115
+ assert out.dyad_cap == 4
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # /api/chat/limits/defaults endpoint
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def test_limits_defaults_endpoint_returns_parallel_maps():
123
+ client = TestClient(app)
124
+ resp = client.get("/api/chat/limits/defaults")
125
+ assert resp.status_code == 200
126
+ body = resp.json()
127
+
128
+ assert set(body.keys()) == {"defaults", "bounds", "descriptions"}
129
+
130
+ # All three maps should agree on the same field set so the
131
+ # frontend can zip them per row.
132
+ field_names = set(ConversationLimits().__dict__.keys())
133
+ assert set(body["defaults"].keys()) == field_names
134
+ assert set(body["bounds"].keys()) == field_names
135
+ assert set(body["descriptions"].keys()) == field_names
136
+
137
+ # Bounds shape sanity-check.
138
+ for field_name, bound in body["bounds"].items():
139
+ assert "min" in bound and "max" in bound
140
+ assert bound["min"] < bound["max"]
141
+ assert bound["min"] <= body["defaults"][field_name] <= bound["max"]
backend/tests/test_csv_export.py CHANGED
@@ -1,9 +1,18 @@
1
- from app.api.chat import _export_csv_table
 
 
 
 
2
  from app.services.models import Phase, Session
3
  from app.services.models import Participant
4
 
5
 
6
- def _mk_session():
 
 
 
 
 
7
  s = Session()
8
  s.question = "Will \"AI\" change, education? Yes, no, maybe.\nNew lines too."
9
  p1 = Participant(
@@ -48,16 +57,35 @@ def _mk_session():
48
  },
49
  ]
50
  s.final_report = {"kind": "majority", "text": "Group decided X."}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  return s
52
 
53
 
54
  def test_csv_export_roundtrips_through_csv_module():
55
  """Ensure values containing commas, quotes, and newlines get quoted
56
- correctly per RFC 4180."""
57
  import csv
58
  import io
59
 
60
- s = _mk_session()
61
  out = _export_csv_table(s)
62
  assert out["filename"] == "ccai_chat_table.csv"
63
  parsed = list(csv.reader(io.StringIO(out["content"])))
@@ -71,6 +99,10 @@ def test_csv_export_roundtrips_through_csv_module():
71
  # column header row
72
  assert parsed[3] == [
73
  "Participant",
 
 
 
 
74
  "First opinion",
75
  "Conversation contribution",
76
  "Revised opinion",
@@ -78,21 +110,87 @@ def test_csv_export_roundtrips_through_csv_module():
78
  ]
79
  alice_row = parsed[4]
80
  assert alice_row[0] == "Alice"
81
- assert "\"quotes\"" in alice_row[1] # csv module preserved the quotes
 
 
 
 
 
 
82
  bob_row = parsed[5]
83
  assert bob_row[0] == "Bob, Ph.D."
84
- assert "multi-line" in bob_row[1]
 
85
 
86
 
87
  def test_csv_export_no_field_count_drift():
88
- """Every row after the header should have exactly 5 columns even when
89
- payload contains pathological characters."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  import csv
91
  import io
92
 
93
- s = _mk_session()
94
  out = _export_csv_table(s)
95
  rows = list(csv.reader(io.StringIO(out["content"])))
96
- data_rows = rows[4:]
97
- for row in data_rows:
98
- assert len(row) == 5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.api.chat import (
2
+ _export_csv_table,
3
+ _export_md,
4
+ _export_txt,
5
+ )
6
  from app.services.models import Phase, Session
7
  from app.services.models import Participant
8
 
9
 
10
+ # Number of columns in the CSV table (kept here for tests to assert
11
+ # field-count stability if the schema ever shifts).
12
+ EXPECTED_CSV_COLUMNS = 9
13
+
14
+
15
+ def _mk_session(*, with_credentials: bool = False) -> Session:
16
  s = Session()
17
  s.question = "Will \"AI\" change, education? Yes, no, maybe.\nNew lines too."
18
  p1 = Participant(
 
57
  },
58
  ]
59
  s.final_report = {"kind": "majority", "text": "Group decided X."}
60
+ if with_credentials:
61
+ s.credential_summary = [
62
+ {
63
+ "participant_id": "extra_a",
64
+ "name": "Alice",
65
+ "expertise": "Comparative education researcher.",
66
+ "personality": "Calm, evidence-driven.",
67
+ "credibility_for_question": 0.78,
68
+ "bias_to_watch": "Tends to over-trust meta-analyses.",
69
+ },
70
+ {
71
+ "participant_id": "expert_b",
72
+ "name": "Bob, Ph.D.",
73
+ "expertise": "K-12 classroom teacher, 20 years.",
74
+ "personality": "Combative; debates loudly.",
75
+ "credibility_for_question": 0.62,
76
+ "bias_to_watch": "Anchors on personal anecdotes.",
77
+ },
78
+ ]
79
  return s
80
 
81
 
82
  def test_csv_export_roundtrips_through_csv_module():
83
  """Ensure values containing commas, quotes, and newlines get quoted
84
+ correctly per RFC 4180, and that credential columns are populated."""
85
  import csv
86
  import io
87
 
88
+ s = _mk_session(with_credentials=True)
89
  out = _export_csv_table(s)
90
  assert out["filename"] == "ccai_chat_table.csv"
91
  parsed = list(csv.reader(io.StringIO(out["content"])))
 
99
  # column header row
100
  assert parsed[3] == [
101
  "Participant",
102
+ "Expertise (orchestrator's read)",
103
+ "Style",
104
+ "Credibility on this question (0-1)",
105
+ "Bias to watch",
106
  "First opinion",
107
  "Conversation contribution",
108
  "Revised opinion",
 
110
  ]
111
  alice_row = parsed[4]
112
  assert alice_row[0] == "Alice"
113
+ # credential columns
114
+ assert alice_row[1] == "Comparative education researcher."
115
+ assert alice_row[2] == "Calm, evidence-driven."
116
+ assert alice_row[3] == "0.78"
117
+ assert alice_row[4] == "Tends to over-trust meta-analyses."
118
+ # opinion columns - "First opinion" still preserves quotes/commas/newlines
119
+ assert "\"quotes\"" in alice_row[5]
120
  bob_row = parsed[5]
121
  assert bob_row[0] == "Bob, Ph.D."
122
+ assert bob_row[3] == "0.62"
123
+ assert "multi-line" in bob_row[5]
124
 
125
 
126
  def test_csv_export_no_field_count_drift():
127
+ """Every data row should have exactly EXPECTED_CSV_COLUMNS columns,
128
+ matching the header row, even with pathological characters and even
129
+ when credentials are missing."""
130
+ import csv
131
+ import io
132
+
133
+ s = _mk_session(with_credentials=False)
134
+ out = _export_csv_table(s)
135
+ rows = list(csv.reader(io.StringIO(out["content"])))
136
+ header = rows[3]
137
+ assert len(header) == EXPECTED_CSV_COLUMNS
138
+ for row in rows[4:]:
139
+ assert len(row) == EXPECTED_CSV_COLUMNS
140
+
141
+
142
+ def test_csv_export_blank_credentials_when_summary_missing():
143
+ """When the orchestrator hasn't built a Credential Summary yet, the
144
+ credential columns should be present but empty - never crash."""
145
  import csv
146
  import io
147
 
148
+ s = _mk_session(with_credentials=False)
149
  out = _export_csv_table(s)
150
  rows = list(csv.reader(io.StringIO(out["content"])))
151
+ alice_row = rows[4]
152
+ # cols 1..4 are credential columns (Expertise, Style, Credibility, Bias)
153
+ assert alice_row[1] == ""
154
+ assert alice_row[2] == ""
155
+ assert alice_row[3] == ""
156
+ assert alice_row[4] == ""
157
+ # but the opinion columns should still be populated
158
+ assert "Alice" in alice_row[0]
159
+ assert alice_row[6] == "Stayed firm." # contribution summary
160
+
161
+
162
+ def test_txt_export_includes_credential_block_when_present():
163
+ s = _mk_session(with_credentials=True)
164
+ out = _export_txt(s)
165
+ body = out["content"]
166
+ assert "Credential Summary" in body
167
+ assert "Comparative education researcher." in body
168
+ assert "0.78" in body
169
+ assert "Tends to over-trust meta-analyses." in body
170
+ # Block precedes the conversation transcript
171
+ cred_idx = body.index("Credential Summary")
172
+ msg_idx = body.index("Final consensus statement A")
173
+ assert cred_idx < msg_idx
174
+
175
+
176
+ def test_txt_export_omits_credential_block_when_empty():
177
+ s = _mk_session(with_credentials=False)
178
+ out = _export_txt(s)
179
+ assert "Credential Summary" not in out["content"]
180
+
181
+
182
+ def test_md_export_includes_credential_block_when_present():
183
+ s = _mk_session(with_credentials=True)
184
+ out = _export_md(s)
185
+ body = out["content"]
186
+ assert "## Credential Summary" in body
187
+ assert "### Alice" in body
188
+ assert "### Bob, Ph.D." in body
189
+ assert "**Credibility on this question:** 0.78" in body
190
+ assert "**Bias to watch:**" in body
191
+
192
+
193
+ def test_md_export_omits_credential_block_when_empty():
194
+ s = _mk_session(with_credentials=False)
195
+ out = _export_md(s)
196
+ assert "## Credential Summary" not in out["content"]
frontend/src/App.js CHANGED
@@ -5,11 +5,18 @@ import ChatControls from './components/ChatControls';
5
  import ChatArea from './components/ChatArea';
6
  import ExpertPersonaModal from './components/ExpertPersonaModal';
7
  import ChatTableView from './components/ChatTableView';
 
 
 
8
  import {
9
  fetchModels, fetchPersonas, fetchDemoQuestions,
10
  startChat, continueChat, getOrchestrator, setOrchestrator,
11
- getSpeedPriority, setSpeedPriority, getAuthStatus,
12
- exportChat, exportApiLog, fetchTableView, getRateLimitStatus,
 
 
 
 
13
  } from './utils/api';
14
  import * as storage from './utils/storage';
15
  import './styles/variables.css';
@@ -43,7 +50,6 @@ export default function App() {
43
  const [demoQuestions, setDemoQuestions] = useState([]);
44
 
45
  // Display options
46
- const [speedPriority, setSpeedPriorityState] = useState(false);
47
  const [showResponseTime, setShowResponseTime] = useState(false);
48
  const [showChatStats, setShowChatStats] = useState(false);
49
 
@@ -66,6 +72,32 @@ export default function App() {
66
  const [expertEditing, setExpertEditing] = useState(null);
67
  const [tableData, setTableData] = useState(null);
68
  const [tableOpen, setTableOpen] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  const abortRef = useRef(null);
71
 
@@ -90,7 +122,6 @@ export default function App() {
90
  setOrchestratorModelState(d.model_id);
91
  }
92
  }).catch(() => {});
93
- getSpeedPriority().then(d => setSpeedPriorityState(!!d.enabled)).catch(() => {});
94
  getAuthStatus().then(setAuth).catch(() => {});
95
  getRateLimitStatus().then(d => {
96
  if (d?.daily_limit) setDailyLimit(d.daily_limit);
@@ -158,12 +189,6 @@ export default function App() {
158
  const handleSummarizerChange = useCallback((modelId) => {
159
  setSummarizerModelState(modelId || null);
160
  }, []);
161
- const handleSpeedPriorityChange = useCallback(async (enabled) => {
162
- try {
163
- await setSpeedPriority(enabled);
164
- setSpeedPriorityState(enabled);
165
- } catch (err) { console.error('Failed to set speed priority:', err); }
166
- }, []);
167
  const handleMaxParticipantsChange = useCallback((n) => {
168
  const clamped = Math.max(3, Math.min(9, n));
169
  setMaxParticipants(clamped);
@@ -212,6 +237,21 @@ export default function App() {
212
  });
213
  }, []);
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  // ─── Expert persona ops ─────────────────────────────────────────
216
  const handleOpenExpertModal = useCallback((personaOrNull) => {
217
  setExpertEditing(personaOrNull);
@@ -285,12 +325,77 @@ export default function App() {
285
  } catch (err) { console.error('Table fetch failed:', err); }
286
  }, [sessionId]);
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  // ─── Build start payload ────────────────────────────────────────
289
- const buildStartPayload = useCallback((theQuestion) => {
290
- const enabledParticipants = selectedParticipants.filter(
291
- p => enabledMap[p.participant_id] !== false,
292
- );
293
- const participants = enabledParticipants.map(p => ({
 
 
294
  participant_id: p.participant_id,
295
  kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon'
296
  : (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')),
@@ -298,7 +403,7 @@ export default function App() {
298
  role_prompt: p.role_prompt || null,
299
  model_id_override: modelAssignments[p.participant_id] || null,
300
  }));
301
- const expert_payload = enabledParticipants
302
  .filter(p => (p.kind || '').startsWith('expert'))
303
  .map(p => ({
304
  participant_id: p.participant_id,
@@ -314,8 +419,10 @@ export default function App() {
314
  orchestrator_model_id: orchestratorModel,
315
  summarizer_model_id: summarizerModel,
316
  max_participants: maxParticipants,
 
 
317
  };
318
- }, [selectedParticipants, enabledMap, modelAssignments, orchestratorModel, summarizerModel, maxParticipants]);
319
 
320
  // ─── Stop / continue ────────────────────────────────────────────
321
  const handleStop = useCallback(() => {
@@ -336,22 +443,94 @@ export default function App() {
336
  // ─── Start chat ─────────────────────────────────────────────────
337
  const handleStart = useCallback(async (theQuestion) => {
338
  if (!theQuestion || !theQuestion.trim()) return;
339
- if (enabledSelectedCount < 2) return;
 
 
340
 
341
  const controller = new AbortController();
342
  abortRef.current = controller;
343
  setIsRunning(true);
344
  setMessages([]);
345
  setSystemMessages([]);
346
- setStatusText('Starting conversation...');
 
 
347
  setSessionId(null);
348
  setSessionParticipants([]);
349
  setPause(null);
350
  setActiveQuestion(theQuestion.trim());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
 
352
  try {
353
  await startChat(
354
- buildStartPayload(theQuestion),
355
  {
356
  onSession: (data) => {
357
  setSessionId(data.session_id);
@@ -393,6 +572,17 @@ export default function App() {
393
  onOrchestratorCapPause: (data) => {
394
  setPause({ reason: 'orchestrator', ...data });
395
  },
 
 
 
 
 
 
 
 
 
 
 
396
  onDone: () => {
397
  setIsRunning(false);
398
  setStatusText('');
@@ -414,7 +604,11 @@ export default function App() {
414
  abortRef.current = null;
415
  getAuthStatus().then(setAuth).catch(() => {});
416
  }
417
- }, [buildStartPayload, enabledSelectedCount, dailyLimit]);
 
 
 
 
418
 
419
  const handleStartRandom = useCallback(() => {
420
  if (demoQuestions.length === 0) {
@@ -425,8 +619,17 @@ export default function App() {
425
  handleStart(q.text);
426
  }, [demoQuestions, handleStart]);
427
 
428
- const startDisabled = isRunning || enabledSelectedCount < 2;
429
- const startDisabledReason = enabledSelectedCount < 2
 
 
 
 
 
 
 
 
 
430
  ? 'Add at least 2 active participants to start.'
431
  : '';
432
 
@@ -443,14 +646,14 @@ export default function App() {
443
  maxParticipants={maxParticipants}
444
  onToggleParticipant={handleToggleParticipant}
445
  onOpenExpertModal={handleOpenExpertModal}
 
 
446
 
447
  allModels={allModelsFlat}
448
  orchestratorModel={orchestratorModel}
449
  onOrchestratorChange={handleOrchestratorChange}
450
  summarizerModel={summarizerModel}
451
  onSummarizerChange={handleSummarizerChange}
452
- speedPriority={speedPriority}
453
- onSpeedPriorityChange={handleSpeedPriorityChange}
454
  showResponseTime={showResponseTime}
455
  onShowResponseTimeChange={setShowResponseTime}
456
  showChatStats={showChatStats}
@@ -460,6 +663,11 @@ export default function App() {
460
  modelAssignments={modelAssignments}
461
  onModelAssignmentChange={handleModelAssignmentChange}
462
  onShowTableView={handleShowTableView}
 
 
 
 
 
463
  onDownloadChatTxt={handleDownloadTxt}
464
  onDownloadChatMd={handleDownloadMd}
465
  onDownloadCsvTable={handleDownloadCsvTable}
@@ -475,6 +683,8 @@ export default function App() {
475
  modelAssignments={modelAssignments}
476
  onToggleEnabled={handleSidebarToggleEnabled}
477
  onRemove={handleSidebarRemove}
 
 
478
  />
479
  <div className="content">
480
  <ChatControls
@@ -520,6 +730,25 @@ export default function App() {
520
  onExportCsv={handleDownloadCsvTable}
521
  />
522
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  </div>
524
  );
525
  }
 
5
  import ChatArea from './components/ChatArea';
6
  import ExpertPersonaModal from './components/ExpertPersonaModal';
7
  import ChatTableView from './components/ChatTableView';
8
+ import CredentialSummaryModal from './components/CredentialSummaryModal';
9
+ import ConversationLimitsModal from './components/ConversationLimitsModal';
10
+ import PromptCatalogModal from './components/PromptCatalogModal';
11
  import {
12
  fetchModels, fetchPersonas, fetchDemoQuestions,
13
  startChat, continueChat, getOrchestrator, setOrchestrator,
14
+ getAuthStatus,
15
+ exportChat, exportApiLog, fetchTableView, fetchCredentials,
16
+ fetchConversationLimitsDefaults,
17
+ autoSelectParticipants,
18
+ fetchPromptCatalog,
19
+ getRateLimitStatus,
20
  } from './utils/api';
21
  import * as storage from './utils/storage';
22
  import './styles/variables.css';
 
50
  const [demoQuestions, setDemoQuestions] = useState([]);
51
 
52
  // Display options
 
53
  const [showResponseTime, setShowResponseTime] = useState(false);
54
  const [showChatStats, setShowChatStats] = useState(false);
55
 
 
72
  const [expertEditing, setExpertEditing] = useState(null);
73
  const [tableData, setTableData] = useState(null);
74
  const [tableOpen, setTableOpen] = useState(false);
75
+ // Credential Summary: cached snapshot fed by SSE `credentials_updated`
76
+ // events, plus an open/closed flag and a question echo for the modal
77
+ // header. Reset on each new chat start.
78
+ const [credentialsData, setCredentialsData] = useState(null);
79
+ const [credentialsOpen, setCredentialsOpen] = useState(false);
80
+ // Conversation limits: schema (defaults + bounds + descriptions)
81
+ // pulled from /api/chat/limits/defaults, plus a sparse map of the
82
+ // user's overrides persisted to localStorage. Empty map means
83
+ // "use server defaults". The schema lazy-loads on first open.
84
+ const [limitsSchema, setLimitsSchema] = useState(null);
85
+ const [limitsOverrides, setLimitsOverrides] = useState(
86
+ persisted.conversation_limits || {},
87
+ );
88
+ const [limitsOpen, setLimitsOpen] = useState(false);
89
+ // Auto-select toggle: when on, the participant dropdown defers
90
+ // selection to the orchestrator LLM at /chat/start time. We also
91
+ // snapshot the user's manual selection before turning it on so we
92
+ // can restore it when it's turned back off.
93
+ const [autoSelectMode, setAutoSelectMode] = useState(
94
+ !!persisted.auto_select_mode,
95
+ );
96
+ const [priorManualSelection, setPriorManualSelection] = useState(null);
97
+ // Prompt catalog: lazily fetched on first open, then cached for the
98
+ // rest of the session. The catalog is static per backend deploy.
99
+ const [promptCatalog, setPromptCatalog] = useState(null);
100
+ const [promptCatalogOpen, setPromptCatalogOpen] = useState(false);
101
 
102
  const abortRef = useRef(null);
103
 
 
122
  setOrchestratorModelState(d.model_id);
123
  }
124
  }).catch(() => {});
 
125
  getAuthStatus().then(setAuth).catch(() => {});
126
  getRateLimitStatus().then(d => {
127
  if (d?.daily_limit) setDailyLimit(d.daily_limit);
 
189
  const handleSummarizerChange = useCallback((modelId) => {
190
  setSummarizerModelState(modelId || null);
191
  }, []);
 
 
 
 
 
 
192
  const handleMaxParticipantsChange = useCallback((n) => {
193
  const clamped = Math.max(3, Math.min(9, n));
194
  setMaxParticipants(clamped);
 
237
  });
238
  }, []);
239
 
240
+ // ─── Auto-select toggle ─────────────────────────────────────────
241
+ // When turning ON, snapshot the current manual selection so we can
242
+ // restore it on OFF. The actual LLM ranking happens in handleStart
243
+ // (so the user's question is available); this just flips the mode.
244
+ const handleToggleAutoSelectMode = useCallback((on) => {
245
+ if (on && !autoSelectMode) {
246
+ setPriorManualSelection([...selectedIds]);
247
+ } else if (!on && autoSelectMode && priorManualSelection !== null) {
248
+ setSelectedIds(priorManualSelection);
249
+ setPriorManualSelection(null);
250
+ }
251
+ setAutoSelectMode(!!on);
252
+ storage.setAutoSelectMode(!!on);
253
+ }, [autoSelectMode, selectedIds, priorManualSelection]);
254
+
255
  // ─── Expert persona ops ─────────────────────────────────────────
256
  const handleOpenExpertModal = useCallback((personaOrNull) => {
257
  setExpertEditing(personaOrNull);
 
325
  } catch (err) { console.error('Table fetch failed:', err); }
326
  }, [sessionId]);
327
 
328
+ // ─── Credential Summary view ────────────────────────────────────
329
+ // Always re-fetch on open so the modal reflects the very latest
330
+ // server-side state (the Phase-3 refresh may have run after the SSE
331
+ // event was missed by a stale tab).
332
+ const handleShowCredentials = useCallback(async () => {
333
+ if (!sessionId) return;
334
+ try {
335
+ const data = await fetchCredentials(sessionId);
336
+ setCredentialsData(data);
337
+ setCredentialsOpen(true);
338
+ } catch (err) { console.error('Credentials fetch failed:', err); }
339
+ }, [sessionId]);
340
+
341
+ const handleRefreshCredentials = useCallback(async () => {
342
+ if (!sessionId) return;
343
+ try {
344
+ const data = await fetchCredentials(sessionId);
345
+ setCredentialsData(data);
346
+ } catch (err) { console.error('Credentials refresh failed:', err); }
347
+ }, [sessionId]);
348
+
349
+ // ─── Conversation limits (settings) ────────────────────────────
350
+ // Lazy-load the schema on first open, then cache it for the rest
351
+ // of the session. The user's override map is already in state
352
+ // and persisted to localStorage; we hand it to the modal as the
353
+ // initial draft and re-persist on every change.
354
+ const handleShowConversationLimits = useCallback(async () => {
355
+ if (!limitsSchema) {
356
+ try {
357
+ const data = await fetchConversationLimitsDefaults();
358
+ setLimitsSchema(data);
359
+ } catch (err) {
360
+ console.error('Conversation-limit schema fetch failed:', err);
361
+ return;
362
+ }
363
+ }
364
+ setLimitsOpen(true);
365
+ }, [limitsSchema]);
366
+
367
+ const handleConversationLimitsChange = useCallback((next) => {
368
+ setLimitsOverrides(next);
369
+ storage.setConversationLimits(next);
370
+ }, []);
371
+
372
+ const handleConversationLimitsResetAll = useCallback(() => {
373
+ setLimitsOverrides({});
374
+ storage.setConversationLimits({});
375
+ }, []);
376
+
377
+ // ─── Prompt catalog (Transparency) ─────────────────────────────
378
+ const handleShowPromptCatalog = useCallback(async () => {
379
+ if (!promptCatalog) {
380
+ try {
381
+ const data = await fetchPromptCatalog();
382
+ setPromptCatalog(data);
383
+ } catch (err) {
384
+ console.error('Prompt catalog fetch failed:', err);
385
+ return;
386
+ }
387
+ }
388
+ setPromptCatalogOpen(true);
389
+ }, [promptCatalog]);
390
+
391
  // ─── Build start payload ────────────────────────────────────────
392
+ // `participantsOverride`, if provided, replaces the
393
+ // selectedParticipants-derived list (used by the auto-select flow
394
+ // because the freshly-chosen list isn't in state yet when we need it).
395
+ const buildStartPayload = useCallback((theQuestion, participantsOverride) => {
396
+ const baseList = participantsOverride
397
+ ?? selectedParticipants.filter(p => enabledMap[p.participant_id] !== false);
398
+ const participants = baseList.map(p => ({
399
  participant_id: p.participant_id,
400
  kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon'
401
  : (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')),
 
403
  role_prompt: p.role_prompt || null,
404
  model_id_override: modelAssignments[p.participant_id] || null,
405
  }));
406
+ const expert_payload = baseList
407
  .filter(p => (p.kind || '').startsWith('expert'))
408
  .map(p => ({
409
  participant_id: p.participant_id,
 
419
  orchestrator_model_id: orchestratorModel,
420
  summarizer_model_id: summarizerModel,
421
  max_participants: maxParticipants,
422
+ // Sparse override map; backend clamps and falls back per-field.
423
+ limits: limitsOverrides,
424
  };
425
+ }, [selectedParticipants, enabledMap, modelAssignments, orchestratorModel, summarizerModel, maxParticipants, limitsOverrides]);
426
 
427
  // ─── Stop / continue ────────────────────────────────────────────
428
  const handleStop = useCallback(() => {
 
443
  // ─── Start chat ─────────────────────────────────────────────────
444
  const handleStart = useCallback(async (theQuestion) => {
445
  if (!theQuestion || !theQuestion.trim()) return;
446
+ // In auto-select mode the dropdown has no manual picks - skip the
447
+ // pre-flight count check and validate the chosen pool below instead.
448
+ if (!autoSelectMode && enabledSelectedCount < 2) return;
449
 
450
  const controller = new AbortController();
451
  abortRef.current = controller;
452
  setIsRunning(true);
453
  setMessages([]);
454
  setSystemMessages([]);
455
+ setStatusText(
456
+ autoSelectMode ? 'Picking participants...' : 'Starting conversation...',
457
+ );
458
  setSessionId(null);
459
  setSessionParticipants([]);
460
  setPause(null);
461
  setActiveQuestion(theQuestion.trim());
462
+ setCredentialsData(null);
463
+
464
+ // Resolve the final participant list. When auto-select is on, ask
465
+ // the orchestrator to rank every available candidate; otherwise
466
+ // fall through to the user's manual selection.
467
+ let resolvedParticipants = null;
468
+ if (autoSelectMode) {
469
+ const candidatePool = Object.values(allCatalogParticipants);
470
+ if (candidatePool.length < 2) {
471
+ setIsRunning(false);
472
+ setStatusText('');
473
+ setSystemMessages(prev => [...prev, {
474
+ text: 'Auto-select needs at least 2 candidate participants available.',
475
+ }]);
476
+ return;
477
+ }
478
+ const candidatesPayload = candidatePool.map(p => ({
479
+ participant_id: p.participant_id,
480
+ name: p.name,
481
+ role_prompt: p.role_prompt || '',
482
+ kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon'
483
+ : (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')),
484
+ model_id: modelAssignments[p.participant_id]
485
+ || p.model_id || p.default_model_id || '',
486
+ }));
487
+ try {
488
+ const result = await autoSelectParticipants({
489
+ question: theQuestion.trim(),
490
+ count: maxParticipants,
491
+ candidates: candidatesPayload,
492
+ orchestrator_model_id: orchestratorModel,
493
+ });
494
+ const chosenIds = result.selected || [];
495
+ resolvedParticipants = chosenIds
496
+ .map(id => allCatalogParticipants[id])
497
+ .filter(Boolean);
498
+ if (resolvedParticipants.length < 2) {
499
+ setIsRunning(false);
500
+ setStatusText('');
501
+ setSystemMessages(prev => [...prev, {
502
+ text: 'Auto-select returned too few participants. '
503
+ + 'Turn auto-select off and pick manually.',
504
+ }]);
505
+ return;
506
+ }
507
+ // Reflect the pick in the sidebar.
508
+ setSelectedIds(chosenIds);
509
+ setEnabledMap(prev => {
510
+ const next = { ...prev };
511
+ for (const id of chosenIds) next[id] = true;
512
+ return next;
513
+ });
514
+ if (result.rationale) {
515
+ setSystemMessages(prev => [...prev, {
516
+ text: `Auto-select rationale: ${result.rationale}`,
517
+ }]);
518
+ }
519
+ setStatusText('Starting conversation...');
520
+ } catch (err) {
521
+ console.error('Auto-select failed:', err);
522
+ setIsRunning(false);
523
+ setStatusText('');
524
+ setSystemMessages(prev => [...prev, {
525
+ text: `Auto-select failed: ${err.message}`,
526
+ }]);
527
+ return;
528
+ }
529
+ }
530
 
531
  try {
532
  await startChat(
533
+ buildStartPayload(theQuestion, resolvedParticipants),
534
  {
535
  onSession: (data) => {
536
  setSessionId(data.session_id);
 
572
  onOrchestratorCapPause: (data) => {
573
  setPause({ reason: 'orchestrator', ...data });
574
  },
575
+ onCredentialsUpdated: (data) => {
576
+ // Backend emits this after the Phase-1.5 build and (when
577
+ // it changes) after the Phase-3 refresh. We cache the
578
+ // payload so the modal opens instantly without a round trip.
579
+ setCredentialsData({
580
+ session_id: data.session_id,
581
+ question: theQuestion.trim(),
582
+ credentials: data.credentials || [],
583
+ stage: data.stage || 'built',
584
+ });
585
+ },
586
  onDone: () => {
587
  setIsRunning(false);
588
  setStatusText('');
 
604
  abortRef.current = null;
605
  getAuthStatus().then(setAuth).catch(() => {});
606
  }
607
+ }, [
608
+ buildStartPayload, enabledSelectedCount, dailyLimit,
609
+ autoSelectMode, allCatalogParticipants, modelAssignments,
610
+ maxParticipants, orchestratorModel,
611
+ ]);
612
 
613
  const handleStartRandom = useCallback(() => {
614
  if (demoQuestions.length === 0) {
 
619
  handleStart(q.text);
620
  }, [demoQuestions, handleStart]);
621
 
622
+ // In auto-select mode we don't require manual picks - the orchestrator
623
+ // will choose them at /chat/start time, so just need 2+ candidates
624
+ // available in the catalog.
625
+ const autoSelectReady = autoSelectMode
626
+ && Object.keys(allCatalogParticipants).length >= 2;
627
+ const startDisabled = isRunning
628
+ || (!autoSelectMode && enabledSelectedCount < 2)
629
+ || (autoSelectMode && !autoSelectReady);
630
+ const startDisabledReason = autoSelectMode
631
+ ? (!autoSelectReady ? 'No candidate participants available for auto-select.' : '')
632
+ : enabledSelectedCount < 2
633
  ? 'Add at least 2 active participants to start.'
634
  : '';
635
 
 
646
  maxParticipants={maxParticipants}
647
  onToggleParticipant={handleToggleParticipant}
648
  onOpenExpertModal={handleOpenExpertModal}
649
+ autoSelectMode={autoSelectMode}
650
+ onToggleAutoSelectMode={handleToggleAutoSelectMode}
651
 
652
  allModels={allModelsFlat}
653
  orchestratorModel={orchestratorModel}
654
  onOrchestratorChange={handleOrchestratorChange}
655
  summarizerModel={summarizerModel}
656
  onSummarizerChange={handleSummarizerChange}
 
 
657
  showResponseTime={showResponseTime}
658
  onShowResponseTimeChange={setShowResponseTime}
659
  showChatStats={showChatStats}
 
663
  modelAssignments={modelAssignments}
664
  onModelAssignmentChange={handleModelAssignmentChange}
665
  onShowTableView={handleShowTableView}
666
+ onShowCredentials={handleShowCredentials}
667
+ hasCredentials={!!sessionId}
668
+ onShowPromptCatalog={handleShowPromptCatalog}
669
+ onShowConversationLimits={handleShowConversationLimits}
670
+ conversationLimitsOverridden={Object.keys(limitsOverrides).length > 0}
671
  onDownloadChatTxt={handleDownloadTxt}
672
  onDownloadChatMd={handleDownloadMd}
673
  onDownloadCsvTable={handleDownloadCsvTable}
 
683
  modelAssignments={modelAssignments}
684
  onToggleEnabled={handleSidebarToggleEnabled}
685
  onRemove={handleSidebarRemove}
686
+ autoSelectMode={autoSelectMode}
687
+ maxParticipants={maxParticipants}
688
  />
689
  <div className="content">
690
  <ChatControls
 
730
  onExportCsv={handleDownloadCsvTable}
731
  />
732
  )}
733
+ <CredentialSummaryModal
734
+ isOpen={credentialsOpen}
735
+ data={credentialsData}
736
+ onClose={() => setCredentialsOpen(false)}
737
+ onRefresh={handleRefreshCredentials}
738
+ />
739
+ <ConversationLimitsModal
740
+ isOpen={limitsOpen}
741
+ schema={limitsSchema}
742
+ overrides={limitsOverrides}
743
+ onClose={() => setLimitsOpen(false)}
744
+ onChange={handleConversationLimitsChange}
745
+ onResetAll={handleConversationLimitsResetAll}
746
+ />
747
+ <PromptCatalogModal
748
+ isOpen={promptCatalogOpen}
749
+ catalog={promptCatalog}
750
+ onClose={() => setPromptCatalogOpen(false)}
751
+ />
752
  </div>
753
  );
754
  }
frontend/src/components/ConversationLimitsModal.js ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useMemo, useState } from 'react';
2
+ import { RotateCcw } from 'lucide-react';
3
+
4
+ /**
5
+ * Settings modal for the user-tunable repetition / failsafe limits.
6
+ *
7
+ * The schema (defaults, bounds, descriptions, group assignments) is
8
+ * fetched from GET /api/chat/limits/defaults so the entire UI is
9
+ * server-driven: adding a knob in `services.models.ConversationLimits`
10
+ * makes it appear here automatically. The user's overrides are
11
+ * persisted to localStorage and sent on the next /chat/start, where
12
+ * the backend re-clamps them to the declared bounds.
13
+ *
14
+ * "Effective" values shown to the user are: override (if set) ->
15
+ * server default. Values are pre-clamped client-side too so the
16
+ * stepper UI stays in range.
17
+ */
18
+ export default function ConversationLimitsModal({
19
+ isOpen,
20
+ schema,
21
+ overrides,
22
+ onClose,
23
+ onChange,
24
+ onResetAll,
25
+ }) {
26
+ const [draft, setDraft] = useState(() => ({ ...(overrides || {}) }));
27
+
28
+ // Reset draft whenever the modal is reopened so we don't leak edits
29
+ // from a previous open.
30
+ React.useEffect(() => {
31
+ if (isOpen) setDraft({ ...(overrides || {}) });
32
+ }, [isOpen, overrides]);
33
+
34
+ const grouped = useMemo(() => groupFields(schema), [schema]);
35
+
36
+ if (!isOpen) return null;
37
+
38
+ if (!schema) {
39
+ return (
40
+ <div className="ccai-credentials-overlay">
41
+ <div className="ccai-credentials-card">
42
+ <div className="ccai-credentials-header">
43
+ <div>
44
+ <h2>Conversation limits</h2>
45
+ </div>
46
+ <div className="ccai-tab-spacer" />
47
+ <button className="modal-close" onClick={onClose}>&times;</button>
48
+ </div>
49
+ <div className="ccai-credentials-body">
50
+ <div className="ccai-credentials-empty">Loading limits...</div>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ const handleChange = (field, value) => {
58
+ const next = { ...draft, [field]: value };
59
+ setDraft(next);
60
+ onChange?.(next);
61
+ };
62
+
63
+ const handleResetField = (field) => {
64
+ const next = { ...draft };
65
+ delete next[field];
66
+ setDraft(next);
67
+ onChange?.(next);
68
+ };
69
+
70
+ const handleResetAll = () => {
71
+ setDraft({});
72
+ onResetAll?.();
73
+ };
74
+
75
+ return (
76
+ <div className="ccai-credentials-overlay">
77
+ <div className="ccai-credentials-card">
78
+ <div className="ccai-credentials-header">
79
+ <div>
80
+ <h2>Conversation limits</h2>
81
+ <div className="ccai-credentials-subtitle">
82
+ These knobs control how long each phase of the discussion
83
+ runs and when the conversation pauses for a Continue
84
+ confirmation. Changes apply to the next chat you start.
85
+ </div>
86
+ </div>
87
+ <div className="ccai-tab-spacer" />
88
+ <button
89
+ className="btn-sm btn-outline"
90
+ onClick={handleResetAll}
91
+ title="Restore every knob to the server default"
92
+ >
93
+ <RotateCcw size={14} style={{ marginRight: 4 }} />
94
+ Reset all
95
+ </button>
96
+ <button className="modal-close" onClick={onClose}>&times;</button>
97
+ </div>
98
+
99
+ <div className="ccai-credentials-body">
100
+ {grouped.map(({ group, fields }) => (
101
+ <div key={group} className="ccai-limits-group">
102
+ <div className="ccai-limits-group-title">{group}</div>
103
+ {fields.map((f) => (
104
+ <LimitRow
105
+ key={f.field}
106
+ field={f.field}
107
+ label={f.label}
108
+ help={f.help}
109
+ defaultValue={f.defaultValue}
110
+ min={f.min}
111
+ max={f.max}
112
+ override={draft[f.field]}
113
+ onChange={(v) => handleChange(f.field, v)}
114
+ onResetField={() => handleResetField(f.field)}
115
+ />
116
+ ))}
117
+ </div>
118
+ ))}
119
+ </div>
120
+ </div>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ function LimitRow({
126
+ field,
127
+ label,
128
+ help,
129
+ defaultValue,
130
+ min,
131
+ max,
132
+ override,
133
+ onChange,
134
+ onResetField,
135
+ }) {
136
+ const isOverride = override !== undefined && override !== null;
137
+ const effective = isOverride ? override : defaultValue;
138
+
139
+ const setClamped = (raw) => {
140
+ if (raw === '' || raw === null || raw === undefined) {
141
+ // Empty input = revert to default for this field.
142
+ onResetField();
143
+ return;
144
+ }
145
+ let n = parseInt(raw, 10);
146
+ if (Number.isNaN(n)) return;
147
+ if (n < min) n = min;
148
+ if (n > max) n = max;
149
+ onChange(n);
150
+ };
151
+
152
+ return (
153
+ <div className="ccai-limit-row">
154
+ <div className="ccai-limit-row-head">
155
+ <label className="ccai-limit-label" htmlFor={`limit-${field}`}>
156
+ {label}
157
+ </label>
158
+ <div className="ccai-limit-stepper">
159
+ <input
160
+ id={`limit-${field}`}
161
+ type="number"
162
+ min={min}
163
+ max={max}
164
+ step={1}
165
+ value={effective}
166
+ onChange={(e) => setClamped(e.target.value)}
167
+ className="ccai-limit-input"
168
+ />
169
+ <span className="ccai-limit-range">
170
+ ({min}-{max}, default {defaultValue})
171
+ </span>
172
+ {isOverride && (
173
+ <button
174
+ className="ccai-limit-reset"
175
+ onClick={onResetField}
176
+ title="Reset this field to the default"
177
+ >
178
+ reset
179
+ </button>
180
+ )}
181
+ </div>
182
+ </div>
183
+ <div className="ccai-limit-help">{help}</div>
184
+ </div>
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Convert the flat `descriptions` map into [{group, fields[]}] in
190
+ * the order the groups first appear, then the order each field is
191
+ * declared in `bounds` (which mirrors the dataclass field order).
192
+ */
193
+ function groupFields(schema) {
194
+ if (!schema) return [];
195
+ const { defaults, bounds, descriptions } = schema;
196
+ const orderedFields = Object.keys(bounds);
197
+ const seen = new Map();
198
+ for (const field of orderedFields) {
199
+ const desc = descriptions[field] || {};
200
+ const group = desc.group || 'Other';
201
+ if (!seen.has(group)) seen.set(group, []);
202
+ seen.get(group).push({
203
+ field,
204
+ label: desc.label || field,
205
+ help: desc.help || '',
206
+ defaultValue: defaults[field],
207
+ min: bounds[field].min,
208
+ max: bounds[field].max,
209
+ });
210
+ }
211
+ return Array.from(seen.entries()).map(([group, fields]) => ({ group, fields }));
212
+ }
frontend/src/components/CredentialSummaryModal.js ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useMemo } from 'react';
2
+ import { Download } from 'lucide-react';
3
+
4
+ /**
5
+ * Read-only modal that surfaces the orchestrator-generated Credential
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
+ *
14
+ * Layout mirrors ChatTableView (overlay + card + close button) for
15
+ * consistency with the existing transparency surfaces.
16
+ */
17
+ export default function CredentialSummaryModal({
18
+ isOpen,
19
+ data,
20
+ onClose,
21
+ onRefresh,
22
+ }) {
23
+ // Hooks must run on every render, so the filename memo lives ABOVE
24
+ // the early return. The dependency on `isOpen` regenerates the
25
+ // timestamp each time the modal opens (matches PromptCatalogModal).
26
+ const filename = useMemo(() => {
27
+ const now = new Date();
28
+ const pad = (n) => String(n).padStart(2, '0');
29
+ return (
30
+ 'ccai-credentials-'
31
+ + `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
32
+ + `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
33
+ + '.txt'
34
+ );
35
+ // eslint-disable-next-line react-hooks/exhaustive-deps
36
+ }, [isOpen]);
37
+
38
+ if (!isOpen) return null;
39
+
40
+ const credentials = (data && data.credentials) || [];
41
+ const question = data?.question || '';
42
+
43
+ const handleDownload = () => {
44
+ if (!credentials.length) return;
45
+ const txt = renderCredentialsAsText(question, credentials);
46
+ const blob = new Blob([txt], { type: 'text/plain;charset=utf-8' });
47
+ const url = URL.createObjectURL(blob);
48
+ const a = document.createElement('a');
49
+ a.href = url;
50
+ a.download = filename;
51
+ a.click();
52
+ URL.revokeObjectURL(url);
53
+ };
54
+
55
+ return (
56
+ <div className="ccai-credentials-overlay">
57
+ <div className="ccai-credentials-card">
58
+ <div className="ccai-credentials-header">
59
+ <div>
60
+ <h2>Credential Summary</h2>
61
+ <div className="ccai-credentials-subtitle">
62
+ The orchestrator's neutral assessment of each participant.
63
+ Built after Phase 1 and refreshed once after Phase 2 critique.
64
+ </div>
65
+ </div>
66
+ <div className="ccai-tab-spacer" />
67
+ {onRefresh && (
68
+ <button
69
+ className="btn-sm btn-outline"
70
+ onClick={onRefresh}
71
+ title="Re-fetch from the server"
72
+ >
73
+ Refresh
74
+ </button>
75
+ )}
76
+ <button
77
+ className="btn-sm btn-outline"
78
+ onClick={handleDownload}
79
+ disabled={credentials.length === 0}
80
+ title="Download the credential summary as a .txt file"
81
+ >
82
+ <Download size={14} style={{ marginRight: 4 }} />
83
+ Download as .txt
84
+ </button>
85
+ <button className="modal-close" onClick={onClose}>&times;</button>
86
+ </div>
87
+
88
+ {question && (
89
+ <div className="ccai-credentials-question">
90
+ <strong>Question:</strong>
91
+ <div>{question}</div>
92
+ </div>
93
+ )}
94
+
95
+ <div className="ccai-credentials-body">
96
+ {credentials.length === 0 ? (
97
+ <div className="ccai-credentials-empty">
98
+ No Credential Summary has been generated yet. The
99
+ orchestrator builds it after Phase 1 (initial opinions).
100
+ </div>
101
+ ) : (
102
+ credentials.map((c) => (
103
+ <CredentialCard key={c.participant_id} cred={c} />
104
+ ))
105
+ )}
106
+ </div>
107
+ </div>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ function CredentialCard({ cred }) {
113
+ const score = toScore(cred.credibility_for_question);
114
+ return (
115
+ <div className="ccai-credential-card">
116
+ <div className="ccai-credential-card-head">
117
+ <div className="ccai-credential-name">
118
+ {cred.name || cred.participant_id}
119
+ </div>
120
+ {score !== null && (
121
+ <div className="ccai-credibility-wrap" title={`Credibility ${score.toFixed(2)} of 1.0`}>
122
+ <span className="ccai-credibility-label">Credibility</span>
123
+ <div className="ccai-credibility-bar">
124
+ <div
125
+ className="ccai-credibility-fill"
126
+ style={{ width: `${Math.round(score * 100)}%` }}
127
+ />
128
+ </div>
129
+ <span className="ccai-credibility-num">{score.toFixed(2)}</span>
130
+ </div>
131
+ )}
132
+ </div>
133
+ <FieldRow label="Expertise" value={cred.expertise} />
134
+ <FieldRow label="Style" value={cred.personality} />
135
+ <FieldRow label="Bias to watch" value={cred.bias_to_watch} />
136
+ </div>
137
+ );
138
+ }
139
+
140
+ function FieldRow({ label, value }) {
141
+ if (!value) return null;
142
+ return (
143
+ <div className="ccai-credential-row">
144
+ <div className="ccai-credential-row-label">{label}</div>
145
+ <div className="ccai-credential-row-value">{value}</div>
146
+ </div>
147
+ );
148
+ }
149
+
150
+ function toScore(value) {
151
+ if (value === null || value === undefined) return null;
152
+ const n = Number(value);
153
+ if (Number.isNaN(n)) return null;
154
+ return Math.max(0, Math.min(1, n));
155
+ }
156
+
157
+ /**
158
+ * Flat human-readable .txt dump used by the Download button. Same
159
+ * banner/separator style as PromptCatalogModal.renderCatalogAsText so
160
+ * the two transparency exports look like a matched set.
161
+ */
162
+ function renderCredentialsAsText(question, credentials) {
163
+ const now = new Date().toISOString();
164
+ const lines = [];
165
+ const banner = '═'.repeat(64);
166
+ lines.push(banner);
167
+ lines.push('Collaborative Conversational AI (CCAI) Demo — Credential Summary');
168
+ lines.push(`Generated: ${now}`);
169
+ lines.push(banner);
170
+ lines.push('');
171
+
172
+ if (question) {
173
+ lines.push('Question:');
174
+ for (const ln of String(question).split('\n')) {
175
+ lines.push(' ' + ln);
176
+ }
177
+ lines.push('');
178
+ }
179
+
180
+ const sep = '─'.repeat(12);
181
+ lines.push(`${sep} Participants ${sep}`);
182
+ lines.push('');
183
+
184
+ for (const cred of credentials) {
185
+ const score = toScore(cred.credibility_for_question);
186
+ const name = cred.name || cred.participant_id || '(unknown)';
187
+ lines.push(`## ${name}`);
188
+ if (score !== null) {
189
+ lines.push(`Credibility: ${score.toFixed(2)} of 1.00`);
190
+ }
191
+ if (cred.expertise) lines.push(`Expertise: ${cred.expertise}`);
192
+ if (cred.personality) lines.push(`Style: ${cred.personality}`);
193
+ if (cred.bias_to_watch) lines.push(`Bias to watch: ${cred.bias_to_watch}`);
194
+ lines.push('');
195
+ }
196
+
197
+ return lines.join('\n');
198
+ }
frontend/src/components/DevMenu.js CHANGED
@@ -1,7 +1,8 @@
1
  import React, { useState, useMemo, useRef, useEffect } from 'react';
2
  import {
3
  ChevronRight, Download, Settings2, Search,
4
- Square, CheckSquare, UserPlus, Table2,
 
5
  } from 'lucide-react';
6
 
7
  /**
@@ -20,8 +21,6 @@ export default function DevMenu({
20
  onOrchestratorChange,
21
  summarizerModel,
22
  onSummarizerChange,
23
- speedPriority,
24
- onSpeedPriorityChange,
25
  showResponseTime,
26
  onShowResponseTimeChange,
27
  showChatStats,
@@ -33,6 +32,11 @@ export default function DevMenu({
33
  onModelAssignmentChange,
34
  onOpenExpertModal,
35
  onShowTableView,
 
 
 
 
 
36
  onDownloadChatTxt,
37
  onDownloadChatMd,
38
  onDownloadCsvTable,
@@ -174,23 +178,6 @@ export default function DevMenu({
174
  );
175
  })}
176
 
177
- <div className="dev-panel-divider" />
178
- <div className="dev-panel-label">Response priority</div>
179
- <button
180
- className={`dev-panel-choice ${!speedPriority ? 'dev-panel-choice-active' : ''}`}
181
- onClick={() => onSpeedPriorityChange(false)}
182
- >
183
- {!speedPriority ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
184
- Prioritize model choice
185
- </button>
186
- <button
187
- className={`dev-panel-choice ${speedPriority ? 'dev-panel-choice-active' : ''}`}
188
- onClick={() => onSpeedPriorityChange(true)}
189
- >
190
- {speedPriority ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
191
- Prioritize conversation speed
192
- </button>
193
-
194
  <div className="dev-panel-divider" />
195
  <div className="dev-panel-label">Display options</div>
196
  <button
@@ -208,6 +195,53 @@ export default function DevMenu({
208
  Chat stats after end
209
  </button>
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  <div className="dev-panel-divider" />
212
  <button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatTxt(); setOpen(false); }}>
213
  Download chat as .txt
 
1
  import React, { useState, useMemo, useRef, useEffect } from 'react';
2
  import {
3
  ChevronRight, Download, Settings2, Search,
4
+ Square, CheckSquare, UserPlus, Table2, ScrollText, SlidersHorizontal,
5
+ BookOpen,
6
  } from 'lucide-react';
7
 
8
  /**
 
21
  onOrchestratorChange,
22
  summarizerModel,
23
  onSummarizerChange,
 
 
24
  showResponseTime,
25
  onShowResponseTimeChange,
26
  showChatStats,
 
32
  onModelAssignmentChange,
33
  onOpenExpertModal,
34
  onShowTableView,
35
+ onShowCredentials,
36
+ hasCredentials,
37
+ onShowPromptCatalog,
38
+ onShowConversationLimits,
39
+ conversationLimitsOverridden,
40
  onDownloadChatTxt,
41
  onDownloadChatMd,
42
  onDownloadCsvTable,
 
178
  );
179
  })}
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  <div className="dev-panel-divider" />
182
  <div className="dev-panel-label">Display options</div>
183
  <button
 
195
  Chat stats after end
196
  </button>
197
 
198
+ <div className="dev-panel-divider" />
199
+ <div className="dev-panel-label">Transparency</div>
200
+ <button
201
+ disabled={!hasCredentials}
202
+ onClick={() => { onShowCredentials?.(); setOpen(false); }}
203
+ title={
204
+ hasCredentials
205
+ ? "View the orchestrator's per-participant Credential Summary"
206
+ : "Credential Summary is built after Phase 1 (initial opinions). Start a chat first."
207
+ }
208
+ >
209
+ <ScrollText size={14} className="dev-check-icon" />
210
+ View Credential Summary…
211
+ <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
212
+ </button>
213
+ <button
214
+ onClick={() => { onShowPromptCatalog?.(); setOpen(false); }}
215
+ title="View every prompt template the orchestrator and participants use, grouped by phase."
216
+ >
217
+ <BookOpen size={14} className="dev-check-icon" />
218
+ View current chat prompts…
219
+ <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
220
+ </button>
221
+
222
+ <div className="dev-panel-divider" />
223
+ <div className="dev-panel-label">Advanced</div>
224
+ <button
225
+ onClick={() => { onShowConversationLimits?.(); setOpen(false); }}
226
+ title="View and adjust the per-phase repetition limits and failsafe pause points."
227
+ >
228
+ <SlidersHorizontal size={14} className="dev-check-icon" />
229
+ Conversation limits…
230
+ {conversationLimitsOverridden && (
231
+ <span
232
+ title="One or more limits are overridden from the defaults"
233
+ style={{
234
+ marginLeft: 6,
235
+ fontSize: 11,
236
+ color: 'var(--text-secondary)',
237
+ }}
238
+ >
239
+ (custom)
240
+ </span>
241
+ )}
242
+ <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
243
+ </button>
244
+
245
  <div className="dev-panel-divider" />
246
  <button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatTxt(); setOpen(false); }}>
247
  Download chat as .txt
frontend/src/components/Header.js CHANGED
@@ -21,6 +21,8 @@ export default function Header({
21
  maxParticipants,
22
  onToggleParticipant,
23
  onOpenExpertModal,
 
 
24
 
25
  // dev menu props passed straight through
26
  ...devProps
@@ -34,7 +36,7 @@ export default function Header({
34
  <h1 className="app-title">
35
  <a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="app-title-link">
36
  Neon.ai
37
- </a> - CCAI Vibe Demo
38
  </h1>
39
  </div>
40
  <div className="header-right">
@@ -46,6 +48,8 @@ export default function Header({
46
  maxParticipants={maxParticipants}
47
  onToggleParticipant={onToggleParticipant}
48
  onOpenExpertModal={onOpenExpertModal}
 
 
49
  />
50
  <button
51
  className="icon-btn"
 
21
  maxParticipants,
22
  onToggleParticipant,
23
  onOpenExpertModal,
24
+ autoSelectMode,
25
+ onToggleAutoSelectMode,
26
 
27
  // dev menu props passed straight through
28
  ...devProps
 
36
  <h1 className="app-title">
37
  <a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="app-title-link">
38
  Neon.ai
39
+ </a> - Collaborative Conversational AI (CCAI) Demo
40
  </h1>
41
  </div>
42
  <div className="header-right">
 
48
  maxParticipants={maxParticipants}
49
  onToggleParticipant={onToggleParticipant}
50
  onOpenExpertModal={onOpenExpertModal}
51
+ autoSelectMode={autoSelectMode}
52
+ onToggleAutoSelectMode={onToggleAutoSelectMode}
53
  />
54
  <button
55
  className="icon-btn"
frontend/src/components/ParticipantDropdown.js CHANGED
@@ -1,5 +1,5 @@
1
  import React, { useState, useRef, useEffect } from 'react';
2
- import { Users, Plus, ChevronDown } from 'lucide-react';
3
 
4
  /**
5
  * Header dropdown that lists every available participant the user can
@@ -8,6 +8,11 @@ import { Users, Plus, ChevronDown } from 'lucide-react';
8
  * - Extra (the four bundled non-Neon-LLM personas)
9
  * - Expert (user-created, stored in localStorage)
10
  *
 
 
 
 
 
11
  * Selecting a participant adds them to the active conversation list. The
12
  * "Create Expert Persona..." entry opens the modal.
13
  */
@@ -18,6 +23,8 @@ export default function ParticipantDropdown({
18
  maxParticipants,
19
  onToggleParticipant,
20
  onOpenExpertModal,
 
 
21
  }) {
22
  const [open, setOpen] = useState(false);
23
  const ref = useRef(null);
@@ -34,21 +41,61 @@ export default function ParticipantDropdown({
34
 
35
  const isSelected = (id) => selectedIds.includes(id);
36
  const atCap = selectedIds.length >= maxParticipants;
 
 
 
37
 
38
  return (
39
  <div className="ccai-dropdown-wrap" ref={ref}>
40
  <button
41
  className="btn-sm btn-outline ccai-dropdown-trigger"
42
  onClick={() => setOpen(o => !o)}
43
- title="Add or remove participants"
 
 
 
 
44
  >
45
  <Users size={14} />
46
- <span>Participants ({selectedIds.length}/{maxParticipants})</span>
 
 
 
 
47
  <ChevronDown size={12} />
48
  </button>
49
  {open && (
50
  <div className="ccai-dropdown-panel">
51
  <div className="ccai-dropdown-section">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  <div className="ccai-dropdown-section-title">Neon.ai Personas</div>
53
  {(catalog?.neon || []).length === 0 && (
54
  <div className="ccai-dropdown-empty">
@@ -60,26 +107,44 @@ export default function ParticipantDropdown({
60
  key={p.participant_id}
61
  participant={p}
62
  checked={isSelected(p.participant_id)}
63
- disabledForAdd={atCap && !isSelected(p.participant_id)}
 
 
 
 
64
  onToggle={() => onToggleParticipant(p, 'neon')}
65
  />
66
  ))}
67
  </div>
68
  <div className="ccai-dropdown-divider" />
69
- <div className="ccai-dropdown-section">
 
 
 
 
 
70
  <div className="ccai-dropdown-section-title">Extra Personas</div>
71
  {(catalog?.extra || []).map((p) => (
72
  <DropdownItem
73
  key={p.participant_id}
74
  participant={p}
75
  checked={isSelected(p.participant_id)}
76
- disabledForAdd={atCap && !isSelected(p.participant_id)}
 
 
 
 
77
  onToggle={() => onToggleParticipant(p, 'extra')}
78
  />
79
  ))}
80
  </div>
81
  <div className="ccai-dropdown-divider" />
82
- <div className="ccai-dropdown-section">
 
 
 
 
 
83
  <div className="ccai-dropdown-section-title">Expert Personas</div>
84
  {(expertPersonas || []).length === 0 && (
85
  <div className="ccai-dropdown-empty">
@@ -91,7 +156,11 @@ export default function ParticipantDropdown({
91
  key={p.participant_id}
92
  participant={p}
93
  checked={isSelected(p.participant_id)}
94
- disabledForAdd={atCap && !isSelected(p.participant_id)}
 
 
 
 
95
  onToggle={() => onToggleParticipant(p, 'expert')}
96
  />
97
  ))}
@@ -109,7 +178,7 @@ export default function ParticipantDropdown({
109
  );
110
  }
111
 
112
- function DropdownItem({ participant, checked, disabledForAdd, onToggle }) {
113
  return (
114
  <label
115
  className={
@@ -117,7 +186,11 @@ function DropdownItem({ participant, checked, disabledForAdd, onToggle }) {
117
  (checked ? ' ccai-dropdown-item-checked' : '') +
118
  (disabledForAdd ? ' ccai-dropdown-item-disabled' : '')
119
  }
120
- title={disabledForAdd ? 'Participant cap reached' : ''}
 
 
 
 
121
  >
122
  <input
123
  type="checkbox"
 
1
  import React, { useState, useRef, useEffect } from 'react';
2
+ import { Users, Plus, ChevronDown, Wand2, Check } from 'lucide-react';
3
 
4
  /**
5
  * Header dropdown that lists every available participant the user can
 
8
  * - Extra (the four bundled non-Neon-LLM personas)
9
  * - Expert (user-created, stored in localStorage)
10
  *
11
+ * A top-of-list "Select N Automatically" toggle defers the choice to
12
+ * the orchestrator LLM: when enabled, manual checkboxes are visually
13
+ * disabled and the actual picks happen at /chat/start time via the
14
+ * /api/chat/auto-select-participants endpoint.
15
+ *
16
  * Selecting a participant adds them to the active conversation list. The
17
  * "Create Expert Persona..." entry opens the modal.
18
  */
 
23
  maxParticipants,
24
  onToggleParticipant,
25
  onOpenExpertModal,
26
+ autoSelectMode,
27
+ onToggleAutoSelectMode,
28
  }) {
29
  const [open, setOpen] = useState(false);
30
  const ref = useRef(null);
 
41
 
42
  const isSelected = (id) => selectedIds.includes(id);
43
  const atCap = selectedIds.length >= maxParticipants;
44
+ // When auto-select is on, checkboxes are deferred entirely - the
45
+ // orchestrator picks at start time, so user picks are ignored.
46
+ const checkboxDisabledForAuto = !!autoSelectMode;
47
 
48
  return (
49
  <div className="ccai-dropdown-wrap" ref={ref}>
50
  <button
51
  className="btn-sm btn-outline ccai-dropdown-trigger"
52
  onClick={() => setOpen(o => !o)}
53
+ title={
54
+ autoSelectMode
55
+ ? 'Auto-select mode: participants picked at chat start by the orchestrator'
56
+ : 'Add or remove participants'
57
+ }
58
  >
59
  <Users size={14} />
60
+ <span>
61
+ {autoSelectMode
62
+ ? 'Participants Auto Selected'
63
+ : `Participants (${selectedIds.length}/${maxParticipants})`}
64
+ </span>
65
  <ChevronDown size={12} />
66
  </button>
67
  {open && (
68
  <div className="ccai-dropdown-panel">
69
  <div className="ccai-dropdown-section">
70
+ <button
71
+ type="button"
72
+ className={
73
+ 'ccai-dropdown-autoselect' +
74
+ (autoSelectMode ? ' ccai-dropdown-autoselect-on' : '')
75
+ }
76
+ onClick={() => onToggleAutoSelectMode?.(!autoSelectMode)}
77
+ title={
78
+ autoSelectMode
79
+ ? 'Turn off auto-select and resume manual picking'
80
+ : 'Defer participant selection until chat start; the orchestrator will pick the most relevant.'
81
+ }
82
+ >
83
+ {autoSelectMode ? <Check size={14} /> : <Wand2 size={14} />}
84
+ <span>Select {maxParticipants} Automatically</span>
85
+ </button>
86
+ <div className="ccai-dropdown-autoselect-help">
87
+ {autoSelectMode
88
+ ? 'The orchestrator will pick the most relevant participants for your question when you start the chat.'
89
+ : 'Or pick manually from the lists below.'}
90
+ </div>
91
+ </div>
92
+ <div className="ccai-dropdown-divider" />
93
+ <div
94
+ className={
95
+ 'ccai-dropdown-section' +
96
+ (checkboxDisabledForAuto ? ' ccai-dropdown-section-muted' : '')
97
+ }
98
+ >
99
  <div className="ccai-dropdown-section-title">Neon.ai Personas</div>
100
  {(catalog?.neon || []).length === 0 && (
101
  <div className="ccai-dropdown-empty">
 
107
  key={p.participant_id}
108
  participant={p}
109
  checked={isSelected(p.participant_id)}
110
+ disabledForAdd={
111
+ checkboxDisabledForAuto
112
+ || (atCap && !isSelected(p.participant_id))
113
+ }
114
+ autoSelectActive={checkboxDisabledForAuto}
115
  onToggle={() => onToggleParticipant(p, 'neon')}
116
  />
117
  ))}
118
  </div>
119
  <div className="ccai-dropdown-divider" />
120
+ <div
121
+ className={
122
+ 'ccai-dropdown-section' +
123
+ (checkboxDisabledForAuto ? ' ccai-dropdown-section-muted' : '')
124
+ }
125
+ >
126
  <div className="ccai-dropdown-section-title">Extra Personas</div>
127
  {(catalog?.extra || []).map((p) => (
128
  <DropdownItem
129
  key={p.participant_id}
130
  participant={p}
131
  checked={isSelected(p.participant_id)}
132
+ disabledForAdd={
133
+ checkboxDisabledForAuto
134
+ || (atCap && !isSelected(p.participant_id))
135
+ }
136
+ autoSelectActive={checkboxDisabledForAuto}
137
  onToggle={() => onToggleParticipant(p, 'extra')}
138
  />
139
  ))}
140
  </div>
141
  <div className="ccai-dropdown-divider" />
142
+ <div
143
+ className={
144
+ 'ccai-dropdown-section' +
145
+ (checkboxDisabledForAuto ? ' ccai-dropdown-section-muted' : '')
146
+ }
147
+ >
148
  <div className="ccai-dropdown-section-title">Expert Personas</div>
149
  {(expertPersonas || []).length === 0 && (
150
  <div className="ccai-dropdown-empty">
 
156
  key={p.participant_id}
157
  participant={p}
158
  checked={isSelected(p.participant_id)}
159
+ disabledForAdd={
160
+ checkboxDisabledForAuto
161
+ || (atCap && !isSelected(p.participant_id))
162
+ }
163
+ autoSelectActive={checkboxDisabledForAuto}
164
  onToggle={() => onToggleParticipant(p, 'expert')}
165
  />
166
  ))}
 
178
  );
179
  }
180
 
181
+ function DropdownItem({ participant, checked, disabledForAdd, autoSelectActive, onToggle }) {
182
  return (
183
  <label
184
  className={
 
186
  (checked ? ' ccai-dropdown-item-checked' : '') +
187
  (disabledForAdd ? ' ccai-dropdown-item-disabled' : '')
188
  }
189
+ title={
190
+ autoSelectActive
191
+ ? 'Disabled while auto-select is on'
192
+ : (disabledForAdd ? 'Participant cap reached' : '')
193
+ }
194
  >
195
  <input
196
  type="checkbox"
frontend/src/components/ParticipantSidebar.js CHANGED
@@ -14,19 +14,39 @@ export default function ParticipantSidebar({
14
  modelAssignments,
15
  onToggleEnabled,
16
  onRemove,
 
 
17
  }) {
 
 
 
 
 
 
18
  return (
19
  <aside className="sidebar ccai-sidebar">
20
  <div className="ccai-sidebar-header">
21
  <h2 className="sidebar-title">Participants</h2>
22
  <div className="ccai-sidebar-help">
23
- {participants.length === 0 ? (
 
 
24
  <em>Use the Participants dropdown in the header to add some.</em>
25
  ) : (
26
  <em>Drag to reorder is not supported yet — order is by selection.</em>
27
  )}
28
  </div>
29
  </div>
 
 
 
 
 
 
 
 
 
 
30
  {participants.map((p) => {
31
  const enabled = enabledMap[p.participant_id] !== false;
32
  const modelOverride = modelAssignments[p.participant_id];
 
14
  modelAssignments,
15
  onToggleEnabled,
16
  onRemove,
17
+ autoSelectMode,
18
+ maxParticipants,
19
  }) {
20
+ // In auto-select mode with no chat in progress, the sidebar shows a
21
+ // placeholder explaining the deferred selection. Once the chat
22
+ // starts, App.js populates `participants` with the LLM-chosen set
23
+ // and the regular cards render normally.
24
+ const showAutoPlaceholder = autoSelectMode && participants.length === 0;
25
+
26
  return (
27
  <aside className="sidebar ccai-sidebar">
28
  <div className="ccai-sidebar-header">
29
  <h2 className="sidebar-title">Participants</h2>
30
  <div className="ccai-sidebar-help">
31
+ {showAutoPlaceholder ? (
32
+ <em>Auto-select is on.</em>
33
+ ) : participants.length === 0 ? (
34
  <em>Use the Participants dropdown in the header to add some.</em>
35
  ) : (
36
  <em>Drag to reorder is not supported yet — order is by selection.</em>
37
  )}
38
  </div>
39
  </div>
40
+ {showAutoPlaceholder && (
41
+ <div className="ccai-sidebar-autoselect-empty">
42
+ <strong>Auto-select: {maxParticipants} participants</strong>
43
+ <div style={{ marginTop: 4 }}>
44
+ When you start the chat, the orchestrator will pick the
45
+ {' '}<strong>{maxParticipants}</strong> participants whose
46
+ expertise best fits your question.
47
+ </div>
48
+ </div>
49
+ )}
50
  {participants.map((p) => {
51
  const enabled = enabledMap[p.participant_id] !== false;
52
  const modelOverride = modelAssignments[p.participant_id];
frontend/src/components/PromptCatalogModal.js ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Copy, Check, Download } from 'lucide-react';
3
+
4
+ /**
5
+ * Transparency modal that surfaces every prompt template the
6
+ * orchestrator and participants use during a chat, grouped by phase.
7
+ * Each item shows a humanized title, a 1-2 sentence purpose, the
8
+ * runtime template variables the backend interpolates, and the full
9
+ * template in a copy-able <pre> block. A "Download as .txt" button
10
+ * in the header dumps the whole catalog in a flat human-readable form.
11
+ *
12
+ * Same shell pattern as ConversationLimitsModal / CredentialSummaryModal.
13
+ */
14
+ export default function PromptCatalogModal({
15
+ isOpen,
16
+ catalog,
17
+ onClose,
18
+ }) {
19
+ // Note: hooks must run on every render, so this filename memo lives
20
+ // ABOVE the `if (!isOpen) return null` early return. The dependency
21
+ // on `isOpen` makes the filename timestamp regenerate each time the
22
+ // modal opens (i.e. each download gets a fresh stamp).
23
+ const filename = useMemo(() => {
24
+ const now = new Date();
25
+ const pad = (n) => String(n).padStart(2, '0');
26
+ return (
27
+ 'ccai-prompts-'
28
+ + `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
29
+ + `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
30
+ + '.txt'
31
+ );
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ }, [isOpen]);
34
+
35
+ if (!isOpen) return null;
36
+
37
+ const handleDownload = () => {
38
+ if (!catalog) return;
39
+ const txt = renderCatalogAsText(catalog);
40
+ const blob = new Blob([txt], { type: 'text/plain;charset=utf-8' });
41
+ const url = URL.createObjectURL(blob);
42
+ const a = document.createElement('a');
43
+ a.href = url;
44
+ a.download = filename;
45
+ a.click();
46
+ URL.revokeObjectURL(url);
47
+ };
48
+
49
+ return (
50
+ <div className="ccai-credentials-overlay">
51
+ <div className="ccai-credentials-card">
52
+ <div className="ccai-credentials-header">
53
+ <div>
54
+ <h2>Current chat prompts</h2>
55
+ <div className="ccai-credentials-subtitle">
56
+ Every prompt template the orchestrator and participants
57
+ use during a chat, in conversation order. Variables in
58
+ {' '}<code>{'{braces}'}</code> are filled in at runtime.
59
+ </div>
60
+ </div>
61
+ <div className="ccai-tab-spacer" />
62
+ <button
63
+ className="btn-sm btn-outline"
64
+ onClick={handleDownload}
65
+ disabled={!catalog}
66
+ title="Download the full catalog as a .txt file"
67
+ >
68
+ <Download size={14} style={{ marginRight: 4 }} />
69
+ Download as .txt
70
+ </button>
71
+ <button className="modal-close" onClick={onClose}>&times;</button>
72
+ </div>
73
+
74
+ <div className="ccai-credentials-body">
75
+ {!catalog && (
76
+ <div className="ccai-credentials-empty">Loading prompts...</div>
77
+ )}
78
+ {catalog && (catalog.groups || []).map((g) => (
79
+ <div key={g.title} className="ccai-prompt-group">
80
+ <div className="ccai-prompt-group-title">{g.title}</div>
81
+ {(g.items || []).map((item) => (
82
+ <PromptItem key={item.name} item={item} />
83
+ ))}
84
+ </div>
85
+ ))}
86
+ </div>
87
+ </div>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ function PromptItem({ item }) {
93
+ const [copied, setCopied] = useState(false);
94
+ const displayName = humanizeName(item.name);
95
+
96
+ const handleCopy = async () => {
97
+ try {
98
+ await navigator.clipboard.writeText(item.template || '');
99
+ setCopied(true);
100
+ setTimeout(() => setCopied(false), 1500);
101
+ } catch (err) {
102
+ console.warn('Clipboard copy failed:', err);
103
+ }
104
+ };
105
+
106
+ return (
107
+ <div className="ccai-prompt-item">
108
+ <div className="ccai-prompt-item-head">
109
+ <div className="ccai-prompt-item-title">{displayName}</div>
110
+ <button
111
+ className="ccai-prompt-copy-btn"
112
+ onClick={handleCopy}
113
+ title="Copy template to clipboard"
114
+ >
115
+ {copied ? <Check size={12} /> : <Copy size={12} />}
116
+ {copied ? 'Copied' : 'Copy'}
117
+ </button>
118
+ </div>
119
+ <div className="ccai-prompt-purpose">{item.purpose}</div>
120
+ {item.variables && item.variables.length > 0 && (
121
+ <div className="ccai-prompt-vars">
122
+ <strong>Variables:</strong>{' '}
123
+ {item.variables.map((v, i) => (
124
+ <span key={v}>
125
+ <code>{`{${v}}`}</code>
126
+ {i < item.variables.length - 1 ? ', ' : ''}
127
+ </span>
128
+ ))}
129
+ </div>
130
+ )}
131
+ <pre className="ccai-prompt-template">{item.template}</pre>
132
+ </div>
133
+ );
134
+ }
135
+
136
+ /**
137
+ * "INITIAL_OPINION_PROMPT" -> "Initial Opinion Prompt".
138
+ * Splits on underscores, lowercases everything, capitalizes each word.
139
+ * Drops nothing (e.g. "Prompt" suffix stays) so the displayed name
140
+ * still matches the constant a developer might grep for.
141
+ */
142
+ function humanizeName(name) {
143
+ if (!name) return '';
144
+ return name
145
+ .split('_')
146
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
147
+ .join(' ');
148
+ }
149
+
150
+ /**
151
+ * Flat human-readable .txt dump used by the Download button. Matches
152
+ * the spec format: title banner, per-group separator, per-item header
153
+ * with purpose + variables, then the indented template body.
154
+ */
155
+ function renderCatalogAsText(catalog) {
156
+ const now = new Date().toISOString();
157
+ const lines = [];
158
+ const banner = '═'.repeat(64);
159
+ lines.push(banner);
160
+ lines.push('Collaborative Conversational AI (CCAI) Demo — Current chat prompts');
161
+ lines.push(`Generated: ${now}`);
162
+ lines.push(banner);
163
+ lines.push('');
164
+
165
+ for (const group of catalog.groups || []) {
166
+ const sep = '─'.repeat(12);
167
+ lines.push(`${sep} ${group.title} ${sep}`);
168
+ lines.push('');
169
+ for (const item of (group.items || [])) {
170
+ lines.push(`## ${humanizeName(item.name)}`);
171
+ lines.push(`Purpose: ${item.purpose}`);
172
+ if (item.variables && item.variables.length > 0) {
173
+ lines.push(
174
+ 'Variables: '
175
+ + item.variables.map(v => `{${v}}`).join(', '),
176
+ );
177
+ }
178
+ lines.push('');
179
+ // Indent each template line by 4 spaces so the body is
180
+ // visually distinct from the metadata in a plain-text viewer.
181
+ for (const ln of (item.template || '').split('\n')) {
182
+ lines.push(' ' + ln);
183
+ }
184
+ lines.push('');
185
+ }
186
+ }
187
+
188
+ return lines.join('\n');
189
+ }
frontend/src/styles/ccai.css CHANGED
@@ -1,5 +1,6 @@
1
  /* ════════════════════════════════════════════════════════════════
2
- CCAI Vibe Demo - additive styles on top of LLMChats3 baseline.
 
3
  Anything new (multi-participant sidebar, dropdown, expert modal,
4
  table view, orchestrator banners, failsafe banner) lives here.
5
  ════════════════════════════════════════════════════════════════ */
@@ -566,6 +567,191 @@
566
  }
567
 
568
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
569
  /* ── Orchestrator messages ─────────────────────────────────────── */
570
 
571
  .ccai-orchestrator-msg {
@@ -833,3 +1019,269 @@
833
  width: 100vw;
834
  }
835
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  /* ════════════════════════════════════════════════════════════════
2
+ Collaborative Conversational AI (CCAI) Demo - additive styles on
3
+ top of LLMChats3 baseline.
4
  Anything new (multi-participant sidebar, dropdown, expert modal,
5
  table view, orchestrator banners, failsafe banner) lives here.
6
  ════════════════════════════════════════════════════════════════ */
 
567
  }
568
 
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
+
576
+ .ccai-credentials-overlay {
577
+ position: fixed;
578
+ inset: 0;
579
+ background: rgba(0, 0, 0, 0.5);
580
+ display: flex;
581
+ align-items: center;
582
+ justify-content: center;
583
+ z-index: 1000;
584
+ }
585
+
586
+ .ccai-credentials-card {
587
+ background: var(--bg-primary);
588
+ border: 1px solid var(--border-primary);
589
+ border-radius: 12px;
590
+ width: min(880px, 95vw);
591
+ max-height: 90vh;
592
+ display: flex;
593
+ flex-direction: column;
594
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
595
+ }
596
+
597
+ .ccai-credentials-header {
598
+ display: flex;
599
+ align-items: flex-start;
600
+ gap: 12px;
601
+ padding: 16px 20px;
602
+ border-bottom: 1px solid var(--border-primary);
603
+ }
604
+
605
+ .ccai-credentials-header h2 {
606
+ margin: 0;
607
+ font-size: 16px;
608
+ font-weight: 600;
609
+ color: var(--text-primary);
610
+ }
611
+
612
+ .ccai-credentials-subtitle {
613
+ margin-top: 4px;
614
+ font-size: 12px;
615
+ color: var(--text-tertiary);
616
+ line-height: 1.4;
617
+ max-width: 560px;
618
+ }
619
+
620
+ .ccai-credentials-question {
621
+ margin: 0 20px;
622
+ padding: 10px 12px;
623
+ background: var(--bg-secondary);
624
+ border-radius: 8px;
625
+ font-size: 13px;
626
+ color: var(--text-secondary);
627
+ }
628
+
629
+ .ccai-credentials-question strong {
630
+ display: block;
631
+ font-size: 11px;
632
+ text-transform: uppercase;
633
+ color: var(--text-tertiary);
634
+ letter-spacing: 0.04em;
635
+ margin-bottom: 4px;
636
+ }
637
+
638
+ .ccai-credentials-body {
639
+ padding: 16px 20px;
640
+ display: flex;
641
+ flex-direction: column;
642
+ gap: 12px;
643
+ overflow-y: auto;
644
+ }
645
+
646
+ .ccai-credentials-empty {
647
+ padding: 24px;
648
+ text-align: center;
649
+ font-size: 13px;
650
+ color: var(--text-tertiary);
651
+ border: 1px dashed var(--border-primary);
652
+ border-radius: 8px;
653
+ }
654
+
655
+ .ccai-credential-card {
656
+ padding: 12px 14px;
657
+ border: 1px solid var(--border-primary);
658
+ border-radius: 10px;
659
+ background: var(--bg-secondary);
660
+ }
661
+
662
+ .ccai-credential-card-head {
663
+ display: flex;
664
+ align-items: center;
665
+ gap: 12px;
666
+ margin-bottom: 8px;
667
+ flex-wrap: wrap;
668
+ }
669
+
670
+ .ccai-credential-name {
671
+ font-size: 14px;
672
+ font-weight: 600;
673
+ color: var(--text-primary);
674
+ }
675
+
676
+ .ccai-credibility-wrap {
677
+ display: inline-flex;
678
+ align-items: center;
679
+ gap: 6px;
680
+ margin-left: auto;
681
+ }
682
+
683
+ .ccai-credibility-label {
684
+ font-size: 10px;
685
+ text-transform: uppercase;
686
+ letter-spacing: 0.04em;
687
+ color: var(--text-tertiary);
688
+ }
689
+
690
+ .ccai-credibility-bar {
691
+ width: 120px;
692
+ height: 6px;
693
+ background: var(--border-primary);
694
+ border-radius: 3px;
695
+ overflow: hidden;
696
+ }
697
+
698
+ .ccai-credibility-fill {
699
+ height: 100%;
700
+ background: linear-gradient(
701
+ 90deg,
702
+ var(--accent-primary, #6366F1),
703
+ var(--accent-primary, #6366F1)
704
+ );
705
+ transition: width 0.3s ease;
706
+ }
707
+
708
+ .ccai-credibility-num {
709
+ font-size: 12px;
710
+ font-weight: 600;
711
+ font-variant-numeric: tabular-nums;
712
+ color: var(--text-secondary);
713
+ min-width: 32px;
714
+ text-align: right;
715
+ }
716
+
717
+ .ccai-credential-row {
718
+ display: grid;
719
+ grid-template-columns: 110px 1fr;
720
+ gap: 8px;
721
+ padding: 4px 0;
722
+ font-size: 13px;
723
+ line-height: 1.45;
724
+ }
725
+
726
+ .ccai-credential-row-label {
727
+ font-size: 10px;
728
+ text-transform: uppercase;
729
+ letter-spacing: 0.04em;
730
+ color: var(--text-tertiary);
731
+ font-weight: 600;
732
+ padding-top: 2px;
733
+ }
734
+
735
+ .ccai-credential-row-value {
736
+ color: var(--text-primary);
737
+ }
738
+
739
+ @media (max-width: 600px) {
740
+ .ccai-credentials-card {
741
+ max-height: 100vh;
742
+ border-radius: 0;
743
+ width: 100vw;
744
+ }
745
+ .ccai-credential-row {
746
+ grid-template-columns: 1fr;
747
+ gap: 2px;
748
+ }
749
+ .ccai-credibility-bar {
750
+ width: 80px;
751
+ }
752
+ }
753
+
754
+
755
  /* ── Orchestrator messages ─────────────────────────────────────── */
756
 
757
  .ccai-orchestrator-msg {
 
1019
  width: 100vw;
1020
  }
1021
  }
1022
+
1023
+
1024
+ /* ── Conversation limits modal ─────────────────────────────���──────
1025
+ Re-uses .ccai-credentials-overlay/.ccai-credentials-card/.ccai-
1026
+ credentials-header/.ccai-credentials-body for the shell so the
1027
+ look matches the credential summary modal. The classes below
1028
+ style the per-section list and per-field stepper row. */
1029
+
1030
+ .ccai-limits-group {
1031
+ display: flex;
1032
+ flex-direction: column;
1033
+ gap: 10px;
1034
+ padding: 12px 14px;
1035
+ background: var(--bg-secondary);
1036
+ border: 1px solid var(--border-primary);
1037
+ border-radius: 10px;
1038
+ }
1039
+
1040
+ .ccai-limits-group + .ccai-limits-group {
1041
+ margin-top: 4px;
1042
+ }
1043
+
1044
+ .ccai-limits-group-title {
1045
+ font-size: 13px;
1046
+ font-weight: 600;
1047
+ color: var(--text-primary);
1048
+ text-transform: uppercase;
1049
+ letter-spacing: 0.04em;
1050
+ border-bottom: 1px solid var(--border-primary);
1051
+ padding-bottom: 6px;
1052
+ margin-bottom: 2px;
1053
+ }
1054
+
1055
+ .ccai-limit-row {
1056
+ display: flex;
1057
+ flex-direction: column;
1058
+ gap: 4px;
1059
+ padding: 6px 0;
1060
+ }
1061
+
1062
+ .ccai-limit-row + .ccai-limit-row {
1063
+ border-top: 1px dashed var(--border-primary);
1064
+ padding-top: 10px;
1065
+ }
1066
+
1067
+ .ccai-limit-row-head {
1068
+ display: flex;
1069
+ align-items: center;
1070
+ justify-content: space-between;
1071
+ gap: 12px;
1072
+ flex-wrap: wrap;
1073
+ }
1074
+
1075
+ .ccai-limit-label {
1076
+ font-size: 14px;
1077
+ font-weight: 600;
1078
+ color: var(--text-primary);
1079
+ }
1080
+
1081
+ .ccai-limit-stepper {
1082
+ display: inline-flex;
1083
+ align-items: center;
1084
+ gap: 8px;
1085
+ }
1086
+
1087
+ .ccai-limit-input {
1088
+ width: 64px;
1089
+ padding: 4px 6px;
1090
+ background: var(--bg-primary);
1091
+ border: 1px solid var(--border-primary);
1092
+ border-radius: 6px;
1093
+ color: var(--text-primary);
1094
+ font-size: 14px;
1095
+ text-align: right;
1096
+ }
1097
+
1098
+ .ccai-limit-range {
1099
+ font-size: 12px;
1100
+ color: var(--text-secondary);
1101
+ }
1102
+
1103
+ .ccai-limit-reset {
1104
+ font-size: 12px;
1105
+ background: transparent;
1106
+ border: 1px solid var(--border-primary);
1107
+ color: var(--text-secondary);
1108
+ padding: 2px 8px;
1109
+ border-radius: 6px;
1110
+ cursor: pointer;
1111
+ }
1112
+
1113
+ .ccai-limit-reset:hover {
1114
+ background: var(--card-bg);
1115
+ color: var(--text-primary);
1116
+ }
1117
+
1118
+ .ccai-limit-help {
1119
+ font-size: 12.5px;
1120
+ color: var(--text-secondary);
1121
+ line-height: 1.45;
1122
+ }
1123
+
1124
+
1125
+ /* ── Auto-select participants (dropdown + sidebar placeholder) ──── */
1126
+
1127
+ .ccai-dropdown-autoselect {
1128
+ display: flex;
1129
+ align-items: center;
1130
+ gap: 8px;
1131
+ width: 100%;
1132
+ padding: 8px 10px;
1133
+ background: var(--bg-secondary);
1134
+ border: 1px solid var(--border-primary);
1135
+ border-radius: 8px;
1136
+ color: var(--text-primary);
1137
+ font-size: 13px;
1138
+ font-weight: 600;
1139
+ cursor: pointer;
1140
+ text-align: left;
1141
+ }
1142
+
1143
+ .ccai-dropdown-autoselect:hover {
1144
+ border-color: var(--accent-primary, var(--text-primary));
1145
+ }
1146
+
1147
+ .ccai-dropdown-autoselect-on {
1148
+ background: var(--accent-primary, var(--bg-primary));
1149
+ color: var(--bg-primary, var(--text-primary));
1150
+ border-color: transparent;
1151
+ }
1152
+
1153
+ .ccai-dropdown-autoselect-help {
1154
+ font-size: 11.5px;
1155
+ color: var(--text-secondary);
1156
+ padding: 6px 4px 0;
1157
+ line-height: 1.4;
1158
+ }
1159
+
1160
+ .ccai-dropdown-section-muted {
1161
+ opacity: 0.55;
1162
+ pointer-events: none;
1163
+ }
1164
+
1165
+ .ccai-sidebar-autoselect-empty {
1166
+ margin: 12px;
1167
+ padding: 12px;
1168
+ border: 1px dashed var(--border-primary);
1169
+ border-radius: 8px;
1170
+ color: var(--text-secondary);
1171
+ font-size: 12.5px;
1172
+ line-height: 1.45;
1173
+ }
1174
+
1175
+ .ccai-sidebar-autoselect-empty strong {
1176
+ color: var(--text-primary);
1177
+ font-size: 13px;
1178
+ }
1179
+
1180
+
1181
+ /* ── Prompt catalog modal ─────────────────────────────────────────
1182
+ Re-uses .ccai-credentials-overlay/.ccai-credentials-card shell.
1183
+ The classes below style the per-group sections and per-item
1184
+ purpose / variables / template blocks. */
1185
+
1186
+ .ccai-prompt-group {
1187
+ display: flex;
1188
+ flex-direction: column;
1189
+ gap: 10px;
1190
+ padding: 12px 14px;
1191
+ background: var(--bg-secondary);
1192
+ border: 1px solid var(--border-primary);
1193
+ border-radius: 10px;
1194
+ }
1195
+
1196
+ .ccai-prompt-group + .ccai-prompt-group {
1197
+ margin-top: 4px;
1198
+ }
1199
+
1200
+ .ccai-prompt-group-title {
1201
+ font-size: 13px;
1202
+ font-weight: 600;
1203
+ color: var(--text-primary);
1204
+ text-transform: uppercase;
1205
+ letter-spacing: 0.04em;
1206
+ border-bottom: 1px solid var(--border-primary);
1207
+ padding-bottom: 6px;
1208
+ }
1209
+
1210
+ .ccai-prompt-item {
1211
+ display: flex;
1212
+ flex-direction: column;
1213
+ gap: 6px;
1214
+ padding: 8px 0;
1215
+ }
1216
+
1217
+ .ccai-prompt-item + .ccai-prompt-item {
1218
+ border-top: 1px dashed var(--border-primary);
1219
+ padding-top: 12px;
1220
+ }
1221
+
1222
+ .ccai-prompt-item-head {
1223
+ display: flex;
1224
+ align-items: center;
1225
+ justify-content: space-between;
1226
+ gap: 12px;
1227
+ }
1228
+
1229
+ .ccai-prompt-item-title {
1230
+ font-size: 14px;
1231
+ font-weight: 600;
1232
+ color: var(--text-primary);
1233
+ }
1234
+
1235
+ .ccai-prompt-copy-btn {
1236
+ display: inline-flex;
1237
+ align-items: center;
1238
+ gap: 4px;
1239
+ font-size: 12px;
1240
+ padding: 3px 8px;
1241
+ background: transparent;
1242
+ border: 1px solid var(--border-primary);
1243
+ color: var(--text-secondary);
1244
+ border-radius: 6px;
1245
+ cursor: pointer;
1246
+ }
1247
+
1248
+ .ccai-prompt-copy-btn:hover {
1249
+ background: var(--card-bg);
1250
+ color: var(--text-primary);
1251
+ }
1252
+
1253
+ .ccai-prompt-purpose {
1254
+ font-size: 12.5px;
1255
+ color: var(--text-secondary);
1256
+ line-height: 1.45;
1257
+ }
1258
+
1259
+ .ccai-prompt-vars {
1260
+ font-size: 12px;
1261
+ color: var(--text-secondary);
1262
+ }
1263
+
1264
+ .ccai-prompt-vars code {
1265
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1266
+ background: var(--bg-primary);
1267
+ border: 1px solid var(--border-primary);
1268
+ border-radius: 4px;
1269
+ padding: 1px 4px;
1270
+ font-size: 11.5px;
1271
+ }
1272
+
1273
+ .ccai-prompt-template {
1274
+ background: var(--bg-primary);
1275
+ border: 1px solid var(--border-primary);
1276
+ border-radius: 6px;
1277
+ padding: 10px 12px;
1278
+ margin: 0;
1279
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1280
+ font-size: 12px;
1281
+ line-height: 1.45;
1282
+ color: var(--text-primary);
1283
+ white-space: pre-wrap;
1284
+ overflow-x: auto;
1285
+ max-height: 280px;
1286
+ overflow-y: auto;
1287
+ }
frontend/src/styles/components.css CHANGED
@@ -747,6 +747,13 @@
747
  top: 100%;
748
  margin-top: 4px;
749
  min-width: 220px;
 
 
 
 
 
 
 
750
  background: var(--card-bg);
751
  border: 1px solid var(--border-primary);
752
  border-radius: 10px;
 
747
  top: 100%;
748
  margin-top: 4px;
749
  min-width: 220px;
750
+ /* Cap the panel at a bit less than the viewport so it never extends
751
+ past the bottom of the window; long content scrolls within. The
752
+ 16px subtraction leaves a comfortable gap for shadow + breathing
753
+ room beneath the header. */
754
+ max-height: calc(100vh - 80px);
755
+ overflow-y: auto;
756
+ overscroll-behavior: contain;
757
  background: var(--card-bg);
758
  border: 1px solid var(--border-primary);
759
  border-radius: 10px;
frontend/src/utils/api.js CHANGED
@@ -1,6 +1,16 @@
 
 
 
 
 
 
 
 
 
 
1
  const API_BASE = process.env.REACT_APP_API_URL !== undefined
2
  ? process.env.REACT_APP_API_URL
3
- : 'http://localhost:8000';
4
 
5
  export async function fetchModels() {
6
  const resp = await fetch(`${API_BASE}/api/models`, { cache: 'no-store' });
@@ -115,6 +125,7 @@ function eventHandlerKey(eventType) {
115
  case 'failsafe_pause': return 'onFailsafePause';
116
  case 'orchestrator_cap_pause': return 'onOrchestratorCapPause';
117
  case 'participant_error': return 'onParticipantError';
 
118
  default: return null;
119
  }
120
  }
@@ -178,6 +189,61 @@ export async function fetchTableView(sessionId) {
178
  return resp.json();
179
  }
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  export async function getAuthStatus() {
182
  const resp = await fetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
183
  if (!resp.ok) return { logged_in: false, remaining_conversations: -1 };
 
1
+ // Default to '' (relative URLs) so any production-style build - whether
2
+ // done inside Docker (Dockerfile sets REACT_APP_API_URL=) or on the host
3
+ // without that env var - hits the same origin that served the page. This
4
+ // avoids the cross-origin trap where a `npm run build` on the host
5
+ // without REACT_APP_API_URL would silently bake `http://localhost:8000`
6
+ // into the bundle and break every API call from a Docker deployment.
7
+ //
8
+ // If you want the CRA dev server (`npm start` on :3000) to talk to a
9
+ // FastAPI backend on :8000, set REACT_APP_API_URL=http://localhost:8000
10
+ // in `frontend/.env.development` or your shell.
11
  const API_BASE = process.env.REACT_APP_API_URL !== undefined
12
  ? process.env.REACT_APP_API_URL
13
+ : '';
14
 
15
  export async function fetchModels() {
16
  const resp = await fetch(`${API_BASE}/api/models`, { cache: 'no-store' });
 
125
  case 'failsafe_pause': return 'onFailsafePause';
126
  case 'orchestrator_cap_pause': return 'onOrchestratorCapPause';
127
  case 'participant_error': return 'onParticipantError';
128
+ case 'credentials_updated': return 'onCredentialsUpdated';
129
  default: return null;
130
  }
131
  }
 
189
  return resp.json();
190
  }
191
 
192
+ /**
193
+ * Fetch the catalog of every prompt template the orchestrator and
194
+ * participants use, grouped by phase and annotated with purpose and
195
+ * runtime variables. Backs the "View current chat prompts" modal.
196
+ */
197
+ export async function fetchPromptCatalog() {
198
+ const resp = await fetch(`${API_BASE}/api/chat/prompts/catalog`, { cache: 'no-store' });
199
+ if (!resp.ok) throw new Error('Failed to fetch prompt catalog');
200
+ return resp.json();
201
+ }
202
+
203
+ /**
204
+ * Ask the backend to pick the top `count` participants from the
205
+ * candidate pool by relevance to the question. Used by the
206
+ * "Select N Automatically" toggle in the participants dropdown.
207
+ *
208
+ * Returns: { selected: [participant_id, ...], rationale: "..." }
209
+ */
210
+ export async function autoSelectParticipants({ question, count, candidates, orchestrator_model_id }) {
211
+ const resp = await fetch(`${API_BASE}/api/chat/auto-select-participants`, {
212
+ method: 'POST',
213
+ headers: { 'Content-Type': 'application/json' },
214
+ body: JSON.stringify({ question, count, candidates, orchestrator_model_id }),
215
+ });
216
+ if (!resp.ok) {
217
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
218
+ throw new Error(err.detail || 'Auto-select failed');
219
+ }
220
+ return resp.json();
221
+ }
222
+
223
+ /**
224
+ * Fetch the user-tunable conversation-limit defaults, bounds, and
225
+ * descriptions. The frontend uses this to render the "Conversation
226
+ * limits" settings modal entirely from the server schema, so adding
227
+ * a new knob in the backend doesn't require a frontend change.
228
+ *
229
+ * Shape: { defaults: {field: int}, bounds: {field: {min, max}},
230
+ * descriptions: {field: {group, label, help}} }
231
+ */
232
+ export async function fetchConversationLimitsDefaults() {
233
+ const resp = await fetch(`${API_BASE}/api/chat/limits/defaults`, { cache: 'no-store' });
234
+ if (!resp.ok) throw new Error('Failed to fetch conversation-limit defaults');
235
+ return resp.json();
236
+ }
237
+
238
+ export async function fetchCredentials(sessionId) {
239
+ const resp = await fetch(
240
+ `${API_BASE}/api/chat/${sessionId}/credentials`,
241
+ { cache: 'no-store' },
242
+ );
243
+ if (!resp.ok) throw new Error('Credentials fetch failed');
244
+ return resp.json();
245
+ }
246
+
247
  export async function getAuthStatus() {
248
  const resp = await fetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
249
  if (!resp.ok) return { logged_in: false, remaining_conversations: -1 };
frontend/src/utils/storage.js CHANGED
@@ -19,6 +19,15 @@ const DEFAULTS = {
19
  summarizer_model_id: null,
20
  max_participants: 5,
21
  theme: null,
 
 
 
 
 
 
 
 
 
22
  };
23
 
24
  function readAll() {
@@ -87,3 +96,11 @@ export function setMaxParticipants(n) {
87
  export function setTheme(theme) {
88
  return patchState({ theme });
89
  }
 
 
 
 
 
 
 
 
 
19
  summarizer_model_id: null,
20
  max_participants: 5,
21
  theme: null,
22
+ // Sparse map of user overrides to ConversationLimits fields. Empty
23
+ // map = "use server defaults". The server clamps anything we send,
24
+ // so storing whatever the user typed (including stale values from
25
+ // an earlier session) is safe.
26
+ conversation_limits: {},
27
+ // When true, the participants dropdown shows "Select N Automatically"
28
+ // as the active mode and the per-persona checkboxes are hidden.
29
+ // The auto-select happens just before /chat/start.
30
+ auto_select_mode: false,
31
  };
32
 
33
  function readAll() {
 
96
  export function setTheme(theme) {
97
  return patchState({ theme });
98
  }
99
+
100
+ export function setConversationLimits(limitsMap) {
101
+ return patchState({ conversation_limits: limitsMap || {} });
102
+ }
103
+
104
+ export function setAutoSelectMode(on) {
105
+ return patchState({ auto_select_mode: !!on });
106
+ }