Spaces:
Running
Running
NeonClary Cursor commited on
Commit ·
d4017c8
1
Parent(s): 9a51d6a
Build credentials during Phase 1 and streamline human participant setup.
Browse filesGenerate per-participant credentials concurrently with initial opinions, rebuild only on model changes, and let humans join from profile text with background credential generation—no question required upfront. Also refresh chat-start controls, rate-limit notices, and header layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
- backend/app/api/chat.py +40 -2
- backend/app/data/demo_questions.json +6 -0
- backend/app/services/credential.py +141 -5
- backend/app/services/models.py +9 -0
- backend/app/services/orchestrator.py +112 -41
- backend/app/services/orchestrator_speed.py +15 -0
- backend/app/services/prompts/__init__.py +4 -0
- backend/app/services/prompts/credential_summary.py +56 -4
- frontend/src/App.js +126 -26
- frontend/src/components/AuthBadge.js +5 -12
- 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/RateLimitNotice.js +64 -0
- frontend/src/styles/ccai.css +28 -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 |
"""
|
|
@@ -770,6 +774,40 @@ async def api_edit_human_credential(
|
|
| 770 |
return {"ok": True, "credential": updated}
|
| 771 |
|
| 772 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 773 |
# Module-level registry of in-flight credential drafts. Each draft is a
|
| 774 |
# tiny piece of state: the question being discussed, the human's name,
|
| 775 |
# the question/answer history, and the configured cap. Drafts are
|
|
|
|
| 23 |
|
| 24 |
from app.clients.hana_client import hana_client
|
| 25 |
from app.services import human_io
|
| 26 |
+
from app.services.credential import (
|
| 27 |
+
build_human_credential_from_profile,
|
| 28 |
+
normalize_one_credential,
|
| 29 |
+
)
|
| 30 |
from app.services.extra_personas import EXTRA_PERSONAS, get_extra_persona
|
| 31 |
from app.services.json_calls import orchestrator_call
|
| 32 |
from app.services.resilience import build_substitution_chain
|
|
|
|
| 135 |
|
| 136 |
|
| 137 |
class HumanCredentialPayload(BaseModel):
|
| 138 |
+
"""Structured credential summary for the in-the-loop human.
|
| 139 |
|
| 140 |
+
Generated from the user's profile text via /credentials/from-profile.
|
| 141 |
The orchestrator prepends this entry to the LLM-built credential
|
| 142 |
summary so the human always appears first in the modal / exports.
|
| 143 |
"""
|
|
|
|
| 774 |
return {"ok": True, "credential": updated}
|
| 775 |
|
| 776 |
|
| 777 |
+
class HumanCredentialFromProfileRequest(BaseModel):
|
| 778 |
+
"""Body for POST /api/chat/credentials/from-profile."""
|
| 779 |
+
|
| 780 |
+
name: str
|
| 781 |
+
question: str = ""
|
| 782 |
+
profile_text: str
|
| 783 |
+
participant_id: str = ""
|
| 784 |
+
orchestrator_model_id: str | None = None
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
@router.post("/chat/credentials/from-profile")
|
| 788 |
+
async def api_human_credential_from_profile(req: HumanCredentialFromProfileRequest):
|
| 789 |
+
"""Generate a structured credential summary from a human's profile text.
|
| 790 |
+
|
| 791 |
+
The orchestrator assesses the self-description the same way it would
|
| 792 |
+
a participant role prompt when building the Phase-1 Credential
|
| 793 |
+
Summary (expertise, style, credibility on this question, biases).
|
| 794 |
+
"""
|
| 795 |
+
if not req.name.strip():
|
| 796 |
+
raise HTTPException(400, "name is required")
|
| 797 |
+
if not req.profile_text.strip():
|
| 798 |
+
raise HTTPException(400, "profile_text is required")
|
| 799 |
+
|
| 800 |
+
orchestrator_id = req.orchestrator_model_id or settings.orchestrator_model
|
| 801 |
+
credential = await build_human_credential_from_profile(
|
| 802 |
+
orchestrator_model_id=orchestrator_id,
|
| 803 |
+
question=(req.question or "").strip(),
|
| 804 |
+
name=req.name.strip(),
|
| 805 |
+
profile_text=req.profile_text.strip(),
|
| 806 |
+
participant_id=req.participant_id.strip(),
|
| 807 |
+
)
|
| 808 |
+
return {"credential": credential}
|
| 809 |
+
|
| 810 |
+
|
| 811 |
# Module-level registry of in-flight credential drafts. Each draft is a
|
| 812 |
# tiny piece of state: the question being discussed, the human's name,
|
| 813 |
# the question/answer history, and the configured cap. Drafts are
|
backend/app/data/demo_questions.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
{
|
| 2 |
"version": 1,
|
| 3 |
"questions": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
{
|
| 5 |
"id": "undergrad_majors",
|
| 6 |
"category": "education",
|
|
|
|
| 1 |
{
|
| 2 |
"version": 1,
|
| 3 |
"questions": [
|
| 4 |
+
{
|
| 5 |
+
"id": "best_pet",
|
| 6 |
+
"category": "lifestyle",
|
| 7 |
+
"title": "Which is the best pet, cats or dogs?",
|
| 8 |
+
"text": "Which is the best pet, cats or dogs?"
|
| 9 |
+
},
|
| 10 |
{
|
| 11 |
"id": "undergrad_majors",
|
| 12 |
"category": "education",
|
backend/app/services/credential.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
"""Credential Summary builder + refresher.
|
| 2 |
|
| 3 |
The Credential Summary is a JSON dict (participant_id -> assessment)
|
| 4 |
-
threaded into every later participant turn.
|
| 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
|
|
@@ -53,9 +53,9 @@ from app.services.context_budget import (
|
|
| 53 |
should_summarize,
|
| 54 |
)
|
| 55 |
from app.services.credential import (
|
| 56 |
-
|
|
|
|
| 57 |
credentials_to_block,
|
| 58 |
-
refresh_credential_summary,
|
| 59 |
)
|
| 60 |
from app.services.json_calls import orchestrator_call
|
| 61 |
from app.services.orchestrator_speed import (
|
|
@@ -765,6 +765,107 @@ def _participant_turn_failure_sse(
|
|
| 765 |
return out
|
| 766 |
|
| 767 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
# ---------------------------------------------------------------------------
|
| 769 |
# Phase implementations
|
| 770 |
# ---------------------------------------------------------------------------
|
|
@@ -790,7 +891,11 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
|
|
| 790 |
yield chunk
|
| 791 |
|
| 792 |
async def _post_initial(result: _AiTurnResult) -> dict[str, Any]:
|
| 793 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 794 |
return {}
|
| 795 |
|
| 796 |
def _build_initial_spec(p: Participant) -> _AiTurnSpec | None:
|
|
@@ -813,21 +918,9 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
|
|
| 813 |
):
|
| 814 |
yield chunk
|
| 815 |
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
orchestrator_model_id=_orchestrator_model_id(session),
|
| 820 |
-
question=session.question,
|
| 821 |
-
participants=_active_participants(session),
|
| 822 |
-
initial_opinions=session.initial_opinions,
|
| 823 |
-
api_log=session.api_log,
|
| 824 |
-
human_credential=session.human_credential,
|
| 825 |
-
)
|
| 826 |
-
_bump_orchestrator_count(session)
|
| 827 |
-
session.credential_summary = creds
|
| 828 |
-
# Surface the freshly-built summary so the frontend can enable the
|
| 829 |
-
# "View Credential Summary" menu item and cache its snapshot. The
|
| 830 |
-
# full list also stays available via GET /api/chat/{id}/credentials.
|
| 831 |
yield _sse("credentials_updated", {
|
| 832 |
"stage": "built",
|
| 833 |
"credentials": session.credential_summary,
|
|
@@ -936,7 +1029,6 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
|
|
| 936 |
yield chunk
|
| 937 |
|
| 938 |
cred_block = credentials_to_block(session.credential_summary)
|
| 939 |
-
cred_refreshed = False
|
| 940 |
|
| 941 |
for iteration in range(session.limits.status_assessment_max):
|
| 942 |
session.status_assessment_iterations = iteration + 1
|
|
@@ -974,27 +1066,6 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
|
|
| 974 |
yield _sse("orchestrator", _msg_payload(msg))
|
| 975 |
return
|
| 976 |
|
| 977 |
-
# Refresh credentials only when follow-ups are actually needed.
|
| 978 |
-
if not cred_refreshed:
|
| 979 |
-
cred_refreshed = True
|
| 980 |
-
full_transcript = _format_history(session.messages)
|
| 981 |
-
refreshed = await refresh_credential_summary(
|
| 982 |
-
orchestrator_model_id=_orchestrator_model_id(session),
|
| 983 |
-
question=session.question,
|
| 984 |
-
participants=_active_participants(session),
|
| 985 |
-
existing=session.credential_summary,
|
| 986 |
-
critique_transcript=full_transcript,
|
| 987 |
-
api_log=session.api_log,
|
| 988 |
-
)
|
| 989 |
-
_bump_orchestrator_count(session)
|
| 990 |
-
if refreshed != session.credential_summary:
|
| 991 |
-
session.credential_summary = refreshed
|
| 992 |
-
yield _sse("credentials_updated", {
|
| 993 |
-
"stage": "refreshed",
|
| 994 |
-
"credentials": session.credential_summary,
|
| 995 |
-
})
|
| 996 |
-
cred_block = credentials_to_block(session.credential_summary)
|
| 997 |
-
|
| 998 |
# Otherwise run targeted follow-ups
|
| 999 |
active_ids = {p.participant_id for p in _active_participants(session)}
|
| 1000 |
for oq in open_qs:
|
|
|
|
| 4 |
Phase outline (matches the build plan):
|
| 5 |
|
| 6 |
1. Initial Opinions (independent, no peeking)
|
| 7 |
+
1.5. Credential Summary (built concurrently during Phase 1)
|
| 8 |
2. Critique x 2 rounds (full history visible)
|
| 9 |
3. Status Assessment (max 3 iterations of targeted follow-ups)
|
| 10 |
4. Opinion Finalization
|
|
|
|
| 53 |
should_summarize,
|
| 54 |
)
|
| 55 |
from app.services.credential import (
|
| 56 |
+
assemble_credential_summary_list,
|
| 57 |
+
build_credential_for_participant,
|
| 58 |
credentials_to_block,
|
|
|
|
| 59 |
)
|
| 60 |
from app.services.json_calls import orchestrator_call
|
| 61 |
from app.services.orchestrator_speed import (
|
|
|
|
| 765 |
return out
|
| 766 |
|
| 767 |
|
| 768 |
+
# ---------------------------------------------------------------------------
|
| 769 |
+
# Credential summary (concurrent with Phase 1)
|
| 770 |
+
# ---------------------------------------------------------------------------
|
| 771 |
+
|
| 772 |
+
async def _credential_build_runner(
|
| 773 |
+
session: Session,
|
| 774 |
+
participant: Participant,
|
| 775 |
+
initial_opinion: str,
|
| 776 |
+
) -> None:
|
| 777 |
+
"""Background task: one participant's credential row."""
|
| 778 |
+
try:
|
| 779 |
+
cred = await build_credential_for_participant(
|
| 780 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 781 |
+
question=session.question,
|
| 782 |
+
participant=participant,
|
| 783 |
+
initial_opinion=initial_opinion,
|
| 784 |
+
api_log=session.api_log,
|
| 785 |
+
)
|
| 786 |
+
session.credential_entries_by_pid[participant.participant_id] = cred
|
| 787 |
+
session.credential_model_by_pid[participant.participant_id] = (
|
| 788 |
+
participant.model_id
|
| 789 |
+
)
|
| 790 |
+
_bump_orchestrator_count(session)
|
| 791 |
+
except Exception as exc:
|
| 792 |
+
LOG.exception(
|
| 793 |
+
"Credential build failed for %s: %s",
|
| 794 |
+
participant.participant_id,
|
| 795 |
+
exc,
|
| 796 |
+
)
|
| 797 |
+
|
| 798 |
+
|
| 799 |
+
def _schedule_phase1_credential_build(
|
| 800 |
+
session: Session,
|
| 801 |
+
participant: Participant,
|
| 802 |
+
initial_opinion: str,
|
| 803 |
+
) -> None:
|
| 804 |
+
"""Start (or restart) a background credential build for one AI participant."""
|
| 805 |
+
if participant.kind == "human":
|
| 806 |
+
return
|
| 807 |
+
if not (initial_opinion or "").strip():
|
| 808 |
+
return
|
| 809 |
+
|
| 810 |
+
pid = participant.participant_id
|
| 811 |
+
existing = session.credential_build_tasks.get(pid)
|
| 812 |
+
if existing is not None and not existing.done():
|
| 813 |
+
existing.cancel()
|
| 814 |
+
|
| 815 |
+
session.credential_build_tasks[pid] = asyncio.create_task(
|
| 816 |
+
_credential_build_runner(session, participant, initial_opinion),
|
| 817 |
+
name=f"credential:{pid}",
|
| 818 |
+
)
|
| 819 |
+
|
| 820 |
+
|
| 821 |
+
def _sync_credential_summary_from_entries(session: Session) -> None:
|
| 822 |
+
session.credential_summary = assemble_credential_summary_list(
|
| 823 |
+
participants=_active_participants(session),
|
| 824 |
+
credential_entries_by_pid=session.credential_entries_by_pid,
|
| 825 |
+
human_credential=session.human_credential,
|
| 826 |
+
)
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
async def _await_phase1_credential_tasks(session: Session) -> None:
|
| 830 |
+
"""Wait for any in-flight per-participant credential builds."""
|
| 831 |
+
tasks = [
|
| 832 |
+
t for t in session.credential_build_tasks.values()
|
| 833 |
+
if t is not None and not t.done()
|
| 834 |
+
]
|
| 835 |
+
if tasks:
|
| 836 |
+
await asyncio.gather(*tasks, return_exceptions=True)
|
| 837 |
+
_sync_credential_summary_from_entries(session)
|
| 838 |
+
|
| 839 |
+
|
| 840 |
+
async def _rebuild_participant_credential_on_model_change(
|
| 841 |
+
session: Session,
|
| 842 |
+
participant: Participant,
|
| 843 |
+
) -> bool:
|
| 844 |
+
"""Rebuild one credential row when the backing LLM model changes."""
|
| 845 |
+
if participant.kind == "human":
|
| 846 |
+
return False
|
| 847 |
+
pid = participant.participant_id
|
| 848 |
+
opinion = (session.initial_opinions or {}).get(pid, "")
|
| 849 |
+
if not opinion.strip():
|
| 850 |
+
return False
|
| 851 |
+
prior_model = session.credential_model_by_pid.get(pid)
|
| 852 |
+
if not prior_model or prior_model == participant.model_id:
|
| 853 |
+
return False
|
| 854 |
+
|
| 855 |
+
cred = await build_credential_for_participant(
|
| 856 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 857 |
+
question=session.question,
|
| 858 |
+
participant=participant,
|
| 859 |
+
initial_opinion=opinion,
|
| 860 |
+
api_log=session.api_log,
|
| 861 |
+
)
|
| 862 |
+
_bump_orchestrator_count(session)
|
| 863 |
+
session.credential_entries_by_pid[pid] = cred
|
| 864 |
+
session.credential_model_by_pid[pid] = participant.model_id
|
| 865 |
+
_sync_credential_summary_from_entries(session)
|
| 866 |
+
return True
|
| 867 |
+
|
| 868 |
+
|
| 869 |
# ---------------------------------------------------------------------------
|
| 870 |
# Phase implementations
|
| 871 |
# ---------------------------------------------------------------------------
|
|
|
|
| 891 |
yield chunk
|
| 892 |
|
| 893 |
async def _post_initial(result: _AiTurnResult) -> dict[str, Any]:
|
| 894 |
+
speaker = result.turn.speaker
|
| 895 |
+
session.initial_opinions[speaker.participant_id] = result.turn.text
|
| 896 |
+
_schedule_phase1_credential_build(
|
| 897 |
+
session, speaker, result.turn.text,
|
| 898 |
+
)
|
| 899 |
return {}
|
| 900 |
|
| 901 |
def _build_initial_spec(p: Participant) -> _AiTurnSpec | None:
|
|
|
|
| 918 |
):
|
| 919 |
yield chunk
|
| 920 |
|
| 921 |
+
# Credential rows were built in parallel as each opinion landed;
|
| 922 |
+
# only wait here if any background task is still finishing.
|
| 923 |
+
await _await_phase1_credential_tasks(session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
yield _sse("credentials_updated", {
|
| 925 |
"stage": "built",
|
| 926 |
"credentials": session.credential_summary,
|
|
|
|
| 1029 |
yield chunk
|
| 1030 |
|
| 1031 |
cred_block = credentials_to_block(session.credential_summary)
|
|
|
|
| 1032 |
|
| 1033 |
for iteration in range(session.limits.status_assessment_max):
|
| 1034 |
session.status_assessment_iterations = iteration + 1
|
|
|
|
| 1066 |
yield _sse("orchestrator", _msg_payload(msg))
|
| 1067 |
return
|
| 1068 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
# Otherwise run targeted follow-ups
|
| 1070 |
active_ids = {p.participant_id for p in _active_participants(session)}
|
| 1071 |
for oq in open_qs:
|
backend/app/services/orchestrator_speed.py
CHANGED
|
@@ -356,7 +356,10 @@ async def _emit_ai_turn_result(
|
|
| 356 |
|
| 357 |
p = result.participant
|
| 358 |
turn = result.turn
|
|
|
|
| 359 |
for ev in turn.sse_events:
|
|
|
|
|
|
|
| 360 |
yield ev
|
| 361 |
if not turn.ok:
|
| 362 |
for chunk in _participant_turn_failure_sse(session, p):
|
|
@@ -380,6 +383,18 @@ async def _emit_ai_turn_result(
|
|
| 380 |
)
|
| 381 |
yield _sse("message", _msg_payload(msg))
|
| 382 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
if _orchestrator_cap_hit(session):
|
| 384 |
async for chunk in _wait_for_continue(session, "orchestrator"):
|
| 385 |
yield chunk
|
|
|
|
| 356 |
|
| 357 |
p = result.participant
|
| 358 |
turn = result.turn
|
| 359 |
+
substituted = False
|
| 360 |
for ev in turn.sse_events:
|
| 361 |
+
if "participant_substituted" in ev:
|
| 362 |
+
substituted = True
|
| 363 |
yield ev
|
| 364 |
if not turn.ok:
|
| 365 |
for chunk in _participant_turn_failure_sse(session, p):
|
|
|
|
| 383 |
)
|
| 384 |
yield _sse("message", _msg_payload(msg))
|
| 385 |
|
| 386 |
+
if substituted:
|
| 387 |
+
from app.services.orchestrator import (
|
| 388 |
+
_rebuild_participant_credential_on_model_change,
|
| 389 |
+
)
|
| 390 |
+
if await _rebuild_participant_credential_on_model_change(
|
| 391 |
+
session, speaker,
|
| 392 |
+
):
|
| 393 |
+
yield _sse("credentials_updated", {
|
| 394 |
+
"stage": "model_changed",
|
| 395 |
+
"credentials": session.credential_summary,
|
| 396 |
+
})
|
| 397 |
+
|
| 398 |
if _orchestrator_cap_hit(session):
|
| 399 |
async for chunk in _wait_for_continue(session, "orchestrator"):
|
| 400 |
yield chunk
|
backend/app/services/prompts/__init__.py
CHANGED
|
@@ -14,6 +14,8 @@ from app.services.prompts.initial_opinions import INITIAL_OPINION_PROMPT
|
|
| 14 |
from app.services.prompts.credential_summary import (
|
| 15 |
CREDENTIAL_BUILD_PROMPT,
|
| 16 |
CREDENTIAL_REFRESH_PROMPT,
|
|
|
|
|
|
|
| 17 |
)
|
| 18 |
from app.services.prompts.critique import CRITIQUE_PROMPT
|
| 19 |
from app.services.prompts.status_assessment import (
|
|
@@ -48,6 +50,8 @@ __all__ = [
|
|
| 48 |
"INITIAL_OPINION_PROMPT",
|
| 49 |
"CREDENTIAL_BUILD_PROMPT",
|
| 50 |
"CREDENTIAL_REFRESH_PROMPT",
|
|
|
|
|
|
|
| 51 |
"CRITIQUE_PROMPT",
|
| 52 |
"STATUS_ASSESSMENT_PROMPT",
|
| 53 |
"TARGETED_FOLLOWUP_PROMPT",
|
|
|
|
| 14 |
from app.services.prompts.credential_summary import (
|
| 15 |
CREDENTIAL_BUILD_PROMPT,
|
| 16 |
CREDENTIAL_REFRESH_PROMPT,
|
| 17 |
+
HUMAN_CREDENTIAL_FROM_PROFILE_PROMPT,
|
| 18 |
+
SINGLE_PARTICIPANT_CREDENTIAL_BUILD_PROMPT,
|
| 19 |
)
|
| 20 |
from app.services.prompts.critique import CRITIQUE_PROMPT
|
| 21 |
from app.services.prompts.status_assessment import (
|
|
|
|
| 50 |
"INITIAL_OPINION_PROMPT",
|
| 51 |
"CREDENTIAL_BUILD_PROMPT",
|
| 52 |
"CREDENTIAL_REFRESH_PROMPT",
|
| 53 |
+
"HUMAN_CREDENTIAL_FROM_PROFILE_PROMPT",
|
| 54 |
+
"SINGLE_PARTICIPANT_CREDENTIAL_BUILD_PROMPT",
|
| 55 |
"CRITIQUE_PROMPT",
|
| 56 |
"STATUS_ASSESSMENT_PROMPT",
|
| 57 |
"TARGETED_FOLLOWUP_PROMPT",
|
backend/app/services/prompts/credential_summary.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
| 1 |
"""Credential Summary: the orchestrator's neutral assessment of each
|
| 2 |
participant's expertise, personality, and credibility on the question.
|
| 3 |
|
| 4 |
-
|
| 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/src/App.js
CHANGED
|
@@ -9,6 +9,7 @@ import CredentialSummaryModal from './components/CredentialSummaryModal';
|
|
| 9 |
import ConversationLimitsModal from './components/ConversationLimitsModal';
|
| 10 |
import PromptCatalogModal from './components/PromptCatalogModal';
|
| 11 |
import HumanParticipantModal from './components/HumanParticipantModal';
|
|
|
|
| 12 |
import {
|
| 13 |
fetchModels, fetchPersonas, fetchDemoQuestions,
|
| 14 |
startChat, continueChat, getOrchestrator, setOrchestrator,
|
|
@@ -21,6 +22,7 @@ import {
|
|
| 21 |
fetchPromptCatalog,
|
| 22 |
getRateLimitStatus,
|
| 23 |
submitHumanResponse, patchHumanCredential,
|
|
|
|
| 24 |
} from './utils/api';
|
| 25 |
import * as storage from './utils/storage';
|
| 26 |
import './styles/variables.css';
|
|
@@ -28,9 +30,9 @@ import './styles/layout.css';
|
|
| 28 |
import './styles/components.css';
|
| 29 |
import './styles/ccai.css';
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
return
|
| 34 |
}
|
| 35 |
|
| 36 |
/** Append a system note at the current point in the chat timeline (not end-of-feed). */
|
|
@@ -159,6 +161,10 @@ export default function App() {
|
|
| 159 |
const [humanSubmitting, setHumanSubmitting] = useState(false);
|
| 160 |
|
| 161 |
const abortRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
// ─── Apply theme ────────────────────────────────────────────────
|
| 164 |
useEffect(() => {
|
|
@@ -196,6 +202,15 @@ export default function App() {
|
|
| 196 |
}).catch(() => {});
|
| 197 |
}, [persisted.orchestrator_model_id]);
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
// ─── Build a flat list of all models for pickers ────────────────
|
| 200 |
const allModelsFlat = useMemo(() => {
|
| 201 |
const list = [];
|
|
@@ -364,13 +379,67 @@ export default function App() {
|
|
| 364 |
setHumanModalOpen(true);
|
| 365 |
}, [humanParticipant]);
|
| 366 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
const handleSaveHuman = useCallback((spec) => {
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
setHumanModalOpen(false);
|
| 370 |
setHumanEditing(null);
|
| 371 |
-
|
|
|
|
|
|
|
| 372 |
|
| 373 |
const handleRemoveHuman = useCallback(() => {
|
|
|
|
| 374 |
setHumanParticipant(null);
|
| 375 |
setHumanModalOpen(false);
|
| 376 |
setHumanEditing(null);
|
|
@@ -421,6 +490,7 @@ export default function App() {
|
|
| 421 |
credibility_for_question: updated.credibility_for_question ?? 0.55,
|
| 422 |
bias_to_watch: updated.bias_to_watch || '',
|
| 423 |
},
|
|
|
|
| 424 |
} : prev);
|
| 425 |
// Refresh the credentials cache so the modal reflects the edit.
|
| 426 |
const data = await fetchCredentials(sessionId);
|
|
@@ -586,7 +656,7 @@ export default function App() {
|
|
| 586 |
// `participantsOverride`, if provided, replaces the
|
| 587 |
// selectedParticipants-derived list (used by the auto-select flow
|
| 588 |
// because the freshly-chosen list isn't in state yet when we need it).
|
| 589 |
-
const buildStartPayload = useCallback((theQuestion, participantsOverride) => {
|
| 590 |
// Always honor the sidebar enabled toggles, including when
|
| 591 |
// auto-select supplies a participantsOverride list (which used to
|
| 592 |
// bypass enabledMap and always prepend a disabled human).
|
|
@@ -616,14 +686,15 @@ export default function App() {
|
|
| 616 |
// participants array. Backend rejects start if it sees a human in
|
| 617 |
// participants but no human_credential, so this MUST be present
|
| 618 |
// whenever the human is enabled.
|
|
|
|
| 619 |
const humanInList = baseList.find(p => p.kind === 'human');
|
| 620 |
let human_credential = null;
|
| 621 |
-
if (humanInList &&
|
| 622 |
-
const cs =
|
| 623 |
human_credential = {
|
| 624 |
participant_id: humanInList.participant_id,
|
| 625 |
name: humanInList.name,
|
| 626 |
-
expertise: cs.expertise || '',
|
| 627 |
personality: cs.personality || '',
|
| 628 |
credibility_for_question: typeof cs.credibility_for_question === 'number'
|
| 629 |
? cs.credibility_for_question
|
|
@@ -673,6 +744,10 @@ export default function App() {
|
|
| 673 |
// ─── Start chat ─────────────────────────────────────────────────
|
| 674 |
const handleStart = useCallback(async (theQuestion) => {
|
| 675 |
if (!theQuestion || !theQuestion.trim()) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
// In auto-select mode the dropdown has no manual picks - skip the
|
| 677 |
// pre-flight count check and validate the chosen pool below instead.
|
| 678 |
if (!autoSelectMode && enabledSelectedCount < 2) return;
|
|
@@ -768,9 +843,41 @@ export default function App() {
|
|
| 768 |
}
|
| 769 |
}
|
| 770 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 771 |
try {
|
| 772 |
await startChat(
|
| 773 |
-
buildStartPayload(theQuestion, resolvedParticipants),
|
| 774 |
{
|
| 775 |
onSession: (data) => {
|
| 776 |
setSessionId(data.session_id);
|
|
@@ -929,21 +1036,12 @@ export default function App() {
|
|
| 929 |
getAuthStatus().then(setAuth).catch(() => {});
|
| 930 |
}
|
| 931 |
}, [
|
| 932 |
-
buildStartPayload, enabledSelectedCount, dailyLimit,
|
| 933 |
autoSelectMode, allCatalogParticipants, modelAssignments,
|
| 934 |
maxParticipants, orchestratorModel, enabledMap,
|
| 935 |
-
humanParticipant, humanCatalogEntry,
|
| 936 |
]);
|
| 937 |
|
| 938 |
-
const handleStartRandom = useCallback(() => {
|
| 939 |
-
if (demoQuestions.length === 0) {
|
| 940 |
-
setSystemMessages(prev => [...prev, { text: 'No demo questions available.' }]);
|
| 941 |
-
return;
|
| 942 |
-
}
|
| 943 |
-
const q = pickRandom(demoQuestions);
|
| 944 |
-
handleStart(q.text);
|
| 945 |
-
}, [demoQuestions, handleStart]);
|
| 946 |
-
|
| 947 |
// In auto-select mode we don't require manual picks - the orchestrator
|
| 948 |
// will choose them at /chat/start time, so just need 2+ candidates
|
| 949 |
// available in the catalog.
|
|
@@ -964,7 +1062,6 @@ export default function App() {
|
|
| 964 |
theme={theme}
|
| 965 |
onToggleTheme={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
|
| 966 |
auth={auth}
|
| 967 |
-
dailyLimit={dailyLimit}
|
| 968 |
catalog={catalog}
|
| 969 |
expertPersonas={expertPersonas}
|
| 970 |
selectedIds={selectedIds}
|
|
@@ -1023,8 +1120,9 @@ export default function App() {
|
|
| 1023 |
/>
|
| 1024 |
<div className="content">
|
| 1025 |
<ChatControls
|
| 1026 |
-
|
| 1027 |
-
|
|
|
|
| 1028 |
onStop={handleStop}
|
| 1029 |
disabled={startDisabled}
|
| 1030 |
isRunning={isRunning}
|
|
@@ -1086,8 +1184,6 @@ export default function App() {
|
|
| 1086 |
<HumanParticipantModal
|
| 1087 |
isOpen={humanModalOpen}
|
| 1088 |
initial={humanEditing}
|
| 1089 |
-
question={activeQuestion}
|
| 1090 |
-
orchestratorModel={orchestratorModel}
|
| 1091 |
onClose={() => { setHumanModalOpen(false); setHumanEditing(null); }}
|
| 1092 |
onSave={handleSaveHuman}
|
| 1093 |
onRemove={humanEditing ? handleRemoveHuman : null}
|
|
@@ -1105,6 +1201,10 @@ export default function App() {
|
|
| 1105 |
catalog={promptCatalog}
|
| 1106 |
onClose={() => setPromptCatalogOpen(false)}
|
| 1107 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1108 |
</div>
|
| 1109 |
);
|
| 1110 |
}
|
|
|
|
| 9 |
import ConversationLimitsModal from './components/ConversationLimitsModal';
|
| 10 |
import PromptCatalogModal from './components/PromptCatalogModal';
|
| 11 |
import HumanParticipantModal from './components/HumanParticipantModal';
|
| 12 |
+
import RateLimitNotice from './components/RateLimitNotice';
|
| 13 |
import {
|
| 14 |
fetchModels, fetchPersonas, fetchDemoQuestions,
|
| 15 |
startChat, continueChat, getOrchestrator, setOrchestrator,
|
|
|
|
| 22 |
fetchPromptCatalog,
|
| 23 |
getRateLimitStatus,
|
| 24 |
submitHumanResponse, patchHumanCredential,
|
| 25 |
+
generateHumanCredentialFromProfile,
|
| 26 |
} from './utils/api';
|
| 27 |
import * as storage from './utils/storage';
|
| 28 |
import './styles/variables.css';
|
|
|
|
| 30 |
import './styles/components.css';
|
| 31 |
import './styles/ccai.css';
|
| 32 |
|
| 33 |
+
/** True when the user is subject to the per-IP daily chat cap. */
|
| 34 |
+
function isRateLimitedUser(auth) {
|
| 35 |
+
return auth && !auth.is_org_member && auth.remaining_conversations >= 0;
|
| 36 |
}
|
| 37 |
|
| 38 |
/** Append a system note at the current point in the chat timeline (not end-of-feed). */
|
|
|
|
| 161 |
const [humanSubmitting, setHumanSubmitting] = useState(false);
|
| 162 |
|
| 163 |
const abortRef = useRef(null);
|
| 164 |
+
const chatControlsRef = useRef(null);
|
| 165 |
+
const humanCredentialGenRef = useRef(null);
|
| 166 |
+
const oneLeftNoticeShownRef = useRef(false);
|
| 167 |
+
const [rateLimitNotice, setRateLimitNotice] = useState(null);
|
| 168 |
|
| 169 |
// ─── Apply theme ────────────────────────────────────────────────
|
| 170 |
useEffect(() => {
|
|
|
|
| 202 |
}).catch(() => {});
|
| 203 |
}, [persisted.orchestrator_model_id]);
|
| 204 |
|
| 205 |
+
// Pop up once per session when the daily cap leaves exactly one chat.
|
| 206 |
+
useEffect(() => {
|
| 207 |
+
if (!isRateLimitedUser(auth)) return;
|
| 208 |
+
if (auth.remaining_conversations === 1 && !oneLeftNoticeShownRef.current) {
|
| 209 |
+
oneLeftNoticeShownRef.current = true;
|
| 210 |
+
setRateLimitNotice('one_left');
|
| 211 |
+
}
|
| 212 |
+
}, [auth]);
|
| 213 |
+
|
| 214 |
// ─── Build a flat list of all models for pickers ────────────────
|
| 215 |
const allModelsFlat = useMemo(() => {
|
| 216 |
const list = [];
|
|
|
|
| 379 |
setHumanModalOpen(true);
|
| 380 |
}, [humanParticipant]);
|
| 381 |
|
| 382 |
+
const runHumanCredentialGeneration = useCallback(async (spec, question) => {
|
| 383 |
+
const result = await generateHumanCredentialFromProfile({
|
| 384 |
+
name: spec.name,
|
| 385 |
+
question: (question || '').trim(),
|
| 386 |
+
profile_text: spec.profile_text,
|
| 387 |
+
participant_id: spec.participant_id,
|
| 388 |
+
orchestrator_model_id: orchestratorModel || null,
|
| 389 |
+
});
|
| 390 |
+
const cred = result.credential || {};
|
| 391 |
+
return {
|
| 392 |
+
...spec,
|
| 393 |
+
credential_pending: false,
|
| 394 |
+
credential_built_for_question: (question || '').trim(),
|
| 395 |
+
credential_summary: {
|
| 396 |
+
name: cred.name || spec.name,
|
| 397 |
+
expertise: cred.expertise || '',
|
| 398 |
+
personality: cred.personality || '',
|
| 399 |
+
credibility_for_question: typeof cred.credibility_for_question === 'number'
|
| 400 |
+
? cred.credibility_for_question
|
| 401 |
+
: 0.55,
|
| 402 |
+
bias_to_watch: cred.bias_to_watch || '',
|
| 403 |
+
},
|
| 404 |
+
};
|
| 405 |
+
}, [orchestratorModel]);
|
| 406 |
+
|
| 407 |
+
const startHumanCredentialGeneration = useCallback((spec, question) => {
|
| 408 |
+
const promise = runHumanCredentialGeneration(spec, question)
|
| 409 |
+
.then((updated) => {
|
| 410 |
+
setHumanParticipant(prev => (
|
| 411 |
+
prev && prev.participant_id === spec.participant_id ? updated : prev
|
| 412 |
+
));
|
| 413 |
+
return updated;
|
| 414 |
+
})
|
| 415 |
+
.catch((err) => {
|
| 416 |
+
console.error('Human credential generation failed:', err);
|
| 417 |
+
setHumanParticipant(prev => (
|
| 418 |
+
prev && prev.participant_id === spec.participant_id
|
| 419 |
+
? { ...prev, credential_pending: false, credential_error: err.message }
|
| 420 |
+
: prev
|
| 421 |
+
));
|
| 422 |
+
throw err;
|
| 423 |
+
});
|
| 424 |
+
humanCredentialGenRef.current = promise;
|
| 425 |
+
return promise;
|
| 426 |
+
}, [runHumanCredentialGeneration]);
|
| 427 |
+
|
| 428 |
const handleSaveHuman = useCallback((spec) => {
|
| 429 |
+
const pending = {
|
| 430 |
+
...spec,
|
| 431 |
+
credential_pending: true,
|
| 432 |
+
credential_summary: null,
|
| 433 |
+
};
|
| 434 |
+
setHumanParticipant(pending);
|
| 435 |
setHumanModalOpen(false);
|
| 436 |
setHumanEditing(null);
|
| 437 |
+
const question = chatControlsRef.current?.getDraftQuestion?.() || '';
|
| 438 |
+
startHumanCredentialGeneration(pending, question);
|
| 439 |
+
}, [startHumanCredentialGeneration]);
|
| 440 |
|
| 441 |
const handleRemoveHuman = useCallback(() => {
|
| 442 |
+
humanCredentialGenRef.current = null;
|
| 443 |
setHumanParticipant(null);
|
| 444 |
setHumanModalOpen(false);
|
| 445 |
setHumanEditing(null);
|
|
|
|
| 490 |
credibility_for_question: updated.credibility_for_question ?? 0.55,
|
| 491 |
bias_to_watch: updated.bias_to_watch || '',
|
| 492 |
},
|
| 493 |
+
// profile_text unchanged — edits in the modal only adjust the summary.
|
| 494 |
} : prev);
|
| 495 |
// Refresh the credentials cache so the modal reflects the edit.
|
| 496 |
const data = await fetchCredentials(sessionId);
|
|
|
|
| 656 |
// `participantsOverride`, if provided, replaces the
|
| 657 |
// selectedParticipants-derived list (used by the auto-select flow
|
| 658 |
// because the freshly-chosen list isn't in state yet when we need it).
|
| 659 |
+
const buildStartPayload = useCallback((theQuestion, participantsOverride, humanOverride) => {
|
| 660 |
// Always honor the sidebar enabled toggles, including when
|
| 661 |
// auto-select supplies a participantsOverride list (which used to
|
| 662 |
// bypass enabledMap and always prepend a disabled human).
|
|
|
|
| 686 |
// participants array. Backend rejects start if it sees a human in
|
| 687 |
// participants but no human_credential, so this MUST be present
|
| 688 |
// whenever the human is enabled.
|
| 689 |
+
const hp = humanOverride ?? humanParticipant;
|
| 690 |
const humanInList = baseList.find(p => p.kind === 'human');
|
| 691 |
let human_credential = null;
|
| 692 |
+
if (humanInList && hp) {
|
| 693 |
+
const cs = hp.credential_summary || {};
|
| 694 |
human_credential = {
|
| 695 |
participant_id: humanInList.participant_id,
|
| 696 |
name: humanInList.name,
|
| 697 |
+
expertise: cs.expertise || hp.profile_text?.slice(0, 500) || '',
|
| 698 |
personality: cs.personality || '',
|
| 699 |
credibility_for_question: typeof cs.credibility_for_question === 'number'
|
| 700 |
? cs.credibility_for_question
|
|
|
|
| 744 |
// ─── Start chat ─────────────────────────────────────────────────
|
| 745 |
const handleStart = useCallback(async (theQuestion) => {
|
| 746 |
if (!theQuestion || !theQuestion.trim()) return;
|
| 747 |
+
if (isRateLimitedUser(auth) && auth.remaining_conversations === 0) {
|
| 748 |
+
setRateLimitNotice('exhausted');
|
| 749 |
+
return;
|
| 750 |
+
}
|
| 751 |
// In auto-select mode the dropdown has no manual picks - skip the
|
| 752 |
// pre-flight count check and validate the chosen pool below instead.
|
| 753 |
if (!autoSelectMode && enabledSelectedCount < 2) return;
|
|
|
|
| 843 |
}
|
| 844 |
}
|
| 845 |
|
| 846 |
+
let humanForStart = humanParticipant;
|
| 847 |
+
const humanEnabledForStart = humanParticipant
|
| 848 |
+
&& enabledMap[humanParticipant.participant_id] !== false;
|
| 849 |
+
if (humanEnabledForStart) {
|
| 850 |
+
const q = theQuestion.trim();
|
| 851 |
+
const needsQuestionRefresh = q
|
| 852 |
+
&& humanParticipant.credential_built_for_question !== q;
|
| 853 |
+
if (humanParticipant.credential_pending && humanCredentialGenRef.current) {
|
| 854 |
+
setStatusText('Finishing credential summary...');
|
| 855 |
+
try {
|
| 856 |
+
humanForStart = await humanCredentialGenRef.current;
|
| 857 |
+
} catch {
|
| 858 |
+
humanForStart = humanParticipant;
|
| 859 |
+
}
|
| 860 |
+
}
|
| 861 |
+
if (!humanForStart?.credential_summary?.expertise || needsQuestionRefresh) {
|
| 862 |
+
setStatusText('Preparing credential summary...');
|
| 863 |
+
try {
|
| 864 |
+
humanForStart = await runHumanCredentialGeneration(humanParticipant, q);
|
| 865 |
+
setHumanParticipant(humanForStart);
|
| 866 |
+
humanCredentialGenRef.current = Promise.resolve(humanForStart);
|
| 867 |
+
} catch (err) {
|
| 868 |
+
setIsRunning(false);
|
| 869 |
+
setStatusText('');
|
| 870 |
+
setSystemMessages(prev => [...prev, {
|
| 871 |
+
text: `Could not generate human credential: ${err.message}`,
|
| 872 |
+
}]);
|
| 873 |
+
return;
|
| 874 |
+
}
|
| 875 |
+
}
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
try {
|
| 879 |
await startChat(
|
| 880 |
+
buildStartPayload(theQuestion, resolvedParticipants, humanForStart),
|
| 881 |
{
|
| 882 |
onSession: (data) => {
|
| 883 |
setSessionId(data.session_id);
|
|
|
|
| 1036 |
getAuthStatus().then(setAuth).catch(() => {});
|
| 1037 |
}
|
| 1038 |
}, [
|
| 1039 |
+
buildStartPayload, enabledSelectedCount, dailyLimit, auth,
|
| 1040 |
autoSelectMode, allCatalogParticipants, modelAssignments,
|
| 1041 |
maxParticipants, orchestratorModel, enabledMap,
|
| 1042 |
+
humanParticipant, humanCatalogEntry, runHumanCredentialGeneration,
|
| 1043 |
]);
|
| 1044 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1045 |
// In auto-select mode we don't require manual picks - the orchestrator
|
| 1046 |
// will choose them at /chat/start time, so just need 2+ candidates
|
| 1047 |
// available in the catalog.
|
|
|
|
| 1062 |
theme={theme}
|
| 1063 |
onToggleTheme={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
|
| 1064 |
auth={auth}
|
|
|
|
| 1065 |
catalog={catalog}
|
| 1066 |
expertPersonas={expertPersonas}
|
| 1067 |
selectedIds={selectedIds}
|
|
|
|
| 1120 |
/>
|
| 1121 |
<div className="content">
|
| 1122 |
<ChatControls
|
| 1123 |
+
ref={chatControlsRef}
|
| 1124 |
+
demoQuestions={demoQuestions}
|
| 1125 |
+
onStart={handleStart}
|
| 1126 |
onStop={handleStop}
|
| 1127 |
disabled={startDisabled}
|
| 1128 |
isRunning={isRunning}
|
|
|
|
| 1184 |
<HumanParticipantModal
|
| 1185 |
isOpen={humanModalOpen}
|
| 1186 |
initial={humanEditing}
|
|
|
|
|
|
|
| 1187 |
onClose={() => { setHumanModalOpen(false); setHumanEditing(null); }}
|
| 1188 |
onSave={handleSaveHuman}
|
| 1189 |
onRemove={humanEditing ? handleRemoveHuman : null}
|
|
|
|
| 1201 |
catalog={promptCatalog}
|
| 1202 |
onClose={() => setPromptCatalogOpen(false)}
|
| 1203 |
/>
|
| 1204 |
+
<RateLimitNotice
|
| 1205 |
+
kind={rateLimitNotice}
|
| 1206 |
+
onClose={() => setRateLimitNotice(null)}
|
| 1207 |
+
/>
|
| 1208 |
</div>
|
| 1209 |
);
|
| 1210 |
}
|
frontend/src/components/AuthBadge.js
CHANGED
|
@@ -1,19 +1,15 @@
|
|
| 1 |
import React from 'react';
|
| 2 |
-
import {
|
| 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/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/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
|
@@ -569,7 +569,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 +617,33 @@
|
|
| 617 |
max-width: 560px;
|
| 618 |
}
|
| 619 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
.ccai-credentials-question {
|
| 621 |
margin: 0 20px;
|
| 622 |
padding: 10px 12px;
|
|
|
|
| 569 |
|
| 570 |
/* ── Credential Summary modal ──────────────────────────────────────
|
| 571 |
Surfaces the orchestrator's per-participant assessment built after
|
| 572 |
+
Phase 1 (concurrent per participant). Reuses the table
|
| 573 |
modal's overlay/card chrome for visual consistency.
|
| 574 |
──────────────────────────────────────────────────────────────── */
|
| 575 |
|
|
|
|
| 617 |
max-width: 560px;
|
| 618 |
}
|
| 619 |
|
| 620 |
+
.ccai-rate-limit-card {
|
| 621 |
+
width: min(480px, 92vw);
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
.ccai-rate-limit-body {
|
| 625 |
+
padding: 16px 20px;
|
| 626 |
+
font-size: 14px;
|
| 627 |
+
line-height: 1.5;
|
| 628 |
+
color: var(--text-secondary);
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
.ccai-rate-limit-body a {
|
| 632 |
+
color: var(--accent-primary);
|
| 633 |
+
text-decoration: none;
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
.ccai-rate-limit-body a:hover {
|
| 637 |
+
text-decoration: underline;
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
.ccai-rate-limit-actions {
|
| 641 |
+
display: flex;
|
| 642 |
+
justify-content: flex-end;
|
| 643 |
+
padding: 12px 20px 16px;
|
| 644 |
+
border-top: 1px solid var(--border-primary);
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
.ccai-credentials-question {
|
| 648 |
margin: 0 20px;
|
| 649 |
padding: 10px 12px;
|
frontend/src/styles/components.css
CHANGED
|
@@ -1144,6 +1144,10 @@
|
|
| 1144 |
color: var(--text-primary);
|
| 1145 |
}
|
| 1146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1147 |
/* ── Footer ─────────────────────────────────────────────────────── */
|
| 1148 |
|
| 1149 |
.app-footer {
|
|
|
|
| 1144 |
color: var(--text-primary);
|
| 1145 |
}
|
| 1146 |
|
| 1147 |
+
.auth-badge-end {
|
| 1148 |
+
margin-left: 4px;
|
| 1149 |
+
}
|
| 1150 |
+
|
| 1151 |
/* ── Footer ─────────────────────────────────────────────────────── */
|
| 1152 |
|
| 1153 |
.app-footer {
|
frontend/src/utils/api.js
CHANGED
|
@@ -310,6 +310,32 @@ export async function patchHumanCredential(sessionId, patch) {
|
|
| 310 |
* first question or (rarely) a final summary if the LLM bails. The
|
| 311 |
* draft_id is needed for subsequent /answer calls.
|
| 312 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
export async function startCredentialDraft({
|
| 314 |
name, question, max_questions = 6, orchestrator_model_id = null,
|
| 315 |
}) {
|
|
|
|
| 310 |
* first question or (rarely) a final summary if the LLM bails. The
|
| 311 |
* draft_id is needed for subsequent /answer calls.
|
| 312 |
*/
|
| 313 |
+
/**
|
| 314 |
+
* Generate a structured credential summary from a human's freeform
|
| 315 |
+
* profile text (experience, personality, etc.). Uses the orchestrator
|
| 316 |
+
* the same way it assesses an LLM participant's role prompt.
|
| 317 |
+
*/
|
| 318 |
+
export async function generateHumanCredentialFromProfile({
|
| 319 |
+
name, question, profile_text, participant_id, orchestrator_model_id,
|
| 320 |
+
}) {
|
| 321 |
+
const resp = await fetch(`${API_BASE}/api/chat/credentials/from-profile`, {
|
| 322 |
+
method: 'POST',
|
| 323 |
+
headers: { 'Content-Type': 'application/json' },
|
| 324 |
+
body: JSON.stringify({
|
| 325 |
+
name,
|
| 326 |
+
question,
|
| 327 |
+
profile_text,
|
| 328 |
+
participant_id: participant_id || '',
|
| 329 |
+
orchestrator_model_id: orchestrator_model_id || null,
|
| 330 |
+
}),
|
| 331 |
+
});
|
| 332 |
+
if (!resp.ok) {
|
| 333 |
+
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 334 |
+
throw new Error(err.detail || 'Credential generation failed');
|
| 335 |
+
}
|
| 336 |
+
return resp.json();
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
export async function startCredentialDraft({
|
| 340 |
name, question, max_questions = 6, orchestrator_model_id = null,
|
| 341 |
}) {
|
frontend/src/utils/storage.js
CHANGED
|
@@ -30,7 +30,8 @@ const DEFAULTS = {
|
|
| 30 |
auto_select_mode: false,
|
| 31 |
// In-the-loop human participant. null when no human is configured.
|
| 32 |
// Shape when set:
|
| 33 |
-
// { participant_id, name,
|
|
|
|
| 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 |
// } }
|