Spaces:
Sleeping
Sleeping
Merge pull request #1 from NeonClary/dev-cursor
Browse filesAdding better chat formatting, menu items, title change & more
- .cursor/rules/docker-build.mdc +87 -0
- .dockerignore +61 -0
- Dockerfile +23 -2
- README.md +2 -2
- backend/app/api/chat.py +242 -2
- backend/app/data/demo_questions.json +4 -4
- backend/app/main.py +5 -1
- backend/app/services/auto_select.py +142 -0
- backend/app/services/models.py +195 -1
- backend/app/services/orchestrator.py +71 -29
- backend/app/services/prompts/__init__.py +2 -0
- backend/app/services/prompts/auto_select.py +28 -0
- backend/app/services/prompts/catalog.py +308 -0
- backend/app/services/prompts/critique.py +1 -1
- backend/tests/test_conversation_limits.py +141 -0
- backend/tests/test_csv_export.py +110 -12
- frontend/src/App.js +254 -25
- frontend/src/components/ConversationLimitsModal.js +212 -0
- frontend/src/components/CredentialSummaryModal.js +198 -0
- frontend/src/components/DevMenu.js +54 -20
- frontend/src/components/Header.js +5 -1
- frontend/src/components/ParticipantDropdown.js +83 -10
- frontend/src/components/ParticipantSidebar.js +21 -1
- frontend/src/components/PromptCatalogModal.js +189 -0
- frontend/src/styles/ccai.css +453 -1
- frontend/src/styles/components.css +7 -0
- frontend/src/utils/api.js +67 -1
- frontend/src/utils/storage.js +17 -0
.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
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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:
|
| 3 |
emoji: 🤝
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
|
@@ -11,7 +11,7 @@ hf_oauth_scopes:
|
|
| 11 |
pinned: false
|
| 12 |
---
|
| 13 |
|
| 14 |
-
#
|
| 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("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 54 |
-
"category": "
|
| 55 |
-
"title": "
|
| 56 |
-
"text": "I'm
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"{
|
| 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"{
|
| 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 >=
|
| 502 |
p.enabled = False
|
| 503 |
yield _sse("status", {
|
| 504 |
-
"message":
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 530 |
-
)
|
| 531 |
yield _sse("status", {
|
| 532 |
-
"message": f"Phase 2: critique round {round_number} of
|
| 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 >=
|
| 562 |
p.enabled = False
|
| 563 |
-
yield _sse("status", {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 611 |
cred_block = credentials_to_block(session.credential_summary)
|
| 612 |
|
| 613 |
-
for iteration in range(
|
| 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
|
| 806 |
-
# that many in a row, we force a round-robin pick
|
| 807 |
queue: list[Participant] = list(actives)
|
| 808 |
last_addressed: str | None = None
|
| 809 |
dyad_run: int = 0
|
| 810 |
-
|
| 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 =
|
|
|
|
|
|
|
| 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 <
|
| 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.
|
| 1085 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 1168 |
-
|
| 1169 |
-
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from app.services.models import Phase, Session
|
| 3 |
from app.services.models import Participant
|
| 4 |
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
bob_row = parsed[5]
|
| 83 |
assert bob_row[0] == "Bob, Ph.D."
|
| 84 |
-
assert
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
def test_csv_export_no_field_count_drift():
|
| 88 |
-
"""Every row
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 12 |
-
exportChat, exportApiLog, fetchTableView,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
const
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
| 340 |
|
| 341 |
const controller = new AbortController();
|
| 342 |
abortRef.current = controller;
|
| 343 |
setIsRunning(true);
|
| 344 |
setMessages([]);
|
| 345 |
setSystemMessages([]);
|
| 346 |
-
setStatusText(
|
|
|
|
|
|
|
| 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 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}>×</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}>×</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}>×</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> -
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
>
|
| 45 |
<Users size={14} />
|
| 46 |
-
<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={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
onToggle={() => onToggleParticipant(p, 'neon')}
|
| 65 |
/>
|
| 66 |
))}
|
| 67 |
</div>
|
| 68 |
<div className="ccai-dropdown-divider" />
|
| 69 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
onToggle={() => onToggleParticipant(p, 'extra')}
|
| 78 |
/>
|
| 79 |
))}
|
| 80 |
</div>
|
| 81 |
<div className="ccai-dropdown-divider" />
|
| 82 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
|
|
|
|
|
|
| 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}>×</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 |
-
|
|
|
|
| 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 |
-
: '
|
| 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 |
+
}
|