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