Spaces:
Sleeping
Build CCAI Vibe Demo on top of LLMChats3 baseline
Browse filesMulti-participant Collaborative Conversational AI demo: the orchestrator
runs a six-phase state machine (Initial Opinions, Critique x2, Status
Assessment, Finalization, Consensus, Closure), produces a JSON
Credential Summary keyed on credibility-for-question, and routes
addressed-to messages through alliance detection. Two failsafes - 60+20
participant messages and 100+50 orchestrator calls - pause the loop
until the user clicks Continue. Every LLM response is funneled through
a centralized strip_thinking sanitizer before being stored, displayed,
or fed back to the summarizer.
Backend
- New: services/{models,prompts/*,credential,consensus,context_budget,
json_calls,extra_personas,orchestrator}.py, utils/sanitize.py,
api/personas.py, data/demo_questions.json (10 long-context prompts).
- Rewrote api/chat.py with /chat/start (N participants, summarizer +
max-participants overrides), /chat/{id}/continue, table-view +
csv-table exports.
- Rate limit bumped to 30/day per IP; HF org bypass kept.
- Sanitizer wired into both OpenAI-compat and HANA paths.
Frontend
- New: Header, ParticipantDropdown (Neon / Extra / Expert + Create...),
ParticipantSidebar (slider on/off, Remove when off, accordion),
ExpertPersonaModal (tabbed Structured/Freeform + role-style),
ChatTableView, OrchestratorMessage, FailsafePauseBanner, storage.js.
- DevMenu rewritten: orchestrator + summarizer pickers (summarizer
defaults to "Same as Orchestrator"), 3-9 max-participants stepper,
per-participant model assignment, table + CSV download buttons.
- localStorage namespace 'ccai-vibe-demo' for personas, selections,
enabled state, model assignments, theme, etc.
- Removed dead LLMSelector / PersonaAccordion / ExportBar.
Infra
- docker-compose.yml port 7860:7860 to match HF Space.
- README rewritten for CCAI architecture, secrets, and HF deployment.
- Pytest suite: 32 tests (sanitize, CSV escaping, tolerant JSON parser,
context-budget thresholds) - all passing.
Co-authored-by: Cursor <cursoragent@cursor.com>
- .gitignore +1 -0
- README.md +86 -16
- backend/app/api/chat.py +344 -67
- backend/app/api/personas.py +65 -0
- backend/app/clients/llm_router.py +7 -2
- backend/app/clients/openai_compat.py +7 -24
- backend/app/data/__init__.py +0 -0
- backend/app/data/demo_questions.json +65 -0
- backend/app/main.py +6 -5
- backend/app/middleware/rate_limit.py +5 -1
- backend/app/services/consensus.py +178 -0
- backend/app/services/context_budget.py +244 -0
- backend/app/services/credential.py +153 -0
- backend/app/services/extra_personas.py +128 -0
- backend/app/services/json_calls.py +145 -0
- backend/app/services/models.py +138 -0
- backend/app/services/orchestrator.py +983 -303
- backend/app/services/prompts/__init__.py +60 -0
- backend/app/services/prompts/closure.py +79 -0
- backend/app/services/prompts/consensus.py +112 -0
- backend/app/services/prompts/credential_summary.py +48 -0
- backend/app/services/prompts/critique.py +22 -0
- backend/app/services/prompts/directives.py +39 -0
- backend/app/services/prompts/finalization.py +22 -0
- backend/app/services/prompts/initial_opinions.py +24 -0
- backend/app/services/prompts/status_assessment.py +38 -0
- backend/app/utils/__init__.py +0 -0
- backend/app/utils/sanitize.py +81 -0
- backend/requirements.txt +2 -1
- backend/tests/__init__.py +0 -0
- backend/tests/test_context_budget.py +97 -0
- backend/tests/test_csv_export.py +98 -0
- backend/tests/test_json_calls.py +48 -0
- backend/tests/test_sanitize.py +60 -0
- docker-compose.yml +5 -2
- frontend/package-lock.json +0 -17
- frontend/src/App.js +357 -216
- frontend/src/components/AuthBadge.js +3 -2
- frontend/src/components/ChatArea.js +54 -18
- frontend/src/components/ChatControls.js +30 -21
- frontend/src/components/ChatTableView.js +72 -0
- frontend/src/components/DevMenu.js +180 -75
- frontend/src/components/ExpertPersonaModal.js +302 -0
- frontend/src/components/ExportBar.js +0 -79
- frontend/src/components/FailsafePauseBanner.js +27 -0
- frontend/src/components/Header.js +64 -0
- frontend/src/components/LLMSelector.js +0 -159
- frontend/src/components/MessageBubble.js +42 -9
- frontend/src/components/OrchestratorMessage.js +32 -0
- frontend/src/components/ParticipantDropdown.js +136 -0
|
@@ -9,3 +9,4 @@ dist/
|
|
| 9 |
.venv/
|
| 10 |
venv/
|
| 11 |
*.log
|
|
|
|
|
|
| 9 |
.venv/
|
| 10 |
venv/
|
| 11 |
*.log
|
| 12 |
+
.commit-msg.txt
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
| 6 |
sdk: docker
|
|
@@ -11,23 +11,72 @@ hf_oauth_scopes:
|
|
| 11 |
pinned: false
|
| 12 |
---
|
| 13 |
|
| 14 |
-
#
|
| 15 |
|
| 16 |
-
A
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
## Quick Start (local development)
|
| 19 |
|
| 20 |
```bash
|
| 21 |
-
# 1. Clone and set up environment
|
| 22 |
cp .env.example .env
|
| 23 |
# Edit .env with your API keys
|
| 24 |
|
| 25 |
-
#
|
| 26 |
cd backend
|
| 27 |
pip install -r requirements.txt
|
| 28 |
uvicorn app.main:app --reload --port 8000
|
| 29 |
|
| 30 |
-
#
|
| 31 |
cd frontend
|
| 32 |
npm install
|
| 33 |
npm start
|
|
@@ -39,20 +88,41 @@ npm start
|
|
| 39 |
cp .env.example .env
|
| 40 |
# Edit .env with your API keys
|
| 41 |
docker compose up --build
|
|
|
|
| 42 |
```
|
| 43 |
|
| 44 |
## HuggingFace Spaces Deployment
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
## Features
|
| 51 |
|
| 52 |
-
-
|
| 53 |
-
|
| 54 |
-
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: CCAI Vibe Demo
|
| 3 |
+
emoji: 🤝
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
| 6 |
sdk: docker
|
|
|
|
| 11 |
pinned: false
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# CCAI Vibe Demo
|
| 15 |
|
| 16 |
+
A demo of **Collaborative Conversational AI (CCAI)** - Neon.ai's patented
|
| 17 |
+
group-discussion technology. Up to 9 participants (any mix of AI personas,
|
| 18 |
+
human-defined "expert" personas, or - in the future - real humans, agents,
|
| 19 |
+
tools, or sensors) hold a structured group conversation facilitated by a
|
| 20 |
+
neutral **Orchestrator**, with the goal of reaching a real group decision
|
| 21 |
+
the way a thoughtful human meeting would.
|
| 22 |
+
|
| 23 |
+
This repo descends from
|
| 24 |
+
[NeonClary/LLMChats3](https://github.com/NeonClary/LLMChats3) and reuses
|
| 25 |
+
its color scheme, branding, settings menu structure, and chat formatting
|
| 26 |
+
verbatim. The CCAI multi-participant orchestration, expert-persona modal,
|
| 27 |
+
participant sidebar, and table view are layered on top.
|
| 28 |
+
|
| 29 |
+
## Architecture (one-pager)
|
| 30 |
+
|
| 31 |
+
- **Frontend** (React 19, react-markdown, lucide-react) - lives in
|
| 32 |
+
`frontend/`. Talks SSE to the backend.
|
| 33 |
+
- **Backend** (FastAPI, httpx) - lives in `backend/`. Routes:
|
| 34 |
+
- `GET /api/personas` — Neon HANA personas (vanilla/RAG filtered),
|
| 35 |
+
bundled extra personas, and (echoed) expert personas.
|
| 36 |
+
- `GET /api/demo-questions` — the bank of 10 long-context demo prompts.
|
| 37 |
+
- `POST /api/chat/start` — kicks off a CCAI session and returns SSE.
|
| 38 |
+
- `POST /api/chat/{id}/continue?reason=…` — resumes a paused session.
|
| 39 |
+
- `GET /api/chat/{id}/export?fmt=txt|md|csv-table` — exports.
|
| 40 |
+
- `GET /api/chat/{id}/table` — JSON for the table view.
|
| 41 |
+
- **Orchestrator state machine** — six phases (Initial Opinions,
|
| 42 |
+
Critique x2, Status Assessment, Finalization, Consensus, Closure)
|
| 43 |
+
with two failsafes (60+20 messages, 100+50 orchestrator calls) and
|
| 44 |
+
per-participant on-demand context summarization.
|
| 45 |
+
|
| 46 |
+
## CCAI Phase Overview
|
| 47 |
+
|
| 48 |
+
1. **Initial Opinions.** Each participant offers an independent first
|
| 49 |
+
opinion. The orchestrator builds a per-participant **Credential
|
| 50 |
+
Summary** in the background.
|
| 51 |
+
2. **Critique x 2.** Each participant gets two turns to critique
|
| 52 |
+
others, ask follow-ups, and revise. After Phase 2 the Credential
|
| 53 |
+
Summary is refreshed.
|
| 54 |
+
3. **Status Assessment.** The orchestrator either proceeds or runs
|
| 55 |
+
targeted follow-ups (max 3 iterations).
|
| 56 |
+
4. **Finalization.** Each participant either revises their own opinion
|
| 57 |
+
or endorses another's.
|
| 58 |
+
5. **Consensus Gathering.** Allied participants advocate, solo
|
| 59 |
+
participants seek allies / switch / propose compromises. The
|
| 60 |
+
orchestrator routes addressed-to messages.
|
| 61 |
+
6. **Closure.** Majority-report (with weighted dissent), or
|
| 62 |
+
unaddressed-factor probe + retry, or no-consensus report.
|
| 63 |
+
|
| 64 |
+
Thinking traces (`<think>`, `<reasoning>`, etc.) are stripped from every
|
| 65 |
+
LLM response in `backend/app/utils/sanitize.py` before being stored,
|
| 66 |
+
displayed, or fed back to the orchestrator/summarizer/Credential builder.
|
| 67 |
|
| 68 |
## Quick Start (local development)
|
| 69 |
|
| 70 |
```bash
|
|
|
|
| 71 |
cp .env.example .env
|
| 72 |
# Edit .env with your API keys
|
| 73 |
|
| 74 |
+
# Backend
|
| 75 |
cd backend
|
| 76 |
pip install -r requirements.txt
|
| 77 |
uvicorn app.main:app --reload --port 8000
|
| 78 |
|
| 79 |
+
# Frontend (separate terminal)
|
| 80 |
cd frontend
|
| 81 |
npm install
|
| 82 |
npm start
|
|
|
|
| 88 |
cp .env.example .env
|
| 89 |
# Edit .env with your API keys
|
| 90 |
docker compose up --build
|
| 91 |
+
# Open http://localhost:7860
|
| 92 |
```
|
| 93 |
|
| 94 |
## HuggingFace Spaces Deployment
|
| 95 |
|
| 96 |
+
Deployed as a Docker Space (`app_port: 7860`).
|
| 97 |
+
|
| 98 |
+
Required Space Secrets:
|
| 99 |
|
| 100 |
+
- `HANA_USERNAME`, `HANA_PASSWORD` — Neon HANA credentials.
|
| 101 |
+
- `HANA_KLATCHAT_PASSWORD` (optional) — BrainForge/Security vLLM access.
|
| 102 |
+
- Provider keys: `OPENAI_API_KEY`, `GEMINI_API_KEY`, `FIREWORKS_API_KEY`,
|
| 103 |
+
`TOGETHER_API_KEY`, `MISTRAL_API_KEY`.
|
| 104 |
+
- `HF_RATE_LIMIT_DAILY=30` — daily per-IP cap (defaults to 30; org
|
| 105 |
+
members are unlimited).
|
| 106 |
+
- `HF_RATE_LIMIT_ORG=neongeckocom` — bypass org name.
|
| 107 |
+
- `SESSION_SECRET` — cookie session secret for OAuth.
|
| 108 |
|
| 109 |
## Features
|
| 110 |
|
| 111 |
+
- **Participant dropdown** in the header with three sections (Neon /
|
| 112 |
+
Extra / Expert) and a `Create Expert Persona...` shortcut.
|
| 113 |
+
- **Participant sidebar** with on/off slider per participant; flipping
|
| 114 |
+
off does not deselect, and a `Remove` button appears for actually
|
| 115 |
+
dropping someone from the conversation.
|
| 116 |
+
- **Settings menu** with searchable orchestrator-model and summarizer-
|
| 117 |
+
model pickers (summarizer defaults to "Same as Orchestrator"),
|
| 118 |
+
a 3-9 max-participants stepper, per-participant model overrides, and
|
| 119 |
+
the same display-options + downloads structure as LLMChats3.
|
| 120 |
+
- **Two failsafes** with explicit Continue buttons (60+20 messages,
|
| 121 |
+
100+50 orchestrator calls).
|
| 122 |
+
- **Exports**: `.txt`, `.md`, RFC-4180 `.csv` table, JSON API log.
|
| 123 |
+
- **Table view** of the whole conversation with per-participant first /
|
| 124 |
+
contribution / revised / final columns.
|
| 125 |
+
- **localStorage persistence** for expert personas, participant
|
| 126 |
+
selection, on/off state, model assignments, orchestrator/summarizer
|
| 127 |
+
picks, and max-participants.
|
| 128 |
+
- **HuggingFace OAuth** with `neongeckocom` org bypass.
|
|
@@ -1,17 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import json
|
| 4 |
import logging
|
| 5 |
import time
|
| 6 |
|
| 7 |
from fastapi import APIRouter, HTTPException, Request
|
| 8 |
-
from fastapi.responses import
|
| 9 |
-
from pydantic import BaseModel
|
| 10 |
|
| 11 |
from app.config import settings
|
| 12 |
-
from app.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from app.services.orchestrator import (
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
)
|
| 16 |
|
| 17 |
router = APIRouter()
|
|
@@ -46,20 +70,43 @@ class SetSpeedPriorityRequest(BaseModel):
|
|
| 46 |
enabled: bool
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
class StartChatRequest(BaseModel):
|
| 50 |
-
|
| 51 |
-
persona_a_name: str
|
| 52 |
-
persona_a_role: str
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
# ---------------------------------------------------------------------------
|
| 62 |
-
#
|
| 63 |
# ---------------------------------------------------------------------------
|
| 64 |
|
| 65 |
@router.get("/chat/orchestrator")
|
|
@@ -84,6 +131,10 @@ async def api_set_speed_priority(req: SetSpeedPriorityRequest):
|
|
| 84 |
return {"enabled": settings.speed_priority}
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
@router.post("/chat/generate-role")
|
| 88 |
async def api_generate_role(req: GenerateRoleRequest):
|
| 89 |
result = await generate_role_prompt(
|
|
@@ -112,68 +163,195 @@ async def api_generate_role_freeform(req: GenerateRoleFreeformRequest):
|
|
| 112 |
return result
|
| 113 |
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
@router.post("/chat/start")
|
| 116 |
async def api_start_chat(req: StartChatRequest, request: Request):
|
| 117 |
"""Create a session and return a streaming SSE response for the conversation."""
|
| 118 |
-
|
|
|
|
| 119 |
|
| 120 |
-
allowed,
|
| 121 |
if not allowed:
|
| 122 |
return JSONResponse(
|
| 123 |
status_code=429,
|
| 124 |
content={
|
| 125 |
-
"detail":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
"remaining": 0,
|
| 127 |
},
|
| 128 |
)
|
| 129 |
-
record_conversation(request)
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
| 138 |
|
| 139 |
session = create_session()
|
| 140 |
-
session.
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
api_key=ra.get("api_key", ""),
|
| 146 |
-
display_name=ra["display_name"],
|
| 147 |
-
is_neon=ra.get("is_neon", False),
|
| 148 |
-
hana_model_id=ra.get("hana_model_id", ""),
|
| 149 |
-
persona_name=ra.get("persona_name", ""),
|
| 150 |
-
neon_direct_vllm=ra.get("neon_direct_vllm", False),
|
| 151 |
-
vllm_base_url=ra.get("vllm_base_url", ""),
|
| 152 |
-
vllm_api_key=ra.get("vllm_api_key", ""),
|
| 153 |
-
)
|
| 154 |
-
session.persona_b = Persona(
|
| 155 |
-
name=req.persona_b_name or "Persona B",
|
| 156 |
-
model_id=rb["model_id"],
|
| 157 |
-
role_prompt=req.persona_b_role,
|
| 158 |
-
base_url=rb.get("base_url", ""),
|
| 159 |
-
api_key=rb.get("api_key", ""),
|
| 160 |
-
display_name=rb["display_name"],
|
| 161 |
-
is_neon=rb.get("is_neon", False),
|
| 162 |
-
hana_model_id=rb.get("hana_model_id", ""),
|
| 163 |
-
persona_name=rb.get("persona_name", ""),
|
| 164 |
-
neon_direct_vllm=rb.get("neon_direct_vllm", False),
|
| 165 |
-
vllm_base_url=rb.get("vllm_base_url", ""),
|
| 166 |
-
vllm_api_key=rb.get("vllm_api_key", ""),
|
| 167 |
-
)
|
| 168 |
|
| 169 |
async def event_stream():
|
| 170 |
-
yield
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
yield chunk
|
| 173 |
|
| 174 |
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
| 175 |
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
@router.get("/chat/{session_id}/export")
|
| 178 |
async def api_export_chat(session_id: str, fmt: str = "txt"):
|
| 179 |
session = get_session(session_id)
|
|
@@ -182,6 +360,8 @@ async def api_export_chat(session_id: str, fmt: str = "txt"):
|
|
| 182 |
|
| 183 |
if fmt == "md":
|
| 184 |
return _export_md(session)
|
|
|
|
|
|
|
| 185 |
return _export_txt(session)
|
| 186 |
|
| 187 |
|
|
@@ -190,38 +370,135 @@ async def api_export_log(session_id: str):
|
|
| 190 |
session = get_session(session_id)
|
| 191 |
if not session:
|
| 192 |
raise HTTPException(404, "Session not found")
|
|
|
|
|
|
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
return {
|
| 195 |
"session_id": session_id,
|
| 196 |
-
"
|
|
|
|
|
|
|
|
|
|
| 197 |
}
|
| 198 |
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
# ---------------------------------------------------------------------------
|
| 201 |
# Export helpers
|
| 202 |
# ---------------------------------------------------------------------------
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
def _export_txt(session: Session) -> dict:
|
| 205 |
-
lines = [
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
| 210 |
lines.append("")
|
| 211 |
for m in session.messages:
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
| 213 |
lines.append("")
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
|
| 217 |
def _export_md(session: Session) -> dict:
|
| 218 |
-
lines = ["#
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
lines.append("\n---\n")
|
| 224 |
for m in session.messages:
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
lines.append("")
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat API: start a CCAI conversation, stream SSE, drive failsafe-pause
|
| 2 |
+
continues, and export results.
|
| 3 |
+
"""
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
+
import csv
|
| 7 |
+
import io
|
| 8 |
import json
|
| 9 |
import logging
|
| 10 |
import time
|
| 11 |
|
| 12 |
from fastapi import APIRouter, HTTPException, Request
|
| 13 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 14 |
+
from pydantic import BaseModel, Field
|
| 15 |
|
| 16 |
from app.config import settings
|
| 17 |
+
from app.middleware.rate_limit import (
|
| 18 |
+
DAILY_LIMIT,
|
| 19 |
+
check_rate_limit,
|
| 20 |
+
record_conversation,
|
| 21 |
+
)
|
| 22 |
+
from app.services.extra_personas import get_extra_persona
|
| 23 |
+
from app.services.models import (
|
| 24 |
+
DEFAULT_MAX_PARTICIPANTS,
|
| 25 |
+
MAX_MAX_PARTICIPANTS,
|
| 26 |
+
MIN_MAX_PARTICIPANTS,
|
| 27 |
+
Participant,
|
| 28 |
+
Phase,
|
| 29 |
+
Session,
|
| 30 |
+
)
|
| 31 |
from app.services.orchestrator import (
|
| 32 |
+
create_session,
|
| 33 |
+
get_session,
|
| 34 |
+
run_conversation,
|
| 35 |
+
)
|
| 36 |
+
from app.services.persona import (
|
| 37 |
+
generate_role_prompt,
|
| 38 |
+
generate_role_prompt_freeform,
|
| 39 |
)
|
| 40 |
|
| 41 |
router = APIRouter()
|
|
|
|
| 70 |
enabled: bool
|
| 71 |
|
| 72 |
|
| 73 |
+
class ExpertPersonaPayload(BaseModel):
|
| 74 |
+
"""Expert Persona created by the user via the popup. Already carries a
|
| 75 |
+
finished `role_prompt` (the frontend calls /generate-role-* for that)."""
|
| 76 |
+
|
| 77 |
+
participant_id: str
|
| 78 |
+
name: str
|
| 79 |
+
model_id: str
|
| 80 |
+
role_prompt: str
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class ParticipantSelectionPayload(BaseModel):
|
| 84 |
+
"""Reference to a participant the user has chosen for this conversation."""
|
| 85 |
+
|
| 86 |
+
participant_id: str
|
| 87 |
+
kind: str # "neon" | "extra" | "expert"
|
| 88 |
+
# For Neon entries: the model_id IS the persona id (neon:model@ver:persona)
|
| 89 |
+
# For extra/expert: defaults to the persona's bound model_id, but the
|
| 90 |
+
# user can override via per-participant model_assignments.
|
| 91 |
+
name: str
|
| 92 |
+
role_prompt: str | None = None
|
| 93 |
+
model_id_override: str | None = None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
class StartChatRequest(BaseModel):
|
| 97 |
+
question: str | None = None
|
|
|
|
|
|
|
| 98 |
|
| 99 |
+
participants: list[ParticipantSelectionPayload]
|
| 100 |
+
expert_personas: list[ExpertPersonaPayload] = Field(default_factory=list)
|
| 101 |
+
model_assignments: dict[str, str] = Field(default_factory=dict)
|
| 102 |
|
| 103 |
+
orchestrator_model_id: str | None = None
|
| 104 |
+
summarizer_model_id: str | None = None
|
| 105 |
+
max_participants: int = DEFAULT_MAX_PARTICIPANTS
|
| 106 |
|
| 107 |
|
| 108 |
# ---------------------------------------------------------------------------
|
| 109 |
+
# Settings endpoints (orchestrator default + speed priority)
|
| 110 |
# ---------------------------------------------------------------------------
|
| 111 |
|
| 112 |
@router.get("/chat/orchestrator")
|
|
|
|
| 131 |
return {"enabled": settings.speed_priority}
|
| 132 |
|
| 133 |
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
# Role-prompt generation (used by the Expert Persona modal)
|
| 136 |
+
# ---------------------------------------------------------------------------
|
| 137 |
+
|
| 138 |
@router.post("/chat/generate-role")
|
| 139 |
async def api_generate_role(req: GenerateRoleRequest):
|
| 140 |
result = await generate_role_prompt(
|
|
|
|
| 163 |
return result
|
| 164 |
|
| 165 |
|
| 166 |
+
# ---------------------------------------------------------------------------
|
| 167 |
+
# Demo questions
|
| 168 |
+
# ---------------------------------------------------------------------------
|
| 169 |
+
|
| 170 |
+
@router.get("/demo-questions")
|
| 171 |
+
async def api_demo_questions():
|
| 172 |
+
from pathlib import Path
|
| 173 |
+
|
| 174 |
+
path = Path(__file__).resolve().parent.parent / "data" / "demo_questions.json"
|
| 175 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 176 |
+
data = json.load(f)
|
| 177 |
+
return data
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ---------------------------------------------------------------------------
|
| 181 |
+
# Start chat
|
| 182 |
+
# ---------------------------------------------------------------------------
|
| 183 |
+
|
| 184 |
+
def _build_participant(
|
| 185 |
+
sel: ParticipantSelectionPayload,
|
| 186 |
+
expert_lookup: dict[str, ExpertPersonaPayload],
|
| 187 |
+
model_assignments: dict[str, str],
|
| 188 |
+
) -> Participant:
|
| 189 |
+
"""Resolve a selection payload into a runnable Participant.
|
| 190 |
+
|
| 191 |
+
Resolution order for the model:
|
| 192 |
+
1. Explicit per-conversation override in `model_assignments`
|
| 193 |
+
2. The persona's selection-time `model_id_override`
|
| 194 |
+
3. The bundled extra persona's default model
|
| 195 |
+
4. For Neon participants, the model_id is the participant_id itself
|
| 196 |
+
5. For Expert personas, the persona's bound model_id
|
| 197 |
+
|
| 198 |
+
Resolution order for the role_prompt:
|
| 199 |
+
1. The selection's role_prompt (most flexible)
|
| 200 |
+
2. The matching expert persona's role_prompt
|
| 201 |
+
3. The bundled extra persona's role_prompt
|
| 202 |
+
4. For Neon participants: a thin role wrapper just naming the persona
|
| 203 |
+
"""
|
| 204 |
+
pid = sel.participant_id
|
| 205 |
+
kind = sel.kind
|
| 206 |
+
name = sel.name
|
| 207 |
+
|
| 208 |
+
role_prompt = sel.role_prompt or ""
|
| 209 |
+
model_id = sel.model_id_override or model_assignments.get(pid, "")
|
| 210 |
+
|
| 211 |
+
if kind == "expert":
|
| 212 |
+
ep = expert_lookup.get(pid)
|
| 213 |
+
if ep is None:
|
| 214 |
+
raise HTTPException(400, f"Expert persona payload missing for id {pid}")
|
| 215 |
+
if not role_prompt:
|
| 216 |
+
role_prompt = ep.role_prompt
|
| 217 |
+
if not model_id:
|
| 218 |
+
model_id = ep.model_id
|
| 219 |
+
if not name:
|
| 220 |
+
name = ep.name
|
| 221 |
+
elif kind == "extra":
|
| 222 |
+
ep = get_extra_persona(pid)
|
| 223 |
+
if ep is None:
|
| 224 |
+
raise HTTPException(400, f"Unknown extra persona: {pid}")
|
| 225 |
+
if not role_prompt:
|
| 226 |
+
role_prompt = ep.role_prompt
|
| 227 |
+
if not model_id:
|
| 228 |
+
model_id = ep.default_model_id
|
| 229 |
+
if not name:
|
| 230 |
+
name = ep.name
|
| 231 |
+
elif kind == "neon":
|
| 232 |
+
# The participant_id IS the model id for Neon personas, so it's
|
| 233 |
+
# required to be a `neon:model@ver:persona` style string.
|
| 234 |
+
if not pid.startswith("neon:"):
|
| 235 |
+
raise HTTPException(
|
| 236 |
+
400, f"Neon participant_id must start with 'neon:': {pid}",
|
| 237 |
+
)
|
| 238 |
+
if not model_id:
|
| 239 |
+
model_id = pid
|
| 240 |
+
if not role_prompt:
|
| 241 |
+
role_prompt = (
|
| 242 |
+
f"You are {name}, a Neon.ai persona. Speak naturally in your "
|
| 243 |
+
"own voice and bring the perspective your background suggests."
|
| 244 |
+
)
|
| 245 |
+
else:
|
| 246 |
+
raise HTTPException(400, f"Unknown participant kind: {kind}")
|
| 247 |
+
|
| 248 |
+
resolved = settings.resolve_model(model_id)
|
| 249 |
+
if not resolved:
|
| 250 |
+
raise HTTPException(400, f"Unknown model: {model_id}")
|
| 251 |
+
|
| 252 |
+
return Participant(
|
| 253 |
+
participant_id=pid,
|
| 254 |
+
name=name,
|
| 255 |
+
role_prompt=role_prompt,
|
| 256 |
+
model_id=resolved["model_id"],
|
| 257 |
+
kind=kind,
|
| 258 |
+
enabled=True,
|
| 259 |
+
base_url=resolved.get("base_url", ""),
|
| 260 |
+
api_key=resolved.get("api_key", ""),
|
| 261 |
+
display_name=resolved.get("display_name", model_id),
|
| 262 |
+
is_neon=resolved.get("is_neon", False),
|
| 263 |
+
hana_model_id=resolved.get("hana_model_id", ""),
|
| 264 |
+
persona_name=resolved.get("persona_name", ""),
|
| 265 |
+
neon_direct_vllm=resolved.get("neon_direct_vllm", False),
|
| 266 |
+
vllm_base_url=resolved.get("vllm_base_url", ""),
|
| 267 |
+
vllm_api_key=resolved.get("vllm_api_key", ""),
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
@router.post("/chat/start")
|
| 272 |
async def api_start_chat(req: StartChatRequest, request: Request):
|
| 273 |
"""Create a session and return a streaming SSE response for the conversation."""
|
| 274 |
+
if not req.question or not req.question.strip():
|
| 275 |
+
raise HTTPException(400, "Question is required")
|
| 276 |
|
| 277 |
+
allowed, _ = check_rate_limit(request)
|
| 278 |
if not allowed:
|
| 279 |
return JSONResponse(
|
| 280 |
status_code=429,
|
| 281 |
content={
|
| 282 |
+
"detail": (
|
| 283 |
+
f"Daily conversation limit reached ({DAILY_LIMIT}/day). "
|
| 284 |
+
"Sign in with HuggingFace as a neongeckocom org member "
|
| 285 |
+
"for unlimited access."
|
| 286 |
+
),
|
| 287 |
"remaining": 0,
|
| 288 |
},
|
| 289 |
)
|
|
|
|
| 290 |
|
| 291 |
+
expert_lookup = {ep.participant_id: ep for ep in req.expert_personas}
|
| 292 |
+
|
| 293 |
+
max_p = max(MIN_MAX_PARTICIPANTS, min(MAX_MAX_PARTICIPANTS, req.max_participants))
|
| 294 |
+
if len(req.participants) < 2:
|
| 295 |
+
raise HTTPException(400, "Need at least 2 participants")
|
| 296 |
+
if len(req.participants) > max_p:
|
| 297 |
+
raise HTTPException(
|
| 298 |
+
400, f"Got {len(req.participants)} participants but max is {max_p}",
|
| 299 |
+
)
|
| 300 |
|
| 301 |
+
participants: list[Participant] = []
|
| 302 |
+
for sel in req.participants:
|
| 303 |
+
participants.append(_build_participant(sel, expert_lookup, req.model_assignments))
|
| 304 |
+
|
| 305 |
+
record_conversation(request)
|
| 306 |
|
| 307 |
session = create_session()
|
| 308 |
+
session.question = req.question.strip()
|
| 309 |
+
session.participants = participants
|
| 310 |
+
session.orchestrator_model_id = req.orchestrator_model_id
|
| 311 |
+
session.summarizer_model_id = req.summarizer_model_id
|
| 312 |
+
session.max_participants = max_p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
async def event_stream():
|
| 315 |
+
yield (
|
| 316 |
+
"event: session\ndata: "
|
| 317 |
+
+ json.dumps({
|
| 318 |
+
"session_id": session.session_id,
|
| 319 |
+
"participants": [
|
| 320 |
+
{
|
| 321 |
+
"participant_id": p.participant_id,
|
| 322 |
+
"name": p.name,
|
| 323 |
+
"model_id": p.model_id,
|
| 324 |
+
"model_display": p.display_name,
|
| 325 |
+
"kind": p.kind,
|
| 326 |
+
} for p in session.participants
|
| 327 |
+
],
|
| 328 |
+
"max_participants": session.max_participants,
|
| 329 |
+
"orchestrator_model_id": session.orchestrator_model_id or settings.orchestrator_model,
|
| 330 |
+
"summarizer_model_id": session.summarizer_model_id,
|
| 331 |
+
})
|
| 332 |
+
+ "\n\n"
|
| 333 |
+
)
|
| 334 |
+
async for chunk in run_conversation(session):
|
| 335 |
yield chunk
|
| 336 |
|
| 337 |
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
| 338 |
|
| 339 |
|
| 340 |
+
@router.post("/chat/{session_id}/continue")
|
| 341 |
+
async def api_continue(session_id: str, reason: str = "messages"):
|
| 342 |
+
session = get_session(session_id)
|
| 343 |
+
if not session:
|
| 344 |
+
raise HTTPException(404, "Session not found")
|
| 345 |
+
if not session.paused_for_continue:
|
| 346 |
+
raise HTTPException(409, "Session is not paused")
|
| 347 |
+
session.pending_continue = True
|
| 348 |
+
return {"ok": True, "reason": reason}
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
# ---------------------------------------------------------------------------
|
| 352 |
+
# Exports
|
| 353 |
+
# ---------------------------------------------------------------------------
|
| 354 |
+
|
| 355 |
@router.get("/chat/{session_id}/export")
|
| 356 |
async def api_export_chat(session_id: str, fmt: str = "txt"):
|
| 357 |
session = get_session(session_id)
|
|
|
|
| 360 |
|
| 361 |
if fmt == "md":
|
| 362 |
return _export_md(session)
|
| 363 |
+
if fmt == "csv-table":
|
| 364 |
+
return _export_csv_table(session)
|
| 365 |
return _export_txt(session)
|
| 366 |
|
| 367 |
|
|
|
|
| 370 |
session = get_session(session_id)
|
| 371 |
if not session:
|
| 372 |
raise HTTPException(404, "Session not found")
|
| 373 |
+
return {"session_id": session_id, "log": session.api_log}
|
| 374 |
+
|
| 375 |
|
| 376 |
+
@router.get("/chat/{session_id}/table")
|
| 377 |
+
async def api_table_view(session_id: str):
|
| 378 |
+
session = get_session(session_id)
|
| 379 |
+
if not session:
|
| 380 |
+
raise HTTPException(404, "Session not found")
|
| 381 |
+
|
| 382 |
+
rows = []
|
| 383 |
+
for p in session.participants:
|
| 384 |
+
first = (session.initial_opinions or {}).get(p.participant_id, "")
|
| 385 |
+
contribution = (session.contribution_summaries or {}).get(p.participant_id, "")
|
| 386 |
+
revised = (session.final_opinions or {}).get(p.participant_id, "")
|
| 387 |
+
final_msg = _last_consensus_message_for(session, p.participant_id) or revised
|
| 388 |
+
rows.append({
|
| 389 |
+
"participant_id": p.participant_id,
|
| 390 |
+
"name": p.name,
|
| 391 |
+
"model_display": p.display_name,
|
| 392 |
+
"first_opinion": first,
|
| 393 |
+
"contribution_summary": contribution,
|
| 394 |
+
"revised_opinion": revised,
|
| 395 |
+
"final_opinion": final_msg,
|
| 396 |
+
})
|
| 397 |
+
final_report = (session.final_report or {}).get("text", "")
|
| 398 |
return {
|
| 399 |
"session_id": session_id,
|
| 400 |
+
"question": session.question,
|
| 401 |
+
"final_report": final_report,
|
| 402 |
+
"final_report_kind": (session.final_report or {}).get("kind", ""),
|
| 403 |
+
"rows": rows,
|
| 404 |
}
|
| 405 |
|
| 406 |
|
| 407 |
+
def _last_consensus_message_for(session: Session, participant_id: str) -> str:
|
| 408 |
+
"""Return the participant's most recent message in the consensus or
|
| 409 |
+
finalization phase - used as the 'final opinion' column."""
|
| 410 |
+
for m in reversed(session.messages):
|
| 411 |
+
if m.get("speaker_id") != participant_id:
|
| 412 |
+
continue
|
| 413 |
+
if m.get("phase") in {Phase.CONSENSUS.value, Phase.FINALIZATION.value}:
|
| 414 |
+
return m.get("text", "")
|
| 415 |
+
return ""
|
| 416 |
+
|
| 417 |
+
|
| 418 |
# ---------------------------------------------------------------------------
|
| 419 |
# Export helpers
|
| 420 |
# ---------------------------------------------------------------------------
|
| 421 |
|
| 422 |
+
def _format_participants_block(session: Session) -> list[str]:
|
| 423 |
+
return [
|
| 424 |
+
f"- {p.name} ({p.display_name})"
|
| 425 |
+
for p in session.participants
|
| 426 |
+
]
|
| 427 |
+
|
| 428 |
+
|
| 429 |
def _export_txt(session: Session) -> dict:
|
| 430 |
+
lines = ["CCAI Conversation Log", "=" * 40, ""]
|
| 431 |
+
lines.append("Question:")
|
| 432 |
+
lines.append(session.question)
|
| 433 |
+
lines.append("")
|
| 434 |
+
lines.append("Participants:")
|
| 435 |
+
lines.extend(_format_participants_block(session))
|
| 436 |
lines.append("")
|
| 437 |
for m in session.messages:
|
| 438 |
+
speaker = m.get("speaker_name") or "(anon)"
|
| 439 |
+
if m.get("role") == "orchestrator":
|
| 440 |
+
speaker = "Orchestrator"
|
| 441 |
+
lines.append(f"{speaker}: {m.get('text', '')}")
|
| 442 |
lines.append("")
|
| 443 |
+
if session.final_report and session.final_report.get("text"):
|
| 444 |
+
lines.append("---")
|
| 445 |
+
lines.append("Final Report:")
|
| 446 |
+
lines.append(session.final_report["text"])
|
| 447 |
+
return {"filename": "ccai_chat.txt", "content": "\n".join(lines)}
|
| 448 |
|
| 449 |
|
| 450 |
def _export_md(session: Session) -> dict:
|
| 451 |
+
lines = ["# CCAI Conversation Log", ""]
|
| 452 |
+
lines.append("## Question")
|
| 453 |
+
lines.append("")
|
| 454 |
+
lines.append(f"> {session.question}")
|
| 455 |
+
lines.append("")
|
| 456 |
+
lines.append("## Participants")
|
| 457 |
+
lines.append("")
|
| 458 |
+
for p in session.participants:
|
| 459 |
+
lines.append(f"- **{p.name}** (*{p.display_name}*)")
|
| 460 |
lines.append("\n---\n")
|
| 461 |
for m in session.messages:
|
| 462 |
+
speaker = m.get("speaker_name") or "(anon)"
|
| 463 |
+
is_orch = m.get("role") == "orchestrator"
|
| 464 |
+
if is_orch:
|
| 465 |
+
speaker = "Orchestrator"
|
| 466 |
+
text = m.get("text", "")
|
| 467 |
+
if is_orch:
|
| 468 |
+
lines.append(f"_**{speaker}:**_ {text}")
|
| 469 |
+
else:
|
| 470 |
+
lines.append(f"**{speaker}:** {text}")
|
| 471 |
+
lines.append("")
|
| 472 |
+
if session.final_report and session.final_report.get("text"):
|
| 473 |
+
lines.append("\n---\n")
|
| 474 |
+
lines.append("## Final Report")
|
| 475 |
lines.append("")
|
| 476 |
+
lines.append(session.final_report["text"])
|
| 477 |
+
return {"filename": "ccai_chat.md", "content": "\n".join(lines)}
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def _export_csv_table(session: Session) -> dict:
|
| 481 |
+
"""RFC-4180 compliant CSV. csv.writer handles quoting/escaping."""
|
| 482 |
+
buf = io.StringIO()
|
| 483 |
+
writer = csv.writer(buf, quoting=csv.QUOTE_MINIMAL, lineterminator="\n")
|
| 484 |
+
|
| 485 |
+
writer.writerow(["Question", session.question])
|
| 486 |
+
final_text = (session.final_report or {}).get("text", "")
|
| 487 |
+
writer.writerow(["Final Group Opinion", final_text])
|
| 488 |
+
writer.writerow([])
|
| 489 |
+
writer.writerow([
|
| 490 |
+
"Participant",
|
| 491 |
+
"First opinion",
|
| 492 |
+
"Conversation contribution",
|
| 493 |
+
"Revised opinion",
|
| 494 |
+
"Final opinion",
|
| 495 |
+
])
|
| 496 |
+
for p in session.participants:
|
| 497 |
+
writer.writerow([
|
| 498 |
+
p.name,
|
| 499 |
+
(session.initial_opinions or {}).get(p.participant_id, ""),
|
| 500 |
+
(session.contribution_summaries or {}).get(p.participant_id, ""),
|
| 501 |
+
(session.final_opinions or {}).get(p.participant_id, ""),
|
| 502 |
+
_last_consensus_message_for(session, p.participant_id),
|
| 503 |
+
])
|
| 504 |
+
return {"filename": "ccai_chat_table.csv", "content": buf.getvalue()}
|
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Participant catalog API.
|
| 2 |
+
|
| 3 |
+
`GET /api/personas` returns three sections - Neon HANA personas (with
|
| 4 |
+
vanilla/RAG personas filtered out), the four bundled extra personas, and
|
| 5 |
+
the user-supplied expert personas (which are local-only on the frontend
|
| 6 |
+
but echoed here for completeness so the API can act as the source of
|
| 7 |
+
truth for participant choices when needed).
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
|
| 16 |
+
from app.clients.hana_client import hana_client
|
| 17 |
+
from app.services.extra_personas import list_extra_personas
|
| 18 |
+
|
| 19 |
+
router = APIRouter()
|
| 20 |
+
LOG = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _is_vanilla_or_rag(persona_name: str) -> bool:
|
| 24 |
+
pn = (persona_name or "").lower()
|
| 25 |
+
return "vanilla" in pn or "rag" in pn
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("/personas")
|
| 29 |
+
async def get_personas():
|
| 30 |
+
"""Return the participant catalog the frontend dropdown shows."""
|
| 31 |
+
try:
|
| 32 |
+
neon_models = await hana_client.get_models()
|
| 33 |
+
except Exception as exc:
|
| 34 |
+
LOG.warning("HANA models unavailable: %s", exc)
|
| 35 |
+
neon_models = []
|
| 36 |
+
|
| 37 |
+
neon_personas = []
|
| 38 |
+
for nm in neon_models or []:
|
| 39 |
+
for p in nm.get("personas", []) or []:
|
| 40 |
+
if p.get("enabled") is False:
|
| 41 |
+
continue
|
| 42 |
+
persona_name = p.get("persona_name") or ""
|
| 43 |
+
if _is_vanilla_or_rag(persona_name):
|
| 44 |
+
continue
|
| 45 |
+
participant_id = f"neon:{nm['model_id']}:{persona_name}"
|
| 46 |
+
neon_personas.append({
|
| 47 |
+
"participant_id": participant_id,
|
| 48 |
+
"kind": "neon",
|
| 49 |
+
"name": persona_name,
|
| 50 |
+
"model_display": f"Neon / {nm['name'].split('/')[-1]}",
|
| 51 |
+
"default_model_id": participant_id,
|
| 52 |
+
"description": p.get("description") or "",
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
extras = list_extra_personas()
|
| 56 |
+
for e in extras:
|
| 57 |
+
e["model_display"] = e["default_model_id"]
|
| 58 |
+
|
| 59 |
+
return JSONResponse(
|
| 60 |
+
content={
|
| 61 |
+
"neon": neon_personas,
|
| 62 |
+
"extra": extras,
|
| 63 |
+
},
|
| 64 |
+
headers={"Cache-Control": "no-store"},
|
| 65 |
+
)
|
|
@@ -6,6 +6,7 @@ from typing import Any
|
|
| 6 |
|
| 7 |
from app.clients.openai_compat import openai_chat_completion
|
| 8 |
from app.clients.hana_client import hana_client
|
|
|
|
| 9 |
|
| 10 |
LOG = logging.getLogger(__name__)
|
| 11 |
|
|
@@ -166,7 +167,7 @@ async def _call_neon_direct_vllm(
|
|
| 166 |
max_tokens=max_tokens,
|
| 167 |
)
|
| 168 |
return {
|
| 169 |
-
"response": result.get("response", ""),
|
| 170 |
"elapsed_seconds": result.get("elapsed_seconds", 0),
|
| 171 |
"model": resolved["model_id"],
|
| 172 |
}
|
|
@@ -210,8 +211,12 @@ async def _call_hana(
|
|
| 210 |
temperature=temperature,
|
| 211 |
max_tokens=max_tokens,
|
| 212 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
return {
|
| 214 |
-
"response":
|
| 215 |
"elapsed_seconds": result.get("elapsed_seconds", 0),
|
| 216 |
"model": resolved["model_id"],
|
| 217 |
}
|
|
|
|
| 6 |
|
| 7 |
from app.clients.openai_compat import openai_chat_completion
|
| 8 |
from app.clients.hana_client import hana_client
|
| 9 |
+
from app.utils.sanitize import strip_thinking, response_has_thinking
|
| 10 |
|
| 11 |
LOG = logging.getLogger(__name__)
|
| 12 |
|
|
|
|
| 167 |
max_tokens=max_tokens,
|
| 168 |
)
|
| 169 |
return {
|
| 170 |
+
"response": strip_thinking(result.get("response", "")),
|
| 171 |
"elapsed_seconds": result.get("elapsed_seconds", 0),
|
| 172 |
"model": resolved["model_id"],
|
| 173 |
}
|
|
|
|
| 211 |
temperature=temperature,
|
| 212 |
max_tokens=max_tokens,
|
| 213 |
)
|
| 214 |
+
raw = result.get("response", "")
|
| 215 |
+
cleaned = strip_thinking(raw)
|
| 216 |
+
if response_has_thinking(raw):
|
| 217 |
+
LOG.info("Stripped thinking content from HANA %s response", resolved["model_id"])
|
| 218 |
return {
|
| 219 |
+
"response": cleaned,
|
| 220 |
"elapsed_seconds": result.get("elapsed_seconds", 0),
|
| 221 |
"model": resolved["model_id"],
|
| 222 |
}
|
|
@@ -2,22 +2,17 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import logging
|
| 5 |
-
import re
|
| 6 |
import time
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
import httpx
|
| 10 |
|
|
|
|
|
|
|
| 11 |
LOG = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
_shared_client: httpx.AsyncClient | None = None
|
| 14 |
|
| 15 |
-
_THINK_TAG_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
| 16 |
-
_REASONING_BLOCK_RE = re.compile(
|
| 17 |
-
r"<(reasoning|reflection|inner_thoughts|scratchpad)>.*?</\1>",
|
| 18 |
-
re.DOTALL,
|
| 19 |
-
)
|
| 20 |
-
|
| 21 |
_MAX_COMPLETION_TOKEN_MODELS = {
|
| 22 |
"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini",
|
| 23 |
"gpt-5", "gpt-oss",
|
|
@@ -32,21 +27,9 @@ def _get_client() -> httpx.AsyncClient:
|
|
| 32 |
return _shared_client
|
| 33 |
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
text = _REASONING_BLOCK_RE.sub("", text)
|
| 39 |
-
return text.strip()
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def _detect_thinking_model(model: str, msg: dict) -> bool:
|
| 43 |
-
"""Detect thinking models from the response itself, not just the model name."""
|
| 44 |
-
if msg.get("reasoning_content") or msg.get("reasoning"):
|
| 45 |
-
return True
|
| 46 |
-
content = msg.get("content") or ""
|
| 47 |
-
if _THINK_TAG_RE.search(content):
|
| 48 |
-
return True
|
| 49 |
-
return False
|
| 50 |
|
| 51 |
|
| 52 |
async def openai_chat_completion(
|
|
@@ -102,8 +85,8 @@ async def openai_chat_completion(
|
|
| 102 |
msg = choices[0].get("message") or {}
|
| 103 |
text = msg.get("content") or ""
|
| 104 |
finish_reason = choices[0].get("finish_reason") or ""
|
| 105 |
-
had_thinking =
|
| 106 |
-
text =
|
| 107 |
|
| 108 |
if had_thinking:
|
| 109 |
LOG.info("Stripped thinking content from %s response", model)
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import logging
|
|
|
|
| 5 |
import time
|
| 6 |
from typing import Any
|
| 7 |
|
| 8 |
import httpx
|
| 9 |
|
| 10 |
+
from app.utils.sanitize import strip_thinking, response_has_thinking
|
| 11 |
+
|
| 12 |
LOG = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
_shared_client: httpx.AsyncClient | None = None
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
_MAX_COMPLETION_TOKEN_MODELS = {
|
| 17 |
"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini",
|
| 18 |
"gpt-5", "gpt-oss",
|
|
|
|
| 27 |
return _shared_client
|
| 28 |
|
| 29 |
|
| 30 |
+
# Thinking-trace detection and stripping live in app.utils.sanitize so every
|
| 31 |
+
# code path (HANA, vLLM-direct, OpenAI-compat, summarizer inputs, credential
|
| 32 |
+
# inputs) uses the same logic. See backend/app/utils/sanitize.py.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
async def openai_chat_completion(
|
|
|
|
| 85 |
msg = choices[0].get("message") or {}
|
| 86 |
text = msg.get("content") or ""
|
| 87 |
finish_reason = choices[0].get("finish_reason") or ""
|
| 88 |
+
had_thinking = response_has_thinking(text, msg)
|
| 89 |
+
text = strip_thinking(text)
|
| 90 |
|
| 91 |
if had_thinking:
|
| 92 |
LOG.info("Stripped thinking content from %s response", model)
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": 1,
|
| 3 |
+
"questions": [
|
| 4 |
+
{
|
| 5 |
+
"id": "undergrad_majors",
|
| 6 |
+
"category": "education",
|
| 7 |
+
"title": "Best undergraduate majors for career prospects",
|
| 8 |
+
"text": "What are the three best majors for my undergraduate degree? I live in Washington State, USA, and I am starting college in the fall. My biggest priority is to make sure I have good job prospects when I graduate in 2031. I love using AI and exploring new technology, math is one of my strongest subjects, I don't want to travel for my job, and I don't want to have to publish papers as part of my job. I like to talk to people, and to travel. I know a lot about finance because my parents are both accountants. I also love animals."
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"id": "small_business_ai",
|
| 12 |
+
"category": "small business",
|
| 13 |
+
"title": "Should a small bakery introduce AI?",
|
| 14 |
+
"text": "Should I introduce AI tools into my small bakery, and if so, where should I start? I run a single-storefront bakery in a tourist town in Vermont with three full-time employees and four part-timers. We do counter sales, weekend wholesale to two coffee shops, and a small custom-cake business. I'm 54, comfortable with tech but not a developer. My priorities, in order: keep the staff feeling valued, hold the food quality steady, save myself maybe 5-10 hours a week of paperwork, and slowly grow custom-cake revenue. I have about $3,000 a year I could redirect into software or services without straining the books."
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"id": "rural_water",
|
| 18 |
+
"category": "public policy",
|
| 19 |
+
"title": "Best fix for a small town's failing water system",
|
| 20 |
+
"text": "Our rural town in central Pennsylvania has roughly 1,800 residents and a 1960s-era municipal water system that has had three boil-water advisories in the last two years. We have a small budget and an aging volunteer water board. The state has offered a 50% matching grant but only if we commit to a project plan within nine months. The three options on the table are: (a) replace the existing main lines incrementally over twelve years, (b) consolidate with a neighboring town's larger system, losing local control but gaining redundancy, or (c) install a smaller modern treatment plant of our own with recurring maintenance contracts. Residents are split, and the median household income is around $52,000."
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"id": "elder_parent",
|
| 24 |
+
"category": "family",
|
| 25 |
+
"title": "Aging parent who wants to stay in their home",
|
| 26 |
+
"text": "My 82-year-old father lives alone in the rural Midwest after losing my mother last year. He's mentally sharp, drives short distances, and absolutely insists on staying in the house they built together. In the last six months he's had two minor falls (no fractures), forgot to take his blood pressure medication twice, and started to lose weight. My sister and I both live three to four hours away with full-time jobs and our own kids. He has roughly $90,000 in savings beyond Social Security and the house is paid off. We're trying to figure out the right next step that respects his autonomy without ignoring the warning signs."
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"id": "ai_in_classroom",
|
| 30 |
+
"category": "education / technology ethics",
|
| 31 |
+
"title": "AI policy for a public middle school",
|
| 32 |
+
"text": "I'm a principal at a public middle school in Texas, grades 6-8, 480 students. Many of my teachers are quietly using ChatGPT and similar tools to plan lessons and draft feedback. About a third of students are using AI for homework, sometimes well, sometimes to skip thinking entirely. The school board wants me to publish a formal AI policy by the end of the semester. I have to balance student learning, equity (not all students have the same access at home), staff workload, parental concerns about screen time and privacy, and our district's tight budget. Should I lean toward restricting AI, embracing it, or some structured middle path - and what would the most important rules be?"
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"id": "career_pivot_30s",
|
| 36 |
+
"category": "career",
|
| 37 |
+
"title": "Pivot from journalism to tech in your 30s",
|
| 38 |
+
"text": "I'm 34, a print journalist for the past 11 years at a regional paper that just announced a 30% layoff. I'm probably going to be cut. I have an undergraduate degree in English, no formal coding experience but I've been an enthusiastic Python tinkerer for two years. My wife is a public school teacher; we have one toddler and a small mortgage in a mid-sized U.S. city. We have about six months of savings. I'm considering three paths: (a) try to land another reporter job and write fiction on the side, (b) do a 9-12 month immersive transition into a junior product/data role at a smaller tech company, or (c) start a paid local newsletter business solo. I want a stable enough income, but I'd also like work that uses my voice and isn't soul-crushing."
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"id": "climate_homestead",
|
| 42 |
+
"category": "environment / personal finance",
|
| 43 |
+
"title": "Buying property knowing the climate is shifting",
|
| 44 |
+
"text": "My partner and I are looking to buy a 5-15 acre property within a four-hour drive of Denver. We want a small homestead - some chickens, a vegetable garden, room for two large dogs - and a place we can live in long term. Our budget is around $550,000 including a modest house. We're worried about climate exposure: wildfire smoke, water-rights pressure on small wells, longer droughts, and how much insurance is going to cost ten years from now. We're also worried about being too far from a hospital. Neither of us has farming experience but we're hands-on. How do we think about which properties to look at, what to actually optimize for, and what we should be willing to compromise on?"
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"id": "open_source_strategy",
|
| 48 |
+
"category": "tech / business strategy",
|
| 49 |
+
"title": "Open-source vs proprietary for a 12-person AI startup",
|
| 50 |
+
"text": "I'm CTO of a 12-person AI startup that's about to release our first real product: a domain-specific reasoning agent for legal-contract review. We've raised a Series A. The founding team is split on whether to release our core inference stack as open source under a permissive license. The arguments for: faster developer adoption, recruiting, brand-building, the ecosystem moves fast and we'll be left behind otherwise. The arguments against: a well-funded competitor could fork us, our investors are nervous, and our actual moat is the domain training data, not the inference code. I'd like to land on a defensible decision in the next six weeks. What framework should we use, and what's the right answer for a company in roughly our position?"
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": "personal_health_diet",
|
| 54 |
+
"category": "health",
|
| 55 |
+
"title": "Diet overhaul with conflicting advice",
|
| 56 |
+
"text": "I'm 41, somewhat overweight, with borderline-high blood pressure and slightly elevated LDL cholesterol. My doctor wants me to lose roughly 30 pounds and lower my blood pressure without yet starting medication. I have done short stints with several diets over the years - low carb, intermittent fasting, Mediterranean, and a vegan stretch - and lost weight on each before regaining it. I have a desk job, two school-age kids, and I cook most of our meals. I'm not interested in supplements with weak evidence, but I am willing to commit to a structured plan if it's actually likely to work for someone like me long-term. What should the plan look like, and what trade-offs am I implicitly making by picking one?"
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"id": "early_retirement",
|
| 60 |
+
"category": "personal finance",
|
| 61 |
+
"title": "FIRE-curious couple weighing semi-retirement at 50",
|
| 62 |
+
"text": "My spouse and I are both 50, two kids in college, a paid-off house in Minnesota, and about $1.6M in retirement and brokerage accounts (~80% in low-cost index funds). Our combined gross income is ~$240k/year and we save aggressively. We're not unhappy at our jobs but we're also not in love with them. We're debating whether to keep working at full pace through 60, semi-retire next year by both moving to ~25 hours/week each (income halves), or one of us fully retires and the other continues full time. We expect health-care costs to be the wildest variable. None of us has a strong intuition about how a 30-40 year retirement actually plays out financially or psychologically."
|
| 63 |
+
}
|
| 64 |
+
]
|
| 65 |
+
}
|
|
@@ -14,10 +14,10 @@ from starlette.middleware.sessions import SessionMiddleware
|
|
| 14 |
from app.config import settings
|
| 15 |
from app.clients.hana_client import hana_client
|
| 16 |
from app.clients.openai_compat import close_shared_client
|
| 17 |
-
from app.api import models, chat
|
| 18 |
from app.middleware.rate_limit import (
|
| 19 |
-
get_oauth_username, is_org_member, get_remaining,
|
| 20 |
-
record_conversation,
|
| 21 |
)
|
| 22 |
|
| 23 |
logging.basicConfig(level=logging.INFO)
|
|
@@ -41,7 +41,7 @@ async def lifespan(app: FastAPI):
|
|
| 41 |
await close_shared_client()
|
| 42 |
|
| 43 |
|
| 44 |
-
app = FastAPI(title="
|
| 45 |
|
| 46 |
app.add_middleware(
|
| 47 |
SessionMiddleware,
|
|
@@ -66,6 +66,7 @@ except Exception as exc:
|
|
| 66 |
|
| 67 |
app.include_router(models.router, prefix="/api")
|
| 68 |
app.include_router(chat.router, prefix="/api")
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
@app.get("/api/health")
|
|
@@ -97,7 +98,7 @@ async def auth_status(request: Request):
|
|
| 97 |
@app.get("/api/rate-limit/status")
|
| 98 |
async def rate_limit_status(request: Request):
|
| 99 |
remaining = get_remaining(request)
|
| 100 |
-
return {"remaining": remaining, "daily_limit":
|
| 101 |
|
| 102 |
|
| 103 |
if STATIC_DIR.is_dir():
|
|
|
|
| 14 |
from app.config import settings
|
| 15 |
from app.clients.hana_client import hana_client
|
| 16 |
from app.clients.openai_compat import close_shared_client
|
| 17 |
+
from app.api import models, chat, personas
|
| 18 |
from app.middleware.rate_limit import (
|
| 19 |
+
DAILY_LIMIT, get_oauth_username, is_org_member, get_remaining,
|
| 20 |
+
check_rate_limit, record_conversation,
|
| 21 |
)
|
| 22 |
|
| 23 |
logging.basicConfig(level=logging.INFO)
|
|
|
|
| 41 |
await close_shared_client()
|
| 42 |
|
| 43 |
|
| 44 |
+
app = FastAPI(title="CCAI Vibe Demo", version="1.0.0", lifespan=lifespan)
|
| 45 |
|
| 46 |
app.add_middleware(
|
| 47 |
SessionMiddleware,
|
|
|
|
| 66 |
|
| 67 |
app.include_router(models.router, prefix="/api")
|
| 68 |
app.include_router(chat.router, prefix="/api")
|
| 69 |
+
app.include_router(personas.router, prefix="/api")
|
| 70 |
|
| 71 |
|
| 72 |
@app.get("/api/health")
|
|
|
|
| 98 |
@app.get("/api/rate-limit/status")
|
| 99 |
async def rate_limit_status(request: Request):
|
| 100 |
remaining = get_remaining(request)
|
| 101 |
+
return {"remaining": remaining, "daily_limit": DAILY_LIMIT}
|
| 102 |
|
| 103 |
|
| 104 |
if STATIC_DIR.is_dir():
|
|
@@ -10,7 +10,11 @@ from fastapi import Request
|
|
| 10 |
LOG = logging.getLogger(__name__)
|
| 11 |
|
| 12 |
ORG_NAME = os.getenv("HF_RATE_LIMIT_ORG", "neongeckocom")
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
_ip_counts: dict[str, dict] = defaultdict(lambda: {"date": "", "count": 0})
|
| 16 |
|
|
|
|
| 10 |
LOG = logging.getLogger(__name__)
|
| 11 |
|
| 12 |
ORG_NAME = os.getenv("HF_RATE_LIMIT_ORG", "neongeckocom")
|
| 13 |
+
# CCAI demo bumps the per-IP daily cap from LLMChats3's 20 to 30 to match
|
| 14 |
+
# the heavier multi-participant conversation pattern (the orchestrator-call
|
| 15 |
+
# backstop and the participant-message failsafe handle per-conversation
|
| 16 |
+
# cost).
|
| 17 |
+
DAILY_LIMIT = int(os.getenv("HF_RATE_LIMIT_DAILY", "30"))
|
| 18 |
|
| 19 |
_ip_counts: dict[str, dict] = defaultdict(lambda: {"date": "", "count": 0})
|
| 20 |
|
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase-5 consensus helpers: alliance detection, addressed-to
|
| 2 |
+
classification, status-checks, and unaddressed-factor probing.
|
| 3 |
+
|
| 4 |
+
All four are short JSON-shaped orchestrator calls layered on top of
|
| 5 |
+
`json_calls.orchestrator_call`.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from app.services.json_calls import orchestrator_call
|
| 13 |
+
from app.services.prompts import (
|
| 14 |
+
ALLIANCE_DETECTION_PROMPT,
|
| 15 |
+
ADDRESSED_TO_PROMPT,
|
| 16 |
+
CONSENSUS_STATUS_PROMPT,
|
| 17 |
+
UNADDRESSED_FACTOR_PROMPT,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
LOG = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _format_finalization_block(
|
| 24 |
+
participants: list[Any],
|
| 25 |
+
final_opinions: dict[str, str],
|
| 26 |
+
) -> str:
|
| 27 |
+
lines: list[str] = []
|
| 28 |
+
for p in participants:
|
| 29 |
+
text = final_opinions.get(p.participant_id, "(no final opinion)").strip()
|
| 30 |
+
lines.append(f"--- {p.name} (id={p.participant_id}) ---")
|
| 31 |
+
lines.append(text)
|
| 32 |
+
lines.append("")
|
| 33 |
+
return "\n".join(lines).strip()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _format_roster_block(participants: list[Any]) -> str:
|
| 37 |
+
return "\n".join(
|
| 38 |
+
f"- id: {p.participant_id} | name: {p.name}" for p in participants
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _format_alliance_block(groups: list[dict[str, Any]]) -> str:
|
| 43 |
+
lines: list[str] = []
|
| 44 |
+
for i, g in enumerate(groups):
|
| 45 |
+
members = ", ".join(g.get("members") or [])
|
| 46 |
+
lines.append(f"Group {i}: stance=\"{g.get('stance', '')}\" members=[{members}]")
|
| 47 |
+
return "\n".join(lines)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def detect_alliances(
|
| 51 |
+
*,
|
| 52 |
+
orchestrator_model_id: str,
|
| 53 |
+
question: str,
|
| 54 |
+
participants: list[Any],
|
| 55 |
+
final_opinions: dict[str, str],
|
| 56 |
+
api_log: list[dict[str, Any]] | None = None,
|
| 57 |
+
) -> list[dict[str, Any]]:
|
| 58 |
+
prompt = ALLIANCE_DETECTION_PROMPT.format(
|
| 59 |
+
question=question,
|
| 60 |
+
finalization_block=_format_finalization_block(participants, final_opinions),
|
| 61 |
+
)
|
| 62 |
+
_raw, parsed = await orchestrator_call(
|
| 63 |
+
orchestrator_model_id=orchestrator_model_id,
|
| 64 |
+
user_prompt=prompt,
|
| 65 |
+
label="alliances",
|
| 66 |
+
api_log=api_log,
|
| 67 |
+
max_tokens=1024,
|
| 68 |
+
)
|
| 69 |
+
if isinstance(parsed, dict) and isinstance(parsed.get("groups"), list):
|
| 70 |
+
groups = parsed["groups"]
|
| 71 |
+
return _normalize_groups(groups, participants)
|
| 72 |
+
|
| 73 |
+
# Fallback: every participant in their own group.
|
| 74 |
+
return [
|
| 75 |
+
{"stance": "(unclassified)", "members": [p.participant_id]}
|
| 76 |
+
for p in participants
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _normalize_groups(
|
| 81 |
+
groups: list[dict[str, Any]],
|
| 82 |
+
participants: list[Any],
|
| 83 |
+
) -> list[dict[str, Any]]:
|
| 84 |
+
"""Make sure every participant id appears in exactly one group."""
|
| 85 |
+
valid_ids = {p.participant_id for p in participants}
|
| 86 |
+
seen: set[str] = set()
|
| 87 |
+
out: list[dict[str, Any]] = []
|
| 88 |
+
for g in groups:
|
| 89 |
+
members = [m for m in (g.get("members") or []) if m in valid_ids and m not in seen]
|
| 90 |
+
seen.update(members)
|
| 91 |
+
if members:
|
| 92 |
+
out.append({
|
| 93 |
+
"stance": g.get("stance", "(unspecified)"),
|
| 94 |
+
"members": members,
|
| 95 |
+
})
|
| 96 |
+
leftovers = [pid for pid in valid_ids if pid not in seen]
|
| 97 |
+
for pid in leftovers:
|
| 98 |
+
out.append({"stance": "(unclassified)", "members": [pid]})
|
| 99 |
+
return out
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
async def classify_addressed_to(
|
| 103 |
+
*,
|
| 104 |
+
orchestrator_model_id: str,
|
| 105 |
+
participants: list[Any],
|
| 106 |
+
speaker_name: str,
|
| 107 |
+
message: str,
|
| 108 |
+
api_log: list[dict[str, Any]] | None = None,
|
| 109 |
+
) -> str | None:
|
| 110 |
+
prompt = ADDRESSED_TO_PROMPT.format(
|
| 111 |
+
roster_block=_format_roster_block(participants),
|
| 112 |
+
speaker=speaker_name,
|
| 113 |
+
message=message,
|
| 114 |
+
)
|
| 115 |
+
_raw, parsed = await orchestrator_call(
|
| 116 |
+
orchestrator_model_id=orchestrator_model_id,
|
| 117 |
+
user_prompt=prompt,
|
| 118 |
+
label="addressed_to",
|
| 119 |
+
api_log=api_log,
|
| 120 |
+
max_tokens=128,
|
| 121 |
+
)
|
| 122 |
+
if isinstance(parsed, dict):
|
| 123 |
+
target = parsed.get("addressed_to")
|
| 124 |
+
if target and any(p.participant_id == target for p in participants):
|
| 125 |
+
return target
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
async def assess_consensus_status(
|
| 130 |
+
*,
|
| 131 |
+
orchestrator_model_id: str,
|
| 132 |
+
question: str,
|
| 133 |
+
transcript: str,
|
| 134 |
+
alliance_groups: list[dict[str, Any]],
|
| 135 |
+
api_log: list[dict[str, Any]] | None = None,
|
| 136 |
+
) -> dict[str, Any]:
|
| 137 |
+
prompt = CONSENSUS_STATUS_PROMPT.format(
|
| 138 |
+
question=question,
|
| 139 |
+
transcript=transcript,
|
| 140 |
+
alliance_block=_format_alliance_block(alliance_groups),
|
| 141 |
+
)
|
| 142 |
+
_raw, parsed = await orchestrator_call(
|
| 143 |
+
orchestrator_model_id=orchestrator_model_id,
|
| 144 |
+
user_prompt=prompt,
|
| 145 |
+
label="consensus_status",
|
| 146 |
+
api_log=api_log,
|
| 147 |
+
max_tokens=256,
|
| 148 |
+
)
|
| 149 |
+
if isinstance(parsed, dict) and parsed.get("status") in {"majority", "productive", "unproductive"}:
|
| 150 |
+
return parsed
|
| 151 |
+
# Default: treat as productive so we keep iterating, but give it a
|
| 152 |
+
# bounded number of attempts via the orchestrator-call cap.
|
| 153 |
+
return {"status": "productive", "majority_group_index": None, "rationale": ""}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
async def find_unaddressed_factor(
|
| 157 |
+
*,
|
| 158 |
+
orchestrator_model_id: str,
|
| 159 |
+
question: str,
|
| 160 |
+
credential_summary_block: str,
|
| 161 |
+
transcript: str,
|
| 162 |
+
api_log: list[dict[str, Any]] | None = None,
|
| 163 |
+
) -> dict[str, Any] | None:
|
| 164 |
+
prompt = UNADDRESSED_FACTOR_PROMPT.format(
|
| 165 |
+
question=question,
|
| 166 |
+
credential_summary=credential_summary_block,
|
| 167 |
+
transcript=transcript,
|
| 168 |
+
)
|
| 169 |
+
_raw, parsed = await orchestrator_call(
|
| 170 |
+
orchestrator_model_id=orchestrator_model_id,
|
| 171 |
+
user_prompt=prompt,
|
| 172 |
+
label="unaddressed_factor",
|
| 173 |
+
api_log=api_log,
|
| 174 |
+
max_tokens=512,
|
| 175 |
+
)
|
| 176 |
+
if isinstance(parsed, dict) and parsed.get("factor"):
|
| 177 |
+
return parsed
|
| 178 |
+
return None
|
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-participant context budgeting with on-demand summarization.
|
| 2 |
+
|
| 3 |
+
Ported from the Ask-A-Neon-LLM-Demos AskJerry pattern: estimate input
|
| 4 |
+
tokens with chars/4, trigger a background summarize at 55% of the model's
|
| 5 |
+
input budget, and once a summary exists trim history aggressively at
|
| 6 |
+
70%. The summarizer model defaults to whichever model is selected as the
|
| 7 |
+
Orchestrator (so changing one auto-changes the other) and is overridable
|
| 8 |
+
in the settings menu.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
from app.clients.llm_router import chat_completion
|
| 17 |
+
from app.config import settings
|
| 18 |
+
from app.utils.sanitize import strip_thinking
|
| 19 |
+
|
| 20 |
+
LOG = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Per-model context windows (input + output tokens)
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
#
|
| 26 |
+
# Lookup precedence: exact model_id match -> prefix match -> fallback.
|
| 27 |
+
# Numbers are deliberately conservative (real windows often advertise a
|
| 28 |
+
# bigger absolute max but degrade well before that).
|
| 29 |
+
DEFAULT_CONTEXT = 8_192
|
| 30 |
+
|
| 31 |
+
EXACT_CONTEXT: dict[str, int] = {
|
| 32 |
+
"gpt-5.4": 200_000,
|
| 33 |
+
"gpt-4.1": 128_000,
|
| 34 |
+
"gpt-4.1-mini": 128_000,
|
| 35 |
+
"gpt-4o": 128_000,
|
| 36 |
+
"gpt-4o-mini": 128_000,
|
| 37 |
+
"o4-mini": 128_000,
|
| 38 |
+
"gemini-2.0-flash": 1_000_000,
|
| 39 |
+
"gemini-2.5-flash": 1_000_000,
|
| 40 |
+
"gemini-2.5-pro": 1_000_000,
|
| 41 |
+
"mistral-small-2506": 131_000,
|
| 42 |
+
"mistral-small-2603": 131_000,
|
| 43 |
+
"devstral-2512": 131_000,
|
| 44 |
+
"meta-llama/Llama-3.3-70B-Instruct-Turbo": 128_000,
|
| 45 |
+
"meta-llama/Meta-Llama-3-8B-Instruct-Lite": 8_192,
|
| 46 |
+
"Qwen/Qwen3-VL-8B-Instruct": 32_000,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
PREFIX_CONTEXT: list[tuple[str, int]] = [
|
| 50 |
+
("accounts/fireworks/models/kimi-", 256_000),
|
| 51 |
+
("accounts/fireworks/models/deepseek-", 128_000),
|
| 52 |
+
("accounts/fireworks/models/gpt-oss-", 128_000),
|
| 53 |
+
("openai/gpt-oss-", 128_000),
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def context_window_for(model_id: str) -> int:
|
| 58 |
+
"""Return the configured input+output token window for a model.
|
| 59 |
+
|
| 60 |
+
BrainForge / unknown Neon models fall back to DEFAULT_CONTEXT (8K).
|
| 61 |
+
"""
|
| 62 |
+
if model_id in EXACT_CONTEXT:
|
| 63 |
+
return EXACT_CONTEXT[model_id]
|
| 64 |
+
for prefix, window in PREFIX_CONTEXT:
|
| 65 |
+
if model_id.startswith(prefix):
|
| 66 |
+
return window
|
| 67 |
+
if model_id.startswith("neon:"):
|
| 68 |
+
return DEFAULT_CONTEXT
|
| 69 |
+
return DEFAULT_CONTEXT
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# Reserve at least this many tokens for the model's reply.
|
| 73 |
+
DEFAULT_REPLY_BUDGET = 2_048
|
| 74 |
+
|
| 75 |
+
# Trigger a summarize when input estimate >= SUMMARIZE_THRESHOLD * input_budget.
|
| 76 |
+
SUMMARIZE_THRESHOLD = 0.55
|
| 77 |
+
# When a summary exists and history still over-fills, trim to last K rounds.
|
| 78 |
+
TRIM_THRESHOLD = 0.70
|
| 79 |
+
# How many of the most recent messages to keep when trimming.
|
| 80 |
+
KEEP_RECENT_MESSAGES = 6
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# Per-participant summary state
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
@dataclass
|
| 88 |
+
class ContextSummary:
|
| 89 |
+
"""Running summary for a single participant.
|
| 90 |
+
|
| 91 |
+
`summary_text` is the latest condensed summary; `summarized_through_idx`
|
| 92 |
+
is the index of the last message included in that summary so we don't
|
| 93 |
+
re-summarize old history every turn.
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
summary_text: str = ""
|
| 97 |
+
summarized_through_idx: int = -1
|
| 98 |
+
last_estimate: int = 0
|
| 99 |
+
|
| 100 |
+
def is_active(self) -> bool:
|
| 101 |
+
return bool(self.summary_text.strip())
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
# Token estimator (chars/4, no real tokenizer)
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
|
| 108 |
+
def _estimate_str_tokens(text: str | None) -> int:
|
| 109 |
+
if not text:
|
| 110 |
+
return 1
|
| 111 |
+
return max(1, len(text) // 4)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def estimate_messages_tokens(messages: list[dict[str, Any]]) -> int:
|
| 115 |
+
total = 0
|
| 116 |
+
for m in messages:
|
| 117 |
+
total += _estimate_str_tokens(m.get("content"))
|
| 118 |
+
total += 4 # per-message framing overhead
|
| 119 |
+
return total
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# Decision: does this participant need a summarize/trim?
|
| 124 |
+
# ---------------------------------------------------------------------------
|
| 125 |
+
|
| 126 |
+
def should_summarize(
|
| 127 |
+
model_id: str,
|
| 128 |
+
api_messages: list[dict[str, Any]],
|
| 129 |
+
summary: ContextSummary,
|
| 130 |
+
) -> tuple[bool, bool, int]:
|
| 131 |
+
"""Return (should_summarize, should_trim, input_budget).
|
| 132 |
+
|
| 133 |
+
`should_summarize` is True when raw input tokens >= 55% of the input
|
| 134 |
+
budget. `should_trim` is True when the budget is so tight (>= 70%)
|
| 135 |
+
that we should drop older messages and rely on the running summary.
|
| 136 |
+
"""
|
| 137 |
+
window = context_window_for(model_id)
|
| 138 |
+
input_budget = max(2_048, window - DEFAULT_REPLY_BUDGET)
|
| 139 |
+
est = estimate_messages_tokens(api_messages)
|
| 140 |
+
summary.last_estimate = est
|
| 141 |
+
return (
|
| 142 |
+
est >= input_budget * SUMMARIZE_THRESHOLD,
|
| 143 |
+
est >= input_budget * TRIM_THRESHOLD and summary.is_active(),
|
| 144 |
+
input_budget,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# ---------------------------------------------------------------------------
|
| 149 |
+
# Build the actual outbound message list for a participant turn
|
| 150 |
+
# ---------------------------------------------------------------------------
|
| 151 |
+
|
| 152 |
+
def build_compressed_messages(
|
| 153 |
+
api_messages: list[dict[str, Any]],
|
| 154 |
+
summary: ContextSummary,
|
| 155 |
+
needs_trim: bool,
|
| 156 |
+
) -> list[dict[str, Any]]:
|
| 157 |
+
"""If we need to trim, replace older messages with a system-summary message.
|
| 158 |
+
|
| 159 |
+
The first message is assumed to be the system prompt for the participant
|
| 160 |
+
and is always preserved. Every other message older than the last
|
| 161 |
+
KEEP_RECENT_MESSAGES is dropped in favor of the running summary.
|
| 162 |
+
"""
|
| 163 |
+
if not needs_trim or not api_messages:
|
| 164 |
+
return api_messages
|
| 165 |
+
|
| 166 |
+
head = api_messages[:1] # original system prompt
|
| 167 |
+
tail = api_messages[-KEEP_RECENT_MESSAGES:]
|
| 168 |
+
summary_msg = {
|
| 169 |
+
"role": "system",
|
| 170 |
+
"content": (
|
| 171 |
+
"Summary of earlier discussion (auto-condensed for context):\n"
|
| 172 |
+
+ summary.summary_text
|
| 173 |
+
),
|
| 174 |
+
}
|
| 175 |
+
return head + [summary_msg] + tail
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ---------------------------------------------------------------------------
|
| 179 |
+
# Run a summarize call against the configured summarizer model
|
| 180 |
+
# ---------------------------------------------------------------------------
|
| 181 |
+
|
| 182 |
+
SUMMARIZER_SYSTEM_PROMPT = (
|
| 183 |
+
"You are a concise discussion summarizer. Condense the following multi-"
|
| 184 |
+
"participant conversation into a tight summary that preserves: who said "
|
| 185 |
+
"what (by name), the key positions taken, agreements and disagreements, "
|
| 186 |
+
"any open questions, and the overall direction. Keep the summary under "
|
| 187 |
+
"300 words. Write in third-person narrative. Do not editorialize, vote, "
|
| 188 |
+
"or take a side. Output only the summary text — no preamble, no "
|
| 189 |
+
"reasoning, no meta-commentary."
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
async def run_summarize(
|
| 194 |
+
summarizer_model_id: str,
|
| 195 |
+
transcript_text: str,
|
| 196 |
+
timeout: float = 30.0,
|
| 197 |
+
) -> str:
|
| 198 |
+
"""Call the summarizer model on a plain-text transcript and return the summary.
|
| 199 |
+
|
| 200 |
+
Empty / failed summaries return an empty string so callers can fall back
|
| 201 |
+
gracefully.
|
| 202 |
+
"""
|
| 203 |
+
if not transcript_text.strip():
|
| 204 |
+
return ""
|
| 205 |
+
|
| 206 |
+
resolved = settings.resolve_model(summarizer_model_id)
|
| 207 |
+
if not resolved:
|
| 208 |
+
LOG.warning("Summarizer model %s not resolvable, skipping summarize", summarizer_model_id)
|
| 209 |
+
return ""
|
| 210 |
+
|
| 211 |
+
messages = [
|
| 212 |
+
{"role": "system", "content": SUMMARIZER_SYSTEM_PROMPT},
|
| 213 |
+
{"role": "user", "content": transcript_text},
|
| 214 |
+
]
|
| 215 |
+
result = await chat_completion(
|
| 216 |
+
resolved=resolved,
|
| 217 |
+
messages=messages,
|
| 218 |
+
temperature=0.2,
|
| 219 |
+
max_tokens=512,
|
| 220 |
+
timeout=timeout,
|
| 221 |
+
)
|
| 222 |
+
if result.get("error"):
|
| 223 |
+
LOG.warning("Summarizer call failed: %s", result.get("response"))
|
| 224 |
+
return ""
|
| 225 |
+
# Defense-in-depth: even if a summarizer model emitted reasoning,
|
| 226 |
+
# never let it leak into participant context.
|
| 227 |
+
return strip_thinking(result.get("response", ""))
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def select_summarizer_model_id(
|
| 231 |
+
summarizer_override: str | None,
|
| 232 |
+
orchestrator_model_id: str | None,
|
| 233 |
+
) -> str:
|
| 234 |
+
"""Resolve the summarizer model id to use, with the rule from the plan:
|
| 235 |
+
|
| 236 |
+
- explicit override wins
|
| 237 |
+
- else fall back to whatever model is selected as the Orchestrator
|
| 238 |
+
- else fall back to the global settings default
|
| 239 |
+
"""
|
| 240 |
+
if summarizer_override:
|
| 241 |
+
return summarizer_override
|
| 242 |
+
if orchestrator_model_id:
|
| 243 |
+
return orchestrator_model_id
|
| 244 |
+
return settings.orchestrator_model
|
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Credential Summary builder + refresher.
|
| 2 |
+
|
| 3 |
+
The Credential Summary is a JSON dict (participant_id -> assessment)
|
| 4 |
+
threaded into every later participant turn. It is built once after Phase
|
| 5 |
+
1 and refreshed once after Phase 2 critique.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from app.services.json_calls import orchestrator_call
|
| 14 |
+
from app.services.prompts import (
|
| 15 |
+
CREDENTIAL_BUILD_PROMPT,
|
| 16 |
+
CREDENTIAL_REFRESH_PROMPT,
|
| 17 |
+
)
|
| 18 |
+
from app.utils.sanitize import strip_thinking
|
| 19 |
+
|
| 20 |
+
LOG = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _format_participants_block(
|
| 24 |
+
participants: list[Any],
|
| 25 |
+
initial_opinions: dict[str, str],
|
| 26 |
+
) -> str:
|
| 27 |
+
"""Render one block per participant containing role prompt + first opinion."""
|
| 28 |
+
lines: list[str] = []
|
| 29 |
+
for p in participants:
|
| 30 |
+
opinion = strip_thinking(initial_opinions.get(p.participant_id, ""))
|
| 31 |
+
lines.append(f"--- Participant id: {p.participant_id} ---")
|
| 32 |
+
lines.append(f"Name: {p.name}")
|
| 33 |
+
lines.append(f"Role prompt: {p.role_prompt}")
|
| 34 |
+
lines.append(f"First opinion: {opinion}")
|
| 35 |
+
lines.append("")
|
| 36 |
+
return "\n".join(lines).strip()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def credentials_to_block(credentials: list[dict[str, Any]]) -> str:
|
| 40 |
+
"""Render the credentials list back into a string for use inside
|
| 41 |
+
participant prompts (so we can keep them readable rather than
|
| 42 |
+
embedding raw JSON in role prompts)."""
|
| 43 |
+
if not credentials:
|
| 44 |
+
return "(no credential summary available yet)"
|
| 45 |
+
lines: list[str] = []
|
| 46 |
+
for c in credentials:
|
| 47 |
+
lines.append(f"- {c.get('name', c.get('participant_id', '?'))} "
|
| 48 |
+
f"(id={c.get('participant_id', '?')})")
|
| 49 |
+
if c.get("expertise"):
|
| 50 |
+
lines.append(f" Expertise: {c['expertise']}")
|
| 51 |
+
if c.get("personality"):
|
| 52 |
+
lines.append(f" Style: {c['personality']}")
|
| 53 |
+
if c.get("credibility_for_question") is not None:
|
| 54 |
+
lines.append(f" Credibility on this question: {c['credibility_for_question']:.2f}")
|
| 55 |
+
if c.get("bias_to_watch"):
|
| 56 |
+
lines.append(f" Bias to watch: {c['bias_to_watch']}")
|
| 57 |
+
return "\n".join(lines)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def build_credential_summary(
|
| 61 |
+
*,
|
| 62 |
+
orchestrator_model_id: str,
|
| 63 |
+
question: str,
|
| 64 |
+
participants: list[Any],
|
| 65 |
+
initial_opinions: dict[str, str],
|
| 66 |
+
api_log: list[dict[str, Any]] | None = None,
|
| 67 |
+
) -> list[dict[str, Any]]:
|
| 68 |
+
"""Build the Credential Summary list. Returns an empty list on parse failure."""
|
| 69 |
+
block = _format_participants_block(participants, initial_opinions)
|
| 70 |
+
prompt = CREDENTIAL_BUILD_PROMPT.format(
|
| 71 |
+
question=question,
|
| 72 |
+
participants_block=block,
|
| 73 |
+
)
|
| 74 |
+
_raw, parsed = await orchestrator_call(
|
| 75 |
+
orchestrator_model_id=orchestrator_model_id,
|
| 76 |
+
user_prompt=prompt,
|
| 77 |
+
label="build_credentials",
|
| 78 |
+
api_log=api_log,
|
| 79 |
+
max_tokens=2048,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
creds: list[dict[str, Any]] = []
|
| 83 |
+
if isinstance(parsed, dict) and isinstance(parsed.get("credentials"), list):
|
| 84 |
+
creds = parsed["credentials"]
|
| 85 |
+
|
| 86 |
+
creds = _normalize_creds(creds, participants)
|
| 87 |
+
return creds
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def refresh_credential_summary(
|
| 91 |
+
*,
|
| 92 |
+
orchestrator_model_id: str,
|
| 93 |
+
question: str,
|
| 94 |
+
participants: list[Any],
|
| 95 |
+
existing: list[dict[str, Any]],
|
| 96 |
+
critique_transcript: str,
|
| 97 |
+
api_log: list[dict[str, Any]] | None = None,
|
| 98 |
+
) -> list[dict[str, Any]]:
|
| 99 |
+
"""Refresh the Credential Summary after Phase 2 critique."""
|
| 100 |
+
if not existing:
|
| 101 |
+
return existing
|
| 102 |
+
prompt = CREDENTIAL_REFRESH_PROMPT.format(
|
| 103 |
+
question=question,
|
| 104 |
+
credential_summary_json=json.dumps({"credentials": existing}, indent=2),
|
| 105 |
+
critique_transcript=critique_transcript,
|
| 106 |
+
)
|
| 107 |
+
_raw, parsed = await orchestrator_call(
|
| 108 |
+
orchestrator_model_id=orchestrator_model_id,
|
| 109 |
+
user_prompt=prompt,
|
| 110 |
+
label="refresh_credentials",
|
| 111 |
+
api_log=api_log,
|
| 112 |
+
max_tokens=2048,
|
| 113 |
+
)
|
| 114 |
+
if isinstance(parsed, dict) and isinstance(parsed.get("credentials"), list):
|
| 115 |
+
return _normalize_creds(parsed["credentials"], participants)
|
| 116 |
+
return existing
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _normalize_creds(
|
| 120 |
+
creds: list[dict[str, Any]],
|
| 121 |
+
participants: list[Any],
|
| 122 |
+
) -> list[dict[str, Any]]:
|
| 123 |
+
"""Defensive cleanup: ensure credibility is a float in [0, 1] and that
|
| 124 |
+
every participant has a row (fill in placeholders if the model dropped
|
| 125 |
+
one)."""
|
| 126 |
+
by_id: dict[str, dict[str, Any]] = {}
|
| 127 |
+
for c in creds:
|
| 128 |
+
pid = c.get("participant_id") or c.get("id") or ""
|
| 129 |
+
if not pid:
|
| 130 |
+
continue
|
| 131 |
+
try:
|
| 132 |
+
score = float(c.get("credibility_for_question", 0.5))
|
| 133 |
+
except Exception:
|
| 134 |
+
score = 0.5
|
| 135 |
+
c["credibility_for_question"] = max(0.0, min(1.0, score))
|
| 136 |
+
by_id[pid] = c
|
| 137 |
+
|
| 138 |
+
out: list[dict[str, Any]] = []
|
| 139 |
+
for p in participants:
|
| 140 |
+
if p.participant_id in by_id:
|
| 141 |
+
row = by_id[p.participant_id]
|
| 142 |
+
row.setdefault("name", p.name)
|
| 143 |
+
out.append(row)
|
| 144 |
+
else:
|
| 145 |
+
out.append({
|
| 146 |
+
"participant_id": p.participant_id,
|
| 147 |
+
"name": p.name,
|
| 148 |
+
"expertise": "(no credential available)",
|
| 149 |
+
"personality": "",
|
| 150 |
+
"credibility_for_question": 0.5,
|
| 151 |
+
"bias_to_watch": "",
|
| 152 |
+
})
|
| 153 |
+
return out
|
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Four bundled "extra" personas powered by non-Neon LLMs.
|
| 2 |
+
|
| 3 |
+
Each pairs a discussion lens with a complementary area of expertise so
|
| 4 |
+
they generalize to any question. The user can replace any of them by
|
| 5 |
+
creating an Expert Persona, or change which LLM powers each one in the
|
| 6 |
+
settings menu.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class ExtraPersonaSpec:
|
| 15 |
+
participant_id: str
|
| 16 |
+
name: str
|
| 17 |
+
default_model_id: str
|
| 18 |
+
role_prompt: str
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
EXTRA_PERSONAS: list[ExtraPersonaSpec] = [
|
| 22 |
+
ExtraPersonaSpec(
|
| 23 |
+
participant_id="extra_pragmatic_generalist",
|
| 24 |
+
name="The Pragmatic Generalist",
|
| 25 |
+
default_model_id="gpt-5.4",
|
| 26 |
+
role_prompt=(
|
| 27 |
+
"You are The Pragmatic Generalist with a complementary specialty in "
|
| 28 |
+
"finance and economics. You have broad general knowledge across "
|
| 29 |
+
"many domains and you instinctively look for what is feasible, "
|
| 30 |
+
"cost-effective, and likely to actually work in practice. Even on "
|
| 31 |
+
"questions that aren't financial, you anchor your reasoning in "
|
| 32 |
+
"monetary cost, return on investment, opportunity cost, time "
|
| 33 |
+
"horizons, and budget realism, and you call out when an idea "
|
| 34 |
+
"sounds great but the numbers don't add up. Your tone is calm, "
|
| 35 |
+
"measured, and faintly skeptical of utopian framing. You speak "
|
| 36 |
+
"like an experienced advisor: short paragraphs, concrete examples, "
|
| 37 |
+
"and a habit of comparing options head-to-head on cost vs benefit "
|
| 38 |
+
"rather than treating any one option as obvious. You are willing "
|
| 39 |
+
"to change your mind when shown a credible argument, but you ask "
|
| 40 |
+
"for a back-of-envelope calculation before doing so."
|
| 41 |
+
),
|
| 42 |
+
),
|
| 43 |
+
ExtraPersonaSpec(
|
| 44 |
+
participant_id="extra_skeptical_critic",
|
| 45 |
+
name="The Skeptical Critic",
|
| 46 |
+
default_model_id="gemini-2.5-flash",
|
| 47 |
+
role_prompt=(
|
| 48 |
+
"You are The Skeptical Critic with a complementary specialty in "
|
| 49 |
+
"philosophy. Your role in a group discussion is to play the "
|
| 50 |
+
"principled devil's advocate: surface assumptions nobody is "
|
| 51 |
+
"examining, pressure-test claims with counterexamples, and ask "
|
| 52 |
+
"the unpopular questions. You frame your challenges through "
|
| 53 |
+
"philosophical fundamentals - epistemology (how do we know that?), "
|
| 54 |
+
"ethics (utilitarian vs deontological framings, consequentialist "
|
| 55 |
+
"tradeoffs), and edge-case thought experiments that expose the "
|
| 56 |
+
"limits of a position. Your tone is sharp but not hostile; you "
|
| 57 |
+
"respect arguments more than people, including your own. You "
|
| 58 |
+
"speak in concise, well-structured sentences, you cite specific "
|
| 59 |
+
"claims by other participants when challenging them, and you are "
|
| 60 |
+
"happy to concede when someone refutes you cleanly - because to "
|
| 61 |
+
"you the goal is the truth, not winning."
|
| 62 |
+
),
|
| 63 |
+
),
|
| 64 |
+
ExtraPersonaSpec(
|
| 65 |
+
participant_id="extra_empathetic_humanist",
|
| 66 |
+
name="The Empathetic Humanist",
|
| 67 |
+
default_model_id="devstral-2512",
|
| 68 |
+
role_prompt=(
|
| 69 |
+
"You are The Empathetic Humanist with a complementary specialty in "
|
| 70 |
+
"world history. You center human, ethical, social, and values "
|
| 71 |
+
"impact in every discussion. You instinctively ask: who is "
|
| 72 |
+
"affected, whose voice is missing, and what does this mean for "
|
| 73 |
+
"the people on the receiving end? You ground your arguments in "
|
| 74 |
+
"historical precedent - how comparable choices have played out "
|
| 75 |
+
"across cultures, civilizations, and eras - and you draw lessons "
|
| 76 |
+
"from them without being preachy. Your tone is warm, thoughtful, "
|
| 77 |
+
"and a bit reflective; you speak in flowing sentences and you "
|
| 78 |
+
"name the human stakes explicitly. You're willing to slow the "
|
| 79 |
+
"group down when something matters morally, and you push back "
|
| 80 |
+
"gently but firmly when an argument treats people as variables. "
|
| 81 |
+
"You change your mind when shown that the human consequences "
|
| 82 |
+
"you feared are not real, or that historical analogues don't "
|
| 83 |
+
"apply."
|
| 84 |
+
),
|
| 85 |
+
),
|
| 86 |
+
ExtraPersonaSpec(
|
| 87 |
+
participant_id="extra_data_driven_analyst",
|
| 88 |
+
name="The Data-Driven Analyst",
|
| 89 |
+
default_model_id="meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
| 90 |
+
role_prompt=(
|
| 91 |
+
"You are The Data-Driven Analyst with a complementary specialty in "
|
| 92 |
+
"geology and Earth-science / physical-systems thinking. You want "
|
| 93 |
+
"evidence: numbers, studies, measurements, and falsifiable claims. "
|
| 94 |
+
"When others speak in generalities, you ask 'how would we measure "
|
| 95 |
+
"that?' or 'what's the magnitude?'. You bring a long-time-horizon, "
|
| 96 |
+
"physical-systems mindset shaped by Earth science: resources are "
|
| 97 |
+
"finite, environmental constraints are real, infrastructure has "
|
| 98 |
+
"lifespans, and feedback loops can take decades to reveal "
|
| 99 |
+
"themselves. Your tone is precise, dry, and quietly rigorous; "
|
| 100 |
+
"you cite figures even when approximate, you flag uncertainty "
|
| 101 |
+
"ranges rather than pretending precision you don't have, and you "
|
| 102 |
+
"respect any participant who shows their work. You are willing "
|
| 103 |
+
"to update your view when better data is presented, and you are "
|
| 104 |
+
"openly suspicious of any claim that has 'never' or 'always' in "
|
| 105 |
+
"it."
|
| 106 |
+
),
|
| 107 |
+
),
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def list_extra_personas() -> list[dict]:
|
| 112 |
+
return [
|
| 113 |
+
{
|
| 114 |
+
"participant_id": p.participant_id,
|
| 115 |
+
"name": p.name,
|
| 116 |
+
"default_model_id": p.default_model_id,
|
| 117 |
+
"role_prompt": p.role_prompt,
|
| 118 |
+
"kind": "extra",
|
| 119 |
+
}
|
| 120 |
+
for p in EXTRA_PERSONAS
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def get_extra_persona(participant_id: str) -> ExtraPersonaSpec | None:
|
| 125 |
+
for p in EXTRA_PERSONAS:
|
| 126 |
+
if p.participant_id == participant_id:
|
| 127 |
+
return p
|
| 128 |
+
return None
|
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers for orchestrator-side LLM calls that need JSON-shaped output."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import re
|
| 7 |
+
import time
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from app.clients.openai_compat import openai_chat_completion
|
| 11 |
+
from app.config import settings
|
| 12 |
+
from app.services.prompts import ORCHESTRATOR_BASE_DIRECTIVE
|
| 13 |
+
from app.utils.sanitize import strip_thinking
|
| 14 |
+
|
| 15 |
+
LOG = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _strip_json_fences(raw: str) -> str:
|
| 19 |
+
"""Some models wrap JSON in ```json ... ``` fences. Peel them off."""
|
| 20 |
+
raw = raw.strip()
|
| 21 |
+
if raw.startswith("```"):
|
| 22 |
+
# drop the first fence line
|
| 23 |
+
first_nl = raw.find("\n")
|
| 24 |
+
if first_nl != -1:
|
| 25 |
+
raw = raw[first_nl + 1:]
|
| 26 |
+
raw = raw.rstrip()
|
| 27 |
+
if raw.endswith("```"):
|
| 28 |
+
raw = raw[:-3].rstrip()
|
| 29 |
+
return raw
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _extract_json_blob(raw: str) -> str:
|
| 33 |
+
"""Best-effort: pull out the first balanced { ... } or [ ... ] block."""
|
| 34 |
+
raw = _strip_json_fences(raw)
|
| 35 |
+
for opener, closer in [("{", "}"), ("[", "]")]:
|
| 36 |
+
start = raw.find(opener)
|
| 37 |
+
if start == -1:
|
| 38 |
+
continue
|
| 39 |
+
depth = 0
|
| 40 |
+
in_str = False
|
| 41 |
+
esc = False
|
| 42 |
+
for i in range(start, len(raw)):
|
| 43 |
+
ch = raw[i]
|
| 44 |
+
if in_str:
|
| 45 |
+
if esc:
|
| 46 |
+
esc = False
|
| 47 |
+
elif ch == "\\":
|
| 48 |
+
esc = True
|
| 49 |
+
elif ch == '"':
|
| 50 |
+
in_str = False
|
| 51 |
+
continue
|
| 52 |
+
if ch == '"':
|
| 53 |
+
in_str = True
|
| 54 |
+
continue
|
| 55 |
+
if ch == opener:
|
| 56 |
+
depth += 1
|
| 57 |
+
elif ch == closer:
|
| 58 |
+
depth -= 1
|
| 59 |
+
if depth == 0:
|
| 60 |
+
return raw[start:i + 1]
|
| 61 |
+
return raw
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def parse_json_response(raw: str) -> dict | list | None:
|
| 65 |
+
"""Tolerant JSON parser for orchestrator outputs.
|
| 66 |
+
|
| 67 |
+
Handles markdown fences, leading/trailing prose, and falls back to
|
| 68 |
+
extracting the first balanced bracket block. Returns None if nothing
|
| 69 |
+
parseable is found.
|
| 70 |
+
"""
|
| 71 |
+
if not raw:
|
| 72 |
+
return None
|
| 73 |
+
candidates = [raw, _strip_json_fences(raw), _extract_json_blob(raw)]
|
| 74 |
+
seen: set[str] = set()
|
| 75 |
+
for c in candidates:
|
| 76 |
+
c = c.strip()
|
| 77 |
+
if not c or c in seen:
|
| 78 |
+
continue
|
| 79 |
+
seen.add(c)
|
| 80 |
+
try:
|
| 81 |
+
return json.loads(c)
|
| 82 |
+
except Exception:
|
| 83 |
+
continue
|
| 84 |
+
LOG.warning("parse_json_response failed; raw=%r", raw[:200])
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def orchestrator_call(
|
| 89 |
+
*,
|
| 90 |
+
orchestrator_model_id: str,
|
| 91 |
+
user_prompt: str,
|
| 92 |
+
label: str,
|
| 93 |
+
api_log: list[dict[str, Any]] | None = None,
|
| 94 |
+
expect_json: bool = True,
|
| 95 |
+
temperature: float = 0.2,
|
| 96 |
+
max_tokens: int = 1024,
|
| 97 |
+
timeout: float = 45.0,
|
| 98 |
+
) -> tuple[str, dict | list | None]:
|
| 99 |
+
"""Run an orchestrator-side LLM call.
|
| 100 |
+
|
| 101 |
+
Returns (raw_text_after_strip, parsed_json_or_None). When `expect_json`
|
| 102 |
+
is False the parsed value will always be None and the caller should use
|
| 103 |
+
the raw text. Any exception is converted into a ("", None) result so
|
| 104 |
+
the orchestrator state machine can degrade gracefully.
|
| 105 |
+
"""
|
| 106 |
+
resolved = settings.resolve_model(orchestrator_model_id)
|
| 107 |
+
if not resolved:
|
| 108 |
+
LOG.warning("Orchestrator model %s not resolvable", orchestrator_model_id)
|
| 109 |
+
return "", None
|
| 110 |
+
|
| 111 |
+
messages = [
|
| 112 |
+
{"role": "system", "content": ORCHESTRATOR_BASE_DIRECTIVE},
|
| 113 |
+
{"role": "user", "content": user_prompt},
|
| 114 |
+
]
|
| 115 |
+
|
| 116 |
+
log_entry: dict[str, Any] = {
|
| 117 |
+
"timestamp": time.time(),
|
| 118 |
+
"label": f"orchestrator:{label}",
|
| 119 |
+
"model": resolved["model_id"],
|
| 120 |
+
"request": {"messages": messages, "max_tokens": max_tokens},
|
| 121 |
+
}
|
| 122 |
+
try:
|
| 123 |
+
result = await openai_chat_completion(
|
| 124 |
+
base_url=resolved["base_url"],
|
| 125 |
+
api_key=resolved["api_key"],
|
| 126 |
+
model=resolved["model_id"],
|
| 127 |
+
messages=messages,
|
| 128 |
+
temperature=temperature,
|
| 129 |
+
max_tokens=max_tokens,
|
| 130 |
+
timeout=timeout,
|
| 131 |
+
)
|
| 132 |
+
except Exception as exc:
|
| 133 |
+
LOG.exception("orchestrator_call %s failed: %s", label, exc)
|
| 134 |
+
log_entry["response"] = {"error": str(exc)}
|
| 135 |
+
if api_log is not None:
|
| 136 |
+
api_log.append(log_entry)
|
| 137 |
+
return "", None
|
| 138 |
+
|
| 139 |
+
log_entry["response"] = result
|
| 140 |
+
if api_log is not None:
|
| 141 |
+
api_log.append(log_entry)
|
| 142 |
+
|
| 143 |
+
raw = strip_thinking(result.get("response", ""))
|
| 144 |
+
parsed = parse_json_response(raw) if expect_json else None
|
| 145 |
+
return raw, parsed
|
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core dataclasses for a CCAI session.
|
| 2 |
+
|
| 3 |
+
Kept in their own module so `orchestrator.py` can import from them
|
| 4 |
+
cleanly and the API layer doesn't need to reach into the orchestrator
|
| 5 |
+
to construct one.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import uuid
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from enum import Enum
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from app.services.context_budget import ContextSummary
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Phase(str, Enum):
|
| 18 |
+
INITIAL_OPINIONS = "initial_opinions"
|
| 19 |
+
CRITIQUE_ROUND_1 = "critique_round_1"
|
| 20 |
+
CRITIQUE_ROUND_2 = "critique_round_2"
|
| 21 |
+
STATUS_ASSESSMENT = "status_assessment"
|
| 22 |
+
FINALIZATION = "finalization"
|
| 23 |
+
CONSENSUS = "consensus"
|
| 24 |
+
CLOSURE = "closure"
|
| 25 |
+
FAILSAFE_PAUSED = "failsafe_paused"
|
| 26 |
+
FINISHED = "finished"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# How many participants a session may include (overridable by the user
|
| 30 |
+
# via Settings; values outside [3, 9] are clamped server-side).
|
| 31 |
+
DEFAULT_MAX_PARTICIPANTS = 5
|
| 32 |
+
MIN_MAX_PARTICIPANTS = 3
|
| 33 |
+
MAX_MAX_PARTICIPANTS = 9
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# Failsafe defaults from the plan: pause every N=60 participant messages
|
| 37 |
+
# (then every +20), and every M=100 orchestrator calls (then every +50).
|
| 38 |
+
PARTICIPANT_MESSAGE_PAUSE_AT = 60
|
| 39 |
+
PARTICIPANT_MESSAGE_PAUSE_INC = 20
|
| 40 |
+
ORCHESTRATOR_CALL_PAUSE_AT = 100
|
| 41 |
+
ORCHESTRATOR_CALL_PAUSE_INC = 50
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class Participant:
|
| 46 |
+
"""One member of the CCAI forum.
|
| 47 |
+
|
| 48 |
+
`kind` distinguishes Neon HANA personas, the four bundled "extra"
|
| 49 |
+
personas, and user-created Expert Personas. `enabled` reflects the
|
| 50 |
+
sidebar slider. Disabled participants are kept on the session so
|
| 51 |
+
the user can re-enable mid-conversation, but they don't take turns.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
participant_id: str
|
| 55 |
+
name: str
|
| 56 |
+
role_prompt: str
|
| 57 |
+
model_id: str
|
| 58 |
+
|
| 59 |
+
kind: str = "expert" # "neon" | "extra" | "expert"
|
| 60 |
+
enabled: bool = True
|
| 61 |
+
|
| 62 |
+
# Resolved provider routing (populated from settings.resolve_model)
|
| 63 |
+
base_url: str = ""
|
| 64 |
+
api_key: str = ""
|
| 65 |
+
display_name: str = ""
|
| 66 |
+
|
| 67 |
+
# Neon-specific routing
|
| 68 |
+
is_neon: bool = False
|
| 69 |
+
hana_model_id: str = ""
|
| 70 |
+
persona_name: str = ""
|
| 71 |
+
neon_direct_vllm: bool = False
|
| 72 |
+
vllm_base_url: str = ""
|
| 73 |
+
vllm_api_key: str = ""
|
| 74 |
+
|
| 75 |
+
# Per-participant context summary (managed by services.context_budget)
|
| 76 |
+
summary: ContextSummary = field(default_factory=ContextSummary)
|
| 77 |
+
|
| 78 |
+
# Robustness counter: 3 consecutive failures auto-disables.
|
| 79 |
+
consecutive_failures: int = 0
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@dataclass
|
| 83 |
+
class Session:
|
| 84 |
+
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
| 85 |
+
|
| 86 |
+
question: str = ""
|
| 87 |
+
participants: list[Participant] = field(default_factory=list)
|
| 88 |
+
|
| 89 |
+
# Both fall through to settings.orchestrator_model when None. The
|
| 90 |
+
# summarizer additionally falls through to the orchestrator's id when
|
| 91 |
+
# None (so changing one auto-changes the other unless overridden).
|
| 92 |
+
orchestrator_model_id: str | None = None
|
| 93 |
+
summarizer_model_id: str | None = None
|
| 94 |
+
|
| 95 |
+
max_participants: int = DEFAULT_MAX_PARTICIPANTS
|
| 96 |
+
|
| 97 |
+
phase: Phase = Phase.INITIAL_OPINIONS
|
| 98 |
+
|
| 99 |
+
# Phase 1 outputs
|
| 100 |
+
initial_opinions: dict[str, str] = field(default_factory=dict)
|
| 101 |
+
credential_summary: list[dict[str, Any]] = field(default_factory=list)
|
| 102 |
+
|
| 103 |
+
# Phase 2 / 3 / 4 / 5 message store. Each entry:
|
| 104 |
+
# { speaker_id, speaker_name, role: "participant"|"orchestrator",
|
| 105 |
+
# text, phase, timestamp, elapsed_seconds, addressed_to,
|
| 106 |
+
# model_id, model_display }
|
| 107 |
+
messages: list[dict[str, Any]] = field(default_factory=list)
|
| 108 |
+
|
| 109 |
+
# Phase-4 state: per-participant final opinion text (for alliances)
|
| 110 |
+
final_opinions: dict[str, str] = field(default_factory=dict)
|
| 111 |
+
alliance_groups: list[dict[str, Any]] = field(default_factory=list)
|
| 112 |
+
|
| 113 |
+
# Phase-3 status-assessment loop counter (max 3)
|
| 114 |
+
status_assessment_iterations: int = 0
|
| 115 |
+
|
| 116 |
+
# Phase-5 / Phase-6 attempts at consensus before giving up (max 2)
|
| 117 |
+
consensus_attempts: int = 0
|
| 118 |
+
|
| 119 |
+
# Final structured report after closure
|
| 120 |
+
final_report: dict[str, Any] | None = None
|
| 121 |
+
|
| 122 |
+
# Per-participant contribution summaries for the table view
|
| 123 |
+
contribution_summaries: dict[str, str] = field(default_factory=dict)
|
| 124 |
+
|
| 125 |
+
# Failsafes
|
| 126 |
+
total_participant_messages: int = 0
|
| 127 |
+
participant_message_cap: int = PARTICIPANT_MESSAGE_PAUSE_AT
|
| 128 |
+
orchestrator_call_count: int = 0
|
| 129 |
+
orchestrator_call_cap: int = ORCHESTRATOR_CALL_PAUSE_AT
|
| 130 |
+
|
| 131 |
+
paused_for_continue: bool = False
|
| 132 |
+
pause_reason: str | None = None # "messages" | "orchestrator"
|
| 133 |
+
finished: bool = False
|
| 134 |
+
|
| 135 |
+
# Streaming control: the orchestrator state-machine writes to this and
|
| 136 |
+
# the API layer reads it.
|
| 137 |
+
api_log: list[dict[str, Any]] = field(default_factory=list)
|
| 138 |
+
pending_continue: bool = False
|
|
@@ -1,108 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import json
|
| 4 |
import logging
|
| 5 |
-
import random
|
| 6 |
import time
|
| 7 |
-
import
|
| 8 |
-
from dataclasses import dataclass, field
|
| 9 |
from typing import Any, AsyncIterator
|
| 10 |
|
| 11 |
-
from app.clients.
|
| 12 |
-
from app.clients.llm_router import chat_completion as unified_chat_completion
|
| 13 |
from app.config import settings
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
_BREVITY = (
|
| 22 |
-
" Keep your reply short — 2-4 sentences, like a casual chat message, not an email or essay."
|
| 23 |
)
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
"Someone just started a conversation with you, this is what they said: {last_message}\n\n"
|
| 35 |
-
"Consider connections between this conversation starter and what you know about yourself, "
|
| 36 |
-
"and say something that could continue the conversation with that new person. Speak in the "
|
| 37 |
-
"first person, as if directly to the other person." + _BREVITY
|
| 38 |
)
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
"Consider how human conversations generally progress, and provide a response. If the last "
|
| 44 |
-
"reply in the conversation is one which might indicate a human is losing interest in or "
|
| 45 |
-
"wrapping up the conversation, then make a response which will help wrap up and close the "
|
| 46 |
-
"conversation." + _BREVITY
|
| 47 |
)
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
| 55 |
)
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
)
|
|
|
|
| 64 |
|
| 65 |
-
|
| 66 |
-
"You are monitoring a conversation between two people. Your job is to determine whether "
|
| 67 |
-
"the latest message indicates the speaker is losing interest or wrapping up the conversation. "
|
| 68 |
-
"Reply with ONLY a JSON object: {{\"winding_down\": true}} or {{\"winding_down\": false}}. "
|
| 69 |
-
"No other text.\n\nLatest message:\n{message}"
|
| 70 |
-
)
|
| 71 |
|
| 72 |
|
| 73 |
# ---------------------------------------------------------------------------
|
| 74 |
-
# Session
|
| 75 |
# ---------------------------------------------------------------------------
|
| 76 |
|
| 77 |
-
@dataclass
|
| 78 |
-
class Persona:
|
| 79 |
-
name: str
|
| 80 |
-
model_id: str
|
| 81 |
-
role_prompt: str
|
| 82 |
-
base_url: str = ""
|
| 83 |
-
api_key: str = ""
|
| 84 |
-
display_name: str = ""
|
| 85 |
-
is_neon: bool = False
|
| 86 |
-
hana_model_id: str = ""
|
| 87 |
-
persona_name: str = ""
|
| 88 |
-
neon_direct_vllm: bool = False
|
| 89 |
-
vllm_base_url: str = ""
|
| 90 |
-
vllm_api_key: str = ""
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
@dataclass
|
| 94 |
-
class Session:
|
| 95 |
-
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
| 96 |
-
persona_a: Persona | None = None
|
| 97 |
-
persona_b: Persona | None = None
|
| 98 |
-
messages: list[dict[str, str]] = field(default_factory=list)
|
| 99 |
-
api_log: list[dict[str, Any]] = field(default_factory=list)
|
| 100 |
-
a_count: int = 0
|
| 101 |
-
b_count: int = 0
|
| 102 |
-
end_mode: bool = False
|
| 103 |
-
finished: bool = False
|
| 104 |
-
|
| 105 |
-
|
| 106 |
_sessions: dict[str, Session] = {}
|
| 107 |
|
| 108 |
|
|
@@ -116,276 +102,970 @@ def create_session() -> Session:
|
|
| 116 |
return s
|
| 117 |
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
# ---------------------------------------------------------------------------
|
| 120 |
# Helpers
|
| 121 |
# ---------------------------------------------------------------------------
|
| 122 |
|
| 123 |
-
def
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
for m in messages:
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
return "\n".join(lines)
|
| 128 |
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
user_content: str,
|
| 134 |
-
session: Session,
|
| 135 |
-
label: str = "",
|
| 136 |
-
max_tokens: int = 500,
|
| 137 |
-
timeout: float = 20,
|
| 138 |
) -> str:
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
)
|
| 145 |
-
|
| 146 |
-
{"role": "system", "content":
|
| 147 |
-
{"role": "user", "content":
|
| 148 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
log_entry: dict[str, Any] = {
|
| 150 |
"timestamp": time.time(),
|
| 151 |
-
"label": label,
|
| 152 |
-
"model":
|
| 153 |
-
"request": {"messages":
|
| 154 |
}
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
"
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
temperature=0.7,
|
| 171 |
-
max_tokens=max_tokens,
|
| 172 |
-
timeout=timeout,
|
| 173 |
-
)
|
| 174 |
|
| 175 |
log_entry["response"] = result
|
| 176 |
session.api_log.append(log_entry)
|
| 177 |
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
-
|
| 182 |
-
prompt: str,
|
| 183 |
session: Session,
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
if resolved:
|
| 198 |
-
break
|
| 199 |
-
|
| 200 |
-
if not resolved:
|
| 201 |
-
return '{"winding_down": false}'
|
| 202 |
-
|
| 203 |
-
messages = [
|
| 204 |
-
{"role": "system", "content": "You are a conversation monitor. Respond only with the requested JSON."},
|
| 205 |
-
{"role": "user", "content": prompt},
|
| 206 |
-
]
|
| 207 |
-
log_entry: dict[str, Any] = {
|
| 208 |
"timestamp": time.time(),
|
| 209 |
-
"
|
| 210 |
-
"
|
| 211 |
-
"
|
|
|
|
| 212 |
}
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
-
result = await openai_chat_completion(
|
| 215 |
-
base_url=resolved["base_url"],
|
| 216 |
-
api_key=resolved["api_key"],
|
| 217 |
-
model=resolved["model_id"],
|
| 218 |
-
messages=messages,
|
| 219 |
-
temperature=0.2,
|
| 220 |
-
max_tokens=256,
|
| 221 |
-
timeout=20,
|
| 222 |
-
)
|
| 223 |
-
|
| 224 |
-
log_entry["response"] = result
|
| 225 |
-
session.api_log.append(log_entry)
|
| 226 |
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
|
| 230 |
-
def
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
if raw.startswith("```"):
|
| 234 |
-
raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0]
|
| 235 |
-
return json.loads(raw).get(key, False)
|
| 236 |
-
except Exception:
|
| 237 |
-
lower = raw.lower()
|
| 238 |
-
return f'"{key}": true' in lower or f'"{key}":true' in lower
|
| 239 |
|
| 240 |
|
| 241 |
# ---------------------------------------------------------------------------
|
| 242 |
-
#
|
| 243 |
# ---------------------------------------------------------------------------
|
| 244 |
|
| 245 |
-
async def
|
| 246 |
-
session
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
-
yield _sse("status", {"message": "Starting conversation..."})
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
label=f"first_reply:{responder.name}",
|
| 273 |
)
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
)
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
responder, responder.role_prompt, reply_prompt, session,
|
| 288 |
-
label=f"first_reply:{responder.name}",
|
| 289 |
)
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
)
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
session.end_mode = True
|
| 317 |
-
|
| 318 |
-
if session.end_mode:
|
| 319 |
-
# Penultimate message: current speaker wraps up
|
| 320 |
-
history_text = _format_history(session.messages)
|
| 321 |
-
wrap_msg, wrap_elapsed = await _call_llm(
|
| 322 |
-
current, current.role_prompt,
|
| 323 |
-
WINDING_NEXT_PROMPT.format(history=history_text),
|
| 324 |
-
session, label=f"winding_next:{current.name}",
|
| 325 |
)
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
)
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
| 350 |
)
|
| 351 |
-
_add_message(session, current, response, current_idx, resp_elapsed)
|
| 352 |
-
yield _sse("message", _msg_payload(session.messages[-1], current_idx))
|
| 353 |
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
|
| 358 |
|
| 359 |
# ---------------------------------------------------------------------------
|
| 360 |
-
#
|
| 361 |
# ---------------------------------------------------------------------------
|
| 362 |
|
| 363 |
-
def
|
| 364 |
-
session.
|
| 365 |
-
|
| 366 |
-
"speaker_idx": speaker_idx,
|
| 367 |
-
"model_id": persona.model_id,
|
| 368 |
-
"model_display": persona.display_name,
|
| 369 |
-
"text": text,
|
| 370 |
-
"timestamp": time.time(),
|
| 371 |
-
"elapsed_seconds": round(elapsed, 2),
|
| 372 |
-
})
|
| 373 |
-
if speaker_idx == 0:
|
| 374 |
-
session.a_count += 1
|
| 375 |
-
else:
|
| 376 |
-
session.b_count += 1
|
| 377 |
|
|
|
|
|
|
|
| 378 |
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
}
|
|
|
|
|
|
|
| 388 |
|
| 389 |
|
| 390 |
-
|
| 391 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CCAI orchestrator: six-phase state machine driving a multi-participant
|
| 2 |
+
group discussion to a consensus (or to a documented failure-to-consense).
|
| 3 |
+
|
| 4 |
+
Phase outline (matches the build plan):
|
| 5 |
+
|
| 6 |
+
1. Initial Opinions (independent, no peeking)
|
| 7 |
+
1.5. Build Credential Summary
|
| 8 |
+
2. Critique x 2 rounds (full history visible)
|
| 9 |
+
3. Status Assessment (max 3 iterations of targeted follow-ups)
|
| 10 |
+
4. Opinion Finalization
|
| 11 |
+
5. Consensus Gathering (alliance-aware, addressed-to aware)
|
| 12 |
+
6. Closure (majority report, or unaddressed-factor probe + retry,
|
| 13 |
+
or failure report)
|
| 14 |
+
|
| 15 |
+
Two failsafes pause the loop until the user clicks "Continue":
|
| 16 |
+
- Participant-message cap: 60, then +20.
|
| 17 |
+
- Orchestrator-call cap: 100, then +50.
|
| 18 |
+
|
| 19 |
+
Every LLM response runs through `app.utils.sanitize.strip_thinking` on
|
| 20 |
+
its way into history, into the orchestrator's prompts, and into the
|
| 21 |
+
summarizer.
|
| 22 |
+
"""
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
+
import asyncio
|
| 26 |
import json
|
| 27 |
import logging
|
|
|
|
| 28 |
import time
|
| 29 |
+
from dataclasses import asdict
|
|
|
|
| 30 |
from typing import Any, AsyncIterator
|
| 31 |
|
| 32 |
+
from app.clients.llm_router import chat_completion
|
|
|
|
| 33 |
from app.config import settings
|
| 34 |
+
from app.services import context_budget
|
| 35 |
+
from app.services.consensus import (
|
| 36 |
+
assess_consensus_status,
|
| 37 |
+
classify_addressed_to,
|
| 38 |
+
detect_alliances,
|
| 39 |
+
find_unaddressed_factor,
|
|
|
|
|
|
|
|
|
|
| 40 |
)
|
| 41 |
+
from app.services.context_budget import (
|
| 42 |
+
ContextSummary,
|
| 43 |
+
DEFAULT_REPLY_BUDGET,
|
| 44 |
+
KEEP_RECENT_MESSAGES,
|
| 45 |
+
build_compressed_messages,
|
| 46 |
+
context_window_for,
|
| 47 |
+
estimate_messages_tokens,
|
| 48 |
+
run_summarize,
|
| 49 |
+
select_summarizer_model_id,
|
| 50 |
+
should_summarize,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
)
|
| 52 |
+
from app.services.credential import (
|
| 53 |
+
build_credential_summary,
|
| 54 |
+
credentials_to_block,
|
| 55 |
+
refresh_credential_summary,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
)
|
| 57 |
+
from app.services.json_calls import orchestrator_call
|
| 58 |
+
from app.services.models import (
|
| 59 |
+
DEFAULT_MAX_PARTICIPANTS,
|
| 60 |
+
MAX_MAX_PARTICIPANTS,
|
| 61 |
+
MIN_MAX_PARTICIPANTS,
|
| 62 |
+
ORCHESTRATOR_CALL_PAUSE_INC,
|
| 63 |
+
PARTICIPANT_MESSAGE_PAUSE_INC,
|
| 64 |
+
Participant,
|
| 65 |
+
Phase,
|
| 66 |
+
Session,
|
| 67 |
)
|
| 68 |
+
from app.services.prompts import (
|
| 69 |
+
CONSENSUS_ALLIED_PROMPT,
|
| 70 |
+
CONSENSUS_SOLO_PROMPT,
|
| 71 |
+
CONSENSUS_TARGETED_RESPONSE_PROMPT,
|
| 72 |
+
CONTRIBUTION_SUMMARY_PROMPT,
|
| 73 |
+
CRITIQUE_PROMPT,
|
| 74 |
+
FINALIZATION_PROMPT,
|
| 75 |
+
INITIAL_OPINION_PROMPT,
|
| 76 |
+
MAJORITY_REPORT_PROMPT,
|
| 77 |
+
NO_CONSENSUS_REPORT_PROMPT,
|
| 78 |
+
NO_REASONING_DIRECTIVE,
|
| 79 |
+
PARTICIPANT_BASE_DIRECTIVE,
|
| 80 |
+
STATUS_ASSESSMENT_PROMPT,
|
| 81 |
+
TARGETED_FOLLOWUP_PROMPT,
|
| 82 |
)
|
| 83 |
+
from app.utils.sanitize import strip_thinking
|
| 84 |
|
| 85 |
+
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
# ---------------------------------------------------------------------------
|
| 89 |
+
# Session registry
|
| 90 |
# ---------------------------------------------------------------------------
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
_sessions: dict[str, Session] = {}
|
| 93 |
|
| 94 |
|
|
|
|
| 102 |
return s
|
| 103 |
|
| 104 |
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
# SSE helpers
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
|
| 109 |
+
def _sse(event: str, data: dict[str, Any]) -> str:
|
| 110 |
+
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
# ---------------------------------------------------------------------------
|
| 114 |
# Helpers
|
| 115 |
# ---------------------------------------------------------------------------
|
| 116 |
|
| 117 |
+
def _active_participants(session: Session) -> list[Participant]:
|
| 118 |
+
return [p for p in session.participants if p.enabled]
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _orchestrator_model_id(session: Session) -> str:
|
| 122 |
+
return session.orchestrator_model_id or settings.orchestrator_model
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _summarizer_model_id(session: Session) -> str:
|
| 126 |
+
return select_summarizer_model_id(
|
| 127 |
+
session.summarizer_model_id,
|
| 128 |
+
session.orchestrator_model_id,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _format_history(
|
| 133 |
+
messages: list[dict[str, Any]],
|
| 134 |
+
*,
|
| 135 |
+
include_orchestrator: bool = True,
|
| 136 |
+
) -> str:
|
| 137 |
+
lines: list[str] = []
|
| 138 |
for m in messages:
|
| 139 |
+
if m.get("role") == "orchestrator" and not include_orchestrator:
|
| 140 |
+
continue
|
| 141 |
+
speaker = m.get("speaker_name") or m.get("speaker_id") or "(anon)"
|
| 142 |
+
if m.get("role") == "orchestrator":
|
| 143 |
+
speaker = "Orchestrator"
|
| 144 |
+
lines.append(f"{speaker}: {m.get('text', '')}")
|
| 145 |
return "\n".join(lines)
|
| 146 |
|
| 147 |
|
| 148 |
+
def _participant_roster_string(
|
| 149 |
+
speaker: Participant,
|
| 150 |
+
participants: list[Participant],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
) -> str:
|
| 152 |
+
others = [p.name for p in participants if p.participant_id != speaker.participant_id]
|
| 153 |
+
return ", ".join(others) if others else "(no other participants)"
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
# Failsafe checks
|
| 158 |
+
# ---------------------------------------------------------------------------
|
| 159 |
+
|
| 160 |
+
def _participant_msg_cap_hit(session: Session) -> bool:
|
| 161 |
+
return session.total_participant_messages >= session.participant_message_cap
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _orchestrator_cap_hit(session: Session) -> bool:
|
| 165 |
+
return session.orchestrator_call_count >= session.orchestrator_call_cap
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _bump_orchestrator_count(session: Session) -> None:
|
| 169 |
+
session.orchestrator_call_count += 1
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
async def _wait_for_continue(
|
| 173 |
+
session: Session,
|
| 174 |
+
reason: str,
|
| 175 |
+
) -> AsyncIterator[str]:
|
| 176 |
+
"""Pause the state machine until the user clicks Continue."""
|
| 177 |
+
session.paused_for_continue = True
|
| 178 |
+
session.pause_reason = reason
|
| 179 |
+
if reason == "messages":
|
| 180 |
+
msg = (
|
| 181 |
+
f"Conversation paused after {session.total_participant_messages} "
|
| 182 |
+
"participant messages. Click Continue to allow another "
|
| 183 |
+
f"{PARTICIPANT_MESSAGE_PAUSE_INC} messages."
|
| 184 |
+
)
|
| 185 |
+
evt = "failsafe_pause"
|
| 186 |
+
bump_inc = PARTICIPANT_MESSAGE_PAUSE_INC
|
| 187 |
+
else:
|
| 188 |
+
msg = (
|
| 189 |
+
f"Conversation paused after {session.orchestrator_call_count} "
|
| 190 |
+
"orchestrator calls. Click Continue to allow another "
|
| 191 |
+
f"{ORCHESTRATOR_CALL_PAUSE_INC} orchestrator calls."
|
| 192 |
+
)
|
| 193 |
+
evt = "orchestrator_cap_pause"
|
| 194 |
+
bump_inc = ORCHESTRATOR_CALL_PAUSE_INC
|
| 195 |
+
|
| 196 |
+
yield _sse(evt, {
|
| 197 |
+
"reason": reason,
|
| 198 |
+
"message": msg,
|
| 199 |
+
"participant_messages": session.total_participant_messages,
|
| 200 |
+
"orchestrator_calls": session.orchestrator_call_count,
|
| 201 |
+
})
|
| 202 |
+
|
| 203 |
+
# Block until pending_continue is flipped by the API layer.
|
| 204 |
+
while session.paused_for_continue and not session.pending_continue:
|
| 205 |
+
await asyncio.sleep(0.25)
|
| 206 |
+
session.pending_continue = False
|
| 207 |
+
session.paused_for_continue = False
|
| 208 |
+
if reason == "messages":
|
| 209 |
+
session.participant_message_cap += bump_inc
|
| 210 |
+
else:
|
| 211 |
+
session.orchestrator_call_cap += bump_inc
|
| 212 |
+
session.pause_reason = None
|
| 213 |
+
yield _sse("status", {"message": "Resuming conversation..."})
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ---------------------------------------------------------------------------
|
| 217 |
+
# Participant turn (with context budgeting + summarize-on-demand)
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
|
| 220 |
+
async def _maybe_summarize_for_participant(
|
| 221 |
+
session: Session,
|
| 222 |
+
participant: Participant,
|
| 223 |
+
api_messages: list[dict[str, Any]],
|
| 224 |
+
) -> None:
|
| 225 |
+
"""If this participant's input estimate exceeds the threshold, run a
|
| 226 |
+
summarize call against the configured summarizer model and update
|
| 227 |
+
`participant.summary` in place."""
|
| 228 |
+
needs_sum, _trim, _budget = should_summarize(
|
| 229 |
+
participant.model_id, api_messages, participant.summary,
|
| 230 |
+
)
|
| 231 |
+
if not needs_sum:
|
| 232 |
+
return
|
| 233 |
+
|
| 234 |
+
# Build a transcript that excludes orchestrator status banners (those
|
| 235 |
+
# don't add information value to a summary) but keeps everything the
|
| 236 |
+
# participant has said and heard.
|
| 237 |
+
summarizable_msgs = [
|
| 238 |
+
m for m in session.messages
|
| 239 |
+
if m.get("role") != "orchestrator_status"
|
| 240 |
+
]
|
| 241 |
+
if not summarizable_msgs:
|
| 242 |
+
return
|
| 243 |
+
|
| 244 |
+
transcript = _format_history(summarizable_msgs, include_orchestrator=False)
|
| 245 |
+
if not transcript.strip():
|
| 246 |
+
return
|
| 247 |
+
|
| 248 |
+
summarizer_id = _summarizer_model_id(session)
|
| 249 |
+
summary_text = await run_summarize(summarizer_id, transcript)
|
| 250 |
+
# The summarizer counts as an orchestrator-side call for cap purposes.
|
| 251 |
+
session.orchestrator_call_count += 1
|
| 252 |
+
if summary_text:
|
| 253 |
+
participant.summary.summary_text = summary_text
|
| 254 |
+
participant.summary.summarized_through_idx = len(session.messages) - 1
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
async def _call_participant(
|
| 258 |
+
*,
|
| 259 |
+
session: Session,
|
| 260 |
+
participant: Participant,
|
| 261 |
+
user_prompt: str,
|
| 262 |
+
label: str,
|
| 263 |
+
max_tokens: int = 600,
|
| 264 |
+
timeout: float = 45.0,
|
| 265 |
+
) -> tuple[str, float, bool]:
|
| 266 |
+
"""Run one participant turn and return (text, elapsed_seconds, ok).
|
| 267 |
+
|
| 268 |
+
The state-machine handles auto-disable on repeated failure.
|
| 269 |
+
"""
|
| 270 |
+
others = _participant_roster_string(participant, _active_participants(session))
|
| 271 |
+
base_directive = PARTICIPANT_BASE_DIRECTIVE.format(
|
| 272 |
+
n_participants=len(_active_participants(session)),
|
| 273 |
+
other_participants=others,
|
| 274 |
+
)
|
| 275 |
+
system_text = (
|
| 276 |
+
f"{participant.role_prompt}\n\n{base_directive}\n\n{NO_REASONING_DIRECTIVE}"
|
| 277 |
)
|
| 278 |
+
api_messages: list[dict[str, Any]] = [
|
| 279 |
+
{"role": "system", "content": system_text},
|
| 280 |
+
{"role": "user", "content": user_prompt},
|
| 281 |
]
|
| 282 |
+
|
| 283 |
+
await _maybe_summarize_for_participant(session, participant, api_messages)
|
| 284 |
+
|
| 285 |
+
needs_sum, needs_trim, _ = should_summarize(
|
| 286 |
+
participant.model_id, api_messages, participant.summary,
|
| 287 |
+
)
|
| 288 |
+
if needs_trim:
|
| 289 |
+
api_messages = build_compressed_messages(
|
| 290 |
+
api_messages, participant.summary, needs_trim,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
resolved = {
|
| 294 |
+
"model_id": participant.model_id,
|
| 295 |
+
"base_url": participant.base_url,
|
| 296 |
+
"api_key": participant.api_key,
|
| 297 |
+
"is_neon": participant.is_neon,
|
| 298 |
+
"hana_model_id": participant.hana_model_id,
|
| 299 |
+
"persona_name": participant.persona_name,
|
| 300 |
+
"neon_direct_vllm": participant.neon_direct_vllm,
|
| 301 |
+
"vllm_base_url": participant.vllm_base_url,
|
| 302 |
+
"vllm_api_key": participant.vllm_api_key,
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
log_entry: dict[str, Any] = {
|
| 306 |
"timestamp": time.time(),
|
| 307 |
+
"label": f"participant:{participant.participant_id}:{label}",
|
| 308 |
+
"model": participant.model_id,
|
| 309 |
+
"request": {"messages": api_messages, "max_tokens": max_tokens},
|
| 310 |
}
|
| 311 |
|
| 312 |
+
try:
|
| 313 |
+
result = await chat_completion(
|
| 314 |
+
resolved=resolved,
|
| 315 |
+
messages=api_messages,
|
| 316 |
+
temperature=0.7,
|
| 317 |
+
max_tokens=max_tokens,
|
| 318 |
+
timeout=timeout,
|
| 319 |
+
)
|
| 320 |
+
except Exception as exc:
|
| 321 |
+
LOG.exception("Participant %s call failed: %s", participant.participant_id, exc)
|
| 322 |
+
log_entry["response"] = {"error": str(exc)}
|
| 323 |
+
session.api_log.append(log_entry)
|
| 324 |
+
participant.consecutive_failures += 1
|
| 325 |
+
return "", 0.0, False
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
log_entry["response"] = result
|
| 328 |
session.api_log.append(log_entry)
|
| 329 |
|
| 330 |
+
if result.get("error"):
|
| 331 |
+
participant.consecutive_failures += 1
|
| 332 |
+
return "", result.get("elapsed_seconds", 0), False
|
| 333 |
+
|
| 334 |
+
participant.consecutive_failures = 0
|
| 335 |
+
text = strip_thinking(result.get("response", ""))
|
| 336 |
+
elapsed = float(result.get("elapsed_seconds", 0) or 0)
|
| 337 |
+
return text, elapsed, True
|
| 338 |
|
| 339 |
|
| 340 |
+
def _add_participant_message(
|
|
|
|
| 341 |
session: Session,
|
| 342 |
+
participant: Participant,
|
| 343 |
+
text: str,
|
| 344 |
+
*,
|
| 345 |
+
phase: Phase,
|
| 346 |
+
elapsed: float,
|
| 347 |
+
addressed_to: str | None = None,
|
| 348 |
+
) -> dict[str, Any]:
|
| 349 |
+
msg = {
|
| 350 |
+
"speaker_id": participant.participant_id,
|
| 351 |
+
"speaker_name": participant.name,
|
| 352 |
+
"role": "participant",
|
| 353 |
+
"text": text,
|
| 354 |
+
"phase": phase.value,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
"timestamp": time.time(),
|
| 356 |
+
"elapsed_seconds": round(elapsed, 2),
|
| 357 |
+
"addressed_to": addressed_to,
|
| 358 |
+
"model_id": participant.model_id,
|
| 359 |
+
"model_display": participant.display_name,
|
| 360 |
}
|
| 361 |
+
session.messages.append(msg)
|
| 362 |
+
session.total_participant_messages += 1
|
| 363 |
+
return msg
|
| 364 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
+
def _add_orchestrator_message(
|
| 367 |
+
session: Session,
|
| 368 |
+
text: str,
|
| 369 |
+
*,
|
| 370 |
+
kind: str,
|
| 371 |
+
extra: dict[str, Any] | None = None,
|
| 372 |
+
) -> dict[str, Any]:
|
| 373 |
+
msg = {
|
| 374 |
+
"speaker_id": "orchestrator",
|
| 375 |
+
"speaker_name": "Orchestrator",
|
| 376 |
+
"role": "orchestrator",
|
| 377 |
+
"kind": kind, # "status" | "factor" | "majority_report" | "no_consensus_report"
|
| 378 |
+
"text": text,
|
| 379 |
+
"phase": session.phase.value,
|
| 380 |
+
"timestamp": time.time(),
|
| 381 |
+
}
|
| 382 |
+
if extra:
|
| 383 |
+
msg.update(extra)
|
| 384 |
+
session.messages.append(msg)
|
| 385 |
+
return msg
|
| 386 |
|
| 387 |
|
| 388 |
+
def _msg_payload(msg: dict[str, Any]) -> dict[str, Any]:
|
| 389 |
+
"""Public payload for a message event over SSE."""
|
| 390 |
+
return msg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
|
| 392 |
|
| 393 |
# ---------------------------------------------------------------------------
|
| 394 |
+
# Phase implementations
|
| 395 |
# ---------------------------------------------------------------------------
|
| 396 |
|
| 397 |
+
async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
|
| 398 |
+
session.phase = Phase.INITIAL_OPINIONS
|
| 399 |
+
yield _sse("status", {"message": "Phase 1: collecting independent first opinions..."})
|
| 400 |
+
|
| 401 |
+
actives = _active_participants(session)
|
| 402 |
+
for p in actives:
|
| 403 |
+
# Phase 1 deliberately uses a *bare* prompt (no transcript) so each
|
| 404 |
+
# participant's first opinion is independent of the others.
|
| 405 |
+
prompt = INITIAL_OPINION_PROMPT.format(question=session.question)
|
| 406 |
+
text, elapsed, ok = await _call_participant(
|
| 407 |
+
session=session, participant=p,
|
| 408 |
+
user_prompt=prompt,
|
| 409 |
+
label="initial_opinion",
|
| 410 |
+
max_tokens=700,
|
| 411 |
+
)
|
| 412 |
+
if not ok or not text.strip():
|
| 413 |
+
yield _sse("participant_error", {
|
| 414 |
+
"participant_id": p.participant_id,
|
| 415 |
+
"name": p.name,
|
| 416 |
+
"phase": session.phase.value,
|
| 417 |
+
})
|
| 418 |
+
if p.consecutive_failures >= 3:
|
| 419 |
+
p.enabled = False
|
| 420 |
+
yield _sse("status", {
|
| 421 |
+
"message": f"{p.name} auto-disabled after 3 failures.",
|
| 422 |
+
})
|
| 423 |
+
continue
|
| 424 |
+
msg = _add_participant_message(session, p, text, phase=session.phase, elapsed=elapsed)
|
| 425 |
+
session.initial_opinions[p.participant_id] = text
|
| 426 |
+
yield _sse("message", _msg_payload(msg))
|
| 427 |
+
|
| 428 |
+
if _participant_msg_cap_hit(session):
|
| 429 |
+
async for chunk in _wait_for_continue(session, "messages"):
|
| 430 |
+
yield chunk
|
| 431 |
+
|
| 432 |
+
yield _sse("status", {"message": "Building Credential Summary..."})
|
| 433 |
+
creds = await build_credential_summary(
|
| 434 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 435 |
+
question=session.question,
|
| 436 |
+
participants=_active_participants(session),
|
| 437 |
+
initial_opinions=session.initial_opinions,
|
| 438 |
+
api_log=session.api_log,
|
| 439 |
+
)
|
| 440 |
+
_bump_orchestrator_count(session)
|
| 441 |
+
session.credential_summary = creds
|
| 442 |
|
|
|
|
| 443 |
|
| 444 |
+
async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[str]:
|
| 445 |
+
session.phase = (
|
| 446 |
+
Phase.CRITIQUE_ROUND_1 if round_number == 1 else Phase.CRITIQUE_ROUND_2
|
| 447 |
+
)
|
| 448 |
+
yield _sse("status", {
|
| 449 |
+
"message": f"Phase 2: critique round {round_number} of 2...",
|
| 450 |
+
})
|
| 451 |
+
cred_block = credentials_to_block(session.credential_summary)
|
| 452 |
+
actives = _active_participants(session)
|
| 453 |
+
for p in actives:
|
| 454 |
+
transcript = _format_history(session.messages)
|
| 455 |
+
prompt = CRITIQUE_PROMPT.format(
|
| 456 |
+
round_number=round_number,
|
| 457 |
+
question=session.question,
|
| 458 |
+
credential_summary=cred_block,
|
| 459 |
+
transcript=transcript,
|
| 460 |
+
)
|
| 461 |
+
text, elapsed, ok = await _call_participant(
|
| 462 |
+
session=session, participant=p,
|
| 463 |
+
user_prompt=prompt,
|
| 464 |
+
label=f"critique_round_{round_number}",
|
| 465 |
+
max_tokens=700,
|
| 466 |
+
)
|
| 467 |
+
if not ok or not text.strip():
|
| 468 |
+
yield _sse("participant_error", {
|
| 469 |
+
"participant_id": p.participant_id, "name": p.name,
|
| 470 |
+
"phase": session.phase.value,
|
| 471 |
+
})
|
| 472 |
+
if p.consecutive_failures >= 3:
|
| 473 |
+
p.enabled = False
|
| 474 |
+
yield _sse("status", {"message": f"{p.name} auto-disabled after 3 failures."})
|
| 475 |
+
continue
|
| 476 |
+
|
| 477 |
+
# Detect addressed_to so the consensus phase's targeted-response
|
| 478 |
+
# logic can also reuse it - cheap classification call.
|
| 479 |
+
addressed = await classify_addressed_to(
|
| 480 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 481 |
+
participants=_active_participants(session),
|
| 482 |
+
speaker_name=p.name,
|
| 483 |
+
message=text,
|
| 484 |
+
api_log=session.api_log,
|
| 485 |
+
)
|
| 486 |
+
_bump_orchestrator_count(session)
|
| 487 |
|
| 488 |
+
msg = _add_participant_message(
|
| 489 |
+
session, p, text, phase=session.phase, elapsed=elapsed,
|
| 490 |
+
addressed_to=addressed,
|
|
|
|
| 491 |
)
|
| 492 |
+
yield _sse("message", _msg_payload(msg))
|
| 493 |
+
|
| 494 |
+
if _participant_msg_cap_hit(session):
|
| 495 |
+
async for chunk in _wait_for_continue(session, "messages"):
|
| 496 |
+
yield chunk
|
| 497 |
+
if _orchestrator_cap_hit(session):
|
| 498 |
+
async for chunk in _wait_for_continue(session, "orchestrator"):
|
| 499 |
+
yield chunk
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
|
| 503 |
+
session.phase = Phase.STATUS_ASSESSMENT
|
| 504 |
+
yield _sse("status", {"message": "Phase 3: assessing whether more questions are needed..."})
|
| 505 |
+
|
| 506 |
+
cred_block = credentials_to_block(session.credential_summary)
|
| 507 |
+
|
| 508 |
+
# Refresh Credential Summary once after Phase 2 critique - participants
|
| 509 |
+
# have revealed a lot more about themselves through critique.
|
| 510 |
+
transcript = _format_history(session.messages)
|
| 511 |
+
refreshed = await refresh_credential_summary(
|
| 512 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 513 |
+
question=session.question,
|
| 514 |
+
participants=_active_participants(session),
|
| 515 |
+
existing=session.credential_summary,
|
| 516 |
+
critique_transcript=transcript,
|
| 517 |
+
api_log=session.api_log,
|
| 518 |
+
)
|
| 519 |
+
_bump_orchestrator_count(session)
|
| 520 |
+
session.credential_summary = refreshed
|
| 521 |
+
cred_block = credentials_to_block(session.credential_summary)
|
| 522 |
+
|
| 523 |
+
for iteration in range(3):
|
| 524 |
+
session.status_assessment_iterations = iteration + 1
|
| 525 |
+
prompt = STATUS_ASSESSMENT_PROMPT.format(
|
| 526 |
+
question=session.question,
|
| 527 |
+
credential_summary=cred_block,
|
| 528 |
+
transcript=_format_history(session.messages),
|
| 529 |
)
|
| 530 |
+
_raw, parsed = await orchestrator_call(
|
| 531 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 532 |
+
user_prompt=prompt,
|
| 533 |
+
label=f"status_assessment_{iteration + 1}",
|
| 534 |
+
api_log=session.api_log,
|
| 535 |
+
max_tokens=512,
|
| 536 |
+
)
|
| 537 |
+
_bump_orchestrator_count(session)
|
| 538 |
|
| 539 |
+
opinions_solidified = bool(
|
| 540 |
+
isinstance(parsed, dict) and parsed.get("opinions_solidified")
|
|
|
|
|
|
|
| 541 |
)
|
| 542 |
+
open_qs: list[dict[str, Any]] = []
|
| 543 |
+
if isinstance(parsed, dict):
|
| 544 |
+
open_qs = parsed.get("open_questions") or []
|
| 545 |
+
|
| 546 |
+
if opinions_solidified or not open_qs:
|
| 547 |
+
yield _sse("orchestrator", {
|
| 548 |
+
"kind": "status",
|
| 549 |
+
"text": "Opinions appear solidified - moving to finalization.",
|
| 550 |
+
})
|
| 551 |
+
return
|
| 552 |
+
|
| 553 |
+
# Otherwise run targeted follow-ups
|
| 554 |
+
active_ids = {p.participant_id for p in _active_participants(session)}
|
| 555 |
+
for oq in open_qs:
|
| 556 |
+
pid = oq.get("participant_id")
|
| 557 |
+
question_text = (oq.get("question") or "").strip()
|
| 558 |
+
if not pid or pid not in active_ids or not question_text:
|
| 559 |
+
continue
|
| 560 |
+
target = next(p for p in session.participants if p.participant_id == pid)
|
| 561 |
+
announce = (
|
| 562 |
+
f"The orchestrator has a follow-up for {target.name}: "
|
| 563 |
+
f"\"{question_text}\""
|
| 564 |
+
)
|
| 565 |
+
announce_msg = _add_orchestrator_message(session, announce, kind="status")
|
| 566 |
+
yield _sse("orchestrator", _msg_payload(announce_msg))
|
| 567 |
+
|
| 568 |
+
transcript = _format_history(session.messages)
|
| 569 |
+
prompt2 = TARGETED_FOLLOWUP_PROMPT.format(
|
| 570 |
+
transcript=transcript,
|
| 571 |
+
credential_summary=cred_block,
|
| 572 |
+
targeted_question=question_text,
|
| 573 |
+
)
|
| 574 |
+
text, elapsed, ok = await _call_participant(
|
| 575 |
+
session=session, participant=target,
|
| 576 |
+
user_prompt=prompt2,
|
| 577 |
+
label="targeted_followup",
|
| 578 |
+
max_tokens=600,
|
| 579 |
)
|
| 580 |
+
if not ok or not text.strip():
|
| 581 |
+
yield _sse("participant_error", {
|
| 582 |
+
"participant_id": target.participant_id, "name": target.name,
|
| 583 |
+
"phase": session.phase.value,
|
| 584 |
+
})
|
| 585 |
+
continue
|
| 586 |
+
msg = _add_participant_message(
|
| 587 |
+
session, target, text, phase=session.phase, elapsed=elapsed,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
)
|
| 589 |
+
yield _sse("message", _msg_payload(msg))
|
| 590 |
+
|
| 591 |
+
if _participant_msg_cap_hit(session):
|
| 592 |
+
async for chunk in _wait_for_continue(session, "messages"):
|
| 593 |
+
yield chunk
|
| 594 |
+
if _orchestrator_cap_hit(session):
|
| 595 |
+
async for chunk in _wait_for_continue(session, "orchestrator"):
|
| 596 |
+
yield chunk
|
| 597 |
+
|
| 598 |
+
yield _sse("orchestrator", {
|
| 599 |
+
"kind": "status",
|
| 600 |
+
"text": "Status assessment limit reached - moving to finalization.",
|
| 601 |
+
})
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
async def _phase_finalization(session: Session) -> AsyncIterator[str]:
|
| 605 |
+
session.phase = Phase.FINALIZATION
|
| 606 |
+
yield _sse("status", {"message": "Phase 4: opinion finalization..."})
|
| 607 |
+
|
| 608 |
+
cred_block = credentials_to_block(session.credential_summary)
|
| 609 |
+
actives = _active_participants(session)
|
| 610 |
+
for p in actives:
|
| 611 |
+
transcript = _format_history(session.messages)
|
| 612 |
+
prompt = FINALIZATION_PROMPT.format(
|
| 613 |
+
question=session.question,
|
| 614 |
+
credential_summary=cred_block,
|
| 615 |
+
transcript=transcript,
|
| 616 |
+
)
|
| 617 |
+
text, elapsed, ok = await _call_participant(
|
| 618 |
+
session=session, participant=p,
|
| 619 |
+
user_prompt=prompt,
|
| 620 |
+
label="finalization",
|
| 621 |
+
max_tokens=600,
|
| 622 |
+
)
|
| 623 |
+
if not ok or not text.strip():
|
| 624 |
+
yield _sse("participant_error", {
|
| 625 |
+
"participant_id": p.participant_id, "name": p.name,
|
| 626 |
+
"phase": session.phase.value,
|
| 627 |
+
})
|
| 628 |
+
continue
|
| 629 |
+
session.final_opinions[p.participant_id] = text
|
| 630 |
+
msg = _add_participant_message(
|
| 631 |
+
session, p, text, phase=session.phase, elapsed=elapsed,
|
| 632 |
+
)
|
| 633 |
+
yield _sse("message", _msg_payload(msg))
|
| 634 |
+
|
| 635 |
+
if _participant_msg_cap_hit(session):
|
| 636 |
+
async for chunk in _wait_for_continue(session, "messages"):
|
| 637 |
+
yield chunk
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
async def _phase_consensus(session: Session) -> AsyncIterator[str]:
|
| 641 |
+
session.phase = Phase.CONSENSUS
|
| 642 |
+
yield _sse("status", {"message": "Phase 5: consensus gathering..."})
|
| 643 |
+
|
| 644 |
+
cred_block = credentials_to_block(session.credential_summary)
|
| 645 |
+
actives = _active_participants(session)
|
| 646 |
+
|
| 647 |
+
# Initial alliance detection from the finalization-phase opinions
|
| 648 |
+
groups = await detect_alliances(
|
| 649 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 650 |
+
question=session.question,
|
| 651 |
+
participants=actives,
|
| 652 |
+
final_opinions=session.final_opinions,
|
| 653 |
+
api_log=session.api_log,
|
| 654 |
+
)
|
| 655 |
+
_bump_orchestrator_count(session)
|
| 656 |
+
session.alliance_groups = groups
|
| 657 |
+
|
| 658 |
+
announce = "Alliance groups detected: " + "; ".join(
|
| 659 |
+
f"\"{g.get('stance', '')}\" -> [{', '.join(g.get('members') or [])}]"
|
| 660 |
+
for g in groups
|
| 661 |
+
)
|
| 662 |
+
msg = _add_orchestrator_message(session, announce, kind="status")
|
| 663 |
+
yield _sse("orchestrator", _msg_payload(msg))
|
| 664 |
+
|
| 665 |
+
# Round-robin among active participants, but yield to the addressed-to
|
| 666 |
+
# target whenever the previous message named one explicitly.
|
| 667 |
+
queue: list[Participant] = list(actives)
|
| 668 |
+
last_addressed: str | None = None
|
| 669 |
+
|
| 670 |
+
# Hard backstop on this phase: if we make a lot of consensus turns
|
| 671 |
+
# without resolving, exit and let closure handle it. The orchestrator-
|
| 672 |
+
# call cap will hit before this, but it's a clean upper bound.
|
| 673 |
+
max_consensus_turns = 6 * len(actives)
|
| 674 |
+
consensus_turns = 0
|
| 675 |
+
|
| 676 |
+
while consensus_turns < max_consensus_turns:
|
| 677 |
+
consensus_turns += 1
|
| 678 |
+
|
| 679 |
+
# Pick speaker
|
| 680 |
+
if last_addressed:
|
| 681 |
+
speaker = next(
|
| 682 |
+
(p for p in actives if p.participant_id == last_addressed),
|
| 683 |
+
None,
|
| 684 |
)
|
| 685 |
+
if speaker is None:
|
| 686 |
+
speaker = queue[0] if queue else actives[0]
|
| 687 |
+
else:
|
| 688 |
+
queue = [p for p in queue if p.participant_id != speaker.participant_id]
|
| 689 |
+
last_addressed = None
|
| 690 |
+
else:
|
| 691 |
+
if not queue:
|
| 692 |
+
queue = list(actives)
|
| 693 |
+
speaker = queue.pop(0)
|
| 694 |
+
|
| 695 |
+
# Decide allied vs solo prompt
|
| 696 |
+
speaker_group, other_groups = _find_speaker_group(speaker, session.alliance_groups)
|
| 697 |
+
prompt = _build_consensus_prompt(
|
| 698 |
+
session, speaker, speaker_group, other_groups,
|
| 699 |
+
actives, cred_block,
|
| 700 |
)
|
|
|
|
|
|
|
| 701 |
|
| 702 |
+
text, elapsed, ok = await _call_participant(
|
| 703 |
+
session=session, participant=speaker,
|
| 704 |
+
user_prompt=prompt,
|
| 705 |
+
label="consensus",
|
| 706 |
+
max_tokens=700,
|
| 707 |
+
)
|
| 708 |
+
if not ok or not text.strip():
|
| 709 |
+
yield _sse("participant_error", {
|
| 710 |
+
"participant_id": speaker.participant_id, "name": speaker.name,
|
| 711 |
+
"phase": session.phase.value,
|
| 712 |
+
})
|
| 713 |
+
continue
|
| 714 |
+
|
| 715 |
+
addressed = await classify_addressed_to(
|
| 716 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 717 |
+
participants=actives,
|
| 718 |
+
speaker_name=speaker.name,
|
| 719 |
+
message=text,
|
| 720 |
+
api_log=session.api_log,
|
| 721 |
+
)
|
| 722 |
+
_bump_orchestrator_count(session)
|
| 723 |
+
last_addressed = addressed
|
| 724 |
+
|
| 725 |
+
msg = _add_participant_message(
|
| 726 |
+
session, speaker, text, phase=session.phase, elapsed=elapsed,
|
| 727 |
+
addressed_to=addressed,
|
| 728 |
+
)
|
| 729 |
+
yield _sse("message", _msg_payload(msg))
|
| 730 |
+
|
| 731 |
+
if _participant_msg_cap_hit(session):
|
| 732 |
+
async for chunk in _wait_for_continue(session, "messages"):
|
| 733 |
+
yield chunk
|
| 734 |
+
if _orchestrator_cap_hit(session):
|
| 735 |
+
async for chunk in _wait_for_continue(session, "orchestrator"):
|
| 736 |
+
yield chunk
|
| 737 |
+
|
| 738 |
+
# Status check every full round (every len(actives) turns)
|
| 739 |
+
if consensus_turns % max(1, len(actives)) == 0:
|
| 740 |
+
transcript = _format_history(session.messages)
|
| 741 |
+
status = await assess_consensus_status(
|
| 742 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 743 |
+
question=session.question,
|
| 744 |
+
transcript=transcript,
|
| 745 |
+
alliance_groups=session.alliance_groups,
|
| 746 |
+
api_log=session.api_log,
|
| 747 |
+
)
|
| 748 |
+
_bump_orchestrator_count(session)
|
| 749 |
+
if status.get("status") == "majority":
|
| 750 |
+
session.alliance_groups = await _refresh_alliance_groups(session, actives)
|
| 751 |
+
yield _sse("orchestrator", {
|
| 752 |
+
"kind": "status",
|
| 753 |
+
"text": f"Majority reached. {status.get('rationale', '')}".strip(),
|
| 754 |
+
})
|
| 755 |
+
return
|
| 756 |
+
if status.get("status") == "unproductive":
|
| 757 |
+
yield _sse("orchestrator", {
|
| 758 |
+
"kind": "status",
|
| 759 |
+
"text": f"Conversation no longer productive. {status.get('rationale', '')}".strip(),
|
| 760 |
+
})
|
| 761 |
+
return
|
| 762 |
+
# else: productive - keep going
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
async def _refresh_alliance_groups(
|
| 766 |
+
session: Session,
|
| 767 |
+
actives: list[Participant],
|
| 768 |
+
) -> list[dict[str, Any]]:
|
| 769 |
+
"""Re-cluster after the consensus phase, treating the latest round of
|
| 770 |
+
consensus statements as each participant's current stance."""
|
| 771 |
+
latest_by_id: dict[str, str] = {}
|
| 772 |
+
for m in session.messages:
|
| 773 |
+
if m.get("role") != "participant":
|
| 774 |
+
continue
|
| 775 |
+
if m.get("phase") != Phase.CONSENSUS.value:
|
| 776 |
+
continue
|
| 777 |
+
latest_by_id[m["speaker_id"]] = m["text"]
|
| 778 |
+
# Fall back to finalization opinions for any participant who didn't
|
| 779 |
+
# speak in the consensus phase yet.
|
| 780 |
+
merged: dict[str, str] = dict(session.final_opinions)
|
| 781 |
+
merged.update(latest_by_id)
|
| 782 |
+
groups = await detect_alliances(
|
| 783 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 784 |
+
question=session.question,
|
| 785 |
+
participants=actives,
|
| 786 |
+
final_opinions=merged,
|
| 787 |
+
api_log=session.api_log,
|
| 788 |
+
)
|
| 789 |
+
_bump_orchestrator_count(session)
|
| 790 |
+
return groups
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
def _find_speaker_group(
|
| 794 |
+
speaker: Participant,
|
| 795 |
+
groups: list[dict[str, Any]],
|
| 796 |
+
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
| 797 |
+
speaker_group: dict[str, Any] | None = None
|
| 798 |
+
others: list[dict[str, Any]] = []
|
| 799 |
+
for g in groups:
|
| 800 |
+
if speaker.participant_id in (g.get("members") or []):
|
| 801 |
+
speaker_group = g
|
| 802 |
+
else:
|
| 803 |
+
others.append(g)
|
| 804 |
+
return speaker_group, others
|
| 805 |
|
| 806 |
+
|
| 807 |
+
def _build_consensus_prompt(
|
| 808 |
+
session: Session,
|
| 809 |
+
speaker: Participant,
|
| 810 |
+
speaker_group: dict[str, Any] | None,
|
| 811 |
+
other_groups: list[dict[str, Any]],
|
| 812 |
+
actives: list[Participant],
|
| 813 |
+
cred_block: str,
|
| 814 |
+
) -> str:
|
| 815 |
+
transcript = _format_history(session.messages)
|
| 816 |
+
|
| 817 |
+
# If the previous message addressed this speaker by id, route a
|
| 818 |
+
# targeted-response prompt instead of the standard allied/solo flow.
|
| 819 |
+
if session.messages:
|
| 820 |
+
last = session.messages[-1]
|
| 821 |
+
if (
|
| 822 |
+
last.get("role") == "participant"
|
| 823 |
+
and last.get("addressed_to") == speaker.participant_id
|
| 824 |
+
):
|
| 825 |
+
return CONSENSUS_TARGETED_RESPONSE_PROMPT.format(
|
| 826 |
+
addressed_by_name=last.get("speaker_name", "another participant"),
|
| 827 |
+
addressed_message=last.get("text", ""),
|
| 828 |
+
question=session.question,
|
| 829 |
+
credential_summary=cred_block,
|
| 830 |
+
transcript=transcript,
|
| 831 |
+
)
|
| 832 |
+
|
| 833 |
+
if speaker_group and len(speaker_group.get("members") or []) > 1:
|
| 834 |
+
members = ", ".join(
|
| 835 |
+
p.name for p in actives
|
| 836 |
+
if p.participant_id in (speaker_group.get("members") or [])
|
| 837 |
+
and p.participant_id != speaker.participant_id
|
| 838 |
+
) or "(no co-allies named)"
|
| 839 |
+
return CONSENSUS_ALLIED_PROMPT.format(
|
| 840 |
+
alliance_members=members,
|
| 841 |
+
alliance_stance=speaker_group.get("stance", "(unspecified)"),
|
| 842 |
+
question=session.question,
|
| 843 |
+
credential_summary=cred_block,
|
| 844 |
+
transcript=transcript,
|
| 845 |
+
)
|
| 846 |
+
|
| 847 |
+
other_groups_block = "\n".join(
|
| 848 |
+
f" - \"{g.get('stance', '')}\" supported by " + ", ".join(
|
| 849 |
+
p.name for p in actives if p.participant_id in (g.get("members") or [])
|
| 850 |
+
)
|
| 851 |
+
for g in other_groups
|
| 852 |
+
) or "(no other groups)"
|
| 853 |
+
return CONSENSUS_SOLO_PROMPT.format(
|
| 854 |
+
your_stance=(speaker_group or {}).get("stance", "(unspecified)"),
|
| 855 |
+
other_groups_block=other_groups_block,
|
| 856 |
+
question=session.question,
|
| 857 |
+
credential_summary=cred_block,
|
| 858 |
+
transcript=transcript,
|
| 859 |
+
)
|
| 860 |
|
| 861 |
|
| 862 |
# ---------------------------------------------------------------------------
|
| 863 |
+
# Closure
|
| 864 |
# ---------------------------------------------------------------------------
|
| 865 |
|
| 866 |
+
async def _phase_closure(session: Session) -> AsyncIterator[str]:
|
| 867 |
+
session.phase = Phase.CLOSURE
|
| 868 |
+
yield _sse("status", {"message": "Phase 6: closure..."})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
|
| 870 |
+
cred_block = credentials_to_block(session.credential_summary)
|
| 871 |
+
transcript = _format_history(session.messages)
|
| 872 |
|
| 873 |
+
status = await assess_consensus_status(
|
| 874 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 875 |
+
question=session.question,
|
| 876 |
+
transcript=transcript,
|
| 877 |
+
alliance_groups=session.alliance_groups,
|
| 878 |
+
api_log=session.api_log,
|
| 879 |
+
)
|
| 880 |
+
_bump_orchestrator_count(session)
|
| 881 |
+
|
| 882 |
+
actives = _active_participants(session)
|
| 883 |
+
if status.get("status") == "majority":
|
| 884 |
+
idx = status.get("majority_group_index")
|
| 885 |
+
majority_group = None
|
| 886 |
+
if isinstance(idx, int) and 0 <= idx < len(session.alliance_groups):
|
| 887 |
+
majority_group = session.alliance_groups[idx]
|
| 888 |
+
else:
|
| 889 |
+
# Fallback: largest group wins
|
| 890 |
+
if session.alliance_groups:
|
| 891 |
+
majority_group = max(
|
| 892 |
+
session.alliance_groups,
|
| 893 |
+
key=lambda g: len(g.get("members") or []),
|
| 894 |
+
)
|
| 895 |
+
if majority_group:
|
| 896 |
+
members_names = [
|
| 897 |
+
p.name for p in actives
|
| 898 |
+
if p.participant_id in (majority_group.get("members") or [])
|
| 899 |
+
]
|
| 900 |
+
stance = majority_group.get("stance", "")
|
| 901 |
+
prompt = MAJORITY_REPORT_PROMPT.format(
|
| 902 |
+
question=session.question,
|
| 903 |
+
credential_summary=cred_block,
|
| 904 |
+
majority_members=", ".join(members_names),
|
| 905 |
+
majority_stance=stance,
|
| 906 |
+
transcript=transcript,
|
| 907 |
+
)
|
| 908 |
+
raw, _ = await orchestrator_call(
|
| 909 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 910 |
+
user_prompt=prompt,
|
| 911 |
+
label="majority_report",
|
| 912 |
+
api_log=session.api_log,
|
| 913 |
+
expect_json=False,
|
| 914 |
+
max_tokens=900,
|
| 915 |
+
temperature=0.3,
|
| 916 |
+
)
|
| 917 |
+
_bump_orchestrator_count(session)
|
| 918 |
+
session.final_report = {
|
| 919 |
+
"kind": "majority",
|
| 920 |
+
"text": raw,
|
| 921 |
+
"majority_members": members_names,
|
| 922 |
+
"majority_stance": stance,
|
| 923 |
+
"alliance_groups": session.alliance_groups,
|
| 924 |
+
}
|
| 925 |
+
msg = _add_orchestrator_message(
|
| 926 |
+
session, raw, kind="majority_report",
|
| 927 |
+
extra={"majority_members": members_names, "majority_stance": stance},
|
| 928 |
+
)
|
| 929 |
+
yield _sse("orchestrator", _msg_payload(msg))
|
| 930 |
+
return
|
| 931 |
+
|
| 932 |
+
# Not productive / no majority. First time -> surface unaddressed factor.
|
| 933 |
+
if session.consensus_attempts < 1:
|
| 934 |
+
session.consensus_attempts += 1
|
| 935 |
+
factor = await find_unaddressed_factor(
|
| 936 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 937 |
+
question=session.question,
|
| 938 |
+
credential_summary_block=cred_block,
|
| 939 |
+
transcript=transcript,
|
| 940 |
+
api_log=session.api_log,
|
| 941 |
+
)
|
| 942 |
+
_bump_orchestrator_count(session)
|
| 943 |
+
if factor and factor.get("factor"):
|
| 944 |
+
announce = (
|
| 945 |
+
f"The discussion has stalled. The orchestrator surfaces a new "
|
| 946 |
+
f"factor for the group to consider: {factor['factor']}"
|
| 947 |
+
)
|
| 948 |
+
msg = _add_orchestrator_message(
|
| 949 |
+
session, announce, kind="factor",
|
| 950 |
+
extra={"expected_to_shift": factor.get("expected_to_shift") or []},
|
| 951 |
+
)
|
| 952 |
+
yield _sse("orchestrator", _msg_payload(msg))
|
| 953 |
+
# Re-run the consensus phase once more
|
| 954 |
+
async for chunk in _phase_consensus(session):
|
| 955 |
+
yield chunk
|
| 956 |
+
async for chunk in _phase_closure(session):
|
| 957 |
+
yield chunk
|
| 958 |
+
return
|
| 959 |
+
|
| 960 |
+
# Failed twice (or no factor surfaced) -> emit no-consensus report
|
| 961 |
+
prompt = NO_CONSENSUS_REPORT_PROMPT.format(
|
| 962 |
+
question=session.question,
|
| 963 |
+
credential_summary=cred_block,
|
| 964 |
+
alliance_block="\n".join(
|
| 965 |
+
f" - \"{g.get('stance', '')}\": "
|
| 966 |
+
+ ", ".join(
|
| 967 |
+
p.name for p in actives
|
| 968 |
+
if p.participant_id in (g.get("members") or [])
|
| 969 |
+
)
|
| 970 |
+
for g in session.alliance_groups
|
| 971 |
+
),
|
| 972 |
+
transcript=transcript,
|
| 973 |
+
)
|
| 974 |
+
raw, _ = await orchestrator_call(
|
| 975 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 976 |
+
user_prompt=prompt,
|
| 977 |
+
label="no_consensus_report",
|
| 978 |
+
api_log=session.api_log,
|
| 979 |
+
expect_json=False,
|
| 980 |
+
max_tokens=900,
|
| 981 |
+
temperature=0.3,
|
| 982 |
+
)
|
| 983 |
+
_bump_orchestrator_count(session)
|
| 984 |
+
session.final_report = {
|
| 985 |
+
"kind": "no_consensus",
|
| 986 |
+
"text": raw,
|
| 987 |
+
"alliance_groups": session.alliance_groups,
|
| 988 |
}
|
| 989 |
+
msg = _add_orchestrator_message(session, raw, kind="no_consensus_report")
|
| 990 |
+
yield _sse("orchestrator", _msg_payload(msg))
|
| 991 |
|
| 992 |
|
| 993 |
+
# ---------------------------------------------------------------------------
|
| 994 |
+
# Public driver
|
| 995 |
+
# ---------------------------------------------------------------------------
|
| 996 |
+
|
| 997 |
+
async def run_conversation(session: Session) -> AsyncIterator[str]:
|
| 998 |
+
"""Drive the full six-phase conversation, yielding SSE chunks."""
|
| 999 |
+
actives = _active_participants(session)
|
| 1000 |
+
if len(actives) < 2:
|
| 1001 |
+
yield _sse("error", {
|
| 1002 |
+
"message": "Need at least 2 active participants to start.",
|
| 1003 |
+
})
|
| 1004 |
+
yield _sse("done", {})
|
| 1005 |
+
return
|
| 1006 |
+
if len(actives) > session.max_participants:
|
| 1007 |
+
# Defense in depth - the API layer should have already enforced this.
|
| 1008 |
+
for extra in actives[session.max_participants:]:
|
| 1009 |
+
extra.enabled = False
|
| 1010 |
+
|
| 1011 |
+
try:
|
| 1012 |
+
async for chunk in _phase_initial_opinions(session):
|
| 1013 |
+
yield chunk
|
| 1014 |
+
|
| 1015 |
+
async for chunk in _phase_critique(session, 1):
|
| 1016 |
+
yield chunk
|
| 1017 |
+
async for chunk in _phase_critique(session, 2):
|
| 1018 |
+
yield chunk
|
| 1019 |
+
|
| 1020 |
+
async for chunk in _phase_status_assessment(session):
|
| 1021 |
+
yield chunk
|
| 1022 |
+
|
| 1023 |
+
async for chunk in _phase_finalization(session):
|
| 1024 |
+
yield chunk
|
| 1025 |
+
|
| 1026 |
+
async for chunk in _phase_consensus(session):
|
| 1027 |
+
yield chunk
|
| 1028 |
+
|
| 1029 |
+
async for chunk in _phase_closure(session):
|
| 1030 |
+
yield chunk
|
| 1031 |
+
except Exception as exc:
|
| 1032 |
+
LOG.exception("Conversation crashed: %s", exc)
|
| 1033 |
+
yield _sse("error", {"message": f"Internal error: {exc}"})
|
| 1034 |
+
finally:
|
| 1035 |
+
session.finished = True
|
| 1036 |
+
session.phase = Phase.FINISHED
|
| 1037 |
+
|
| 1038 |
+
# Build per-participant contribution summaries for the table view.
|
| 1039 |
+
try:
|
| 1040 |
+
await _build_contribution_summaries(session)
|
| 1041 |
+
except Exception as exc:
|
| 1042 |
+
LOG.warning("Failed to build contribution summaries: %s", exc)
|
| 1043 |
+
|
| 1044 |
+
yield _sse("system", {"text": "End of Chat", "phase": session.phase.value})
|
| 1045 |
+
yield _sse("done", {})
|
| 1046 |
+
|
| 1047 |
+
|
| 1048 |
+
async def _build_contribution_summaries(session: Session) -> None:
|
| 1049 |
+
actives = _active_participants(session)
|
| 1050 |
+
roster = "\n".join(
|
| 1051 |
+
f"- id: {p.participant_id} | name: {p.name}" for p in actives
|
| 1052 |
+
)
|
| 1053 |
+
transcript = _format_history(session.messages)
|
| 1054 |
+
prompt = CONTRIBUTION_SUMMARY_PROMPT.format(
|
| 1055 |
+
roster_block=roster,
|
| 1056 |
+
transcript=transcript,
|
| 1057 |
+
)
|
| 1058 |
+
_raw, parsed = await orchestrator_call(
|
| 1059 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 1060 |
+
user_prompt=prompt,
|
| 1061 |
+
label="contribution_summaries",
|
| 1062 |
+
api_log=session.api_log,
|
| 1063 |
+
max_tokens=900,
|
| 1064 |
+
)
|
| 1065 |
+
session.orchestrator_call_count += 1
|
| 1066 |
+
if isinstance(parsed, dict) and isinstance(parsed.get("contributions"), list):
|
| 1067 |
+
for c in parsed["contributions"]:
|
| 1068 |
+
pid = c.get("participant_id")
|
| 1069 |
+
summary = (c.get("summary") or "").strip()
|
| 1070 |
+
if pid and summary:
|
| 1071 |
+
session.contribution_summaries[pid] = summary
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-phase prompt templates for the CCAI orchestrator.
|
| 2 |
+
|
| 3 |
+
Each phase's templates live in their own file so prompt iteration doesn't
|
| 4 |
+
churn the state machine in `orchestrator.py`. All templates here are pure
|
| 5 |
+
strings; they're formatted and combined in `orchestrator.py`.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from app.services.prompts.directives import (
|
| 9 |
+
PARTICIPANT_BASE_DIRECTIVE,
|
| 10 |
+
NO_REASONING_DIRECTIVE,
|
| 11 |
+
ORCHESTRATOR_BASE_DIRECTIVE,
|
| 12 |
+
)
|
| 13 |
+
from app.services.prompts.initial_opinions import INITIAL_OPINION_PROMPT
|
| 14 |
+
from app.services.prompts.credential_summary import (
|
| 15 |
+
CREDENTIAL_BUILD_PROMPT,
|
| 16 |
+
CREDENTIAL_REFRESH_PROMPT,
|
| 17 |
+
)
|
| 18 |
+
from app.services.prompts.critique import CRITIQUE_PROMPT
|
| 19 |
+
from app.services.prompts.status_assessment import (
|
| 20 |
+
STATUS_ASSESSMENT_PROMPT,
|
| 21 |
+
TARGETED_FOLLOWUP_PROMPT,
|
| 22 |
+
)
|
| 23 |
+
from app.services.prompts.finalization import FINALIZATION_PROMPT
|
| 24 |
+
from app.services.prompts.consensus import (
|
| 25 |
+
ALLIANCE_DETECTION_PROMPT,
|
| 26 |
+
ADDRESSED_TO_PROMPT,
|
| 27 |
+
CONSENSUS_ALLIED_PROMPT,
|
| 28 |
+
CONSENSUS_SOLO_PROMPT,
|
| 29 |
+
CONSENSUS_TARGETED_RESPONSE_PROMPT,
|
| 30 |
+
CONSENSUS_STATUS_PROMPT,
|
| 31 |
+
)
|
| 32 |
+
from app.services.prompts.closure import (
|
| 33 |
+
UNADDRESSED_FACTOR_PROMPT,
|
| 34 |
+
MAJORITY_REPORT_PROMPT,
|
| 35 |
+
NO_CONSENSUS_REPORT_PROMPT,
|
| 36 |
+
CONTRIBUTION_SUMMARY_PROMPT,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
__all__ = [
|
| 40 |
+
"PARTICIPANT_BASE_DIRECTIVE",
|
| 41 |
+
"NO_REASONING_DIRECTIVE",
|
| 42 |
+
"ORCHESTRATOR_BASE_DIRECTIVE",
|
| 43 |
+
"INITIAL_OPINION_PROMPT",
|
| 44 |
+
"CREDENTIAL_BUILD_PROMPT",
|
| 45 |
+
"CREDENTIAL_REFRESH_PROMPT",
|
| 46 |
+
"CRITIQUE_PROMPT",
|
| 47 |
+
"STATUS_ASSESSMENT_PROMPT",
|
| 48 |
+
"TARGETED_FOLLOWUP_PROMPT",
|
| 49 |
+
"FINALIZATION_PROMPT",
|
| 50 |
+
"ALLIANCE_DETECTION_PROMPT",
|
| 51 |
+
"ADDRESSED_TO_PROMPT",
|
| 52 |
+
"CONSENSUS_ALLIED_PROMPT",
|
| 53 |
+
"CONSENSUS_SOLO_PROMPT",
|
| 54 |
+
"CONSENSUS_TARGETED_RESPONSE_PROMPT",
|
| 55 |
+
"CONSENSUS_STATUS_PROMPT",
|
| 56 |
+
"UNADDRESSED_FACTOR_PROMPT",
|
| 57 |
+
"MAJORITY_REPORT_PROMPT",
|
| 58 |
+
"NO_CONSENSUS_REPORT_PROMPT",
|
| 59 |
+
"CONTRIBUTION_SUMMARY_PROMPT",
|
| 60 |
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 6: closure prompts (majority report, unaddressed-factor probe,
|
| 2 |
+
no-consensus failure report, and per-participant contribution summaries
|
| 3 |
+
used by the table view).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
UNADDRESSED_FACTOR_PROMPT = (
|
| 7 |
+
"The discussion has stalled in the consensus-gathering phase. As the "
|
| 8 |
+
"neutral orchestrator, review the conversation and the Credential "
|
| 9 |
+
"Summary. Identify ONE important factor that has not been adequately "
|
| 10 |
+
"discussed and that is likely to shift the opinion of at least one "
|
| 11 |
+
"current participant if surfaced.\n\n"
|
| 12 |
+
"Question:\n<<<\n{question}\n>>>\n\n"
|
| 13 |
+
"Credential Summary:\n{credential_summary}\n\n"
|
| 14 |
+
"Conversation so far:\n{transcript}\n\n"
|
| 15 |
+
"Return JSON ONLY in this exact shape:\n"
|
| 16 |
+
"{{\n"
|
| 17 |
+
' "factor": "<one short paragraph framing the factor as a question or consideration the group should now address>",\n'
|
| 18 |
+
' "expected_to_shift": ["<participant_id>", "..."]\n'
|
| 19 |
+
"}}\n\n"
|
| 20 |
+
"The factor must be a real consideration that genuinely hasn't been "
|
| 21 |
+
"raised - not a rephrasing of what was already said. Output JSON only."
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
MAJORITY_REPORT_PROMPT = (
|
| 25 |
+
"The group has reached majority agreement in the discussion. As the "
|
| 26 |
+
"neutral orchestrator, produce a clear final report for the user who "
|
| 27 |
+
"asked the original question.\n\n"
|
| 28 |
+
"Question:\n<<<\n{question}\n>>>\n\n"
|
| 29 |
+
"Credential Summary (with credibility_for_question scores):\n{credential_summary}\n\n"
|
| 30 |
+
"Majority alliance: members={majority_members}, stance=\"{majority_stance}\".\n\n"
|
| 31 |
+
"Full conversation:\n{transcript}\n\n"
|
| 32 |
+
"Write the report as plain prose (no markdown headers required) covering:\n"
|
| 33 |
+
" 1. The decision the group reached, in one or two clear sentences.\n"
|
| 34 |
+
" 2. The strongest reasons the majority gave.\n"
|
| 35 |
+
" 3. Important dissenting points raised by participants whose "
|
| 36 |
+
"credibility_for_question >= 0.6 - quote or paraphrase them by name. "
|
| 37 |
+
"Skip dissent from participants with credibility below 0.6.\n"
|
| 38 |
+
" 4. Caveats or open questions worth flagging for the user.\n\n"
|
| 39 |
+
"Stay neutral. Do not editorialize beyond what the participants said. "
|
| 40 |
+
"Keep the whole report under ~250 words."
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
NO_CONSENSUS_REPORT_PROMPT = (
|
| 44 |
+
"The group has tried twice to reach consensus and has not succeeded. "
|
| 45 |
+
"As the neutral orchestrator, produce a final report for the user who "
|
| 46 |
+
"asked the original question.\n\n"
|
| 47 |
+
"Question:\n<<<\n{question}\n>>>\n\n"
|
| 48 |
+
"Credential Summary:\n{credential_summary}\n\n"
|
| 49 |
+
"Final alliance groups:\n{alliance_block}\n\n"
|
| 50 |
+
"Full conversation:\n{transcript}\n\n"
|
| 51 |
+
"Write the report as plain prose covering:\n"
|
| 52 |
+
" 1. A short statement that the group did not reach consensus.\n"
|
| 53 |
+
" 2. Each major opinion that emerged, who supported it (by name), "
|
| 54 |
+
"and the strongest reason given for it.\n"
|
| 55 |
+
" 3. Your own neutral recommendation for the most defensible position, "
|
| 56 |
+
"based purely on the strength of the arguments and the credibility_for_"
|
| 57 |
+
"question scores - not your own opinion. Make clear this is a "
|
| 58 |
+
"recommendation, not a decision.\n"
|
| 59 |
+
" 4. A brief suggestion that the user weigh these and make their own "
|
| 60 |
+
"decision.\n\n"
|
| 61 |
+
"Stay neutral. Keep the report under ~300 words."
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
CONTRIBUTION_SUMMARY_PROMPT = (
|
| 65 |
+
"Below is the full transcript of a multi-participant discussion. For "
|
| 66 |
+
"each listed participant, write a 2-3 sentence neutral summary of "
|
| 67 |
+
"their overall contribution: the position they took, how it evolved, "
|
| 68 |
+
"and the strongest argument they made. Do not editorialize. Do not "
|
| 69 |
+
"rank or grade them.\n\n"
|
| 70 |
+
"Participants:\n{roster_block}\n\n"
|
| 71 |
+
"Transcript:\n{transcript}\n\n"
|
| 72 |
+
"Return JSON ONLY in this exact shape:\n"
|
| 73 |
+
"{{\n"
|
| 74 |
+
' "contributions": [\n'
|
| 75 |
+
' {{ "participant_id": "<id>", "summary": "<2-3 sentences>" }}\n'
|
| 76 |
+
" ]\n"
|
| 77 |
+
"}}\n\n"
|
| 78 |
+
"Output JSON only."
|
| 79 |
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 5: consensus-gathering prompts.
|
| 2 |
+
|
| 3 |
+
The orchestrator detects "alliance groups" of participants with similar
|
| 4 |
+
revised opinions, then nudges them toward a group decision. Allied
|
| 5 |
+
participants are prompted to argue and recruit; solo participants are
|
| 6 |
+
prompted to seek allies, switch, or propose compromises.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
ALLIANCE_DETECTION_PROMPT = (
|
| 10 |
+
"Below are each participant's revised opinions from the finalization "
|
| 11 |
+
"phase. Your job, as the orchestrator, is to cluster them into "
|
| 12 |
+
"alliance groups: sets of participants whose opinions are similar "
|
| 13 |
+
"enough that they would naturally team up in a real meeting.\n\n"
|
| 14 |
+
"Question:\n<<<\n{question}\n>>>\n\n"
|
| 15 |
+
"Revised opinions:\n{finalization_block}\n\n"
|
| 16 |
+
"Return JSON ONLY in this exact shape:\n"
|
| 17 |
+
"{{\n"
|
| 18 |
+
' "groups": [\n'
|
| 19 |
+
" {{\n"
|
| 20 |
+
' "stance": "<one short sentence describing the shared position>",\n'
|
| 21 |
+
' "members": ["<participant_id>", "..."]\n'
|
| 22 |
+
" }}\n"
|
| 23 |
+
" ]\n"
|
| 24 |
+
"}}\n\n"
|
| 25 |
+
"Every participant must appear in exactly one group. Solo participants "
|
| 26 |
+
"(no allies) get their own single-member group. Output JSON only."
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
ADDRESSED_TO_PROMPT = (
|
| 30 |
+
"Below is the most recent message in a multi-participant discussion. "
|
| 31 |
+
"Decide whether it is primarily aimed at one specific other participant "
|
| 32 |
+
"(e.g. challenging them, asking them a question, calling on them to "
|
| 33 |
+
"respond), or whether it is a broadcast to the whole group.\n\n"
|
| 34 |
+
"Available participants and their ids:\n{roster_block}\n\n"
|
| 35 |
+
"Speaker: {speaker}\n"
|
| 36 |
+
"Message: {message}\n\n"
|
| 37 |
+
"Return JSON ONLY: {{\"addressed_to\": \"<participant_id or null>\"}}. "
|
| 38 |
+
"Use the literal JSON null (no quotes) when no specific addressee is "
|
| 39 |
+
"obvious. Output JSON only."
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
CONSENSUS_ALLIED_PROMPT = (
|
| 43 |
+
"Phase 5 of the discussion: Consensus Gathering.\n\n"
|
| 44 |
+
"The orchestrator has clustered the group into alliances based on the "
|
| 45 |
+
"revised opinions. You are part of an alliance with: {alliance_members}. "
|
| 46 |
+
"Your shared stance: \"{alliance_stance}\".\n\n"
|
| 47 |
+
"Other groups currently disagree. Your job, in 4-8 sentences, is to "
|
| 48 |
+
"advocate for your alliance's position and try to win over participants "
|
| 49 |
+
"from other groups. Strategies that work in real human meetings:\n"
|
| 50 |
+
" - Counter the main points of contrasting opinions with concrete facts "
|
| 51 |
+
"or arguments (cite specific things others said).\n"
|
| 52 |
+
" - Reinforce your own side's main points with additional supporting "
|
| 53 |
+
"facts or by emphasizing the credibility of an ally on this topic.\n"
|
| 54 |
+
" - Address specific participants by name when challenging or inviting "
|
| 55 |
+
"them.\n\n"
|
| 56 |
+
"Question:\n<<<\n{question}\n>>>\n\n"
|
| 57 |
+
"Credential Summary:\n{credential_summary}\n\n"
|
| 58 |
+
"Conversation so far:\n{transcript}"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
CONSENSUS_SOLO_PROMPT = (
|
| 62 |
+
"Phase 5 of the discussion: Consensus Gathering.\n\n"
|
| 63 |
+
"Right now you are the sole holder of your stance: \"{your_stance}\". "
|
| 64 |
+
"The other groups are: {other_groups_block}. In a real human meeting, "
|
| 65 |
+
"someone in your position has three good options - pick whichever fits "
|
| 66 |
+
"your character and what's been said:\n"
|
| 67 |
+
" 1. Pick the existing group whose stance is closest to yours and try "
|
| 68 |
+
"to get them to shift toward your view.\n"
|
| 69 |
+
" 2. Switch your support to whichever group's stance you can honestly "
|
| 70 |
+
"live with, naming them and explaining why.\n"
|
| 71 |
+
" 3. Propose a compromise position that both you and at least one "
|
| 72 |
+
"other group might find acceptable.\n\n"
|
| 73 |
+
"Respond in 4-8 sentences. Address other participants by name when "
|
| 74 |
+
"doing so makes sense. Stay in character.\n\n"
|
| 75 |
+
"Question:\n<<<\n{question}\n>>>\n\n"
|
| 76 |
+
"Credential Summary:\n{credential_summary}\n\n"
|
| 77 |
+
"Conversation so far:\n{transcript}"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
CONSENSUS_TARGETED_RESPONSE_PROMPT = (
|
| 81 |
+
"Phase 5 of the discussion: Consensus Gathering.\n\n"
|
| 82 |
+
"{addressed_by_name} just spoke and aimed their message at you "
|
| 83 |
+
"specifically. The orchestrator is giving you the floor to respond.\n\n"
|
| 84 |
+
"Their message: \"{addressed_message}\"\n\n"
|
| 85 |
+
"Question:\n<<<\n{question}\n>>>\n\n"
|
| 86 |
+
"Credential Summary:\n{credential_summary}\n\n"
|
| 87 |
+
"Conversation so far:\n{transcript}\n\n"
|
| 88 |
+
"Respond directly to {addressed_by_name} in 3-7 sentences. You may "
|
| 89 |
+
"concede a point, push back with a counter-argument, ask a clarifying "
|
| 90 |
+
"question, or propose a compromise. Stay in character."
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
CONSENSUS_STATUS_PROMPT = (
|
| 94 |
+
"Below is the discussion through the consensus-gathering phase so far. "
|
| 95 |
+
"As the orchestrator, decide whether (a) a majority has reached "
|
| 96 |
+
"agreement, (b) opinions are still actively shifting in a productive "
|
| 97 |
+
"direction, or (c) opinions have stopped shifting and the conversation "
|
| 98 |
+
"is no longer productive.\n\n"
|
| 99 |
+
"Question:\n<<<\n{question}\n>>>\n\n"
|
| 100 |
+
"Latest alliance groups:\n{alliance_block}\n\n"
|
| 101 |
+
"Conversation so far:\n{transcript}\n\n"
|
| 102 |
+
"Return JSON ONLY in this exact shape:\n"
|
| 103 |
+
"{{\n"
|
| 104 |
+
' "status": "<majority|productive|unproductive>",\n'
|
| 105 |
+
' "majority_group_index": <integer index into alliance_groups, or null>,\n'
|
| 106 |
+
' "rationale": "<one short sentence>"\n'
|
| 107 |
+
"}}\n\n"
|
| 108 |
+
"Use \"majority\" only if more than half of all participants now share a "
|
| 109 |
+
"single stance. Use \"unproductive\" only if the last few exchanges "
|
| 110 |
+
"have been repetitive or the participants are clearly entrenched. Output "
|
| 111 |
+
"JSON only."
|
| 112 |
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Credential Summary: the orchestrator's neutral assessment of each
|
| 2 |
+
participant's expertise, personality, and credibility on the question.
|
| 3 |
+
|
| 4 |
+
Built once after Phase 1 from each participant's role prompt + first
|
| 5 |
+
opinion. Refreshed once after Phase 2 critique because participants
|
| 6 |
+
reveal a lot more about themselves through critique than through their
|
| 7 |
+
opening pitch.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
CREDENTIAL_BUILD_PROMPT = (
|
| 11 |
+
"Below is the question being discussed and, for each participant, "
|
| 12 |
+
"their role prompt and their first opinion (Phase 1). Build a Credential "
|
| 13 |
+
"Summary: a neutral, third-person assessment of each participant that "
|
| 14 |
+
"any other participant could use to weight their statements.\n\n"
|
| 15 |
+
"Question:\n<<<\n{question}\n>>>\n\n"
|
| 16 |
+
"Participants:\n{participants_block}\n\n"
|
| 17 |
+
"Return JSON ONLY in this exact shape:\n"
|
| 18 |
+
"{{\n"
|
| 19 |
+
' "credentials": [\n'
|
| 20 |
+
" {{\n"
|
| 21 |
+
' "participant_id": "<participant_id>",\n'
|
| 22 |
+
' "name": "<participant_name>",\n'
|
| 23 |
+
' "expertise": "<1-2 sentences on what they know about and don\'t know about>",\n'
|
| 24 |
+
' "personality": "<1 sentence on debating style / temperament>",\n'
|
| 25 |
+
' "credibility_for_question": <number 0.0 to 1.0>,\n'
|
| 26 |
+
' "bias_to_watch": "<1 sentence on biases or blind spots>"\n'
|
| 27 |
+
" }}\n"
|
| 28 |
+
" ]\n"
|
| 29 |
+
"}}\n\n"
|
| 30 |
+
"credibility_for_question is YOUR neutral estimate of how much weight "
|
| 31 |
+
"their voice should carry on THIS specific question, given their stated "
|
| 32 |
+
"background and how they framed their first opinion. Use the full 0-1 "
|
| 33 |
+
"scale; do not bunch everyone near the top. Do NOT favor or disfavor "
|
| 34 |
+
"any participant. Output JSON only - no commentary, no markdown."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
CREDENTIAL_REFRESH_PROMPT = (
|
| 38 |
+
"Below is the original Credential Summary you produced after Phase 1. "
|
| 39 |
+
"After two rounds of critique, the participants have revealed more "
|
| 40 |
+
"about themselves. Update the Credential Summary if anything material "
|
| 41 |
+
"changed: shifts in apparent expertise, observed reasoning quality, "
|
| 42 |
+
"newly visible biases, or revised credibility for THIS question. Keep "
|
| 43 |
+
"anything that's still accurate. Return JSON in the same shape as the "
|
| 44 |
+
"input. JSON only.\n\n"
|
| 45 |
+
"Question:\n<<<\n{question}\n>>>\n\n"
|
| 46 |
+
"Original Credential Summary:\n{credential_summary_json}\n\n"
|
| 47 |
+
"Critique-round transcript:\n{critique_transcript}"
|
| 48 |
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 2: each participant gets two turns of critique."""
|
| 2 |
+
|
| 3 |
+
CRITIQUE_PROMPT = (
|
| 4 |
+
"Phase 2 of the discussion: Critique (round {round_number} of 2).\n\n"
|
| 5 |
+
"The group has been asked the following question:\n\n"
|
| 6 |
+
"<<<\n{question}\n>>>\n\n"
|
| 7 |
+
"Here is a Credential Summary the orchestrator built about each "
|
| 8 |
+
"participant. Use it the way a thoughtful person in a real meeting would: "
|
| 9 |
+
"weight statements appropriately, but don't let it shut down good ideas "
|
| 10 |
+
"from less-credentialed voices.\n\n"
|
| 11 |
+
"Credential Summary:\n{credential_summary}\n\n"
|
| 12 |
+
"Conversation so far:\n{transcript}\n\n"
|
| 13 |
+
"It is now your turn. In a focused 5-10 sentence response:\n"
|
| 14 |
+
" 1. Offer constructive criticism of one or more other participants' "
|
| 15 |
+
"opinions, naming them directly. Cite specific points, not vibes.\n"
|
| 16 |
+
" 2. Ask any follow-up questions of specific participants where you "
|
| 17 |
+
"want a clearer answer.\n"
|
| 18 |
+
" 3. Revise your own opinion if (and only if) the discussion has "
|
| 19 |
+
"given you reason to. If you've revised, say so explicitly.\n\n"
|
| 20 |
+
"Stay in character. Do not try to wrap up the discussion - we are not "
|
| 21 |
+
"near the end yet."
|
| 22 |
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared directive blocks injected into every participant / orchestrator call."""
|
| 2 |
+
|
| 3 |
+
# Always appended to a participant's role_prompt before any phase template.
|
| 4 |
+
# Establishes the CCAI ground rules: you are one of N participants, the
|
| 5 |
+
# orchestrator runs the conversation, you don't speak for anyone else and
|
| 6 |
+
# you don't speak out of turn.
|
| 7 |
+
PARTICIPANT_BASE_DIRECTIVE = (
|
| 8 |
+
"You are one of {n_participants} participants in a structured group "
|
| 9 |
+
"discussion facilitated by a neutral orchestrator. The other "
|
| 10 |
+
"participants are: {other_participants}. The orchestrator will tell "
|
| 11 |
+
"you when it is your turn, what phase of the discussion you are in, "
|
| 12 |
+
"and exactly what is being asked of you. Do NOT simulate the other "
|
| 13 |
+
"participants, do NOT speak out of turn, and do NOT address the "
|
| 14 |
+
"orchestrator as if it were one of the participants - it has no "
|
| 15 |
+
"opinion and is not part of the decision."
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# Same hard "no reasoning, no meta-commentary" guard the upstream LLMChats3
|
| 19 |
+
# orchestrator used. Always appended to the system message of any
|
| 20 |
+
# participant call. The sanitizer in app.utils.sanitize is the actual
|
| 21 |
+
# guarantee, but this directive makes a lot of models cooperate.
|
| 22 |
+
NO_REASONING_DIRECTIVE = (
|
| 23 |
+
"IMPORTANT: Respond ONLY with your in-character contribution. Do NOT "
|
| 24 |
+
"include your reasoning, thought process, analysis of the prompt, "
|
| 25 |
+
"meta-commentary, internal monologue, scratchpad, draft notes, or any "
|
| 26 |
+
"tags such as <think>, <reasoning>, or <scratchpad>. Output ONLY the "
|
| 27 |
+
"words your character would actually say to the group."
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# Used as the system prompt for every orchestrator-side LLM call (status
|
| 31 |
+
# checks, alliance detection, addressed-to classification, summaries).
|
| 32 |
+
ORCHESTRATOR_BASE_DIRECTIVE = (
|
| 33 |
+
"You are the neutral orchestrator of a structured group discussion. "
|
| 34 |
+
"You do NOT have an opinion on the question being discussed, you do "
|
| 35 |
+
"NOT pick a side, and you do NOT decide any issue. Your only job is "
|
| 36 |
+
"to assess the conversation and produce the exact output format the "
|
| 37 |
+
"instruction asks for. When the instruction asks for JSON, return ONLY "
|
| 38 |
+
"valid JSON with no surrounding prose, markdown fences, or commentary."
|
| 39 |
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 4: each participant either states a revised post-discussion
|
| 2 |
+
opinion or endorses another participant's revised opinion (with optional
|
| 3 |
+
added comment).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
FINALIZATION_PROMPT = (
|
| 7 |
+
"Phase 4 of the discussion: Opinion Finalization.\n\n"
|
| 8 |
+
"The question:\n<<<\n{question}\n>>>\n\n"
|
| 9 |
+
"Credential Summary:\n{credential_summary}\n\n"
|
| 10 |
+
"Full conversation so far:\n{transcript}\n\n"
|
| 11 |
+
"Now, considering everything that has been said, state your post-"
|
| 12 |
+
"discussion opinion. You have two options:\n\n"
|
| 13 |
+
" Option A - State your own revised opinion. If you have moved at all "
|
| 14 |
+
"from your first opinion, say what changed and why.\n\n"
|
| 15 |
+
" Option B - Endorse another participant's revised opinion. Name the "
|
| 16 |
+
"participant. Optionally add a sentence or two of your own (a caveat, "
|
| 17 |
+
"an additional argument, a slight tweak).\n\n"
|
| 18 |
+
"Begin your response with one of:\n"
|
| 19 |
+
" - \"My revised opinion:\" (if Option A)\n"
|
| 20 |
+
" - \"I agree with <participant name>:\" (if Option B)\n\n"
|
| 21 |
+
"Keep the whole response under 10 sentences."
|
| 22 |
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 1: each participant offers an *independent* first opinion.
|
| 2 |
+
|
| 3 |
+
The orchestrator hides every other participant's response until the round
|
| 4 |
+
is finished, so first opinions are genuinely independent. That is what
|
| 5 |
+
makes the Credential Summary in the next step meaningful.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
INITIAL_OPINION_PROMPT = (
|
| 9 |
+
"Phase 1 of the discussion: First Opinions.\n\n"
|
| 10 |
+
"The group has been asked the following question:\n\n"
|
| 11 |
+
"<<<\n{question}\n>>>\n\n"
|
| 12 |
+
"You are speaking before any other participant has shared their view. "
|
| 13 |
+
"Read the question carefully, consider it through the lens of who you "
|
| 14 |
+
"are (your background, expertise, values, and personality), and offer "
|
| 15 |
+
"your initial opinion.\n\n"
|
| 16 |
+
"Your first opinion should:\n"
|
| 17 |
+
" 1. Take a clear, specific position on the question.\n"
|
| 18 |
+
" 2. Explain the 1-3 most important reasons behind your position, "
|
| 19 |
+
"drawing on your particular background or expertise.\n"
|
| 20 |
+
" 3. Acknowledge any uncertainty or trade-offs you see.\n\n"
|
| 21 |
+
"Speak in the first person. Keep it focused: 4-8 sentences. Do not "
|
| 22 |
+
"address other participants by name yet - you have not heard them "
|
| 23 |
+
"speak."
|
| 24 |
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 3: orchestrator decides whether the conversation needs more
|
| 2 |
+
targeted follow-ups before moving to finalization.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
STATUS_ASSESSMENT_PROMPT = (
|
| 6 |
+
"Below is the question being discussed, the current Credential Summary, "
|
| 7 |
+
"and the conversation transcript through the critique rounds. Your job, "
|
| 8 |
+
"as the orchestrator, is to decide whether the participants have "
|
| 9 |
+
"solidified their opinions or whether there are still important open "
|
| 10 |
+
"questions that warrant a targeted follow-up to specific participants "
|
| 11 |
+
"before we move on to opinion finalization.\n\n"
|
| 12 |
+
"Question:\n<<<\n{question}\n>>>\n\n"
|
| 13 |
+
"Credential Summary:\n{credential_summary}\n\n"
|
| 14 |
+
"Conversation so far:\n{transcript}\n\n"
|
| 15 |
+
"Return JSON ONLY in this exact shape:\n"
|
| 16 |
+
"{{\n"
|
| 17 |
+
' "opinions_solidified": <true|false>,\n'
|
| 18 |
+
' "open_questions": [\n'
|
| 19 |
+
' {{ "participant_id": "<participant_id>", "question": "<one direct question>" }}\n'
|
| 20 |
+
" ],\n"
|
| 21 |
+
' "notes": "<one short sentence on the discussion state>"\n'
|
| 22 |
+
"}}\n\n"
|
| 23 |
+
"If opinions are clearly solidified, return an empty open_questions list "
|
| 24 |
+
"and opinions_solidified=true. Otherwise list 1-3 high-leverage "
|
| 25 |
+
"questions, each aimed at one specific participant by participant_id. "
|
| 26 |
+
"Each follow-up should target a real ambiguity or unresolved disagreement "
|
| 27 |
+
"in the transcript - never invent topics that haven't come up. Output "
|
| 28 |
+
"JSON only."
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
TARGETED_FOLLOWUP_PROMPT = (
|
| 32 |
+
"The orchestrator has a follow-up question for you specifically.\n\n"
|
| 33 |
+
"Conversation so far:\n{transcript}\n\n"
|
| 34 |
+
"Credential Summary of the group:\n{credential_summary}\n\n"
|
| 35 |
+
"Follow-up question for you: {targeted_question}\n\n"
|
| 36 |
+
"Answer the question directly, in 3-6 sentences. You may reference "
|
| 37 |
+
"other participants' statements by name. Stay in character."
|
| 38 |
+
)
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Centralized sanitizer for LLM responses.
|
| 2 |
+
|
| 3 |
+
CCAI conversations don't work well when thinking traces leak into chat or
|
| 4 |
+
into orchestrator/summarizer/Credential-Summary inputs, so every LLM
|
| 5 |
+
response funnels through `strip_thinking` before being stored, displayed,
|
| 6 |
+
or forwarded to another LLM.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
|
| 12 |
+
# Top-level reasoning blocks emitted as XML-ish tags. DOTALL so we catch
|
| 13 |
+
# multi-line reasoning blocks; non-greedy so adjacent blocks don't merge.
|
| 14 |
+
_THINK_TAG_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
|
| 15 |
+
_REASONING_BLOCK_RE = re.compile(
|
| 16 |
+
r"<(reasoning|reflection|inner_thoughts|scratchpad|analysis|plan)>.*?</\1>",
|
| 17 |
+
re.DOTALL | re.IGNORECASE,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
# Bare "thought:" / "reasoning:" prologues some models emit before content
|
| 21 |
+
# (only at the very start of the response, otherwise we'd nuke the body).
|
| 22 |
+
_PROLOGUE_RE = re.compile(
|
| 23 |
+
r"^\s*(thought|thinking|reasoning|analysis|scratchpad)\s*:\s*"
|
| 24 |
+
r".*?(?=\n\n|\Z)",
|
| 25 |
+
re.DOTALL | re.IGNORECASE,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# Some providers wrap thinking in special framing tokens. We try to strip
|
| 29 |
+
# the *paired* form (open ... close) first so the body in between is
|
| 30 |
+
# removed, and fall back to stripping any leftover bare markers.
|
| 31 |
+
_PAIRED_FRAMING_RES = [
|
| 32 |
+
re.compile(r"<\|reasoning\|>.*?<\|/reasoning\|>", re.DOTALL | re.IGNORECASE),
|
| 33 |
+
re.compile(r"<\|think\|>.*?<\|/think\|>", re.DOTALL | re.IGNORECASE),
|
| 34 |
+
]
|
| 35 |
+
_FRAMING_TOKENS = ["<|reasoning|>", "<|/reasoning|>", "<|think|>", "<|/think|>"]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def strip_thinking(text: str | None) -> str:
|
| 39 |
+
"""Return `text` with all reasoning artifacts removed.
|
| 40 |
+
|
| 41 |
+
Safe to call on empty, whitespace-only, or None inputs (returns empty
|
| 42 |
+
string in those cases). Idempotent: calling twice yields the same result.
|
| 43 |
+
"""
|
| 44 |
+
if not text:
|
| 45 |
+
return ""
|
| 46 |
+
|
| 47 |
+
out = _THINK_TAG_RE.sub("", text)
|
| 48 |
+
out = _REASONING_BLOCK_RE.sub("", out)
|
| 49 |
+
|
| 50 |
+
for paired in _PAIRED_FRAMING_RES:
|
| 51 |
+
out = paired.sub("", out)
|
| 52 |
+
for tok in _FRAMING_TOKENS:
|
| 53 |
+
out = out.replace(tok, "")
|
| 54 |
+
|
| 55 |
+
out = _PROLOGUE_RE.sub("", out)
|
| 56 |
+
|
| 57 |
+
return out.strip()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def response_has_thinking(text: str | None, msg: dict | None = None) -> bool:
|
| 61 |
+
"""Return True if the raw response had any thinking artifact.
|
| 62 |
+
|
| 63 |
+
Checks both the textual content and any `reasoning_content` /
|
| 64 |
+
`reasoning` fields the OpenAI-compat client may have surfaced.
|
| 65 |
+
"""
|
| 66 |
+
if msg is not None:
|
| 67 |
+
if msg.get("reasoning_content") or msg.get("reasoning"):
|
| 68 |
+
return True
|
| 69 |
+
|
| 70 |
+
if not text:
|
| 71 |
+
return False
|
| 72 |
+
|
| 73 |
+
if _THINK_TAG_RE.search(text):
|
| 74 |
+
return True
|
| 75 |
+
if _REASONING_BLOCK_RE.search(text):
|
| 76 |
+
return True
|
| 77 |
+
if any(tok in text for tok in _FRAMING_TOKENS):
|
| 78 |
+
return True
|
| 79 |
+
if _PROLOGUE_RE.match(text):
|
| 80 |
+
return True
|
| 81 |
+
return False
|
|
@@ -5,4 +5,5 @@ pydantic-settings>=2.6.0
|
|
| 5 |
python-multipart>=0.0.12
|
| 6 |
python-dotenv>=1.0.0
|
| 7 |
huggingface_hub>=0.25.0
|
| 8 |
-
itsdangerous>=2.2.0
|
|
|
|
|
|
| 5 |
python-multipart>=0.0.12
|
| 6 |
python-dotenv>=1.0.0
|
| 7 |
huggingface_hub>=0.25.0
|
| 8 |
+
itsdangerous>=2.2.0
|
| 9 |
+
pytest>=8.0.0
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.services.context_budget import (
|
| 2 |
+
ContextSummary,
|
| 3 |
+
DEFAULT_CONTEXT,
|
| 4 |
+
DEFAULT_REPLY_BUDGET,
|
| 5 |
+
SUMMARIZE_THRESHOLD,
|
| 6 |
+
TRIM_THRESHOLD,
|
| 7 |
+
build_compressed_messages,
|
| 8 |
+
context_window_for,
|
| 9 |
+
estimate_messages_tokens,
|
| 10 |
+
select_summarizer_model_id,
|
| 11 |
+
should_summarize,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_context_window_known_model():
|
| 16 |
+
assert context_window_for("gpt-4.1-mini") == 128_000
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_context_window_unknown_falls_back():
|
| 20 |
+
assert context_window_for("totally-unknown") == DEFAULT_CONTEXT
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_context_window_neon_falls_back():
|
| 24 |
+
assert context_window_for("neon:Foo/Bar@2025.10.01:Researcher") == DEFAULT_CONTEXT
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_estimate_tokens_grows_with_text():
|
| 28 |
+
a = estimate_messages_tokens([{"role": "user", "content": "hi"}])
|
| 29 |
+
b = estimate_messages_tokens([{"role": "user", "content": "hi" * 1000}])
|
| 30 |
+
assert b > a
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_should_summarize_below_threshold():
|
| 34 |
+
"""A small ~10-token prompt against a 1M-token gemini window: never
|
| 35 |
+
should we trigger summarize.
|
| 36 |
+
"""
|
| 37 |
+
summary = ContextSummary()
|
| 38 |
+
api = [{"role": "user", "content": "hello"}]
|
| 39 |
+
needs_sum, needs_trim, _ = should_summarize("gemini-2.5-flash", api, summary)
|
| 40 |
+
assert needs_sum is False
|
| 41 |
+
assert needs_trim is False
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_should_summarize_above_threshold_triggers_summarize():
|
| 45 |
+
summary = ContextSummary()
|
| 46 |
+
big = "x" * 10_000 # ~2500 estimated tokens
|
| 47 |
+
api = [
|
| 48 |
+
{"role": "system", "content": big},
|
| 49 |
+
{"role": "user", "content": big},
|
| 50 |
+
]
|
| 51 |
+
# Small 8K-window model -> 6K input budget -> 55% = 3300; we're way over.
|
| 52 |
+
needs_sum, _, budget = should_summarize("totally-unknown", api, summary)
|
| 53 |
+
assert needs_sum is True
|
| 54 |
+
assert budget >= 2_048
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_should_trim_only_when_summary_exists():
|
| 58 |
+
"""Even at 70%, trim should only happen once we already have a
|
| 59 |
+
running summary - otherwise we'd drop history with no replacement.
|
| 60 |
+
"""
|
| 61 |
+
summary = ContextSummary()
|
| 62 |
+
big = "x" * 30_000
|
| 63 |
+
api = [
|
| 64 |
+
{"role": "system", "content": big},
|
| 65 |
+
{"role": "user", "content": big},
|
| 66 |
+
]
|
| 67 |
+
_, needs_trim_no_sum, _ = should_summarize("totally-unknown", api, summary)
|
| 68 |
+
assert needs_trim_no_sum is False
|
| 69 |
+
|
| 70 |
+
summary.summary_text = "Previously, the group discussed X."
|
| 71 |
+
_, needs_trim_yes_sum, _ = should_summarize("totally-unknown", api, summary)
|
| 72 |
+
assert needs_trim_yes_sum is True
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_build_compressed_keeps_system_and_recent():
|
| 76 |
+
summary = ContextSummary(summary_text="condensed history")
|
| 77 |
+
msgs = [
|
| 78 |
+
{"role": "system", "content": "you are X"},
|
| 79 |
+
{"role": "user", "content": "old1"},
|
| 80 |
+
{"role": "assistant", "content": "oldA"},
|
| 81 |
+
{"role": "user", "content": "old2"},
|
| 82 |
+
{"role": "assistant", "content": "oldB"},
|
| 83 |
+
{"role": "user", "content": "recent"},
|
| 84 |
+
]
|
| 85 |
+
out = build_compressed_messages(msgs, summary, needs_trim=True)
|
| 86 |
+
# head + summary + last KEEP_RECENT_MESSAGES (=6) <= len(msgs) so we
|
| 87 |
+
# might end up with everything; the contract is just that the
|
| 88 |
+
# original system and the running summary are preserved.
|
| 89 |
+
assert out[0] == msgs[0]
|
| 90 |
+
assert any("Summary of earlier" in m["content"] for m in out)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_select_summarizer_falls_back():
|
| 94 |
+
# Override wins
|
| 95 |
+
assert select_summarizer_model_id("custom", "orch") == "custom"
|
| 96 |
+
# Falls back to orchestrator if no override
|
| 97 |
+
assert select_summarizer_model_id(None, "orch") == "orch"
|
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.api.chat import _export_csv_table
|
| 2 |
+
from app.services.models import Phase, Session
|
| 3 |
+
from app.services.models import Participant
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def _mk_session():
|
| 7 |
+
s = Session()
|
| 8 |
+
s.question = "Will \"AI\" change, education? Yes, no, maybe.\nNew lines too."
|
| 9 |
+
p1 = Participant(
|
| 10 |
+
participant_id="extra_a",
|
| 11 |
+
name="Alice",
|
| 12 |
+
role_prompt="rp",
|
| 13 |
+
model_id="model-a",
|
| 14 |
+
kind="extra",
|
| 15 |
+
display_name="Provider/Model A",
|
| 16 |
+
)
|
| 17 |
+
p2 = Participant(
|
| 18 |
+
participant_id="expert_b",
|
| 19 |
+
name="Bob, Ph.D.",
|
| 20 |
+
role_prompt="rp",
|
| 21 |
+
model_id="model-b",
|
| 22 |
+
kind="expert",
|
| 23 |
+
display_name="Provider/Model B",
|
| 24 |
+
)
|
| 25 |
+
s.participants = [p1, p2]
|
| 26 |
+
s.initial_opinions = {
|
| 27 |
+
"extra_a": "Alice's, opinion has commas, and \"quotes\".",
|
| 28 |
+
"expert_b": "Bob's\nmulti-line\nopinion.",
|
| 29 |
+
}
|
| 30 |
+
s.contribution_summaries = {
|
| 31 |
+
"extra_a": "Stayed firm.",
|
| 32 |
+
"expert_b": "Pushed hard.",
|
| 33 |
+
}
|
| 34 |
+
s.final_opinions = {
|
| 35 |
+
"extra_a": "Final A",
|
| 36 |
+
"expert_b": "Final B",
|
| 37 |
+
}
|
| 38 |
+
s.messages = [
|
| 39 |
+
{
|
| 40 |
+
"speaker_id": "extra_a", "speaker_name": "Alice",
|
| 41 |
+
"role": "participant", "phase": Phase.CONSENSUS.value,
|
| 42 |
+
"text": "Final consensus statement A",
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"speaker_id": "expert_b", "speaker_name": "Bob, Ph.D.",
|
| 46 |
+
"role": "participant", "phase": Phase.CONSENSUS.value,
|
| 47 |
+
"text": "Final consensus statement B",
|
| 48 |
+
},
|
| 49 |
+
]
|
| 50 |
+
s.final_report = {"kind": "majority", "text": "Group decided X."}
|
| 51 |
+
return s
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_csv_export_roundtrips_through_csv_module():
|
| 55 |
+
"""Ensure values containing commas, quotes, and newlines get quoted
|
| 56 |
+
correctly per RFC 4180."""
|
| 57 |
+
import csv
|
| 58 |
+
import io
|
| 59 |
+
|
| 60 |
+
s = _mk_session()
|
| 61 |
+
out = _export_csv_table(s)
|
| 62 |
+
assert out["filename"] == "ccai_chat_table.csv"
|
| 63 |
+
parsed = list(csv.reader(io.StringIO(out["content"])))
|
| 64 |
+
# Header is question, then final, then blank, then column row.
|
| 65 |
+
assert parsed[0][0] == "Question"
|
| 66 |
+
assert "AI" in parsed[0][1] and "education" in parsed[0][1]
|
| 67 |
+
assert parsed[1][0] == "Final Group Opinion"
|
| 68 |
+
assert "Group decided X." in parsed[1][1]
|
| 69 |
+
# blank row
|
| 70 |
+
assert parsed[2] == []
|
| 71 |
+
# column header row
|
| 72 |
+
assert parsed[3] == [
|
| 73 |
+
"Participant",
|
| 74 |
+
"First opinion",
|
| 75 |
+
"Conversation contribution",
|
| 76 |
+
"Revised opinion",
|
| 77 |
+
"Final opinion",
|
| 78 |
+
]
|
| 79 |
+
alice_row = parsed[4]
|
| 80 |
+
assert alice_row[0] == "Alice"
|
| 81 |
+
assert "\"quotes\"" in alice_row[1] # csv module preserved the quotes
|
| 82 |
+
bob_row = parsed[5]
|
| 83 |
+
assert bob_row[0] == "Bob, Ph.D."
|
| 84 |
+
assert "multi-line" in bob_row[1]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_csv_export_no_field_count_drift():
|
| 88 |
+
"""Every row after the header should have exactly 5 columns even when
|
| 89 |
+
payload contains pathological characters."""
|
| 90 |
+
import csv
|
| 91 |
+
import io
|
| 92 |
+
|
| 93 |
+
s = _mk_session()
|
| 94 |
+
out = _export_csv_table(s)
|
| 95 |
+
rows = list(csv.reader(io.StringIO(out["content"])))
|
| 96 |
+
data_rows = rows[4:]
|
| 97 |
+
for row in data_rows:
|
| 98 |
+
assert len(row) == 5
|
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.services.json_calls import parse_json_response
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_plain_json_object():
|
| 5 |
+
assert parse_json_response('{"a": 1}') == {"a": 1}
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_plain_json_array():
|
| 9 |
+
assert parse_json_response('[1, 2, 3]') == [1, 2, 3]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_markdown_fence():
|
| 13 |
+
raw = "```json\n{\"a\": 1}\n```"
|
| 14 |
+
assert parse_json_response(raw) == {"a": 1}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_markdown_fence_no_lang():
|
| 18 |
+
raw = "```\n{\"a\": 1}\n```"
|
| 19 |
+
assert parse_json_response(raw) == {"a": 1}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_prose_then_json():
|
| 23 |
+
raw = "Sure, here's the result:\n\n{\"a\": 1, \"b\": [2,3]}"
|
| 24 |
+
assert parse_json_response(raw) == {"a": 1, "b": [2, 3]}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_json_then_prose():
|
| 28 |
+
raw = "{\"a\": 1}\nThe end."
|
| 29 |
+
assert parse_json_response(raw) == {"a": 1}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_nested_braces_ok():
|
| 33 |
+
raw = "Look:\n{\"outer\": {\"inner\": [1, {\"k\": \"v\"}]}}"
|
| 34 |
+
assert parse_json_response(raw) == {"outer": {"inner": [1, {"k": "v"}]}}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_string_with_braces_doesnt_confuse_balancer():
|
| 38 |
+
raw = '{"text": "this has } and { in it", "ok": true}'
|
| 39 |
+
assert parse_json_response(raw) == {"text": "this has } and { in it", "ok": True}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_unparseable_returns_none():
|
| 43 |
+
assert parse_json_response("not json at all") is None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_empty_returns_none():
|
| 47 |
+
assert parse_json_response("") is None
|
| 48 |
+
assert parse_json_response(None) is None
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.utils.sanitize import strip_thinking, response_has_thinking
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_strip_simple_think_tag():
|
| 5 |
+
assert strip_thinking("<think>plan</think>final") == "final"
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_strip_multiline_think_tag():
|
| 9 |
+
raw = "<think>line1\nline2\n</think>actual reply"
|
| 10 |
+
assert strip_thinking(raw) == "actual reply"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_strip_nested_reasoning_blocks():
|
| 14 |
+
raw = (
|
| 15 |
+
"<reasoning>step 1\nstep 2</reasoning>"
|
| 16 |
+
"<analysis>more thinking</analysis>"
|
| 17 |
+
"real text"
|
| 18 |
+
)
|
| 19 |
+
assert strip_thinking(raw) == "real text"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_strip_multiple_think_blocks():
|
| 23 |
+
raw = "<think>a</think>middle<think>b</think>end"
|
| 24 |
+
assert strip_thinking(raw) == "middleend"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_strip_uppercase_tag():
|
| 28 |
+
assert strip_thinking("<THINK>plan</THINK>final") == "final"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_idempotent():
|
| 32 |
+
cleaned = strip_thinking("<think>foo</think>bar")
|
| 33 |
+
assert strip_thinking(cleaned) == cleaned
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_empty_inputs_safe():
|
| 37 |
+
assert strip_thinking(None) == ""
|
| 38 |
+
assert strip_thinking("") == ""
|
| 39 |
+
assert strip_thinking(" \n\t ") == ""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_thought_prologue():
|
| 43 |
+
raw = "Thought: I should probably mention X.\n\nReal response here."
|
| 44 |
+
assert strip_thinking(raw) == "Real response here."
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_response_has_thinking_via_text():
|
| 48 |
+
assert response_has_thinking("<think>plan</think>x")
|
| 49 |
+
assert not response_has_thinking("just plain text")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_response_has_thinking_via_msg_field():
|
| 53 |
+
assert response_has_thinking("plain text", {"reasoning_content": "stuff"})
|
| 54 |
+
assert response_has_thinking("plain text", {"reasoning": "stuff"})
|
| 55 |
+
assert not response_has_thinking("plain text", {"content": "x"})
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_strip_framing_tokens():
|
| 59 |
+
raw = "<|reasoning|>plan<|/reasoning|>final"
|
| 60 |
+
assert strip_thinking(raw) == "final"
|
|
@@ -2,9 +2,12 @@ services:
|
|
| 2 |
app:
|
| 3 |
build: .
|
| 4 |
ports:
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
env_file:
|
| 7 |
- .env
|
| 8 |
environment:
|
| 9 |
-
CORS_ORIGINS: "http://localhost:
|
|
|
|
| 10 |
restart: unless-stopped
|
|
|
|
| 2 |
app:
|
| 3 |
build: .
|
| 4 |
ports:
|
| 5 |
+
# Match the HuggingFace Space app_port so docker compose and HF
|
| 6 |
+
# behave identically. The container always listens on 7860.
|
| 7 |
+
- "7860:7860"
|
| 8 |
env_file:
|
| 9 |
- .env
|
| 10 |
environment:
|
| 11 |
+
CORS_ORIGINS: "http://localhost:7860,http://localhost:3000"
|
| 12 |
+
HF_RATE_LIMIT_DAILY: "30"
|
| 13 |
restart: unless-stopped
|
|
@@ -16725,23 +16725,6 @@
|
|
| 16725 |
}
|
| 16726 |
}
|
| 16727 |
},
|
| 16728 |
-
"node_modules/tailwindcss/node_modules/yaml": {
|
| 16729 |
-
"version": "2.8.3",
|
| 16730 |
-
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
| 16731 |
-
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
| 16732 |
-
"license": "ISC",
|
| 16733 |
-
"optional": true,
|
| 16734 |
-
"peer": true,
|
| 16735 |
-
"bin": {
|
| 16736 |
-
"yaml": "bin.mjs"
|
| 16737 |
-
},
|
| 16738 |
-
"engines": {
|
| 16739 |
-
"node": ">= 14.6"
|
| 16740 |
-
},
|
| 16741 |
-
"funding": {
|
| 16742 |
-
"url": "https://github.com/sponsors/eemeli"
|
| 16743 |
-
}
|
| 16744 |
-
},
|
| 16745 |
"node_modules/tapable": {
|
| 16746 |
"version": "2.3.0",
|
| 16747 |
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
|
|
|
| 16725 |
}
|
| 16726 |
}
|
| 16727 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16728 |
"node_modules/tapable": {
|
| 16729 |
"version": "2.3.0",
|
| 16730 |
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
|
@@ -1,81 +1,102 @@
|
|
| 1 |
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
| 2 |
-
import
|
| 3 |
-
import
|
| 4 |
-
import PersonaAccordion from './components/PersonaAccordion';
|
| 5 |
import ChatControls from './components/ChatControls';
|
| 6 |
import ChatArea from './components/ChatArea';
|
| 7 |
-
import
|
| 8 |
-
import
|
| 9 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
import './styles/variables.css';
|
| 11 |
import './styles/layout.css';
|
| 12 |
import './styles/components.css';
|
|
|
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
if (!modelId) return '';
|
| 18 |
-
if (modelId.startsWith('neon:')) {
|
| 19 |
-
return modelId.split(':')[2] || modelId;
|
| 20 |
-
}
|
| 21 |
-
for (const p of (providers || [])) {
|
| 22 |
-
for (const m of p.models) {
|
| 23 |
-
if (m.id === modelId) return m.name;
|
| 24 |
-
}
|
| 25 |
-
}
|
| 26 |
-
return modelId;
|
| 27 |
}
|
| 28 |
|
| 29 |
export default function App() {
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
const [providers, setProviders] = useState([]);
|
| 34 |
const [neonModels, setNeonModels] = useState([]);
|
| 35 |
-
const [
|
| 36 |
-
const [
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
const [messages, setMessages] = useState([]);
|
| 40 |
const [systemMessages, setSystemMessages] = useState([]);
|
| 41 |
const [isRunning, setIsRunning] = useState(false);
|
| 42 |
const [statusText, setStatusText] = useState('');
|
| 43 |
const [sessionId, setSessionId] = useState(null);
|
| 44 |
-
const [
|
| 45 |
-
const [
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
const [
|
| 49 |
-
const [
|
| 50 |
-
const [
|
| 51 |
-
const [
|
| 52 |
-
|
| 53 |
-
const [rolePromptsOpen, setRolePromptsOpen] = useState(false);
|
| 54 |
const abortRef = useRef(null);
|
| 55 |
-
const lastRoleConfigRef = useRef(null);
|
| 56 |
|
|
|
|
| 57 |
useEffect(() => {
|
| 58 |
document.documentElement.setAttribute('data-theme', theme);
|
|
|
|
| 59 |
}, [theme]);
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
useEffect(() => {
|
| 64 |
-
fetchModels()
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
| 76 |
getAuthStatus().then(setAuth).catch(() => {});
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
| 78 |
|
|
|
|
| 79 |
const allModelsFlat = useMemo(() => {
|
| 80 |
const list = [];
|
| 81 |
for (const p of providers) {
|
|
@@ -96,31 +117,127 @@ export default function App() {
|
|
| 96 |
return list;
|
| 97 |
}, [providers, neonModels]);
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
const handleOrchestratorChange = useCallback(async (modelId) => {
|
| 100 |
try {
|
| 101 |
await setOrchestrator(modelId || '');
|
| 102 |
-
|
| 103 |
} catch (err) {
|
| 104 |
console.error('Failed to set orchestrator:', err);
|
| 105 |
}
|
| 106 |
}, []);
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
setPersonaMode(mode);
|
| 110 |
-
setRoleStyle(mode === 'freeform' ? 'ai_completed' : 'exact');
|
| 111 |
}, []);
|
| 112 |
-
|
| 113 |
const handleSpeedPriorityChange = useCallback(async (enabled) => {
|
| 114 |
try {
|
| 115 |
await setSpeedPriority(enabled);
|
| 116 |
setSpeedPriorityState(enabled);
|
| 117 |
-
} catch (err) {
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
}, []);
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
const url = URL.createObjectURL(blob);
|
| 125 |
const a = document.createElement('a');
|
| 126 |
a.href = url;
|
|
@@ -128,125 +245,152 @@ export default function App() {
|
|
| 128 |
a.click();
|
| 129 |
URL.revokeObjectURL(url);
|
| 130 |
}, []);
|
| 131 |
-
|
| 132 |
const handleDownloadTxt = useCallback(async () => {
|
| 133 |
if (!sessionId) return;
|
| 134 |
try {
|
| 135 |
-
const
|
| 136 |
-
downloadFile(
|
| 137 |
} catch (err) { console.error('Export failed:', err); }
|
| 138 |
}, [sessionId, downloadFile]);
|
| 139 |
-
|
| 140 |
const handleDownloadMd = useCallback(async () => {
|
| 141 |
if (!sessionId) return;
|
| 142 |
try {
|
| 143 |
-
const
|
| 144 |
-
downloadFile(
|
| 145 |
} catch (err) { console.error('Export failed:', err); }
|
| 146 |
}, [sessionId, downloadFile]);
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
const handleDownloadApiLog = useCallback(async () => {
|
| 149 |
if (!sessionId) return;
|
| 150 |
try {
|
| 151 |
-
const
|
| 152 |
-
downloadFile('api_log.json', JSON.stringify(
|
| 153 |
} catch (err) { console.error('API log export failed:', err); }
|
| 154 |
}, [sessionId, downloadFile]);
|
| 155 |
|
| 156 |
-
|
| 157 |
-
const
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
const handleStop = useCallback(() => {
|
| 162 |
-
if (abortRef.current) {
|
| 163 |
-
abortRef.current.abort();
|
| 164 |
-
abortRef.current = null;
|
| 165 |
-
}
|
| 166 |
setIsRunning(false);
|
| 167 |
-
setChatFinished(true);
|
| 168 |
setStatusText('');
|
|
|
|
| 169 |
setSystemMessages(prev => [...prev, { text: 'Chat stopped by user.' }]);
|
| 170 |
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
| 174 |
|
| 175 |
const controller = new AbortController();
|
| 176 |
abortRef.current = controller;
|
| 177 |
-
|
| 178 |
setIsRunning(true);
|
| 179 |
-
setAccordionOpen(false);
|
| 180 |
setMessages([]);
|
| 181 |
setSystemMessages([]);
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
try {
|
| 185 |
-
const currentConfig = JSON.stringify({
|
| 186 |
-
selections, personaMode, roleStyle,
|
| 187 |
-
a: personaMode === 'freeform'
|
| 188 |
-
? { name: personaA.name, freeform: personaA.freeform || '' }
|
| 189 |
-
: { name: personaA.name, profile: personaA.profile, identity: personaA.identity, samples: personaA.samples },
|
| 190 |
-
b: personaMode === 'freeform'
|
| 191 |
-
? { name: personaB.name, freeform: personaB.freeform || '' }
|
| 192 |
-
: { name: personaB.name, profile: personaB.profile, identity: personaB.identity, samples: personaB.samples },
|
| 193 |
-
});
|
| 194 |
-
|
| 195 |
-
let cachedPrompts = rolePrompts;
|
| 196 |
-
const configChanged = currentConfig !== lastRoleConfigRef.current;
|
| 197 |
-
|
| 198 |
-
if (configChanged || !cachedPrompts) {
|
| 199 |
-
setStatusText('Generating expert persona roles...');
|
| 200 |
-
|
| 201 |
-
const genA = personaMode === 'freeform'
|
| 202 |
-
? generateRoleFreeform({ model_id: selections[0], name: personaA.name, text: personaA.freeform || '', role_style: roleStyle })
|
| 203 |
-
: generateRole({ model_id: selections[0], name: personaA.name, profile: personaA.profile, identity: personaA.identity, samples: personaA.samples, role_style: roleStyle });
|
| 204 |
-
const genB = personaMode === 'freeform'
|
| 205 |
-
? generateRoleFreeform({ model_id: selections[1], name: personaB.name, text: personaB.freeform || '', role_style: roleStyle })
|
| 206 |
-
: generateRole({ model_id: selections[1], name: personaB.name, profile: personaB.profile, identity: personaB.identity, samples: personaB.samples, role_style: roleStyle });
|
| 207 |
-
|
| 208 |
-
const [roleA, roleB] = await Promise.all([genA, genB]);
|
| 209 |
-
|
| 210 |
-
if (controller.signal.aborted) return;
|
| 211 |
-
|
| 212 |
-
cachedPrompts = {
|
| 213 |
-
a: { name: personaA.name || 'Expert Persona A', model: getDisplayName(selections[0], providers, neonModels), prompt: roleA.role_prompt },
|
| 214 |
-
b: { name: personaB.name || 'Expert Persona B', model: getDisplayName(selections[1], providers, neonModels), prompt: roleB.role_prompt },
|
| 215 |
-
};
|
| 216 |
-
setRolePrompts(cachedPrompts);
|
| 217 |
-
lastRoleConfigRef.current = currentConfig;
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
setStatusText('Starting conversation...');
|
| 221 |
-
|
| 222 |
await startChat(
|
|
|
|
| 223 |
{
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
persona_b_name: cachedPrompts.b.name,
|
| 229 |
-
persona_b_role: cachedPrompts.b.prompt,
|
| 230 |
-
starter_text: starterText,
|
| 231 |
-
},
|
| 232 |
-
{
|
| 233 |
-
onSession: (data) => setSessionId(data.session_id),
|
| 234 |
onMessage: (data) => {
|
| 235 |
setMessages(prev => [...prev, data]);
|
| 236 |
setStatusText('Conversation in progress...');
|
| 237 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
onSystem: (data) => {
|
| 239 |
setSystemMessages(prev => [...prev, data]);
|
| 240 |
if (data.text === 'End of Chat') {
|
| 241 |
-
setChatFinished(true);
|
| 242 |
setStatusText('');
|
| 243 |
}
|
| 244 |
},
|
| 245 |
-
onStatus: (data) => setStatusText(data.message || ''),
|
| 246 |
onError: (data) => {
|
| 247 |
setStatusText('');
|
| 248 |
setSystemMessages(prev => [...prev, { text: `Error: ${data.message}` }]);
|
| 249 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
onDone: () => {
|
| 251 |
setIsRunning(false);
|
| 252 |
setStatusText('');
|
|
@@ -260,7 +404,7 @@ export default function App() {
|
|
| 260 |
const isRateLimit = err.message && err.message.includes('Daily conversation limit');
|
| 261 |
setSystemMessages(prev => [...prev, {
|
| 262 |
text: isRateLimit
|
| 263 |
-
?
|
| 264 |
: `Error: ${err.message}`,
|
| 265 |
}]);
|
| 266 |
} finally {
|
|
@@ -268,84 +412,85 @@ export default function App() {
|
|
| 268 |
abortRef.current = null;
|
| 269 |
getAuthStatus().then(setAuth).catch(() => {});
|
| 270 |
}
|
| 271 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
return (
|
| 274 |
<div className="app">
|
| 275 |
-
<
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
hasChat={messages.length > 0}
|
| 311 |
-
hasApiLog={!!sessionId}
|
| 312 |
-
/>
|
| 313 |
-
</div>
|
| 314 |
-
</header>
|
| 315 |
|
| 316 |
<main className="app-main">
|
| 317 |
-
<
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
|
|
|
| 322 |
/>
|
| 323 |
-
|
| 324 |
<div className="content">
|
| 325 |
-
<PersonaAccordion
|
| 326 |
-
isOpen={accordionOpen}
|
| 327 |
-
onToggle={() => setAccordionOpen(o => !o)}
|
| 328 |
-
personaA={personaA}
|
| 329 |
-
personaB={personaB}
|
| 330 |
-
onChangeA={setPersonaA}
|
| 331 |
-
onChangeB={setPersonaB}
|
| 332 |
-
selectedNameA={selectedNameA}
|
| 333 |
-
selectedNameB={selectedNameB}
|
| 334 |
-
mode={personaMode}
|
| 335 |
-
/>
|
| 336 |
-
|
| 337 |
<ChatControls
|
| 338 |
-
|
|
|
|
| 339 |
onStop={handleStop}
|
| 340 |
-
disabled={
|
| 341 |
isRunning={isRunning}
|
|
|
|
| 342 |
/>
|
| 343 |
-
|
| 344 |
<ChatArea
|
| 345 |
messages={messages}
|
| 346 |
systemMessages={systemMessages}
|
| 347 |
isRunning={isRunning}
|
| 348 |
statusText={statusText}
|
|
|
|
|
|
|
|
|
|
| 349 |
showResponseTime={showResponseTime}
|
| 350 |
showChatStats={showChatStats}
|
| 351 |
/>
|
|
@@ -356,25 +501,21 @@ export default function App() {
|
|
| 356 |
<a href="https://www.neon.ai/contact" target="_blank" rel="noopener noreferrer">Patents and licensing</a>
|
| 357 |
</footer>
|
| 358 |
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
</div>
|
| 375 |
-
</div>
|
| 376 |
-
</div>
|
| 377 |
-
</div>
|
| 378 |
)}
|
| 379 |
</div>
|
| 380 |
);
|
|
|
|
| 1 |
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
| 2 |
+
import Header from './components/Header';
|
| 3 |
+
import ParticipantSidebar from './components/ParticipantSidebar';
|
|
|
|
| 4 |
import ChatControls from './components/ChatControls';
|
| 5 |
import ChatArea from './components/ChatArea';
|
| 6 |
+
import ExpertPersonaModal from './components/ExpertPersonaModal';
|
| 7 |
+
import ChatTableView from './components/ChatTableView';
|
| 8 |
+
import {
|
| 9 |
+
fetchModels, fetchPersonas, fetchDemoQuestions,
|
| 10 |
+
startChat, continueChat, getOrchestrator, setOrchestrator,
|
| 11 |
+
getSpeedPriority, setSpeedPriority, getAuthStatus,
|
| 12 |
+
exportChat, exportApiLog, fetchTableView, getRateLimitStatus,
|
| 13 |
+
} from './utils/api';
|
| 14 |
+
import * as storage from './utils/storage';
|
| 15 |
import './styles/variables.css';
|
| 16 |
import './styles/layout.css';
|
| 17 |
import './styles/components.css';
|
| 18 |
+
import './styles/ccai.css';
|
| 19 |
|
| 20 |
+
function pickRandom(list) {
|
| 21 |
+
if (!list || list.length === 0) return null;
|
| 22 |
+
return list[Math.floor(Math.random() * list.length)];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
export default function App() {
|
| 26 |
+
// Persistent state
|
| 27 |
+
const persisted = useMemo(() => storage.loadState(), []);
|
| 28 |
+
const [theme, setTheme] = useState(() => persisted.theme
|
| 29 |
+
|| (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
| 30 |
);
|
| 31 |
+
const [expertPersonas, setExpertPersonas] = useState(persisted.expert_personas || []);
|
| 32 |
+
const [selectedIds, setSelectedIds] = useState(persisted.participants_selected || []);
|
| 33 |
+
const [enabledMap, setEnabledMap] = useState(persisted.participants_enabled || {});
|
| 34 |
+
const [modelAssignments, setModelAssignments] = useState(persisted.model_assignments || {});
|
| 35 |
+
const [orchestratorModel, setOrchestratorModelState] = useState(persisted.orchestrator_model_id);
|
| 36 |
+
const [summarizerModel, setSummarizerModelState] = useState(persisted.summarizer_model_id);
|
| 37 |
+
const [maxParticipants, setMaxParticipants] = useState(persisted.max_participants || 5);
|
| 38 |
+
|
| 39 |
+
// Backend catalog
|
| 40 |
const [providers, setProviders] = useState([]);
|
| 41 |
const [neonModels, setNeonModels] = useState([]);
|
| 42 |
+
const [catalog, setCatalog] = useState({ neon: [], extra: [] });
|
| 43 |
+
const [demoQuestions, setDemoQuestions] = useState([]);
|
| 44 |
+
|
| 45 |
+
// Display options
|
| 46 |
+
const [speedPriority, setSpeedPriorityState] = useState(false);
|
| 47 |
+
const [showResponseTime, setShowResponseTime] = useState(false);
|
| 48 |
+
const [showChatStats, setShowChatStats] = useState(false);
|
| 49 |
+
|
| 50 |
+
// Auth + rate limit
|
| 51 |
+
const [auth, setAuth] = useState(null);
|
| 52 |
+
const [dailyLimit, setDailyLimit] = useState(30);
|
| 53 |
+
|
| 54 |
+
// Conversation state
|
| 55 |
const [messages, setMessages] = useState([]);
|
| 56 |
const [systemMessages, setSystemMessages] = useState([]);
|
| 57 |
const [isRunning, setIsRunning] = useState(false);
|
| 58 |
const [statusText, setStatusText] = useState('');
|
| 59 |
const [sessionId, setSessionId] = useState(null);
|
| 60 |
+
const [sessionParticipants, setSessionParticipants] = useState([]);
|
| 61 |
+
const [pause, setPause] = useState(null);
|
| 62 |
+
|
| 63 |
+
// Modals
|
| 64 |
+
const [expertModalOpen, setExpertModalOpen] = useState(false);
|
| 65 |
+
const [expertEditing, setExpertEditing] = useState(null);
|
| 66 |
+
const [tableData, setTableData] = useState(null);
|
| 67 |
+
const [tableOpen, setTableOpen] = useState(false);
|
| 68 |
+
|
|
|
|
| 69 |
const abortRef = useRef(null);
|
|
|
|
| 70 |
|
| 71 |
+
// ─── Apply theme ────────────────────────────────────────────────
|
| 72 |
useEffect(() => {
|
| 73 |
document.documentElement.setAttribute('data-theme', theme);
|
| 74 |
+
storage.setTheme(theme);
|
| 75 |
}, [theme]);
|
| 76 |
|
| 77 |
+
// ─── Load catalogs ──────────────────────────────────────────────
|
|
|
|
| 78 |
useEffect(() => {
|
| 79 |
+
fetchModels().then(d => {
|
| 80 |
+
setProviders(d.providers || []);
|
| 81 |
+
setNeonModels(d.neon_models || []);
|
| 82 |
+
}).catch(err => console.error('Failed to load models:', err));
|
| 83 |
+
fetchPersonas().then(setCatalog).catch(err => console.error('Failed to load personas:', err));
|
| 84 |
+
fetchDemoQuestions().then(d => setDemoQuestions(d.questions || []))
|
| 85 |
+
.catch(err => console.error('Failed to load demo questions:', err));
|
| 86 |
+
getOrchestrator().then(d => {
|
| 87 |
+
// Only sync if user hasn't explicitly chosen one (localStorage wins)
|
| 88 |
+
if (!persisted.orchestrator_model_id && d?.model_id) {
|
| 89 |
+
setOrchestratorModelState(d.model_id);
|
| 90 |
+
}
|
| 91 |
+
}).catch(() => {});
|
| 92 |
+
getSpeedPriority().then(d => setSpeedPriorityState(!!d.enabled)).catch(() => {});
|
| 93 |
getAuthStatus().then(setAuth).catch(() => {});
|
| 94 |
+
getRateLimitStatus().then(d => {
|
| 95 |
+
if (d?.daily_limit) setDailyLimit(d.daily_limit);
|
| 96 |
+
}).catch(() => {});
|
| 97 |
+
}, [persisted.orchestrator_model_id]);
|
| 98 |
|
| 99 |
+
// ─── Build a flat list of all models for pickers ────────────────
|
| 100 |
const allModelsFlat = useMemo(() => {
|
| 101 |
const list = [];
|
| 102 |
for (const p of providers) {
|
|
|
|
| 117 |
return list;
|
| 118 |
}, [providers, neonModels]);
|
| 119 |
|
| 120 |
+
// ─── Active participants resolved from selectedIds ──────────────
|
| 121 |
+
const allCatalogParticipants = useMemo(() => {
|
| 122 |
+
const map = {};
|
| 123 |
+
for (const p of (catalog.neon || [])) map[p.participant_id] = p;
|
| 124 |
+
for (const p of (catalog.extra || [])) map[p.participant_id] = p;
|
| 125 |
+
for (const p of (expertPersonas || [])) map[p.participant_id] = p;
|
| 126 |
+
return map;
|
| 127 |
+
}, [catalog, expertPersonas]);
|
| 128 |
+
|
| 129 |
+
const selectedParticipants = useMemo(() => {
|
| 130 |
+
return selectedIds
|
| 131 |
+
.map(id => allCatalogParticipants[id])
|
| 132 |
+
.filter(Boolean);
|
| 133 |
+
}, [selectedIds, allCatalogParticipants]);
|
| 134 |
+
|
| 135 |
+
const enabledSelectedCount = useMemo(() => {
|
| 136 |
+
return selectedParticipants.filter(p => enabledMap[p.participant_id] !== false).length;
|
| 137 |
+
}, [selectedParticipants, enabledMap]);
|
| 138 |
+
|
| 139 |
+
// ─── Persistence ────────────────────────────────────────────────
|
| 140 |
+
useEffect(() => { storage.setExpertPersonas(expertPersonas); }, [expertPersonas]);
|
| 141 |
+
useEffect(() => { storage.setParticipantsSelected(selectedIds); }, [selectedIds]);
|
| 142 |
+
useEffect(() => { storage.setParticipantsEnabled(enabledMap); }, [enabledMap]);
|
| 143 |
+
useEffect(() => { storage.setModelAssignments(modelAssignments); }, [modelAssignments]);
|
| 144 |
+
useEffect(() => { storage.setOrchestratorModelId(orchestratorModel); }, [orchestratorModel]);
|
| 145 |
+
useEffect(() => { storage.setSummarizerModelId(summarizerModel); }, [summarizerModel]);
|
| 146 |
+
useEffect(() => { storage.setMaxParticipants(maxParticipants); }, [maxParticipants]);
|
| 147 |
+
|
| 148 |
+
// ─── Settings handlers ──────────────────────────────────────────
|
| 149 |
const handleOrchestratorChange = useCallback(async (modelId) => {
|
| 150 |
try {
|
| 151 |
await setOrchestrator(modelId || '');
|
| 152 |
+
setOrchestratorModelState(modelId || null);
|
| 153 |
} catch (err) {
|
| 154 |
console.error('Failed to set orchestrator:', err);
|
| 155 |
}
|
| 156 |
}, []);
|
| 157 |
+
const handleSummarizerChange = useCallback((modelId) => {
|
| 158 |
+
setSummarizerModelState(modelId || null);
|
|
|
|
|
|
|
| 159 |
}, []);
|
|
|
|
| 160 |
const handleSpeedPriorityChange = useCallback(async (enabled) => {
|
| 161 |
try {
|
| 162 |
await setSpeedPriority(enabled);
|
| 163 |
setSpeedPriorityState(enabled);
|
| 164 |
+
} catch (err) { console.error('Failed to set speed priority:', err); }
|
| 165 |
+
}, []);
|
| 166 |
+
const handleMaxParticipantsChange = useCallback((n) => {
|
| 167 |
+
const clamped = Math.max(3, Math.min(9, n));
|
| 168 |
+
setMaxParticipants(clamped);
|
| 169 |
+
if (selectedIds.length > clamped) {
|
| 170 |
+
setSelectedIds(prev => prev.slice(0, clamped));
|
| 171 |
}
|
| 172 |
+
}, [selectedIds]);
|
| 173 |
+
const handleModelAssignmentChange = useCallback((participantId, modelId) => {
|
| 174 |
+
setModelAssignments(prev => {
|
| 175 |
+
const next = { ...prev };
|
| 176 |
+
if (modelId) next[participantId] = modelId;
|
| 177 |
+
else delete next[participantId];
|
| 178 |
+
return next;
|
| 179 |
+
});
|
| 180 |
+
}, []);
|
| 181 |
+
|
| 182 |
+
// ─── Participant ops ────────────────────────────────────────────
|
| 183 |
+
const handleToggleParticipant = useCallback((participant, kind) => {
|
| 184 |
+
const id = participant.participant_id;
|
| 185 |
+
setSelectedIds(prev => {
|
| 186 |
+
if (prev.includes(id)) {
|
| 187 |
+
// Deselect entirely
|
| 188 |
+
setEnabledMap(em => {
|
| 189 |
+
const next = { ...em };
|
| 190 |
+
delete next[id];
|
| 191 |
+
return next;
|
| 192 |
+
});
|
| 193 |
+
return prev.filter(x => x !== id);
|
| 194 |
+
}
|
| 195 |
+
if (prev.length >= maxParticipants) return prev;
|
| 196 |
+
setEnabledMap(em => ({ ...em, [id]: true }));
|
| 197 |
+
return [...prev, id];
|
| 198 |
+
});
|
| 199 |
+
}, [maxParticipants]);
|
| 200 |
+
|
| 201 |
+
const handleSidebarToggleEnabled = useCallback((participantId, enabled) => {
|
| 202 |
+
setEnabledMap(em => ({ ...em, [participantId]: enabled }));
|
| 203 |
+
}, []);
|
| 204 |
+
|
| 205 |
+
const handleSidebarRemove = useCallback((participantId) => {
|
| 206 |
+
setSelectedIds(prev => prev.filter(x => x !== participantId));
|
| 207 |
+
setEnabledMap(em => {
|
| 208 |
+
const next = { ...em };
|
| 209 |
+
delete next[participantId];
|
| 210 |
+
return next;
|
| 211 |
+
});
|
| 212 |
}, []);
|
| 213 |
|
| 214 |
+
// ─── Expert persona ops ─────────────────────────────────────────
|
| 215 |
+
const handleOpenExpertModal = useCallback((personaOrNull) => {
|
| 216 |
+
setExpertEditing(personaOrNull);
|
| 217 |
+
setExpertModalOpen(true);
|
| 218 |
+
}, []);
|
| 219 |
+
const handleSaveExpert = useCallback((persona) => {
|
| 220 |
+
setExpertPersonas(prev => {
|
| 221 |
+
const idx = prev.findIndex(p => p.participant_id === persona.participant_id);
|
| 222 |
+
if (idx === -1) return [...prev, persona];
|
| 223 |
+
const next = [...prev];
|
| 224 |
+
next[idx] = persona;
|
| 225 |
+
return next;
|
| 226 |
+
});
|
| 227 |
+
setExpertModalOpen(false);
|
| 228 |
+
setExpertEditing(null);
|
| 229 |
+
}, []);
|
| 230 |
+
const handleDeleteExpert = useCallback((id) => {
|
| 231 |
+
setExpertPersonas(prev => prev.filter(p => p.participant_id !== id));
|
| 232 |
+
setSelectedIds(prev => prev.filter(x => x !== id));
|
| 233 |
+
setEnabledMap(em => { const n = { ...em }; delete n[id]; return n; });
|
| 234 |
+
setExpertModalOpen(false);
|
| 235 |
+
setExpertEditing(null);
|
| 236 |
+
}, []);
|
| 237 |
+
|
| 238 |
+
// ─── Downloads ──────────────────────────────────────────────────
|
| 239 |
+
const downloadFile = useCallback((filename, content, mime = 'text/plain;charset=utf-8') => {
|
| 240 |
+
const blob = new Blob([content], { type: mime });
|
| 241 |
const url = URL.createObjectURL(blob);
|
| 242 |
const a = document.createElement('a');
|
| 243 |
a.href = url;
|
|
|
|
| 245 |
a.click();
|
| 246 |
URL.revokeObjectURL(url);
|
| 247 |
}, []);
|
|
|
|
| 248 |
const handleDownloadTxt = useCallback(async () => {
|
| 249 |
if (!sessionId) return;
|
| 250 |
try {
|
| 251 |
+
const r = await exportChat(sessionId, 'txt');
|
| 252 |
+
downloadFile(r.filename, r.content);
|
| 253 |
} catch (err) { console.error('Export failed:', err); }
|
| 254 |
}, [sessionId, downloadFile]);
|
|
|
|
| 255 |
const handleDownloadMd = useCallback(async () => {
|
| 256 |
if (!sessionId) return;
|
| 257 |
try {
|
| 258 |
+
const r = await exportChat(sessionId, 'md');
|
| 259 |
+
downloadFile(r.filename, r.content);
|
| 260 |
} catch (err) { console.error('Export failed:', err); }
|
| 261 |
}, [sessionId, downloadFile]);
|
| 262 |
+
const handleDownloadCsvTable = useCallback(async () => {
|
| 263 |
+
if (!sessionId) return;
|
| 264 |
+
try {
|
| 265 |
+
const r = await exportChat(sessionId, 'csv-table');
|
| 266 |
+
downloadFile(r.filename, r.content, 'text/csv;charset=utf-8');
|
| 267 |
+
} catch (err) { console.error('CSV export failed:', err); }
|
| 268 |
+
}, [sessionId, downloadFile]);
|
| 269 |
const handleDownloadApiLog = useCallback(async () => {
|
| 270 |
if (!sessionId) return;
|
| 271 |
try {
|
| 272 |
+
const r = await exportApiLog(sessionId);
|
| 273 |
+
downloadFile('api_log.json', JSON.stringify(r, null, 2), 'application/json');
|
| 274 |
} catch (err) { console.error('API log export failed:', err); }
|
| 275 |
}, [sessionId, downloadFile]);
|
| 276 |
|
| 277 |
+
// ─── Table view ─────────────────────────────────────────────────
|
| 278 |
+
const handleShowTableView = useCallback(async () => {
|
| 279 |
+
if (!sessionId) return;
|
| 280 |
+
try {
|
| 281 |
+
const data = await fetchTableView(sessionId);
|
| 282 |
+
setTableData(data);
|
| 283 |
+
setTableOpen(true);
|
| 284 |
+
} catch (err) { console.error('Table fetch failed:', err); }
|
| 285 |
+
}, [sessionId]);
|
| 286 |
+
|
| 287 |
+
// ─── Build start payload ────────────────────────────────────────
|
| 288 |
+
const buildStartPayload = useCallback((theQuestion) => {
|
| 289 |
+
const enabledParticipants = selectedParticipants.filter(
|
| 290 |
+
p => enabledMap[p.participant_id] !== false,
|
| 291 |
+
);
|
| 292 |
+
const participants = enabledParticipants.map(p => ({
|
| 293 |
+
participant_id: p.participant_id,
|
| 294 |
+
kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon'
|
| 295 |
+
: (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')),
|
| 296 |
+
name: p.name,
|
| 297 |
+
role_prompt: p.role_prompt || null,
|
| 298 |
+
model_id_override: modelAssignments[p.participant_id] || null,
|
| 299 |
+
}));
|
| 300 |
+
const expert_payload = enabledParticipants
|
| 301 |
+
.filter(p => (p.kind || '').startsWith('expert'))
|
| 302 |
+
.map(p => ({
|
| 303 |
+
participant_id: p.participant_id,
|
| 304 |
+
name: p.name,
|
| 305 |
+
model_id: modelAssignments[p.participant_id] || p.model_id,
|
| 306 |
+
role_prompt: p.role_prompt,
|
| 307 |
+
}));
|
| 308 |
+
return {
|
| 309 |
+
question: theQuestion,
|
| 310 |
+
participants,
|
| 311 |
+
expert_personas: expert_payload,
|
| 312 |
+
model_assignments: modelAssignments,
|
| 313 |
+
orchestrator_model_id: orchestratorModel,
|
| 314 |
+
summarizer_model_id: summarizerModel,
|
| 315 |
+
max_participants: maxParticipants,
|
| 316 |
+
};
|
| 317 |
+
}, [selectedParticipants, enabledMap, modelAssignments, orchestratorModel, summarizerModel, maxParticipants]);
|
| 318 |
+
|
| 319 |
+
// ─── Stop / continue ────────────────────────────────────────────
|
| 320 |
const handleStop = useCallback(() => {
|
| 321 |
+
if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; }
|
|
|
|
|
|
|
|
|
|
| 322 |
setIsRunning(false);
|
|
|
|
| 323 |
setStatusText('');
|
| 324 |
+
setPause(null);
|
| 325 |
setSystemMessages(prev => [...prev, { text: 'Chat stopped by user.' }]);
|
| 326 |
}, []);
|
| 327 |
+
const handleContinuePause = useCallback(async (reason) => {
|
| 328 |
+
if (!sessionId) return;
|
| 329 |
+
try {
|
| 330 |
+
await continueChat(sessionId, reason);
|
| 331 |
+
setPause(null);
|
| 332 |
+
} catch (err) { console.error('Continue failed:', err); }
|
| 333 |
+
}, [sessionId]);
|
| 334 |
|
| 335 |
+
// ─── Start chat ─────────────────────────────────────────────────
|
| 336 |
+
const handleStart = useCallback(async (theQuestion) => {
|
| 337 |
+
if (!theQuestion || !theQuestion.trim()) return;
|
| 338 |
+
if (enabledSelectedCount < 2) return;
|
| 339 |
|
| 340 |
const controller = new AbortController();
|
| 341 |
abortRef.current = controller;
|
|
|
|
| 342 |
setIsRunning(true);
|
|
|
|
| 343 |
setMessages([]);
|
| 344 |
setSystemMessages([]);
|
| 345 |
+
setStatusText('Starting conversation...');
|
| 346 |
+
setSessionId(null);
|
| 347 |
+
setSessionParticipants([]);
|
| 348 |
+
setPause(null);
|
| 349 |
|
| 350 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
await startChat(
|
| 352 |
+
buildStartPayload(theQuestion),
|
| 353 |
{
|
| 354 |
+
onSession: (data) => {
|
| 355 |
+
setSessionId(data.session_id);
|
| 356 |
+
setSessionParticipants(data.participants || []);
|
| 357 |
+
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
onMessage: (data) => {
|
| 359 |
setMessages(prev => [...prev, data]);
|
| 360 |
setStatusText('Conversation in progress...');
|
| 361 |
},
|
| 362 |
+
onOrchestrator: (data) => {
|
| 363 |
+
// Orchestrator events with kind == "status" but no text are
|
| 364 |
+
// status banners; bubble them into a message-style entry so
|
| 365 |
+
// they render with the orchestrator pill.
|
| 366 |
+
if (data && data.text) {
|
| 367 |
+
setMessages(prev => [...prev, { ...data, role: 'orchestrator' }]);
|
| 368 |
+
} else if (data?.message) {
|
| 369 |
+
setStatusText(data.message);
|
| 370 |
+
}
|
| 371 |
+
},
|
| 372 |
+
onStatus: (data) => setStatusText(data.message || ''),
|
| 373 |
onSystem: (data) => {
|
| 374 |
setSystemMessages(prev => [...prev, data]);
|
| 375 |
if (data.text === 'End of Chat') {
|
|
|
|
| 376 |
setStatusText('');
|
| 377 |
}
|
| 378 |
},
|
|
|
|
| 379 |
onError: (data) => {
|
| 380 |
setStatusText('');
|
| 381 |
setSystemMessages(prev => [...prev, { text: `Error: ${data.message}` }]);
|
| 382 |
},
|
| 383 |
+
onParticipantError: (data) => {
|
| 384 |
+
setSystemMessages(prev => [...prev, {
|
| 385 |
+
text: `${data.name || 'A participant'} couldn't respond this turn.`,
|
| 386 |
+
}]);
|
| 387 |
+
},
|
| 388 |
+
onFailsafePause: (data) => {
|
| 389 |
+
setPause({ reason: 'messages', ...data });
|
| 390 |
+
},
|
| 391 |
+
onOrchestratorCapPause: (data) => {
|
| 392 |
+
setPause({ reason: 'orchestrator', ...data });
|
| 393 |
+
},
|
| 394 |
onDone: () => {
|
| 395 |
setIsRunning(false);
|
| 396 |
setStatusText('');
|
|
|
|
| 404 |
const isRateLimit = err.message && err.message.includes('Daily conversation limit');
|
| 405 |
setSystemMessages(prev => [...prev, {
|
| 406 |
text: isRateLimit
|
| 407 |
+
? `Daily conversation limit reached (${dailyLimit}/day). Sign in with HuggingFace for unlimited access.`
|
| 408 |
: `Error: ${err.message}`,
|
| 409 |
}]);
|
| 410 |
} finally {
|
|
|
|
| 412 |
abortRef.current = null;
|
| 413 |
getAuthStatus().then(setAuth).catch(() => {});
|
| 414 |
}
|
| 415 |
+
}, [buildStartPayload, enabledSelectedCount, dailyLimit]);
|
| 416 |
+
|
| 417 |
+
const handleStartRandom = useCallback(() => {
|
| 418 |
+
if (demoQuestions.length === 0) {
|
| 419 |
+
setSystemMessages(prev => [...prev, { text: 'No demo questions available.' }]);
|
| 420 |
+
return;
|
| 421 |
+
}
|
| 422 |
+
const q = pickRandom(demoQuestions);
|
| 423 |
+
handleStart(q.text);
|
| 424 |
+
}, [demoQuestions, handleStart]);
|
| 425 |
+
|
| 426 |
+
const startDisabled = isRunning || enabledSelectedCount < 2;
|
| 427 |
+
const startDisabledReason = enabledSelectedCount < 2
|
| 428 |
+
? 'Add at least 2 active participants to start.'
|
| 429 |
+
: '';
|
| 430 |
|
| 431 |
return (
|
| 432 |
<div className="app">
|
| 433 |
+
<Header
|
| 434 |
+
theme={theme}
|
| 435 |
+
onToggleTheme={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
|
| 436 |
+
auth={auth}
|
| 437 |
+
dailyLimit={dailyLimit}
|
| 438 |
+
catalog={catalog}
|
| 439 |
+
expertPersonas={expertPersonas}
|
| 440 |
+
selectedIds={selectedIds}
|
| 441 |
+
maxParticipants={maxParticipants}
|
| 442 |
+
onToggleParticipant={handleToggleParticipant}
|
| 443 |
+
onOpenExpertModal={handleOpenExpertModal}
|
| 444 |
+
|
| 445 |
+
allModels={allModelsFlat}
|
| 446 |
+
orchestratorModel={orchestratorModel}
|
| 447 |
+
onOrchestratorChange={handleOrchestratorChange}
|
| 448 |
+
summarizerModel={summarizerModel}
|
| 449 |
+
onSummarizerChange={handleSummarizerChange}
|
| 450 |
+
speedPriority={speedPriority}
|
| 451 |
+
onSpeedPriorityChange={handleSpeedPriorityChange}
|
| 452 |
+
showResponseTime={showResponseTime}
|
| 453 |
+
onShowResponseTimeChange={setShowResponseTime}
|
| 454 |
+
showChatStats={showChatStats}
|
| 455 |
+
onShowChatStatsChange={setShowChatStats}
|
| 456 |
+
onMaxParticipantsChange={handleMaxParticipantsChange}
|
| 457 |
+
participants={selectedParticipants}
|
| 458 |
+
modelAssignments={modelAssignments}
|
| 459 |
+
onModelAssignmentChange={handleModelAssignmentChange}
|
| 460 |
+
onShowTableView={handleShowTableView}
|
| 461 |
+
onDownloadChatTxt={handleDownloadTxt}
|
| 462 |
+
onDownloadChatMd={handleDownloadMd}
|
| 463 |
+
onDownloadCsvTable={handleDownloadCsvTable}
|
| 464 |
+
onDownloadApiLog={handleDownloadApiLog}
|
| 465 |
+
hasApiLog={!!sessionId}
|
| 466 |
+
hasChat={messages.length > 0}
|
| 467 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
|
| 469 |
<main className="app-main">
|
| 470 |
+
<ParticipantSidebar
|
| 471 |
+
participants={selectedParticipants}
|
| 472 |
+
enabledMap={enabledMap}
|
| 473 |
+
modelAssignments={modelAssignments}
|
| 474 |
+
onToggleEnabled={handleSidebarToggleEnabled}
|
| 475 |
+
onRemove={handleSidebarRemove}
|
| 476 |
/>
|
|
|
|
| 477 |
<div className="content">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
<ChatControls
|
| 479 |
+
onStartRandom={handleStartRandom}
|
| 480 |
+
onStartTyped={handleStart}
|
| 481 |
onStop={handleStop}
|
| 482 |
+
disabled={startDisabled}
|
| 483 |
isRunning={isRunning}
|
| 484 |
+
disabledReason={startDisabledReason}
|
| 485 |
/>
|
|
|
|
| 486 |
<ChatArea
|
| 487 |
messages={messages}
|
| 488 |
systemMessages={systemMessages}
|
| 489 |
isRunning={isRunning}
|
| 490 |
statusText={statusText}
|
| 491 |
+
pause={pause}
|
| 492 |
+
onContinuePause={handleContinuePause}
|
| 493 |
+
participants={sessionParticipants.length > 0 ? sessionParticipants : selectedParticipants}
|
| 494 |
showResponseTime={showResponseTime}
|
| 495 |
showChatStats={showChatStats}
|
| 496 |
/>
|
|
|
|
| 501 |
<a href="https://www.neon.ai/contact" target="_blank" rel="noopener noreferrer">Patents and licensing</a>
|
| 502 |
</footer>
|
| 503 |
|
| 504 |
+
<ExpertPersonaModal
|
| 505 |
+
isOpen={expertModalOpen}
|
| 506 |
+
initial={expertEditing}
|
| 507 |
+
onClose={() => { setExpertModalOpen(false); setExpertEditing(null); }}
|
| 508 |
+
onSave={handleSaveExpert}
|
| 509 |
+
onDelete={handleDeleteExpert}
|
| 510 |
+
allModels={allModelsFlat}
|
| 511 |
+
defaultModelId={orchestratorModel || ''}
|
| 512 |
+
/>
|
| 513 |
+
{tableOpen && (
|
| 514 |
+
<ChatTableView
|
| 515 |
+
data={tableData}
|
| 516 |
+
onClose={() => setTableOpen(false)}
|
| 517 |
+
onExportCsv={handleDownloadCsvTable}
|
| 518 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
)}
|
| 520 |
</div>
|
| 521 |
);
|
|
@@ -1,8 +1,9 @@
|
|
| 1 |
import React from 'react';
|
| 2 |
import { LogIn, LogOut, User } from 'lucide-react';
|
| 3 |
|
| 4 |
-
export default function AuthBadge({ auth }) {
|
| 5 |
if (!auth) return null;
|
|
|
|
| 6 |
|
| 7 |
if (auth.logged_in) {
|
| 8 |
return (
|
|
@@ -23,7 +24,7 @@ export default function AuthBadge({ auth }) {
|
|
| 23 |
return (
|
| 24 |
<div className="auth-badge">
|
| 25 |
{auth.remaining_conversations >= 0 && (
|
| 26 |
-
<span className="auth-remaining">{auth.remaining_conversations}/
|
| 27 |
)}
|
| 28 |
<a href="/oauth/huggingface/login" className="auth-link auth-login">
|
| 29 |
<LogIn size={13} /> Sign in
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
import { LogIn, LogOut, User } from 'lucide-react';
|
| 3 |
|
| 4 |
+
export default function AuthBadge({ auth, dailyLimit }) {
|
| 5 |
if (!auth) return null;
|
| 6 |
+
const cap = dailyLimit || 30;
|
| 7 |
|
| 8 |
if (auth.logged_in) {
|
| 9 |
return (
|
|
|
|
| 24 |
return (
|
| 25 |
<div className="auth-badge">
|
| 26 |
{auth.remaining_conversations >= 0 && (
|
| 27 |
+
<span className="auth-remaining">{auth.remaining_conversations}/{cap} chats</span>
|
| 28 |
)}
|
| 29 |
<a href="/oauth/huggingface/login" className="auth-link auth-login">
|
| 30 |
<LogIn size={13} /> Sign in
|
|
@@ -1,35 +1,73 @@
|
|
| 1 |
import React, { useEffect, useRef, useMemo } from 'react';
|
| 2 |
import MessageBubble from './MessageBubble';
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
const endRef = useRef(null);
|
| 6 |
|
| 7 |
useEffect(() => {
|
| 8 |
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 9 |
-
}, [messages, systemMessages]);
|
| 10 |
|
| 11 |
-
const
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
const stats = useMemo(() => {
|
| 15 |
-
if (!chatEnded || messages.length === 0) return null;
|
| 16 |
-
const
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
| 18 |
}, [chatEnded, messages]);
|
| 19 |
|
| 20 |
return (
|
| 21 |
<div className="chat-area">
|
| 22 |
{!hasContent && !isRunning && (
|
| 23 |
<div className="chat-empty">
|
| 24 |
-
|
| 25 |
</div>
|
| 26 |
)}
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
<div
|
| 34 |
key={`sys-${i}`}
|
| 35 |
className={`system-message ${sys.text === 'End of Chat' ? 'end-of-chat' : ''}`}
|
|
@@ -37,20 +75,18 @@ export default function ChatArea({ messages, systemMessages, isRunning, statusTe
|
|
| 37 |
{sys.text}
|
| 38 |
</div>
|
| 39 |
))}
|
| 40 |
-
|
| 41 |
{showChatStats && stats && (
|
| 42 |
<div className="chat-stats">
|
| 43 |
-
{stats.count} messages · {stats.totalTime}s total generation time
|
| 44 |
</div>
|
| 45 |
)}
|
| 46 |
-
|
| 47 |
{isRunning && statusText && (
|
| 48 |
<div className="status-bar">
|
| 49 |
<div className="spinner" />
|
| 50 |
<span>{statusText}</span>
|
| 51 |
</div>
|
| 52 |
)}
|
| 53 |
-
|
| 54 |
<div ref={endRef} />
|
| 55 |
</div>
|
| 56 |
);
|
|
|
|
| 1 |
import React, { useEffect, useRef, useMemo } from 'react';
|
| 2 |
import MessageBubble from './MessageBubble';
|
| 3 |
+
import OrchestratorMessage from './OrchestratorMessage';
|
| 4 |
+
import FailsafePauseBanner from './FailsafePauseBanner';
|
| 5 |
|
| 6 |
+
/**
|
| 7 |
+
* Renders the conversation: a mix of participant bubbles, orchestrator
|
| 8 |
+
* status banners, and the failsafe-pause continue control. Participant
|
| 9 |
+
* coloring is derived from each participant's index in the active
|
| 10 |
+
* roster, so colors are stable per-participant for the whole chat.
|
| 11 |
+
*/
|
| 12 |
+
export default function ChatArea({
|
| 13 |
+
messages,
|
| 14 |
+
systemMessages,
|
| 15 |
+
isRunning,
|
| 16 |
+
statusText,
|
| 17 |
+
pause,
|
| 18 |
+
onContinuePause,
|
| 19 |
+
participants,
|
| 20 |
+
showResponseTime,
|
| 21 |
+
showChatStats,
|
| 22 |
+
}) {
|
| 23 |
const endRef = useRef(null);
|
| 24 |
|
| 25 |
useEffect(() => {
|
| 26 |
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 27 |
+
}, [messages, systemMessages, statusText, pause]);
|
| 28 |
|
| 29 |
+
const speakerIdxFor = useMemo(() => {
|
| 30 |
+
const map = {};
|
| 31 |
+
(participants || []).forEach((p, i) => {
|
| 32 |
+
map[p.participant_id] = i;
|
| 33 |
+
});
|
| 34 |
+
return map;
|
| 35 |
+
}, [participants]);
|
| 36 |
+
|
| 37 |
+
const hasContent = (messages?.length || 0) + (systemMessages?.length || 0) > 0;
|
| 38 |
+
const chatEnded = (systemMessages || []).some(s => s.text === 'End of Chat');
|
| 39 |
|
| 40 |
const stats = useMemo(() => {
|
| 41 |
+
if (!chatEnded || !messages || messages.length === 0) return null;
|
| 42 |
+
const participantMsgs = messages.filter(m => m.role !== 'orchestrator');
|
| 43 |
+
const totalTime = participantMsgs.reduce(
|
| 44 |
+
(sum, m) => sum + (m.elapsed_seconds || 0), 0,
|
| 45 |
+
);
|
| 46 |
+
return { count: participantMsgs.length, totalTime: totalTime.toFixed(1) };
|
| 47 |
}, [chatEnded, messages]);
|
| 48 |
|
| 49 |
return (
|
| 50 |
<div className="chat-area">
|
| 51 |
{!hasContent && !isRunning && (
|
| 52 |
<div className="chat-empty">
|
| 53 |
+
Add at least 2 participants from the header dropdown, then start a conversation.
|
| 54 |
</div>
|
| 55 |
)}
|
| 56 |
+
{(messages || []).map((msg, i) => {
|
| 57 |
+
if (msg.role === 'orchestrator') {
|
| 58 |
+
return <OrchestratorMessage key={i} message={msg} />;
|
| 59 |
+
}
|
| 60 |
+
const idx = speakerIdxFor[msg.speaker_id] ?? i;
|
| 61 |
+
return (
|
| 62 |
+
<MessageBubble
|
| 63 |
+
key={i}
|
| 64 |
+
message={msg}
|
| 65 |
+
idx={idx}
|
| 66 |
+
showResponseTime={showResponseTime}
|
| 67 |
+
/>
|
| 68 |
+
);
|
| 69 |
+
})}
|
| 70 |
+
{(systemMessages || []).map((sys, i) => (
|
| 71 |
<div
|
| 72 |
key={`sys-${i}`}
|
| 73 |
className={`system-message ${sys.text === 'End of Chat' ? 'end-of-chat' : ''}`}
|
|
|
|
| 75 |
{sys.text}
|
| 76 |
</div>
|
| 77 |
))}
|
|
|
|
| 78 |
{showChatStats && stats && (
|
| 79 |
<div className="chat-stats">
|
| 80 |
+
{stats.count} participant messages · {stats.totalTime}s total generation time
|
| 81 |
</div>
|
| 82 |
)}
|
| 83 |
+
<FailsafePauseBanner pause={pause} onContinue={onContinuePause} />
|
| 84 |
{isRunning && statusText && (
|
| 85 |
<div className="status-bar">
|
| 86 |
<div className="spinner" />
|
| 87 |
<span>{statusText}</span>
|
| 88 |
</div>
|
| 89 |
)}
|
|
|
|
| 90 |
<div ref={endRef} />
|
| 91 |
</div>
|
| 92 |
);
|
|
@@ -1,21 +1,29 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import { Play, Shuffle, Square } from 'lucide-react';
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
return (
|
| 16 |
<div className="chat-controls">
|
| 17 |
{isRunning ? (
|
| 18 |
-
<button className="btn-stop" onClick={onStop}
|
| 19 |
<Square size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 20 |
Stop Chat
|
| 21 |
</button>
|
|
@@ -23,28 +31,29 @@ export default function ChatControls({ onStart, onStop, disabled, isRunning }) {
|
|
| 23 |
<>
|
| 24 |
<button
|
| 25 |
className="btn-primary"
|
| 26 |
-
onClick={handleAutoStart}
|
| 27 |
disabled={disabled}
|
| 28 |
-
|
|
|
|
| 29 |
>
|
| 30 |
<Shuffle size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 31 |
Let Them Start
|
| 32 |
</button>
|
| 33 |
<input
|
| 34 |
type="text"
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
onChange={e => setStarterText(e.target.value)}
|
| 38 |
disabled={disabled}
|
|
|
|
| 39 |
onKeyDown={e => {
|
| 40 |
-
if (e.key === 'Enter' && !disabled
|
|
|
|
|
|
|
| 41 |
}}
|
| 42 |
/>
|
| 43 |
<button
|
| 44 |
className="btn-primary"
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
title="Start with your message"
|
| 48 |
>
|
| 49 |
<Play size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 50 |
Start Chat With My Prompt
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import { Play, Shuffle, Square } from 'lucide-react';
|
| 3 |
|
| 4 |
+
/**
|
| 5 |
+
* "Let Them Start" picks a random demo question from the bank.
|
| 6 |
+
* "Start Chat With My Prompt" uses the typed-in question.
|
| 7 |
+
*
|
| 8 |
+
* Both require >=2 enabled participants - that's enforced upstream and
|
| 9 |
+
* mirrored here as a disabled state.
|
| 10 |
+
*/
|
| 11 |
+
export default function ChatControls({
|
| 12 |
+
onStartRandom,
|
| 13 |
+
onStartTyped,
|
| 14 |
+
onStop,
|
| 15 |
+
disabled,
|
| 16 |
+
isRunning,
|
| 17 |
+
disabledReason,
|
| 18 |
+
}) {
|
| 19 |
+
const [text, setText] = useState('');
|
| 20 |
+
const placeholder = disabled
|
| 21 |
+
? (disabledReason || 'Add participants to start a conversation')
|
| 22 |
+
: 'Or type your own question for the group...';
|
| 23 |
return (
|
| 24 |
<div className="chat-controls">
|
| 25 |
{isRunning ? (
|
| 26 |
+
<button className="btn-stop" onClick={onStop}>
|
| 27 |
<Square size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 28 |
Stop Chat
|
| 29 |
</button>
|
|
|
|
| 31 |
<>
|
| 32 |
<button
|
| 33 |
className="btn-primary"
|
|
|
|
| 34 |
disabled={disabled}
|
| 35 |
+
onClick={() => onStartRandom()}
|
| 36 |
+
title="Pick a random demo question and start"
|
| 37 |
>
|
| 38 |
<Shuffle size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 39 |
Let Them Start
|
| 40 |
</button>
|
| 41 |
<input
|
| 42 |
type="text"
|
| 43 |
+
value={text}
|
| 44 |
+
placeholder={placeholder}
|
|
|
|
| 45 |
disabled={disabled}
|
| 46 |
+
onChange={e => setText(e.target.value)}
|
| 47 |
onKeyDown={e => {
|
| 48 |
+
if (e.key === 'Enter' && !disabled && text.trim()) {
|
| 49 |
+
onStartTyped(text.trim());
|
| 50 |
+
}
|
| 51 |
}}
|
| 52 |
/>
|
| 53 |
<button
|
| 54 |
className="btn-primary"
|
| 55 |
+
disabled={disabled || !text.trim()}
|
| 56 |
+
onClick={() => onStartTyped(text.trim())}
|
|
|
|
| 57 |
>
|
| 58 |
<Play size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 59 |
Start Chat With My Prompt
|
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Phase-by-phase summary of the conversation rendered as a table.
|
| 5 |
+
* Question on top, final group opinion under it, one row per
|
| 6 |
+
* participant with first / contribution / revised / final columns.
|
| 7 |
+
*
|
| 8 |
+
* Driven by the GET /api/chat/{id}/table endpoint - so this component
|
| 9 |
+
* just renders the JSON response.
|
| 10 |
+
*/
|
| 11 |
+
export default function ChatTableView({ data, onClose, onExportCsv }) {
|
| 12 |
+
if (!data) return null;
|
| 13 |
+
return (
|
| 14 |
+
<div className="ccai-table-overlay">
|
| 15 |
+
<div className="ccai-table-card">
|
| 16 |
+
<div className="ccai-table-header">
|
| 17 |
+
<h2>Conversation Summary Table</h2>
|
| 18 |
+
<div className="ccai-tab-spacer" />
|
| 19 |
+
<button
|
| 20 |
+
className="btn-sm btn-outline"
|
| 21 |
+
onClick={onExportCsv}
|
| 22 |
+
title="Export this table as CSV"
|
| 23 |
+
>
|
| 24 |
+
Export CSV
|
| 25 |
+
</button>
|
| 26 |
+
<button className="modal-close" onClick={onClose}>×</button>
|
| 27 |
+
</div>
|
| 28 |
+
<div className="ccai-table-body">
|
| 29 |
+
<div className="ccai-table-question">
|
| 30 |
+
<strong>Question:</strong>
|
| 31 |
+
<div>{data.question}</div>
|
| 32 |
+
</div>
|
| 33 |
+
<div className="ccai-table-final">
|
| 34 |
+
<strong>Final group opinion:</strong>
|
| 35 |
+
<div>
|
| 36 |
+
{data.final_report ? data.final_report : (
|
| 37 |
+
<em>No final report yet.</em>
|
| 38 |
+
)}
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
<div className="ccai-table-scroll">
|
| 42 |
+
<table className="ccai-table">
|
| 43 |
+
<thead>
|
| 44 |
+
<tr>
|
| 45 |
+
<th>Participant</th>
|
| 46 |
+
<th>First opinion</th>
|
| 47 |
+
<th>Conversation contribution</th>
|
| 48 |
+
<th>Revised opinion</th>
|
| 49 |
+
<th>Final opinion</th>
|
| 50 |
+
</tr>
|
| 51 |
+
</thead>
|
| 52 |
+
<tbody>
|
| 53 |
+
{(data.rows || []).map(row => (
|
| 54 |
+
<tr key={row.participant_id}>
|
| 55 |
+
<td className="ccai-table-name">
|
| 56 |
+
<div>{row.name}</div>
|
| 57 |
+
<small>{row.model_display}</small>
|
| 58 |
+
</td>
|
| 59 |
+
<td>{row.first_opinion}</td>
|
| 60 |
+
<td>{row.contribution_summary || <em>(no summary)</em>}</td>
|
| 61 |
+
<td>{row.revised_opinion}</td>
|
| 62 |
+
<td>{row.final_opinion}</td>
|
| 63 |
+
</tr>
|
| 64 |
+
))}
|
| 65 |
+
</tbody>
|
| 66 |
+
</table>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
);
|
| 72 |
+
}
|
|
@@ -1,43 +1,60 @@
|
|
| 1 |
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
| 2 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
export default function DevMenu({
|
| 5 |
allModels,
|
| 6 |
orchestratorModel,
|
| 7 |
onOrchestratorChange,
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
roleStyle,
|
| 11 |
-
onRoleStyleChange,
|
| 12 |
speedPriority,
|
| 13 |
onSpeedPriorityChange,
|
| 14 |
showResponseTime,
|
| 15 |
onShowResponseTimeChange,
|
| 16 |
showChatStats,
|
| 17 |
onShowChatStatsChange,
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
onDownloadChatTxt,
|
| 22 |
onDownloadChatMd,
|
|
|
|
|
|
|
| 23 |
hasApiLog,
|
| 24 |
hasChat,
|
| 25 |
}) {
|
| 26 |
const [open, setOpen] = useState(false);
|
| 27 |
-
const [
|
| 28 |
const [q, setQ] = useState('');
|
| 29 |
const wrapRef = useRef(null);
|
| 30 |
const searchRef = useRef(null);
|
| 31 |
|
| 32 |
useEffect(() => {
|
| 33 |
-
if (
|
| 34 |
-
}, [
|
| 35 |
|
| 36 |
useEffect(() => {
|
| 37 |
function handleClickOutside(e) {
|
| 38 |
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
| 39 |
setOpen(false);
|
| 40 |
-
|
| 41 |
setQ('');
|
| 42 |
}
|
| 43 |
}
|
|
@@ -54,11 +71,21 @@ export default function DevMenu({
|
|
| 54 |
});
|
| 55 |
}, [allModels, q]);
|
| 56 |
|
| 57 |
-
const
|
| 58 |
-
if (!
|
| 59 |
-
const m = allModels.find(
|
| 60 |
-
return m ? m.name :
|
| 61 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
return (
|
| 64 |
<div className="dev-wrap" ref={wrapRef}>
|
|
@@ -69,17 +96,84 @@ export default function DevMenu({
|
|
| 69 |
<button className="btn-sm btn-outline" disabled={!hasChat} onClick={onDownloadChatMd}>
|
| 70 |
<Download size={14} /> .md
|
| 71 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</div>
|
| 73 |
|
| 74 |
<div className="dev-dropdown-header">
|
| 75 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
<Settings2 size={16} />
|
| 77 |
</button>
|
| 78 |
{open && (
|
| 79 |
<div className="dev-panel">
|
| 80 |
-
<button onClick={() => {
|
| 81 |
-
Orchestrator model… <
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
<div className="dev-panel-divider" />
|
| 84 |
<div className="dev-panel-label">Response priority</div>
|
| 85 |
<button
|
|
@@ -96,38 +190,7 @@ export default function DevMenu({
|
|
| 96 |
{speedPriority ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 97 |
Prioritize conversation speed
|
| 98 |
</button>
|
| 99 |
-
|
| 100 |
-
<div className="dev-panel-label">Expert persona input</div>
|
| 101 |
-
<button
|
| 102 |
-
className={`dev-panel-choice ${personaMode === 'structured' ? 'dev-panel-choice-active' : ''}`}
|
| 103 |
-
onClick={() => onPersonaModeChange('structured')}
|
| 104 |
-
>
|
| 105 |
-
{personaMode === 'structured' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 106 |
-
Structured expert persona input
|
| 107 |
-
</button>
|
| 108 |
-
<button
|
| 109 |
-
className={`dev-panel-choice ${personaMode === 'freeform' ? 'dev-panel-choice-active' : ''}`}
|
| 110 |
-
onClick={() => onPersonaModeChange('freeform')}
|
| 111 |
-
>
|
| 112 |
-
{personaMode === 'freeform' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 113 |
-
Freeform expert persona input
|
| 114 |
-
</button>
|
| 115 |
-
<div className="dev-panel-divider" />
|
| 116 |
-
<div className="dev-panel-label">Role generation</div>
|
| 117 |
-
<button
|
| 118 |
-
className={`dev-panel-choice ${roleStyle === 'ai_completed' ? 'dev-panel-choice-active' : ''}`}
|
| 119 |
-
onClick={() => onRoleStyleChange('ai_completed')}
|
| 120 |
-
>
|
| 121 |
-
{roleStyle === 'ai_completed' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 122 |
-
AI completed roles
|
| 123 |
-
</button>
|
| 124 |
-
<button
|
| 125 |
-
className={`dev-panel-choice ${roleStyle === 'exact' ? 'dev-panel-choice-active' : ''}`}
|
| 126 |
-
onClick={() => onRoleStyleChange('exact')}
|
| 127 |
-
>
|
| 128 |
-
{roleStyle === 'exact' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 129 |
-
Exact user roles
|
| 130 |
-
</button>
|
| 131 |
<div className="dev-panel-divider" />
|
| 132 |
<div className="dev-panel-label">Display options</div>
|
| 133 |
<button
|
|
@@ -144,10 +207,7 @@ export default function DevMenu({
|
|
| 144 |
{showChatStats ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 145 |
Chat stats after end
|
| 146 |
</button>
|
| 147 |
-
|
| 148 |
-
<FileText size={14} className="dev-check-icon" />
|
| 149 |
-
View role prompts
|
| 150 |
-
</button>
|
| 151 |
<div className="dev-panel-divider" />
|
| 152 |
<button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatTxt(); setOpen(false); }}>
|
| 153 |
Download chat as .txt
|
|
@@ -155,17 +215,32 @@ export default function DevMenu({
|
|
| 155 |
<button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatMd(); setOpen(false); }}>
|
| 156 |
Download chat as .md
|
| 157 |
</button>
|
|
|
|
|
|
|
|
|
|
| 158 |
<button disabled={!hasApiLog} onClick={() => { onDownloadApiLog(); setOpen(false); }}>
|
| 159 |
Download full API history
|
| 160 |
</button>
|
| 161 |
</div>
|
| 162 |
)}
|
| 163 |
|
| 164 |
-
{open &&
|
| 165 |
<div className="dev-sub-panel">
|
| 166 |
<div className="dev-sub-header">
|
| 167 |
-
<span className="dev-sub-title">
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
</div>
|
| 170 |
<div className="dev-sub-search">
|
| 171 |
<Search size={14} className="dev-sub-search-icon" />
|
|
@@ -178,26 +253,56 @@ export default function DevMenu({
|
|
| 178 |
/>
|
| 179 |
</div>
|
| 180 |
<ul className="dev-sub-list">
|
| 181 |
-
|
| 182 |
-
<
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
<button
|
| 193 |
-
className={`dev-sub-item ${
|
| 194 |
-
onClick={() => {
|
| 195 |
>
|
| 196 |
-
<strong>
|
| 197 |
-
<span className="dev-sub-provider">
|
| 198 |
</button>
|
| 199 |
</li>
|
| 200 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
</ul>
|
| 202 |
</div>
|
| 203 |
)}
|
|
|
|
| 1 |
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
ChevronRight, Download, Settings2, Search,
|
| 4 |
+
Square, CheckSquare, UserPlus, Table2,
|
| 5 |
+
} from 'lucide-react';
|
| 6 |
|
| 7 |
+
/**
|
| 8 |
+
* Settings menu, structurally identical to LLMChats3 but populated with
|
| 9 |
+
* CCAI controls:
|
| 10 |
+
* - Orchestrator model (searchable)
|
| 11 |
+
* - Summarizer model (searchable, with "Same as Orchestrator" default)
|
| 12 |
+
* - Max participants (3-9, default 5)
|
| 13 |
+
* - Per-participant model assignments
|
| 14 |
+
* - "Create Expert Persona..." shortcut
|
| 15 |
+
* - Display options + downloads (txt / md / csv-table / api-log)
|
| 16 |
+
*/
|
| 17 |
export default function DevMenu({
|
| 18 |
allModels,
|
| 19 |
orchestratorModel,
|
| 20 |
onOrchestratorChange,
|
| 21 |
+
summarizerModel,
|
| 22 |
+
onSummarizerChange,
|
|
|
|
|
|
|
| 23 |
speedPriority,
|
| 24 |
onSpeedPriorityChange,
|
| 25 |
showResponseTime,
|
| 26 |
onShowResponseTimeChange,
|
| 27 |
showChatStats,
|
| 28 |
onShowChatStatsChange,
|
| 29 |
+
maxParticipants,
|
| 30 |
+
onMaxParticipantsChange,
|
| 31 |
+
participants,
|
| 32 |
+
modelAssignments,
|
| 33 |
+
onModelAssignmentChange,
|
| 34 |
+
onOpenExpertModal,
|
| 35 |
+
onShowTableView,
|
| 36 |
onDownloadChatTxt,
|
| 37 |
onDownloadChatMd,
|
| 38 |
+
onDownloadCsvTable,
|
| 39 |
+
onDownloadApiLog,
|
| 40 |
hasApiLog,
|
| 41 |
hasChat,
|
| 42 |
}) {
|
| 43 |
const [open, setOpen] = useState(false);
|
| 44 |
+
const [activeSub, setActiveSub] = useState(null); // null | "orch" | "sum" | <participant_id>
|
| 45 |
const [q, setQ] = useState('');
|
| 46 |
const wrapRef = useRef(null);
|
| 47 |
const searchRef = useRef(null);
|
| 48 |
|
| 49 |
useEffect(() => {
|
| 50 |
+
if (activeSub && searchRef.current) searchRef.current.focus();
|
| 51 |
+
}, [activeSub]);
|
| 52 |
|
| 53 |
useEffect(() => {
|
| 54 |
function handleClickOutside(e) {
|
| 55 |
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
| 56 |
setOpen(false);
|
| 57 |
+
setActiveSub(null);
|
| 58 |
setQ('');
|
| 59 |
}
|
| 60 |
}
|
|
|
|
| 71 |
});
|
| 72 |
}, [allModels, q]);
|
| 73 |
|
| 74 |
+
const nameForModel = (id) => {
|
| 75 |
+
if (!id) return null;
|
| 76 |
+
const m = allModels.find(x => x.id === id);
|
| 77 |
+
return m ? m.name : id;
|
| 78 |
+
};
|
| 79 |
+
const orchName = nameForModel(orchestratorModel) || 'Default (backend)';
|
| 80 |
+
const sumName = summarizerModel
|
| 81 |
+
? (nameForModel(summarizerModel) || summarizerModel)
|
| 82 |
+
: 'Same as Orchestrator';
|
| 83 |
+
|
| 84 |
+
const onPickForSubject = (id, subject) => {
|
| 85 |
+
if (subject === 'orch') onOrchestratorChange(id);
|
| 86 |
+
else if (subject === 'sum') onSummarizerChange(id);
|
| 87 |
+
else if (subject) onModelAssignmentChange(subject, id);
|
| 88 |
+
};
|
| 89 |
|
| 90 |
return (
|
| 91 |
<div className="dev-wrap" ref={wrapRef}>
|
|
|
|
| 96 |
<button className="btn-sm btn-outline" disabled={!hasChat} onClick={onDownloadChatMd}>
|
| 97 |
<Download size={14} /> .md
|
| 98 |
</button>
|
| 99 |
+
<button
|
| 100 |
+
className="btn-sm btn-outline"
|
| 101 |
+
disabled={!hasChat}
|
| 102 |
+
onClick={onShowTableView}
|
| 103 |
+
title="Open the conversation summary table"
|
| 104 |
+
>
|
| 105 |
+
<Table2 size={14} /> Table
|
| 106 |
+
</button>
|
| 107 |
+
<button
|
| 108 |
+
className="btn-sm btn-outline"
|
| 109 |
+
disabled={!hasChat}
|
| 110 |
+
onClick={onDownloadCsvTable}
|
| 111 |
+
title="Download the table view as CSV"
|
| 112 |
+
>
|
| 113 |
+
<Download size={14} /> .csv
|
| 114 |
+
</button>
|
| 115 |
</div>
|
| 116 |
|
| 117 |
<div className="dev-dropdown-header">
|
| 118 |
+
<button
|
| 119 |
+
className="icon-btn"
|
| 120 |
+
onClick={() => { setOpen(o => !o); setActiveSub(null); setQ(''); }}
|
| 121 |
+
title="Settings"
|
| 122 |
+
>
|
| 123 |
<Settings2 size={16} />
|
| 124 |
</button>
|
| 125 |
{open && (
|
| 126 |
<div className="dev-panel">
|
| 127 |
+
<button onClick={() => { setActiveSub(s => s === 'orch' ? null : 'orch'); setQ(''); }}>
|
| 128 |
+
Orchestrator model… <span className="dev-panel-hint">{orchName}</span>
|
| 129 |
+
<ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
|
| 130 |
+
</button>
|
| 131 |
+
<button onClick={() => { setActiveSub(s => s === 'sum' ? null : 'sum'); setQ(''); }}>
|
| 132 |
+
Summarizer model… <span className="dev-panel-hint">{sumName}</span>
|
| 133 |
+
<ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
|
| 134 |
</button>
|
| 135 |
+
|
| 136 |
+
<div className="dev-panel-divider" />
|
| 137 |
+
<div className="dev-panel-label">Max participants ({maxParticipants})</div>
|
| 138 |
+
<div className="ccai-stepper-row">
|
| 139 |
+
<button
|
| 140 |
+
className="btn-sm btn-outline ccai-stepper-btn"
|
| 141 |
+
disabled={maxParticipants <= 3}
|
| 142 |
+
onClick={() => onMaxParticipantsChange(Math.max(3, maxParticipants - 1))}
|
| 143 |
+
>−</button>
|
| 144 |
+
<div className="ccai-stepper-val">{maxParticipants}</div>
|
| 145 |
+
<button
|
| 146 |
+
className="btn-sm btn-outline ccai-stepper-btn"
|
| 147 |
+
disabled={maxParticipants >= 9}
|
| 148 |
+
onClick={() => onMaxParticipantsChange(Math.min(9, maxParticipants + 1))}
|
| 149 |
+
>+</button>
|
| 150 |
+
<span className="dev-panel-hint">3-9</span>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<div className="dev-panel-divider" />
|
| 154 |
+
<div className="dev-panel-label">Participants</div>
|
| 155 |
+
<button onClick={() => { onOpenExpertModal(null); setOpen(false); }}>
|
| 156 |
+
<UserPlus size={14} className="dev-check-icon" />
|
| 157 |
+
Create Expert Persona…
|
| 158 |
+
</button>
|
| 159 |
+
{(participants || []).length > 0 && (
|
| 160 |
+
<div className="dev-panel-label">Per-participant model</div>
|
| 161 |
+
)}
|
| 162 |
+
{(participants || []).map(p => {
|
| 163 |
+
const assigned = modelAssignments[p.participant_id];
|
| 164 |
+
const labelName = assigned ? nameForModel(assigned)
|
| 165 |
+
: (p.default_model_id ? nameForModel(p.default_model_id) : '(default)');
|
| 166 |
+
return (
|
| 167 |
+
<button
|
| 168 |
+
key={p.participant_id}
|
| 169 |
+
onClick={() => { setActiveSub(s => s === p.participant_id ? null : p.participant_id); setQ(''); }}
|
| 170 |
+
>
|
| 171 |
+
{p.name}<span className="dev-panel-hint"> {labelName}</span>
|
| 172 |
+
<ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
|
| 173 |
+
</button>
|
| 174 |
+
);
|
| 175 |
+
})}
|
| 176 |
+
|
| 177 |
<div className="dev-panel-divider" />
|
| 178 |
<div className="dev-panel-label">Response priority</div>
|
| 179 |
<button
|
|
|
|
| 190 |
{speedPriority ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 191 |
Prioritize conversation speed
|
| 192 |
</button>
|
| 193 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
<div className="dev-panel-divider" />
|
| 195 |
<div className="dev-panel-label">Display options</div>
|
| 196 |
<button
|
|
|
|
| 207 |
{showChatStats ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 208 |
Chat stats after end
|
| 209 |
</button>
|
| 210 |
+
|
|
|
|
|
|
|
|
|
|
| 211 |
<div className="dev-panel-divider" />
|
| 212 |
<button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatTxt(); setOpen(false); }}>
|
| 213 |
Download chat as .txt
|
|
|
|
| 215 |
<button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatMd(); setOpen(false); }}>
|
| 216 |
Download chat as .md
|
| 217 |
</button>
|
| 218 |
+
<button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadCsvTable(); setOpen(false); }}>
|
| 219 |
+
Download summary table as .csv
|
| 220 |
+
</button>
|
| 221 |
<button disabled={!hasApiLog} onClick={() => { onDownloadApiLog(); setOpen(false); }}>
|
| 222 |
Download full API history
|
| 223 |
</button>
|
| 224 |
</div>
|
| 225 |
)}
|
| 226 |
|
| 227 |
+
{open && activeSub && (
|
| 228 |
<div className="dev-sub-panel">
|
| 229 |
<div className="dev-sub-header">
|
| 230 |
+
<span className="dev-sub-title">
|
| 231 |
+
{activeSub === 'orch' && 'Orchestrator model'}
|
| 232 |
+
{activeSub === 'sum' && 'Summarizer model'}
|
| 233 |
+
{activeSub !== 'orch' && activeSub !== 'sum' && (
|
| 234 |
+
<>Model for {participants.find(p => p.participant_id === activeSub)?.name || activeSub}</>
|
| 235 |
+
)}
|
| 236 |
+
</span>
|
| 237 |
+
<span className="dev-sub-current">
|
| 238 |
+
{activeSub === 'orch' && orchName}
|
| 239 |
+
{activeSub === 'sum' && sumName}
|
| 240 |
+
{activeSub !== 'orch' && activeSub !== 'sum' && (
|
| 241 |
+
nameForModel(modelAssignments[activeSub]) || '(default)'
|
| 242 |
+
)}
|
| 243 |
+
</span>
|
| 244 |
</div>
|
| 245 |
<div className="dev-sub-search">
|
| 246 |
<Search size={14} className="dev-sub-search-icon" />
|
|
|
|
| 253 |
/>
|
| 254 |
</div>
|
| 255 |
<ul className="dev-sub-list">
|
| 256 |
+
{activeSub === 'sum' && (
|
| 257 |
+
<li>
|
| 258 |
+
<button
|
| 259 |
+
className={`dev-sub-item ${!summarizerModel ? 'dev-sub-item-active' : ''}`}
|
| 260 |
+
onClick={() => { onPickForSubject(null, 'sum'); setActiveSub(null); setQ(''); }}
|
| 261 |
+
>
|
| 262 |
+
<strong>Same as Orchestrator (default)</strong>
|
| 263 |
+
<span className="dev-sub-provider">Use whichever model is currently the orchestrator</span>
|
| 264 |
+
</button>
|
| 265 |
+
</li>
|
| 266 |
+
)}
|
| 267 |
+
{activeSub === 'orch' && (
|
| 268 |
+
<li>
|
| 269 |
+
<button
|
| 270 |
+
className={`dev-sub-item ${!orchestratorModel ? 'dev-sub-item-active' : ''}`}
|
| 271 |
+
onClick={() => { onPickForSubject(null, 'orch'); setActiveSub(null); setQ(''); }}
|
| 272 |
+
>
|
| 273 |
+
<strong>Default (backend)</strong>
|
| 274 |
+
<span className="dev-sub-provider">Use server default</span>
|
| 275 |
+
</button>
|
| 276 |
+
</li>
|
| 277 |
+
)}
|
| 278 |
+
{activeSub !== 'orch' && activeSub !== 'sum' && (
|
| 279 |
+
<li>
|
| 280 |
<button
|
| 281 |
+
className={`dev-sub-item ${!modelAssignments[activeSub] ? 'dev-sub-item-active' : ''}`}
|
| 282 |
+
onClick={() => { onPickForSubject(null, activeSub); setActiveSub(null); setQ(''); }}
|
| 283 |
>
|
| 284 |
+
<strong>(persona default)</strong>
|
| 285 |
+
<span className="dev-sub-provider">Use the persona's bundled or saved default</span>
|
| 286 |
</button>
|
| 287 |
</li>
|
| 288 |
+
)}
|
| 289 |
+
{filtered.map(m => {
|
| 290 |
+
const currentId =
|
| 291 |
+
activeSub === 'orch' ? orchestratorModel
|
| 292 |
+
: activeSub === 'sum' ? summarizerModel
|
| 293 |
+
: modelAssignments[activeSub];
|
| 294 |
+
return (
|
| 295 |
+
<li key={m.id}>
|
| 296 |
+
<button
|
| 297 |
+
className={`dev-sub-item ${currentId === m.id ? 'dev-sub-item-active' : ''}`}
|
| 298 |
+
onClick={() => { onPickForSubject(m.id, activeSub); setActiveSub(null); setQ(''); }}
|
| 299 |
+
>
|
| 300 |
+
<strong>{m.name}</strong>
|
| 301 |
+
<span className="dev-sub-provider">{m.provider}</span>
|
| 302 |
+
</button>
|
| 303 |
+
</li>
|
| 304 |
+
);
|
| 305 |
+
})}
|
| 306 |
</ul>
|
| 307 |
</div>
|
| 308 |
)}
|
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { Upload, Save, Trash2 } from 'lucide-react';
|
| 3 |
+
import { generateRole, generateRoleFreeform } from '../utils/api';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Single source of truth for creating Expert Personas. Replaces the
|
| 7 |
+
* inline PersonaAccordion + DevMenu persona-mode/role-style settings
|
| 8 |
+
* from LLMChats3 - those choices now live inside this modal.
|
| 9 |
+
*
|
| 10 |
+
* Tabs: Structured | Freeform
|
| 11 |
+
* Role-style toggle: AI-completed | Exact (matches LLMChats3 semantics)
|
| 12 |
+
* Freeform tab supports a file upload for writing samples.
|
| 13 |
+
*/
|
| 14 |
+
export default function ExpertPersonaModal({
|
| 15 |
+
isOpen,
|
| 16 |
+
initial, // existing persona to edit, or null for new
|
| 17 |
+
onClose,
|
| 18 |
+
onSave,
|
| 19 |
+
onDelete,
|
| 20 |
+
allModels, // [{ id, name, provider }]
|
| 21 |
+
defaultModelId,
|
| 22 |
+
}) {
|
| 23 |
+
const [activeTab, setActiveTab] = useState('freeform');
|
| 24 |
+
const [name, setName] = useState('');
|
| 25 |
+
const [profile, setProfile] = useState('');
|
| 26 |
+
const [identity, setIdentity] = useState('');
|
| 27 |
+
const [samples, setSamples] = useState('');
|
| 28 |
+
const [freeText, setFreeText] = useState('');
|
| 29 |
+
const [roleStyle, setRoleStyle] = useState('ai_completed');
|
| 30 |
+
const [modelId, setModelId] = useState(defaultModelId || '');
|
| 31 |
+
const [generatedPrompt, setGeneratedPrompt] = useState('');
|
| 32 |
+
const [busy, setBusy] = useState(false);
|
| 33 |
+
const [error, setError] = useState('');
|
| 34 |
+
const fileInputRef = useRef(null);
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (!isOpen) return;
|
| 38 |
+
if (initial) {
|
| 39 |
+
setActiveTab(initial.input_mode || 'freeform');
|
| 40 |
+
setName(initial.name || '');
|
| 41 |
+
setProfile(initial.profile || '');
|
| 42 |
+
setIdentity(initial.identity || '');
|
| 43 |
+
setSamples(initial.samples || '');
|
| 44 |
+
setFreeText(initial.freeform || '');
|
| 45 |
+
setRoleStyle(initial.role_style || 'ai_completed');
|
| 46 |
+
setModelId(initial.model_id || defaultModelId || '');
|
| 47 |
+
setGeneratedPrompt(initial.role_prompt || '');
|
| 48 |
+
} else {
|
| 49 |
+
setActiveTab('freeform');
|
| 50 |
+
setName('');
|
| 51 |
+
setProfile('');
|
| 52 |
+
setIdentity('');
|
| 53 |
+
setSamples('');
|
| 54 |
+
setFreeText('');
|
| 55 |
+
setRoleStyle('ai_completed');
|
| 56 |
+
setModelId(defaultModelId || '');
|
| 57 |
+
setGeneratedPrompt('');
|
| 58 |
+
}
|
| 59 |
+
setError('');
|
| 60 |
+
}, [isOpen, initial, defaultModelId]);
|
| 61 |
+
|
| 62 |
+
if (!isOpen) return null;
|
| 63 |
+
|
| 64 |
+
const handleFileUpload = async (e) => {
|
| 65 |
+
const file = e.target.files?.[0];
|
| 66 |
+
if (!file) return;
|
| 67 |
+
try {
|
| 68 |
+
const text = await file.text();
|
| 69 |
+
setFreeText(prev => (prev ? prev + '\n\n' : '') + text);
|
| 70 |
+
} catch (err) {
|
| 71 |
+
setError(`File read failed: ${err.message}`);
|
| 72 |
+
}
|
| 73 |
+
e.target.value = '';
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
const handleGenerate = async () => {
|
| 77 |
+
setError('');
|
| 78 |
+
if (!modelId) {
|
| 79 |
+
setError('Pick a model to power this persona first.');
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
if (!name.trim()) {
|
| 83 |
+
setError('Persona needs a name.');
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
setBusy(true);
|
| 87 |
+
try {
|
| 88 |
+
const result = activeTab === 'freeform'
|
| 89 |
+
? await generateRoleFreeform({
|
| 90 |
+
model_id: modelId,
|
| 91 |
+
name: name.trim(),
|
| 92 |
+
text: freeText,
|
| 93 |
+
role_style: roleStyle,
|
| 94 |
+
})
|
| 95 |
+
: await generateRole({
|
| 96 |
+
model_id: modelId,
|
| 97 |
+
name: name.trim(),
|
| 98 |
+
profile,
|
| 99 |
+
identity,
|
| 100 |
+
samples,
|
| 101 |
+
role_style: roleStyle,
|
| 102 |
+
});
|
| 103 |
+
setGeneratedPrompt(result.role_prompt || '');
|
| 104 |
+
} catch (err) {
|
| 105 |
+
setError(err.message || String(err));
|
| 106 |
+
} finally {
|
| 107 |
+
setBusy(false);
|
| 108 |
+
}
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const canSave = name.trim() && modelId && generatedPrompt.trim();
|
| 112 |
+
const handleSave = () => {
|
| 113 |
+
if (!canSave) return;
|
| 114 |
+
onSave({
|
| 115 |
+
participant_id: initial?.participant_id || `expert_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
| 116 |
+
kind: 'expert',
|
| 117 |
+
name: name.trim(),
|
| 118 |
+
model_id: modelId,
|
| 119 |
+
role_prompt: generatedPrompt.trim(),
|
| 120 |
+
input_mode: activeTab,
|
| 121 |
+
role_style: roleStyle,
|
| 122 |
+
profile,
|
| 123 |
+
identity,
|
| 124 |
+
samples,
|
| 125 |
+
freeform: freeText,
|
| 126 |
+
});
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
return (
|
| 130 |
+
<div className="modal-overlay" onClick={onClose}>
|
| 131 |
+
<div
|
| 132 |
+
className="modal-content ccai-expert-modal"
|
| 133 |
+
onClick={e => e.stopPropagation()}
|
| 134 |
+
>
|
| 135 |
+
<div className="modal-header">
|
| 136 |
+
<h2>{initial ? `Edit Expert Persona: ${initial.name}` : 'Create Expert Persona'}</h2>
|
| 137 |
+
<button className="modal-close" onClick={onClose}>×</button>
|
| 138 |
+
</div>
|
| 139 |
+
<div className="modal-body">
|
| 140 |
+
<div className="ccai-expert-row">
|
| 141 |
+
<div className="ccai-expert-field">
|
| 142 |
+
<label>Name</label>
|
| 143 |
+
<input
|
| 144 |
+
type="text"
|
| 145 |
+
value={name}
|
| 146 |
+
placeholder="e.g. Dr. Patel - Pediatric Cardiologist"
|
| 147 |
+
onChange={e => setName(e.target.value)}
|
| 148 |
+
/>
|
| 149 |
+
</div>
|
| 150 |
+
<div className="ccai-expert-field">
|
| 151 |
+
<label>Powered by LLM</label>
|
| 152 |
+
<select
|
| 153 |
+
value={modelId}
|
| 154 |
+
onChange={e => setModelId(e.target.value)}
|
| 155 |
+
>
|
| 156 |
+
<option value="">Pick a model...</option>
|
| 157 |
+
{(allModels || []).map(m => (
|
| 158 |
+
<option key={m.id} value={m.id}>
|
| 159 |
+
{m.name} {m.provider ? `(${m.provider})` : ''}
|
| 160 |
+
</option>
|
| 161 |
+
))}
|
| 162 |
+
</select>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<div className="ccai-tab-row">
|
| 167 |
+
<button
|
| 168 |
+
className={'ccai-tab-btn' + (activeTab === 'freeform' ? ' ccai-tab-btn-active' : '')}
|
| 169 |
+
onClick={() => setActiveTab('freeform')}
|
| 170 |
+
>
|
| 171 |
+
Freeform
|
| 172 |
+
</button>
|
| 173 |
+
<button
|
| 174 |
+
className={'ccai-tab-btn' + (activeTab === 'structured' ? ' ccai-tab-btn-active' : '')}
|
| 175 |
+
onClick={() => setActiveTab('structured')}
|
| 176 |
+
>
|
| 177 |
+
Structured
|
| 178 |
+
</button>
|
| 179 |
+
<div className="ccai-tab-spacer" />
|
| 180 |
+
<label className="ccai-role-style">
|
| 181 |
+
<input
|
| 182 |
+
type="radio"
|
| 183 |
+
name="role-style"
|
| 184 |
+
checked={roleStyle === 'ai_completed'}
|
| 185 |
+
onChange={() => setRoleStyle('ai_completed')}
|
| 186 |
+
/>
|
| 187 |
+
AI-completed
|
| 188 |
+
</label>
|
| 189 |
+
<label className="ccai-role-style">
|
| 190 |
+
<input
|
| 191 |
+
type="radio"
|
| 192 |
+
name="role-style"
|
| 193 |
+
checked={roleStyle === 'exact'}
|
| 194 |
+
onChange={() => setRoleStyle('exact')}
|
| 195 |
+
/>
|
| 196 |
+
Exact (no inferring)
|
| 197 |
+
</label>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
{activeTab === 'freeform' ? (
|
| 201 |
+
<div className="ccai-expert-freeform">
|
| 202 |
+
<div className="freeform-label-row">
|
| 203 |
+
<label>Persona description, writing samples, anything you want the LLM to know:</label>
|
| 204 |
+
<button
|
| 205 |
+
className="btn-sm btn-outline upload-btn"
|
| 206 |
+
onClick={() => fileInputRef.current?.click()}
|
| 207 |
+
>
|
| 208 |
+
<Upload size={12} /> Upload .txt
|
| 209 |
+
</button>
|
| 210 |
+
<input
|
| 211 |
+
type="file"
|
| 212 |
+
accept=".txt,.md"
|
| 213 |
+
ref={fileInputRef}
|
| 214 |
+
onChange={handleFileUpload}
|
| 215 |
+
style={{ display: 'none' }}
|
| 216 |
+
/>
|
| 217 |
+
</div>
|
| 218 |
+
<textarea
|
| 219 |
+
className="freeform-textarea"
|
| 220 |
+
value={freeText}
|
| 221 |
+
placeholder="Drop in any background, transcript, writing samples, biography, etc. Sparse input is fine - the AI-completed mode will fill in plausible details."
|
| 222 |
+
onChange={e => setFreeText(e.target.value)}
|
| 223 |
+
/>
|
| 224 |
+
</div>
|
| 225 |
+
) : (
|
| 226 |
+
<div className="ccai-expert-structured">
|
| 227 |
+
<div className="ccai-expert-field">
|
| 228 |
+
<label>Identity statement</label>
|
| 229 |
+
<input
|
| 230 |
+
type="text"
|
| 231 |
+
value={identity}
|
| 232 |
+
onChange={e => setIdentity(e.target.value)}
|
| 233 |
+
placeholder="One-sentence 'who are you'"
|
| 234 |
+
/>
|
| 235 |
+
</div>
|
| 236 |
+
<div className="ccai-expert-field">
|
| 237 |
+
<label>Profile / background</label>
|
| 238 |
+
<textarea
|
| 239 |
+
value={profile}
|
| 240 |
+
onChange={e => setProfile(e.target.value)}
|
| 241 |
+
rows={3}
|
| 242 |
+
/>
|
| 243 |
+
</div>
|
| 244 |
+
<div className="ccai-expert-field">
|
| 245 |
+
<label>Writing / speech samples</label>
|
| 246 |
+
<textarea
|
| 247 |
+
value={samples}
|
| 248 |
+
onChange={e => setSamples(e.target.value)}
|
| 249 |
+
rows={3}
|
| 250 |
+
/>
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
)}
|
| 254 |
+
|
| 255 |
+
<div className="ccai-expert-actions">
|
| 256 |
+
<button
|
| 257 |
+
className="btn-secondary"
|
| 258 |
+
onClick={handleGenerate}
|
| 259 |
+
disabled={busy || !modelId || !name.trim()}
|
| 260 |
+
>
|
| 261 |
+
{busy ? 'Generating role prompt...' : 'Generate role prompt'}
|
| 262 |
+
</button>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
{generatedPrompt && (
|
| 266 |
+
<div className="ccai-expert-prompt">
|
| 267 |
+
<label>Generated role prompt (editable)</label>
|
| 268 |
+
<textarea
|
| 269 |
+
value={generatedPrompt}
|
| 270 |
+
onChange={e => setGeneratedPrompt(e.target.value)}
|
| 271 |
+
rows={6}
|
| 272 |
+
/>
|
| 273 |
+
</div>
|
| 274 |
+
)}
|
| 275 |
+
|
| 276 |
+
{error && <div className="ccai-expert-error">{error}</div>}
|
| 277 |
+
|
| 278 |
+
<div className="ccai-expert-footer">
|
| 279 |
+
{initial && onDelete && (
|
| 280 |
+
<button
|
| 281 |
+
className="btn-sm ccai-remove-btn"
|
| 282 |
+
onClick={() => onDelete(initial.participant_id)}
|
| 283 |
+
>
|
| 284 |
+
<Trash2 size={12} /> Delete
|
| 285 |
+
</button>
|
| 286 |
+
)}
|
| 287 |
+
<div className="ccai-tab-spacer" />
|
| 288 |
+
<button className="btn-secondary" onClick={onClose}>Cancel</button>
|
| 289 |
+
<button
|
| 290 |
+
className="btn-primary"
|
| 291 |
+
disabled={!canSave}
|
| 292 |
+
onClick={handleSave}
|
| 293 |
+
>
|
| 294 |
+
<Save size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
|
| 295 |
+
{initial ? 'Save changes' : 'Save persona'}
|
| 296 |
+
</button>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
);
|
| 302 |
+
}
|
|
@@ -1,79 +0,0 @@
|
|
| 1 |
-
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
-
import { Download, Settings } from 'lucide-react';
|
| 3 |
-
import { exportChat, exportApiLog } from '../utils/api';
|
| 4 |
-
|
| 5 |
-
export default function ExportBar({ sessionId }) {
|
| 6 |
-
const [devOpen, setDevOpen] = useState(false);
|
| 7 |
-
const dropdownRef = useRef(null);
|
| 8 |
-
|
| 9 |
-
useEffect(() => {
|
| 10 |
-
const handleClickOutside = (e) => {
|
| 11 |
-
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
| 12 |
-
setDevOpen(false);
|
| 13 |
-
}
|
| 14 |
-
};
|
| 15 |
-
document.addEventListener('mousedown', handleClickOutside);
|
| 16 |
-
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 17 |
-
}, []);
|
| 18 |
-
|
| 19 |
-
const downloadFile = (filename, content) => {
|
| 20 |
-
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
| 21 |
-
const url = URL.createObjectURL(blob);
|
| 22 |
-
const a = document.createElement('a');
|
| 23 |
-
a.href = url;
|
| 24 |
-
a.download = filename;
|
| 25 |
-
a.click();
|
| 26 |
-
URL.revokeObjectURL(url);
|
| 27 |
-
};
|
| 28 |
-
|
| 29 |
-
const handleExport = async (fmt) => {
|
| 30 |
-
try {
|
| 31 |
-
const result = await exportChat(sessionId, fmt);
|
| 32 |
-
downloadFile(result.filename, result.content);
|
| 33 |
-
} catch (err) {
|
| 34 |
-
console.error('Export failed:', err);
|
| 35 |
-
}
|
| 36 |
-
};
|
| 37 |
-
|
| 38 |
-
const handleApiLogExport = async () => {
|
| 39 |
-
try {
|
| 40 |
-
const result = await exportApiLog(sessionId);
|
| 41 |
-
downloadFile('api_log.json', JSON.stringify(result, null, 2));
|
| 42 |
-
setDevOpen(false);
|
| 43 |
-
} catch (err) {
|
| 44 |
-
console.error('API log export failed:', err);
|
| 45 |
-
}
|
| 46 |
-
};
|
| 47 |
-
|
| 48 |
-
if (!sessionId) return null;
|
| 49 |
-
|
| 50 |
-
return (
|
| 51 |
-
<div className="export-bar">
|
| 52 |
-
<button className="btn-secondary" onClick={() => handleExport('txt')}>
|
| 53 |
-
<Download size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 54 |
-
Download .txt
|
| 55 |
-
</button>
|
| 56 |
-
<button className="btn-secondary" onClick={() => handleExport('md')}>
|
| 57 |
-
<Download size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 58 |
-
Download .md
|
| 59 |
-
</button>
|
| 60 |
-
|
| 61 |
-
<div className="dev-dropdown" ref={dropdownRef}>
|
| 62 |
-
<button
|
| 63 |
-
className="icon-btn"
|
| 64 |
-
onClick={() => setDevOpen(o => !o)}
|
| 65 |
-
title="Developer Options"
|
| 66 |
-
>
|
| 67 |
-
<Settings size={16} />
|
| 68 |
-
</button>
|
| 69 |
-
{devOpen && (
|
| 70 |
-
<div className="dev-dropdown-menu">
|
| 71 |
-
<button onClick={handleApiLogExport}>
|
| 72 |
-
Download Full API Log
|
| 73 |
-
</button>
|
| 74 |
-
</div>
|
| 75 |
-
)}
|
| 76 |
-
</div>
|
| 77 |
-
</div>
|
| 78 |
-
);
|
| 79 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Play } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Inline banner shown when the orchestrator hits one of the two
|
| 6 |
+
* failsafes (60+20 messages, 100+50 orchestrator calls). User clicks
|
| 7 |
+
* Continue to grant another batch.
|
| 8 |
+
*/
|
| 9 |
+
export default function FailsafePauseBanner({ pause, onContinue }) {
|
| 10 |
+
if (!pause) return null;
|
| 11 |
+
const incLabel = pause.reason === 'messages' ? '+20 messages' : '+50 orchestrator calls';
|
| 12 |
+
const titleLabel = pause.reason === 'messages'
|
| 13 |
+
? 'Conversation paused (message cap)'
|
| 14 |
+
: 'Conversation paused (orchestrator call cap)';
|
| 15 |
+
return (
|
| 16 |
+
<div className="ccai-failsafe-banner">
|
| 17 |
+
<div>
|
| 18 |
+
<div className="ccai-failsafe-title">{titleLabel}</div>
|
| 19 |
+
<div className="ccai-failsafe-text">{pause.message}</div>
|
| 20 |
+
</div>
|
| 21 |
+
<button className="btn-primary" onClick={() => onContinue(pause.reason)}>
|
| 22 |
+
<Play size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 23 |
+
Continue conversation ({incLabel})
|
| 24 |
+
</button>
|
| 25 |
+
</div>
|
| 26 |
+
);
|
| 27 |
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Sun, Moon } from 'lucide-react';
|
| 3 |
+
import AuthBadge from './AuthBadge';
|
| 4 |
+
import ParticipantDropdown from './ParticipantDropdown';
|
| 5 |
+
import DevMenu from './DevMenu';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Header bar: brand on the left; on the right, participant dropdown,
|
| 9 |
+
* settings, exports, table-view toggle, and the rate-limit-aware auth
|
| 10 |
+
* badge.
|
| 11 |
+
*/
|
| 12 |
+
export default function Header({
|
| 13 |
+
theme,
|
| 14 |
+
onToggleTheme,
|
| 15 |
+
auth,
|
| 16 |
+
dailyLimit,
|
| 17 |
+
|
| 18 |
+
catalog,
|
| 19 |
+
expertPersonas,
|
| 20 |
+
selectedIds,
|
| 21 |
+
maxParticipants,
|
| 22 |
+
onToggleParticipant,
|
| 23 |
+
onOpenExpertModal,
|
| 24 |
+
|
| 25 |
+
// dev menu props passed straight through
|
| 26 |
+
...devProps
|
| 27 |
+
}) {
|
| 28 |
+
return (
|
| 29 |
+
<header className="app-header">
|
| 30 |
+
<div className="header-left">
|
| 31 |
+
<a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="header-brand-link">
|
| 32 |
+
<img src="/neon-logo.png" alt="Neon.ai" className="app-logo" />
|
| 33 |
+
</a>
|
| 34 |
+
<h1 className="app-title">
|
| 35 |
+
<a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="app-title-link">
|
| 36 |
+
Neon.ai
|
| 37 |
+
</a> - CCAI Vibe Demo
|
| 38 |
+
</h1>
|
| 39 |
+
</div>
|
| 40 |
+
<div className="header-right">
|
| 41 |
+
<AuthBadge auth={auth} dailyLimit={dailyLimit} />
|
| 42 |
+
<ParticipantDropdown
|
| 43 |
+
catalog={catalog}
|
| 44 |
+
expertPersonas={expertPersonas}
|
| 45 |
+
selectedIds={selectedIds}
|
| 46 |
+
maxParticipants={maxParticipants}
|
| 47 |
+
onToggleParticipant={onToggleParticipant}
|
| 48 |
+
onOpenExpertModal={onOpenExpertModal}
|
| 49 |
+
/>
|
| 50 |
+
<button
|
| 51 |
+
className="icon-btn"
|
| 52 |
+
onClick={onToggleTheme}
|
| 53 |
+
title="Toggle theme"
|
| 54 |
+
>
|
| 55 |
+
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
| 56 |
+
</button>
|
| 57 |
+
<DevMenu
|
| 58 |
+
{...devProps}
|
| 59 |
+
onOpenExpertModal={onOpenExpertModal}
|
| 60 |
+
/>
|
| 61 |
+
</div>
|
| 62 |
+
</header>
|
| 63 |
+
);
|
| 64 |
+
}
|
|
@@ -1,159 +0,0 @@
|
|
| 1 |
-
import React, { useCallback, useState } from 'react';
|
| 2 |
-
import { Cloud, ChevronDown, ChevronRight, User } from 'lucide-react';
|
| 3 |
-
|
| 4 |
-
export default function LLMSelector({ providers, neonModels, selections, onSelectionsChange }) {
|
| 5 |
-
const [openGroups, setOpenGroups] = useState({});
|
| 6 |
-
|
| 7 |
-
const toggleGroup = (key) => {
|
| 8 |
-
setOpenGroups(prev => ({ ...prev, [key]: !prev[key] }));
|
| 9 |
-
};
|
| 10 |
-
|
| 11 |
-
const handleClick = useCallback((modelId) => {
|
| 12 |
-
onSelectionsChange(prev => {
|
| 13 |
-
const isSelected = prev.includes(modelId);
|
| 14 |
-
const isBoth = prev.length === 2 && prev[0] === modelId && prev[1] === modelId;
|
| 15 |
-
|
| 16 |
-
if (isBoth) return [];
|
| 17 |
-
|
| 18 |
-
if (isSelected) return [modelId, modelId];
|
| 19 |
-
|
| 20 |
-
if (prev.length < 2) return [...prev, modelId];
|
| 21 |
-
|
| 22 |
-
return [prev[1], modelId];
|
| 23 |
-
});
|
| 24 |
-
}, [onSelectionsChange]);
|
| 25 |
-
|
| 26 |
-
const getIndicatorClass = (modelId) => {
|
| 27 |
-
const [a, b] = selections;
|
| 28 |
-
if (a === modelId && b === modelId) return 'select-indicator double-selected';
|
| 29 |
-
if (a === modelId) return 'select-indicator selected-a';
|
| 30 |
-
if (b === modelId) return 'select-indicator selected-b';
|
| 31 |
-
return 'select-indicator';
|
| 32 |
-
};
|
| 33 |
-
|
| 34 |
-
const getLabel = (modelId) => {
|
| 35 |
-
const [a, b] = selections;
|
| 36 |
-
if (a === modelId && b === modelId) return 'AB';
|
| 37 |
-
if (a === modelId) return 'A';
|
| 38 |
-
if (b === modelId) return 'B';
|
| 39 |
-
return '';
|
| 40 |
-
};
|
| 41 |
-
|
| 42 |
-
const shortName = (name) => name.split('/').pop() || name;
|
| 43 |
-
|
| 44 |
-
const renderModel = (model) => (
|
| 45 |
-
<button
|
| 46 |
-
key={model.id}
|
| 47 |
-
className="model-btn"
|
| 48 |
-
onClick={() => handleClick(model.id)}
|
| 49 |
-
>
|
| 50 |
-
<div className={getIndicatorClass(model.id)}>
|
| 51 |
-
{getLabel(model.id) && <span className="selection-label">{getLabel(model.id)}</span>}
|
| 52 |
-
</div>
|
| 53 |
-
<span className="model-name">{model.name}</span>
|
| 54 |
-
{model.params && <span className="model-params">{model.params}</span>}
|
| 55 |
-
</button>
|
| 56 |
-
);
|
| 57 |
-
|
| 58 |
-
const renderNeonPersona = (persona) => (
|
| 59 |
-
<button
|
| 60 |
-
key={persona.id}
|
| 61 |
-
className="neon-persona-item"
|
| 62 |
-
onClick={() => handleClick(persona.id)}
|
| 63 |
-
>
|
| 64 |
-
<div className={getIndicatorClass(persona.id)}>
|
| 65 |
-
{getLabel(persona.id) && <span className="selection-label">{getLabel(persona.id)}</span>}
|
| 66 |
-
</div>
|
| 67 |
-
<div className="persona-details">
|
| 68 |
-
<div className="persona-name-row">
|
| 69 |
-
<User size={12} />
|
| 70 |
-
{persona.name}
|
| 71 |
-
</div>
|
| 72 |
-
{persona.systemPrompt && (
|
| 73 |
-
<div className="persona-prompt-preview">
|
| 74 |
-
{persona.systemPrompt.slice(0, 120)}
|
| 75 |
-
{persona.systemPrompt.length > 120 ? '…' : ''}
|
| 76 |
-
</div>
|
| 77 |
-
)}
|
| 78 |
-
{!persona.systemPrompt && (
|
| 79 |
-
<div className="persona-prompt-preview">No system prompt (vanilla)</div>
|
| 80 |
-
)}
|
| 81 |
-
</div>
|
| 82 |
-
</button>
|
| 83 |
-
);
|
| 84 |
-
|
| 85 |
-
return (
|
| 86 |
-
<div className="sidebar">
|
| 87 |
-
<h2 className="sidebar-title">AI Models</h2>
|
| 88 |
-
|
| 89 |
-
{(neonModels || []).length > 0 && (
|
| 90 |
-
<div className="sidebar-section">
|
| 91 |
-
<h3 className="selector-title">
|
| 92 |
-
<img src="/neon-logo.png" alt="" className="selector-title-icon" />
|
| 93 |
-
Neon.ai Models
|
| 94 |
-
</h3>
|
| 95 |
-
<div className="neon-model-list">
|
| 96 |
-
{[...(neonModels || [])].sort((a, b) => shortName(a.name).localeCompare(shortName(b.name))).map(model => {
|
| 97 |
-
const key = `neon-${model.model_id}`;
|
| 98 |
-
const isOpen = !!openGroups[key];
|
| 99 |
-
const activePersonas = (model.personas || []).filter(p => p.enabled !== false);
|
| 100 |
-
return (
|
| 101 |
-
<div key={key} className="neon-model-card">
|
| 102 |
-
<button
|
| 103 |
-
className="neon-model-header"
|
| 104 |
-
onClick={() => toggleGroup(key)}
|
| 105 |
-
>
|
| 106 |
-
<div className="neon-model-info">
|
| 107 |
-
<span className="neon-model-name">{shortName(model.name)}</span>
|
| 108 |
-
{model.version && <span className="neon-model-version">v{model.version}</span>}
|
| 109 |
-
</div>
|
| 110 |
-
<div className="neon-model-meta">
|
| 111 |
-
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
| 112 |
-
</div>
|
| 113 |
-
</button>
|
| 114 |
-
{isOpen && (
|
| 115 |
-
<div className="neon-persona-list">
|
| 116 |
-
{activePersonas.map(persona => renderNeonPersona({
|
| 117 |
-
id: `neon:${model.model_id}:${persona.persona_name}`,
|
| 118 |
-
name: persona.persona_name,
|
| 119 |
-
systemPrompt: persona.system_prompt || '',
|
| 120 |
-
}))}
|
| 121 |
-
</div>
|
| 122 |
-
)}
|
| 123 |
-
</div>
|
| 124 |
-
);
|
| 125 |
-
})}
|
| 126 |
-
</div>
|
| 127 |
-
</div>
|
| 128 |
-
)}
|
| 129 |
-
|
| 130 |
-
{(providers || []).length > 0 && (
|
| 131 |
-
<div className="sidebar-section">
|
| 132 |
-
<h3 className="selector-title">
|
| 133 |
-
<Cloud size={16} />
|
| 134 |
-
Other Models
|
| 135 |
-
</h3>
|
| 136 |
-
{[...(providers || [])].sort((a, b) => a.name.localeCompare(b.name)).map(provider => {
|
| 137 |
-
const key = `prov-${provider.id}`;
|
| 138 |
-
const isOpen = !!openGroups[key];
|
| 139 |
-
return (
|
| 140 |
-
<div key={key} className="provider-group comp-group">
|
| 141 |
-
<button className="provider-accordion-header" onClick={() => toggleGroup(key)}>
|
| 142 |
-
<span className="provider-accordion-title">{provider.name}</span>
|
| 143 |
-
<span className="provider-accordion-meta">
|
| 144 |
-
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
| 145 |
-
</span>
|
| 146 |
-
</button>
|
| 147 |
-
{isOpen && (
|
| 148 |
-
<div className="model-list">
|
| 149 |
-
{provider.models.map(renderModel)}
|
| 150 |
-
</div>
|
| 151 |
-
)}
|
| 152 |
-
</div>
|
| 153 |
-
);
|
| 154 |
-
})}
|
| 155 |
-
</div>
|
| 156 |
-
)}
|
| 157 |
-
</div>
|
| 158 |
-
);
|
| 159 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -2,19 +2,52 @@ import React from 'react';
|
|
| 2 |
import ReactMarkdown from 'react-markdown';
|
| 3 |
import remarkGfm from 'remark-gfm';
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
return (
|
| 12 |
-
<div className=
|
| 13 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 14 |
{initial}
|
| 15 |
</div>
|
| 16 |
-
<div
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
| 19 |
{message.text}
|
| 20 |
</ReactMarkdown>
|
|
|
|
| 2 |
import ReactMarkdown from 'react-markdown';
|
| 3 |
import remarkGfm from 'remark-gfm';
|
| 4 |
|
| 5 |
+
const PALETTE = [
|
| 6 |
+
{ color: '#6366F1', bg: '#EEF2FF' }, // indigo
|
| 7 |
+
{ color: '#059669', bg: '#ECFDF5' }, // emerald
|
| 8 |
+
{ color: '#D97706', bg: '#FFFBEB' }, // amber
|
| 9 |
+
{ color: '#DC2626', bg: '#FEE2E2' }, // red
|
| 10 |
+
{ color: '#0891B2', bg: '#ECFEFF' }, // cyan
|
| 11 |
+
{ color: '#7C3AED', bg: '#F5F3FF' }, // violet
|
| 12 |
+
{ color: '#0D9488', bg: '#F0FDFA' }, // teal
|
| 13 |
+
{ color: '#DB2777', bg: '#FDF2F8' }, // pink
|
| 14 |
+
{ color: '#65A30D', bg: '#F7FEE7' }, // lime
|
| 15 |
+
];
|
| 16 |
+
|
| 17 |
+
function colorForIdx(idx) {
|
| 18 |
+
return PALETTE[idx % PALETTE.length];
|
| 19 |
+
}
|
| 20 |
|
| 21 |
+
/**
|
| 22 |
+
* Generic participant bubble. The CCAI demo can have up to 9 active
|
| 23 |
+
* participants, so we colorize by their index in the active roster
|
| 24 |
+
* rather than the original A/B scheme.
|
| 25 |
+
*/
|
| 26 |
+
export default function MessageBubble({ message, idx, showResponseTime }) {
|
| 27 |
+
const tone = colorForIdx(idx);
|
| 28 |
+
const initial = (message.speaker_name || '?').charAt(0).toUpperCase();
|
| 29 |
+
const elapsed = message.elapsed_seconds;
|
| 30 |
return (
|
| 31 |
+
<div className="message-row ccai-message-row">
|
| 32 |
+
<div
|
| 33 |
+
className="avatar"
|
| 34 |
+
style={{ background: tone.color, borderRadius: '50%' }}
|
| 35 |
+
>
|
| 36 |
{initial}
|
| 37 |
</div>
|
| 38 |
+
<div
|
| 39 |
+
className="message-bubble ccai-bubble"
|
| 40 |
+
style={{
|
| 41 |
+
background: tone.bg,
|
| 42 |
+
border: `1px solid ${tone.color}33`,
|
| 43 |
+
}}
|
| 44 |
+
>
|
| 45 |
+
<div className="message-speaker" style={{ color: tone.color }}>
|
| 46 |
+
{message.speaker_name}
|
| 47 |
+
{message.model_display && (
|
| 48 |
+
<span className="ccai-bubble-model"> · {message.model_display}</span>
|
| 49 |
+
)}
|
| 50 |
+
</div>
|
| 51 |
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
| 52 |
{message.text}
|
| 53 |
</ReactMarkdown>
|
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactMarkdown from 'react-markdown';
|
| 3 |
+
import remarkGfm from 'remark-gfm';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Distinct rendering for orchestrator messages. Centered, italic, and
|
| 7 |
+
* a different color than participant bubbles so users always know who's
|
| 8 |
+
* speaking. Used for status updates, follow-up announcements, factor
|
| 9 |
+
* surfacing, and the final majority/no-consensus reports.
|
| 10 |
+
*/
|
| 11 |
+
export default function OrchestratorMessage({ message }) {
|
| 12 |
+
const isReport = message.kind === 'majority_report' || message.kind === 'no_consensus_report';
|
| 13 |
+
const className = (
|
| 14 |
+
'ccai-orchestrator-msg' +
|
| 15 |
+
(isReport ? ' ccai-orchestrator-msg-report' : '')
|
| 16 |
+
);
|
| 17 |
+
return (
|
| 18 |
+
<div className={className}>
|
| 19 |
+
<div className="ccai-orchestrator-msg-label">
|
| 20 |
+
Orchestrator{message.kind === 'majority_report' ? ' - Majority Report'
|
| 21 |
+
: message.kind === 'no_consensus_report' ? ' - No-Consensus Report'
|
| 22 |
+
: message.kind === 'factor' ? ' - New Consideration'
|
| 23 |
+
: ''}
|
| 24 |
+
</div>
|
| 25 |
+
<div className="ccai-orchestrator-msg-body">
|
| 26 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
| 27 |
+
{message.text || ''}
|
| 28 |
+
</ReactMarkdown>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
);
|
| 32 |
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { Users, Plus, ChevronDown } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Header dropdown that lists every available participant the user can
|
| 6 |
+
* pull into the conversation. Three sections:
|
| 7 |
+
* - Neon (HANA personas, vanilla/RAG already filtered server-side)
|
| 8 |
+
* - Extra (the four bundled non-Neon-LLM personas)
|
| 9 |
+
* - Expert (user-created, stored in localStorage)
|
| 10 |
+
*
|
| 11 |
+
* Selecting a participant adds them to the active conversation list. The
|
| 12 |
+
* "Create Expert Persona..." entry opens the modal.
|
| 13 |
+
*/
|
| 14 |
+
export default function ParticipantDropdown({
|
| 15 |
+
catalog,
|
| 16 |
+
expertPersonas,
|
| 17 |
+
selectedIds,
|
| 18 |
+
maxParticipants,
|
| 19 |
+
onToggleParticipant,
|
| 20 |
+
onOpenExpertModal,
|
| 21 |
+
}) {
|
| 22 |
+
const [open, setOpen] = useState(false);
|
| 23 |
+
const ref = useRef(null);
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
function handleClickOutside(e) {
|
| 27 |
+
if (open && ref.current && !ref.current.contains(e.target)) {
|
| 28 |
+
setOpen(false);
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 32 |
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 33 |
+
}, [open]);
|
| 34 |
+
|
| 35 |
+
const isSelected = (id) => selectedIds.includes(id);
|
| 36 |
+
const atCap = selectedIds.length >= maxParticipants;
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="ccai-dropdown-wrap" ref={ref}>
|
| 40 |
+
<button
|
| 41 |
+
className="btn-sm btn-outline ccai-dropdown-trigger"
|
| 42 |
+
onClick={() => setOpen(o => !o)}
|
| 43 |
+
title="Add or remove participants"
|
| 44 |
+
>
|
| 45 |
+
<Users size={14} />
|
| 46 |
+
<span>Participants ({selectedIds.length}/{maxParticipants})</span>
|
| 47 |
+
<ChevronDown size={12} />
|
| 48 |
+
</button>
|
| 49 |
+
{open && (
|
| 50 |
+
<div className="ccai-dropdown-panel">
|
| 51 |
+
<div className="ccai-dropdown-section">
|
| 52 |
+
<div className="ccai-dropdown-section-title">Neon.ai Personas</div>
|
| 53 |
+
{(catalog?.neon || []).length === 0 && (
|
| 54 |
+
<div className="ccai-dropdown-empty">
|
| 55 |
+
Neon personas unavailable - check HANA auth.
|
| 56 |
+
</div>
|
| 57 |
+
)}
|
| 58 |
+
{(catalog?.neon || []).map((p) => (
|
| 59 |
+
<DropdownItem
|
| 60 |
+
key={p.participant_id}
|
| 61 |
+
participant={p}
|
| 62 |
+
checked={isSelected(p.participant_id)}
|
| 63 |
+
disabledForAdd={atCap && !isSelected(p.participant_id)}
|
| 64 |
+
onToggle={() => onToggleParticipant(p, 'neon')}
|
| 65 |
+
/>
|
| 66 |
+
))}
|
| 67 |
+
</div>
|
| 68 |
+
<div className="ccai-dropdown-divider" />
|
| 69 |
+
<div className="ccai-dropdown-section">
|
| 70 |
+
<div className="ccai-dropdown-section-title">Extra Personas</div>
|
| 71 |
+
{(catalog?.extra || []).map((p) => (
|
| 72 |
+
<DropdownItem
|
| 73 |
+
key={p.participant_id}
|
| 74 |
+
participant={p}
|
| 75 |
+
checked={isSelected(p.participant_id)}
|
| 76 |
+
disabledForAdd={atCap && !isSelected(p.participant_id)}
|
| 77 |
+
onToggle={() => onToggleParticipant(p, 'extra')}
|
| 78 |
+
/>
|
| 79 |
+
))}
|
| 80 |
+
</div>
|
| 81 |
+
<div className="ccai-dropdown-divider" />
|
| 82 |
+
<div className="ccai-dropdown-section">
|
| 83 |
+
<div className="ccai-dropdown-section-title">Expert Personas</div>
|
| 84 |
+
{(expertPersonas || []).length === 0 && (
|
| 85 |
+
<div className="ccai-dropdown-empty">
|
| 86 |
+
You haven't created any expert personas yet.
|
| 87 |
+
</div>
|
| 88 |
+
)}
|
| 89 |
+
{(expertPersonas || []).map((p) => (
|
| 90 |
+
<DropdownItem
|
| 91 |
+
key={p.participant_id}
|
| 92 |
+
participant={p}
|
| 93 |
+
checked={isSelected(p.participant_id)}
|
| 94 |
+
disabledForAdd={atCap && !isSelected(p.participant_id)}
|
| 95 |
+
onToggle={() => onToggleParticipant(p, 'expert')}
|
| 96 |
+
/>
|
| 97 |
+
))}
|
| 98 |
+
<button
|
| 99 |
+
className="ccai-dropdown-create-btn"
|
| 100 |
+
onClick={() => { setOpen(false); onOpenExpertModal(null); }}
|
| 101 |
+
>
|
| 102 |
+
<Plus size={12} />
|
| 103 |
+
Create Expert Persona...
|
| 104 |
+
</button>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
)}
|
| 108 |
+
</div>
|
| 109 |
+
);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function DropdownItem({ participant, checked, disabledForAdd, onToggle }) {
|
| 113 |
+
return (
|
| 114 |
+
<label
|
| 115 |
+
className={
|
| 116 |
+
'ccai-dropdown-item' +
|
| 117 |
+
(checked ? ' ccai-dropdown-item-checked' : '') +
|
| 118 |
+
(disabledForAdd ? ' ccai-dropdown-item-disabled' : '')
|
| 119 |
+
}
|
| 120 |
+
title={disabledForAdd ? 'Participant cap reached' : ''}
|
| 121 |
+
>
|
| 122 |
+
<input
|
| 123 |
+
type="checkbox"
|
| 124 |
+
checked={checked}
|
| 125 |
+
disabled={disabledForAdd}
|
| 126 |
+
onChange={onToggle}
|
| 127 |
+
/>
|
| 128 |
+
<div className="ccai-dropdown-item-text">
|
| 129 |
+
<div className="ccai-dropdown-item-name">{participant.name}</div>
|
| 130 |
+
<div className="ccai-dropdown-item-sub">
|
| 131 |
+
{participant.model_display || participant.default_model_id || ''}
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</label>
|
| 135 |
+
);
|
| 136 |
+
}
|