NeonClary commited on
Commit
f0ea5f3
·
unverified ·
2 Parent(s): 1c62c48958ebfa

Merge pull request #6 from Neon-Jordan/master

Browse files

Demo trio personas, Expert Persona hardening, and mobile UX polish

.env.example CHANGED
@@ -1,24 +1,30 @@
1
- # HANA API (Neon BrainForge)
 
 
 
 
 
 
 
2
  HANA_BASE_URL=https://hana.neonaialpha.com
3
  HANA_USERNAME=guest
4
  HANA_PASSWORD=password
5
- # BrainForge/Security (4090-x1-3): OpenAI-compatible API key for direct vLLM (same as HF Space API_KEY in brainforge-webapp).
6
- # NOT used for HANA /auth/login — see NeonGeckoCom/brainforge-webapp docker_overlay/config/config.yaml
7
  HANA_KLATCHAT_PASSWORD=sk_klatchat2024
8
- # Direct vLLM host (default matches demo 4090-x1-3)
9
  # NEON_SECURITY_VLLM_BASE_URL=https://4090-x1-3.neonaiservices2.com/vllm0
10
 
11
- # Neon vLLM direct access
12
  VLLM_API_KEY=sk_n30nn30n
13
 
14
- # Comparison providers
15
  FIREWORKS_API_KEY=your-fireworks-api-key-here
16
  TOGETHER_API_KEY=your-together-api-key-here
17
  OPENAI_API_KEY=your-openai-api-key-here
18
  GEMINI_API_KEY=your-gemini-api-key-here
 
19
 
20
  # Orchestrator LLM (used to monitor conversation endings)
21
- # Defaults to gpt-4o-mini via OpenAI if OPENAI_API_KEY is set
22
  ORCHESTRATOR_MODEL=gpt-4o-mini
23
 
24
  # App
 
1
+ # Project-local overrides. API keys and shared Neon credentials can live in
2
+ # a cross-project shared.env instead (see README). Values here override shared.env.
3
+
4
+ # Optional: explicit path to shared secrets (otherwise auto-detected at
5
+ # ~/.secrets/shared.env or ~/Downloads/shared.env)
6
+ # SHARED_ENV_PATH=/Users/you/Downloads/shared.env
7
+
8
+ # HANA API (Neon BrainForge) — usually provided by shared.env
9
  HANA_BASE_URL=https://hana.neonaialpha.com
10
  HANA_USERNAME=guest
11
  HANA_PASSWORD=password
12
+ # BrainForge/Security (4090-x1-3): OpenAI-compatible API key for direct vLLM
 
13
  HANA_KLATCHAT_PASSWORD=sk_klatchat2024
14
+ # Direct vLLM host (default matches demo 4090-x1-3; shared.env may use VLLM_BASE_URL)
15
  # NEON_SECURITY_VLLM_BASE_URL=https://4090-x1-3.neonaiservices2.com/vllm0
16
 
17
+ # Neon vLLM direct access — usually provided by shared.env
18
  VLLM_API_KEY=sk_n30nn30n
19
 
20
+ # Comparison providers — usually provided by shared.env
21
  FIREWORKS_API_KEY=your-fireworks-api-key-here
22
  TOGETHER_API_KEY=your-together-api-key-here
23
  OPENAI_API_KEY=your-openai-api-key-here
24
  GEMINI_API_KEY=your-gemini-api-key-here
25
+ MISTRAL_API_KEY=your-mistral-api-key-here
26
 
27
  # Orchestrator LLM (used to monitor conversation endings)
 
28
  ORCHESTRATOR_MODEL=gpt-4o-mini
29
 
30
  # App
.gitignore CHANGED
@@ -1,6 +1,8 @@
1
  __pycache__/
2
  *.pyc
3
  .env
 
 
4
  node_modules/
5
  frontend/build/
6
  .DS_Store
 
1
  __pycache__/
2
  *.pyc
3
  .env
4
+ shared.env
5
+ **/.secrets/
6
  node_modules/
7
  frontend/build/
8
  .DS_Store
README.md CHANGED
@@ -67,10 +67,29 @@ displayed, or fed back to the orchestrator/summarizer/Credential builder.
67
 
68
  ## Quick Start (local development)
69
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  ```bash
 
71
  cp .env.example .env
72
- # Edit .env with your API keys
73
 
 
 
 
 
 
 
 
74
  # Backend
75
  cd backend
76
  pip install -r requirements.txt
@@ -86,7 +105,8 @@ npm start
86
 
87
  ```bash
88
  cp .env.example .env
89
- # Edit .env with your API keys
 
90
  docker compose up --build
91
  # Open http://localhost:7860
92
  ```
 
67
 
68
  ## Quick Start (local development)
69
 
70
+ ### Shared secrets (`shared.env`)
71
+
72
+ This project supports a cross-project secrets file (same pattern as other Neon
73
+ Cursor repos). Place API keys in `shared.env` **outside the repo** — never
74
+ commit it. The backend loads it automatically before the project `.env`:
75
+
76
+ 1. `SHARED_ENV_PATH` or `SHARED_ENV_FILE` if set in the shell
77
+ 2. `~/.secrets/shared.env`
78
+ 3. `~/Downloads/shared.env`
79
+
80
+ Project `.env` overrides any duplicate keys from `shared.env`.
81
+
82
  ```bash
83
+ # Optional: only project-specific overrides (orchestrator model, CORS, etc.)
84
  cp .env.example .env
 
85
 
86
+ # Or use the dev script (auto-detects shared.env + starts both servers)
87
+ ./scripts/dev-local.sh
88
+ ```
89
+
90
+ Manual start:
91
+
92
+ ```bash
93
  # Backend
94
  cd backend
95
  pip install -r requirements.txt
 
105
 
106
  ```bash
107
  cp .env.example .env
108
+ # Optional: point Docker at your shared secrets file
109
+ export SHARED_ENV_FILE="$HOME/Downloads/shared.env"
110
  docker compose up --build
111
  # Open http://localhost:7860
112
  ```
backend/app/api/chat.py CHANGED
@@ -47,6 +47,7 @@ from app.services.models import (
47
  clamp_conversation_limits,
48
  )
49
  from app.services.auto_select import auto_select_participants
 
50
  from app.services.prompts.catalog import build_prompt_catalog
51
  from app.services.orchestrator import (
52
  create_session,
@@ -67,19 +68,53 @@ LOG = logging.getLogger(__name__)
67
  # ---------------------------------------------------------------------------
68
 
69
  class GenerateRoleRequest(BaseModel):
70
- model_id: str
 
 
71
  name: str = ""
72
  profile: str = ""
73
  identity: str = ""
74
  samples: str = ""
75
  role_style: str = "exact"
 
76
 
77
 
78
  class GenerateRoleFreeformRequest(BaseModel):
79
- model_id: str
 
 
80
  name: str = ""
81
  text: str = ""
82
  role_style: str = "ai_completed"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
 
85
  class SetOrchestratorRequest(BaseModel):
@@ -231,15 +266,35 @@ async def api_set_speed_priority(req: SetSpeedPriorityRequest):
231
  # Role-prompt generation (used by the Expert Persona modal)
232
  # ---------------------------------------------------------------------------
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  @router.post("/chat/generate-role")
235
  async def api_generate_role(req: GenerateRoleRequest):
 
 
236
  result = await generate_role_prompt(
237
- model_id=req.model_id,
238
  name=req.name,
239
  profile=req.profile,
240
  identity=req.identity,
241
  samples=req.samples,
242
  role_style=req.role_style,
 
 
243
  )
244
  if result.get("error"):
245
  raise HTTPException(status_code=400, detail=result["error"])
@@ -248,11 +303,40 @@ async def api_generate_role(req: GenerateRoleRequest):
248
 
249
  @router.post("/chat/generate-role-freeform")
250
  async def api_generate_role_freeform(req: GenerateRoleFreeformRequest):
 
 
251
  result = await generate_role_prompt_freeform(
252
- model_id=req.model_id,
253
  name=req.name,
254
  text=req.text,
255
  role_style=req.role_style,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  )
257
  if result.get("error"):
258
  raise HTTPException(status_code=400, detail=result["error"])
 
47
  clamp_conversation_limits,
48
  )
49
  from app.services.auto_select import auto_select_participants
50
+ from app.services.model_recommend import suggest_model_for_persona
51
  from app.services.prompts.catalog import build_prompt_catalog
52
  from app.services.orchestrator import (
53
  create_session,
 
68
  # ---------------------------------------------------------------------------
69
 
70
  class GenerateRoleRequest(BaseModel):
71
+ """Structured persona fields → role prompt. `model_id` is ignored (legacy)."""
72
+
73
+ model_id: str = ""
74
  name: str = ""
75
  profile: str = ""
76
  identity: str = ""
77
  samples: str = ""
78
  role_style: str = "exact"
79
+ orchestrator_model_id: str | None = None
80
 
81
 
82
  class GenerateRoleFreeformRequest(BaseModel):
83
+ """Freeform persona text → role prompt. `model_id` is ignored (legacy)."""
84
+
85
+ model_id: str = ""
86
  name: str = ""
87
  text: str = ""
88
  role_style: str = "ai_completed"
89
+ orchestrator_model_id: str | None = None
90
+
91
+
92
+ class AvailableModelPayload(BaseModel):
93
+ """One row of the builder's live model list (from allModelsFlat)."""
94
+
95
+ id: str
96
+ name: str = ""
97
+ provider: str = ""
98
+ kind: str = "provider"
99
+
100
+
101
+ class PanelMemberPayload(BaseModel):
102
+ """Another participant already in the panel (for diversity hints)."""
103
+
104
+ name: str = ""
105
+ model_id: str = ""
106
+ provider: str = ""
107
+
108
+
109
+ class SuggestModelRequest(BaseModel):
110
+ """Body of POST /api/chat/suggest-model."""
111
+
112
+ persona_name: str = ""
113
+ source_text: str = ""
114
+ role_prompt: str = ""
115
+ available_models: list[AvailableModelPayload]
116
+ panel_context: list[PanelMemberPayload] = Field(default_factory=list)
117
+ orchestrator_model_id: str | None = None
118
 
119
 
120
  class SetOrchestratorRequest(BaseModel):
 
266
  # Role-prompt generation (used by the Expert Persona modal)
267
  # ---------------------------------------------------------------------------
268
 
269
+ async def _builder_neon_model_ids() -> list[str]:
270
+ """Flat neon:model@ver:persona ids for neutral writer fallback."""
271
+ ids: list[str] = []
272
+ try:
273
+ for nm in await hana_client.get_models():
274
+ base = nm.get("model_id") or ""
275
+ for p in nm.get("personas") or []:
276
+ if p.get("enabled") is False:
277
+ continue
278
+ pname = p.get("persona_name") or ""
279
+ if base and pname:
280
+ ids.append(f"neon:{base}:{pname}")
281
+ except Exception as exc:
282
+ LOG.warning("Could not list Neon models for role writer pick: %s", exc)
283
+ return ids
284
+
285
+
286
  @router.post("/chat/generate-role")
287
  async def api_generate_role(req: GenerateRoleRequest):
288
+ neon_ids = await _builder_neon_model_ids()
289
+ orchestrator_id = req.orchestrator_model_id or settings.orchestrator_model
290
  result = await generate_role_prompt(
 
291
  name=req.name,
292
  profile=req.profile,
293
  identity=req.identity,
294
  samples=req.samples,
295
  role_style=req.role_style,
296
+ orchestrator_model_id=orchestrator_id,
297
+ extra_model_ids=neon_ids,
298
  )
299
  if result.get("error"):
300
  raise HTTPException(status_code=400, detail=result["error"])
 
303
 
304
  @router.post("/chat/generate-role-freeform")
305
  async def api_generate_role_freeform(req: GenerateRoleFreeformRequest):
306
+ neon_ids = await _builder_neon_model_ids()
307
+ orchestrator_id = req.orchestrator_model_id or settings.orchestrator_model
308
  result = await generate_role_prompt_freeform(
 
309
  name=req.name,
310
  text=req.text,
311
  role_style=req.role_style,
312
+ orchestrator_model_id=orchestrator_id,
313
+ extra_model_ids=neon_ids,
314
+ )
315
+ if result.get("error"):
316
+ raise HTTPException(status_code=400, detail=result["error"])
317
+ return result
318
+
319
+
320
+ @router.post("/chat/suggest-model")
321
+ async def api_suggest_model(req: SuggestModelRequest):
322
+ """Recommend an LLM for an Expert Persona from the builder's model list.
323
+
324
+ Returns: {"recommended_model_id": "...", "rationale": "..."} on success,
325
+ or {"error": "..."} when the description is empty, the model list is
326
+ empty, or the orchestrator call fails / returns an invalid id.
327
+ """
328
+ if not req.available_models:
329
+ raise HTTPException(400, "At least one available model is required")
330
+
331
+ orchestrator_id = req.orchestrator_model_id or settings.orchestrator_model
332
+ neon_ids = await _builder_neon_model_ids()
333
+ result = await suggest_model_for_persona(
334
+ orchestrator_model_id=orchestrator_id,
335
+ persona_name=req.persona_name,
336
+ source_text=req.source_text,
337
+ role_prompt=req.role_prompt,
338
+ available_models=[m.model_dump() for m in req.available_models],
339
+ panel_context=[p.model_dump() for p in req.panel_context],
340
  )
341
  if result.get("error"):
342
  raise HTTPException(status_code=400, detail=result["error"])
backend/app/api/personas.py CHANGED
@@ -2,7 +2,7 @@
2
 
3
  `GET /api/personas` returns three sections - Neon HANA personas (with
4
  vanilla/RAG personas filtered out and display names reformatted via an
5
- LLM), the four bundled extra personas, and the user-supplied expert
6
  personas (which are local-only on the frontend but echoed here for
7
  completeness so the API can act as the source of truth for participant
8
  choices when needed).
 
2
 
3
  `GET /api/personas` returns three sections - Neon HANA personas (with
4
  vanilla/RAG personas filtered out and display names reformatted via an
5
+ LLM), the bundled extra personas, and the user-supplied expert
6
  personas (which are local-only on the frontend but echoed here for
7
  completeness so the API can act as the source of truth for participant
8
  choices when needed).
backend/app/config.py CHANGED
@@ -1,14 +1,44 @@
1
  from __future__ import annotations
2
 
 
3
  from pathlib import Path
4
 
5
- from pydantic import AliasChoices, Field
6
- from pydantic_settings import BaseSettings
7
 
8
  _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
9
  _ENV_FILE = _PROJECT_ROOT / ".env"
10
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  class Settings(BaseSettings):
13
  hana_base_url: str = "https://hana.neonaialpha.com"
14
  hana_username: str = "guest"
@@ -21,7 +51,10 @@ class Settings(BaseSettings):
21
  validation_alias=AliasChoices("HANA_KLATCHAT_PASSWORD", "HANA_PASSWORD_KLATCHAT"),
22
  )
23
  # Direct vLLM base (no /v1); matches brainforge-webapp docker config 4090-x1-3 host.
24
- neon_security_vllm_base_url: str = "https://4090-x1-3.neonaiservices2.com/vllm0"
 
 
 
25
  # Comma-separated model_id values to merge via get_personas when get_models omits them (needs HANA access)
26
  hana_neon_model_supplement_ids: str = "BrainForge/Security@2026.03.18"
27
 
@@ -44,7 +77,17 @@ class Settings(BaseSettings):
44
 
45
  cors_origins: str = "http://localhost:3000,http://localhost:3001,http://localhost:3002"
46
 
47
- model_config = {"env_file": str(_ENV_FILE), "env_file_encoding": "utf-8"}
 
 
 
 
 
 
 
 
 
 
48
 
49
  @property
50
  def cors_origin_list(self) -> list[str]:
@@ -218,40 +261,3 @@ class Settings(BaseSettings):
218
 
219
 
220
  settings = Settings()
221
-
222
- # region agent log
223
- def _agent_log_settings_env() -> None:
224
- import json
225
- import time
226
-
227
- _path = r"c:\Users\dream\CCAI-Demo-FEAT_Config\debug-c86901.log"
228
- try:
229
- raw = _ENV_FILE.read_text(encoding="utf-8") if _ENV_FILE.is_file() else ""
230
- data = {
231
- "app": "LLMChats3",
232
- "env_file": str(_ENV_FILE),
233
- "env_file_exists": _ENV_FILE.is_file(),
234
- "dotenv_line_HANA_KLATCHAT_PASSWORD": "HANA_KLATCHAT_PASSWORD" in raw,
235
- "dotenv_line_HANA_PASSWORD_KLATCHAT": "HANA_PASSWORD_KLATCHAT" in raw,
236
- "settings_hana_password_klatchat_nonempty": bool((settings.hana_password_klatchat or "").strip()),
237
- }
238
- with open(_path, "a", encoding="utf-8") as f:
239
- f.write(
240
- json.dumps(
241
- {
242
- "sessionId": "c86901",
243
- "hypothesisId": "H1",
244
- "location": "LLMChats3/config.py:settings",
245
- "message": "env_binding",
246
- "data": data,
247
- "timestamp": int(time.time() * 1000),
248
- }
249
- )
250
- + "\n"
251
- )
252
- except Exception:
253
- pass
254
-
255
-
256
- _agent_log_settings_env()
257
- # endregion
 
1
  from __future__ import annotations
2
 
3
+ import os
4
  from pathlib import Path
5
 
6
+ from pydantic import AliasChoices, Field, field_validator
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
 
9
  _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
10
  _ENV_FILE = _PROJECT_ROOT / ".env"
11
 
12
 
13
+ def _shared_env_candidates() -> list[Path]:
14
+ """Locations for the cross-project shared.env (never committed)."""
15
+ explicit = (os.getenv("SHARED_ENV_PATH") or os.getenv("SHARED_ENV_FILE") or "").strip()
16
+ if explicit:
17
+ return [Path(explicit).expanduser()]
18
+ return [
19
+ Path.home() / ".secrets" / "shared.env",
20
+ Path.home() / "Downloads" / "shared.env",
21
+ ]
22
+
23
+
24
+ def _resolve_shared_env() -> Path | None:
25
+ for candidate in _shared_env_candidates():
26
+ if candidate.is_file():
27
+ return candidate
28
+ return None
29
+
30
+
31
+ def _settings_env_files() -> tuple[str, ...]:
32
+ """Shared secrets first; project .env overrides."""
33
+ files: list[str] = []
34
+ shared = _resolve_shared_env()
35
+ if shared:
36
+ files.append(str(shared))
37
+ if _ENV_FILE.is_file():
38
+ files.append(str(_ENV_FILE))
39
+ return tuple(files)
40
+
41
+
42
  class Settings(BaseSettings):
43
  hana_base_url: str = "https://hana.neonaialpha.com"
44
  hana_username: str = "guest"
 
51
  validation_alias=AliasChoices("HANA_KLATCHAT_PASSWORD", "HANA_PASSWORD_KLATCHAT"),
52
  )
53
  # Direct vLLM base (no /v1); matches brainforge-webapp docker config 4090-x1-3 host.
54
+ neon_security_vllm_base_url: str = Field(
55
+ default="https://4090-x1-3.neonaiservices2.com/vllm0",
56
+ validation_alias=AliasChoices("NEON_SECURITY_VLLM_BASE_URL", "VLLM_BASE_URL"),
57
+ )
58
  # Comma-separated model_id values to merge via get_personas when get_models omits them (needs HANA access)
59
  hana_neon_model_supplement_ids: str = "BrainForge/Security@2026.03.18"
60
 
 
77
 
78
  cors_origins: str = "http://localhost:3000,http://localhost:3001,http://localhost:3002"
79
 
80
+ model_config = SettingsConfigDict(
81
+ env_file=_settings_env_files(),
82
+ env_file_encoding="utf-8",
83
+ )
84
+
85
+ @field_validator("neon_security_vllm_base_url", mode="before")
86
+ @classmethod
87
+ def _strip_vllm_v1_suffix(cls, value: object) -> object:
88
+ if isinstance(value, str):
89
+ return value.rstrip("/").removesuffix("/v1")
90
+ return value
91
 
92
  @property
93
  def cors_origin_list(self) -> list[str]:
 
261
 
262
 
263
  settings = Settings()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/data/demo_questions.json CHANGED
@@ -2,22 +2,10 @@
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",
13
- "title": "Best undergraduate majors for career prospects",
14
- "text": "What are the three best majors for my undergraduate degree? I live in Washington State, USA, and I am starting college in the fall. My biggest priority is to make sure I have good job prospects when I graduate in 2031. I love using AI and exploring new technology, math is one of my strongest subjects, I don't want to travel for my job, and I don't want to have to publish papers as part of my job. I like to talk to people, and to travel. I know a lot about finance because my parents are both accountants. I also love animals."
15
- },
16
- {
17
- "id": "small_business_ai",
18
- "category": "small business",
19
- "title": "Should a small bakery introduce AI?",
20
- "text": "Should I introduce AI tools into my small bakery, and if so, where should I start? I run a single-storefront bakery in a tourist town in Vermont with three full-time employees and four part-timers. We do counter sales, weekend wholesale to two coffee shops, and a small custom-cake business. I'm 54, comfortable with tech but not a developer. My priorities, in order: keep the staff feeling valued, hold the food quality steady, save myself maybe 5-10 hours a week of paperwork, and slowly grow custom-cake revenue. I have about $3,000 a year I could redirect into software or services without straining the books."
21
  },
22
  {
23
  "id": "rural_water",
@@ -25,6 +13,12 @@
25
  "title": "Best fix for a small town's failing water system",
26
  "text": "Our rural town in central Pennsylvania has roughly 1,800 residents and a 1960s-era municipal water system that has had three boil-water advisories in the last two years. We have a small budget and an aging volunteer water board. The state has offered a 50% matching grant but only if we commit to a project plan within nine months. The three options on the table are: (a) replace the existing main lines incrementally over twelve years, (b) consolidate with a neighboring town's larger system, losing local control but gaining redundancy, or (c) install a smaller modern treatment plant of our own with recurring maintenance contracts. Residents are split, and the median household income is around $52,000."
27
  },
 
 
 
 
 
 
28
  {
29
  "id": "acquisition_decision",
30
  "category": "business / M&A",
@@ -37,6 +31,12 @@
37
  "title": "AI policy for a public middle school",
38
  "text": "I'm a principal at a public middle school in Texas, grades 6-8, 480 students. Many of my teachers are quietly using ChatGPT and similar tools to plan lessons and draft feedback. About a third of students are using AI for homework, sometimes well, sometimes to skip thinking entirely. The school board wants me to publish a formal AI policy by the end of the semester. I have to balance student learning, equity (not all students have the same access at home), staff workload, parental concerns about screen time and privacy, and our district's tight budget. Should I lean toward restricting AI, embracing it, or some structured middle path - and what would the most important rules be?"
39
  },
 
 
 
 
 
 
40
  {
41
  "id": "career_pivot_30s",
42
  "category": "career",
@@ -49,6 +49,12 @@
49
  "title": "Buying property knowing the climate is shifting",
50
  "text": "My partner and I are looking to buy a 5-15 acre property within a four-hour drive of Denver. We want a small homestead - some chickens, a vegetable garden, room for two large dogs - and a place we can live in long term. Our budget is around $550,000 including a modest house. We're worried about climate exposure: wildfire smoke, water-rights pressure on small wells, longer droughts, and how much insurance is going to cost ten years from now. We're also worried about being too far from a hospital. Neither of us has farming experience but we're hands-on. How do we think about which properties to look at, what to actually optimize for, and what we should be willing to compromise on?"
51
  },
 
 
 
 
 
 
52
  {
53
  "id": "open_source_strategy",
54
  "category": "tech / business strategy",
 
2
  "version": 1,
3
  "questions": [
4
  {
5
+ "id": "build_vs_buy_ai",
6
+ "category": "tech / business strategy",
7
+ "title": "Should a company build its own AI system or rely on a third-party provider?",
8
+ "text": "Should a company build its own AI system or rely on a third-party provider?"
 
 
 
 
 
 
 
 
 
 
 
 
9
  },
10
  {
11
  "id": "rural_water",
 
13
  "title": "Best fix for a small town's failing water system",
14
  "text": "Our rural town in central Pennsylvania has roughly 1,800 residents and a 1960s-era municipal water system that has had three boil-water advisories in the last two years. We have a small budget and an aging volunteer water board. The state has offered a 50% matching grant but only if we commit to a project plan within nine months. The three options on the table are: (a) replace the existing main lines incrementally over twelve years, (b) consolidate with a neighboring town's larger system, losing local control but gaining redundancy, or (c) install a smaller modern treatment plant of our own with recurring maintenance contracts. Residents are split, and the median household income is around $52,000."
15
  },
16
+ {
17
+ "id": "undergrad_majors",
18
+ "category": "education",
19
+ "title": "Best undergraduate majors for career prospects",
20
+ "text": "What are the three best majors for my undergraduate degree? I live in Washington State, USA, and I am starting college in the fall. My biggest priority is to make sure I have good job prospects when I graduate in 2031. I love using AI and exploring new technology, math is one of my strongest subjects, I don't want to travel for my job, and I don't want to have to publish papers as part of my job. I like to talk to people, and to travel. I know a lot about finance because my parents are both accountants. I also love animals."
21
+ },
22
  {
23
  "id": "acquisition_decision",
24
  "category": "business / M&A",
 
31
  "title": "AI policy for a public middle school",
32
  "text": "I'm a principal at a public middle school in Texas, grades 6-8, 480 students. Many of my teachers are quietly using ChatGPT and similar tools to plan lessons and draft feedback. About a third of students are using AI for homework, sometimes well, sometimes to skip thinking entirely. The school board wants me to publish a formal AI policy by the end of the semester. I have to balance student learning, equity (not all students have the same access at home), staff workload, parental concerns about screen time and privacy, and our district's tight budget. Should I lean toward restricting AI, embracing it, or some structured middle path - and what would the most important rules be?"
33
  },
34
+ {
35
+ "id": "small_business_ai",
36
+ "category": "small business",
37
+ "title": "Should a small bakery introduce AI?",
38
+ "text": "Should I introduce AI tools into my small bakery, and if so, where should I start? I run a single-storefront bakery in a tourist town in Vermont with three full-time employees and four part-timers. We do counter sales, weekend wholesale to two coffee shops, and a small custom-cake business. I'm 54, comfortable with tech but not a developer. My priorities, in order: keep the staff feeling valued, hold the food quality steady, save myself maybe 5-10 hours a week of paperwork, and slowly grow custom-cake revenue. I have about $3,000 a year I could redirect into software or services without straining the books."
39
+ },
40
  {
41
  "id": "career_pivot_30s",
42
  "category": "career",
 
49
  "title": "Buying property knowing the climate is shifting",
50
  "text": "My partner and I are looking to buy a 5-15 acre property within a four-hour drive of Denver. We want a small homestead - some chickens, a vegetable garden, room for two large dogs - and a place we can live in long term. Our budget is around $550,000 including a modest house. We're worried about climate exposure: wildfire smoke, water-rights pressure on small wells, longer droughts, and how much insurance is going to cost ten years from now. We're also worried about being too far from a hospital. Neither of us has farming experience but we're hands-on. How do we think about which properties to look at, what to actually optimize for, and what we should be willing to compromise on?"
51
  },
52
+ {
53
+ "id": "best_pet",
54
+ "category": "lifestyle",
55
+ "title": "Which is the best pet, cats or dogs?",
56
+ "text": "Which is the best pet, cats or dogs?"
57
+ },
58
  {
59
  "id": "open_source_strategy",
60
  "category": "tech / business strategy",
backend/app/services/extra_personas.py CHANGED
@@ -1,4 +1,4 @@
1
- """Four bundled "extra" personas powered by non-Neon LLMs.
2
 
3
  Each pairs a discussion lens with a complementary area of expertise so
4
  they generalize to any question. The user can replace any of them by
@@ -105,6 +105,67 @@ EXTRA_PERSONAS: list[ExtraPersonaSpec] = [
105
  "it."
106
  ),
107
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  ]
109
 
110
 
 
1
+ """Bundled "extra" personas powered by provider and Neon LLMs.
2
 
3
  Each pairs a discussion lens with a complementary area of expertise so
4
  they generalize to any question. The user can replace any of them by
 
105
  "it."
106
  ),
107
  ),
108
+ ExtraPersonaSpec(
109
+ participant_id="extra_elena_financial_strategist",
110
+ name="Elena — Financial Strategist",
111
+ default_model_id="gpt-4.1",
112
+ role_prompt=(
113
+ "You are Elena, a financial strategist. You evaluate every question "
114
+ "through cost, return on investment, total cost of ownership, budget "
115
+ "tradeoffs, and financial risk. You think in three-to-five-year "
116
+ "horizons and default to back-of-envelope math rather than "
117
+ "hand-waving. Your tone is pragmatic and numbers-grounded: you "
118
+ "compare options on unit economics, break-even timing, and what has "
119
+ "to be true for the numbers to work. You are general-purpose enough "
120
+ "to join any panel discussion, but you always bring a finance lens — "
121
+ "opportunity cost, capital allocation, and whether proposed benefits "
122
+ "justify the spend. You speak in short, clear paragraphs with "
123
+ "concrete figures when you can estimate them, and you flag when "
124
+ "someone's case depends on assumptions they have not priced in. You "
125
+ "update your view when shown credible financial evidence, but you "
126
+ "ask what the downside looks like on the balance sheet before "
127
+ "agreeing."
128
+ ),
129
+ ),
130
+ ExtraPersonaSpec(
131
+ participant_id="extra_marcus_technology_strategist",
132
+ name="Marcus — Technology Strategist",
133
+ default_model_id="mistral-small-2603",
134
+ role_prompt=(
135
+ "You are Marcus, a technology strategist. You evaluate technical "
136
+ "feasibility, architecture, build-versus-buy tradeoffs, integration "
137
+ "complexity, and long-term maintainability. Your tone is direct and "
138
+ "practical: you focus on what is actually buildable, operable, and "
139
+ "worth owning. On any topic you ask what the system has to do, what "
140
+ "already exists to reuse, and where the hard integration or scaling "
141
+ "risks live. You favor options that ship reliably over clever ones "
142
+ "that require heroic engineering. You speak in concise, structured "
143
+ "points, call out hidden dependencies and operational burden, and "
144
+ "distinguish 'possible in a demo' from 'sustainable in production.' "
145
+ "You advocate building when the product is the technology itself, "
146
+ "and buying when the problem is well-served commodity capability. "
147
+ "You change your mind when someone shows a simpler architecture or "
148
+ "a realistic delivery path you had not considered."
149
+ ),
150
+ ),
151
+ ExtraPersonaSpec(
152
+ participant_id="extra_amira_security_advisor",
153
+ name="Dr. Amira — Security & Privacy Advisor",
154
+ default_model_id="neon:BrainForge/Security@2026.05.13:CybersecurityExpert",
155
+ role_prompt=(
156
+ "You are Dr. Amira, a security and privacy advisor. You evaluate every question "
157
+ "through the lens of data security, privacy, regulatory and compliance risk, and "
158
+ "where data actually lives — including retention, subprocessors, data residency, "
159
+ "and whether sensitive information ever leaves an organization's boundary. Your "
160
+ "tone is careful, precise, and risk-aware. You weigh the real-world tradeoffs "
161
+ "between security and practicality rather than defaulting to 'lock everything "
162
+ "down,' and you flag when a decision depends on the specific regulatory "
163
+ "environment (healthcare, finance, government, etc.). You give clear, substantive "
164
+ "replies and engage directly with other participants' points, especially when a "
165
+ "technical or cost argument has security or privacy implications they haven't "
166
+ "accounted for."
167
+ ),
168
+ ),
169
  ]
170
 
171
 
backend/app/services/model_picker.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pick a neutral, general-purpose model for meta-LLM tasks.
2
+
3
+ Used by role-prompt generation and model suggestion so those calls do
4
+ not run through a Neon character persona (which injects its own identity).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from app.config import settings
9
+
10
+
11
+ def is_neon_character_model_id(model_id: str | None) -> bool:
12
+ return bool(model_id and model_id.startswith("neon:"))
13
+
14
+
15
+ def is_vanilla_neon_model_id(model_id: str | None) -> bool:
16
+ return is_neon_character_model_id(model_id) and model_id.rsplit(":", 1)[-1] == "vanilla"
17
+
18
+
19
+ def pick_general_purpose_model(
20
+ preferred: str | None = None,
21
+ *,
22
+ extra_model_ids: list[str] | None = None,
23
+ ) -> str | None:
24
+ """Return a model id suitable for neutral writing / analysis tasks.
25
+
26
+ Resolution order:
27
+ 1. preferred if resolvable and NOT a Neon character
28
+ 2. settings.orchestrator_model if resolvable and NOT Neon
29
+ 3. first resolvable model from settings.providers
30
+ 4. extra_model_ids Neon entries ending in ':vanilla'
31
+ 5. any other resolvable Neon id from extra_model_ids (last resort)
32
+ """
33
+ seen: set[str] = set()
34
+ non_neon: list[str] = []
35
+ vanilla_neon: list[str] = []
36
+ other_neon: list[str] = []
37
+
38
+ def _bucket(mid: str) -> None:
39
+ if not mid or mid in seen or not settings.resolve_model(mid):
40
+ return
41
+ seen.add(mid)
42
+ if is_neon_character_model_id(mid):
43
+ if is_vanilla_neon_model_id(mid):
44
+ vanilla_neon.append(mid)
45
+ else:
46
+ other_neon.append(mid)
47
+ else:
48
+ non_neon.append(mid)
49
+
50
+ for mid in [preferred, settings.orchestrator_model]:
51
+ if mid and not is_neon_character_model_id(mid):
52
+ _bucket(mid)
53
+
54
+ for prov in settings.providers:
55
+ for m in prov.get("models") or []:
56
+ _bucket((m.get("id") or "").strip())
57
+
58
+ for mid in extra_model_ids or []:
59
+ _bucket((mid or "").strip())
60
+
61
+ ordered = non_neon + vanilla_neon + other_neon
62
+ return ordered[0] if ordered else None
backend/app/services/model_recommend.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Suggest an LLM for an Expert Persona based on its prompt text."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import re
6
+ from typing import Any
7
+
8
+ from app.config import settings
9
+ from app.clients.llm_router import chat_completion
10
+ from app.services.json_calls import parse_json_response
11
+ from app.services.model_picker import (
12
+ is_neon_character_model_id,
13
+ is_vanilla_neon_model_id,
14
+ pick_general_purpose_model,
15
+ )
16
+ from app.services.prompts.model_recommend import SUGGEST_MODEL_PROMPT
17
+ from app.utils.sanitize import strip_thinking
18
+
19
+ LOG = logging.getLogger(__name__)
20
+
21
+ _SOURCE_TEXT_MAX_CHARS = 4000
22
+ _ROLE_PROMPT_MAX_CHARS = 4000
23
+
24
+ _SUGGEST_SYSTEM_DIRECTIVE = (
25
+ "You help users pick an LLM model for a persona. "
26
+ "Follow the output format in the user message exactly. "
27
+ "Return ONLY the two requested lines — no preamble, analysis, or markdown."
28
+ )
29
+
30
+
31
+ def _truncate(text: str, max_chars: int) -> str:
32
+ text = (text or "").strip()
33
+ if len(text) <= max_chars:
34
+ return text
35
+ return text[:max_chars].rstrip() + "..."
36
+
37
+
38
+ def _models_block(models: list[dict[str, Any]]) -> str:
39
+ """One line per model: id, display name, provider/family, kind."""
40
+ lines: list[str] = []
41
+ for i, m in enumerate(models, start=1):
42
+ mid = (m.get("id") or "").strip()
43
+ if not mid:
44
+ continue
45
+ name = (m.get("name") or mid).strip()
46
+ provider = (m.get("provider") or "").strip()
47
+ family = provider or "Unknown"
48
+ kind = (m.get("kind") or "provider").strip()
49
+ lines.append(
50
+ f"{i}. id={mid} | name={name} | family={family} | kind={kind}"
51
+ )
52
+ return "\n".join(lines) if lines else "(no models provided)"
53
+
54
+
55
+ def _panel_block(panel: list[dict[str, Any]]) -> str:
56
+ """Describe other participants already in the panel."""
57
+ if not panel:
58
+ return ""
59
+ lines: list[str] = [
60
+ "Other participants already in this panel (avoid recommending "
61
+ "the same model family for every persona when alternatives "
62
+ "fit equally well):\n",
63
+ ]
64
+ for i, p in enumerate(panel, start=1):
65
+ name = (p.get("name") or "Unnamed").strip()
66
+ mid = (p.get("model_id") or "").strip()
67
+ provider = (p.get("provider") or "").strip()
68
+ lines.append(
69
+ f"{i}. name={name} | model_id={mid or '(default)'} "
70
+ f"| family={provider or 'Unknown'}"
71
+ )
72
+ lines.append("")
73
+ return "\n".join(lines)
74
+
75
+
76
+ def _validate_model_id(model_id: str | None, models: list[dict[str, Any]]) -> str | None:
77
+ """Return model_id if it exists in the submitted list, else None."""
78
+ if not model_id or not isinstance(model_id, str):
79
+ return None
80
+ valid = {(m.get("id") or "").strip() for m in models}
81
+ mid = model_id.strip()
82
+ return mid if mid in valid else None
83
+
84
+
85
+ def _parse_suggest_response(
86
+ raw: str,
87
+ models: list[dict[str, Any]],
88
+ ) -> tuple[str | None, str]:
89
+ """Extract recommended_model_id + rationale from LLM output."""
90
+ parsed = parse_json_response(raw)
91
+ if isinstance(parsed, dict):
92
+ rid = parsed.get("recommended_model_id")
93
+ rat = parsed.get("rationale", "")
94
+ if isinstance(rid, str) and rid.strip():
95
+ return rid.strip(), rat.strip() if isinstance(rat, str) else ""
96
+
97
+ id_match = re.search(
98
+ r"recommended_model_id\s*[:=]\s*[\"']?([^\s\"'\n]+)",
99
+ raw,
100
+ re.IGNORECASE,
101
+ )
102
+ if id_match:
103
+ rid = id_match.group(1).strip().strip('"').strip("'")
104
+ rat_match = re.search(
105
+ r"rationale\s*[:=]\s*(.+)",
106
+ raw,
107
+ re.IGNORECASE | re.DOTALL,
108
+ )
109
+ rationale = rat_match.group(1).strip() if rat_match else ""
110
+ validated = _validate_model_id(rid, models)
111
+ if validated:
112
+ return validated, rationale
113
+
114
+ for model in sorted(models, key=lambda m: len(m.get("id") or ""), reverse=True):
115
+ mid = (model.get("id") or "").strip()
116
+ if mid and mid in raw:
117
+ return mid, "Inferred from model analysis."
118
+
119
+ raw_lower = raw.lower()
120
+ for model in models:
121
+ name = (model.get("name") or "").strip()
122
+ if name and len(name) >= 4 and name.lower() in raw_lower:
123
+ mid = (model.get("id") or "").strip()
124
+ if mid:
125
+ return mid, "Inferred from model name in response."
126
+ provider = (model.get("provider") or "").strip()
127
+ for part in provider.replace(",", "/").split("/"):
128
+ token = part.strip()
129
+ if len(token) >= 6 and token.lower() in raw_lower:
130
+ mid = (model.get("id") or "").strip()
131
+ if mid:
132
+ return mid, f"Inferred from '{token}' in response."
133
+
134
+ return None, ""
135
+
136
+
137
+ def _meta_model_candidates(
138
+ preferred: str,
139
+ available_models: list[dict[str, Any]],
140
+ ) -> list[str]:
141
+ """Ordered model ids for the meta-LLM call (neutral writer first)."""
142
+ extra = [(m.get("id") or "").strip() for m in available_models]
143
+ seen: set[str] = set()
144
+ out: list[str] = []
145
+
146
+ primary = pick_general_purpose_model(preferred, extra_model_ids=extra)
147
+ for mid in [primary]:
148
+ if mid and mid not in seen:
149
+ seen.add(mid)
150
+ out.append(mid)
151
+
152
+ for prov in settings.providers:
153
+ for m in prov.get("models") or []:
154
+ mid = (m.get("id") or "").strip()
155
+ if (
156
+ mid
157
+ and mid not in seen
158
+ and not is_neon_character_model_id(mid)
159
+ and settings.resolve_model(mid)
160
+ ):
161
+ seen.add(mid)
162
+ out.append(mid)
163
+
164
+ for m in available_models:
165
+ mid = (m.get("id") or "").strip()
166
+ if (
167
+ mid
168
+ and mid not in seen
169
+ and is_vanilla_neon_model_id(mid)
170
+ and settings.resolve_model(mid)
171
+ ):
172
+ seen.add(mid)
173
+ out.append(mid)
174
+
175
+ return out
176
+
177
+
178
+ def _source_mentions_neon_character(source_text: str, model: dict[str, Any]) -> bool:
179
+ """True when source text plausibly references this named Neon character."""
180
+ source_lower = (source_text or "").lower()
181
+ if not source_lower:
182
+ return False
183
+
184
+ tokens: set[str] = set()
185
+ name = (model.get("name") or "").strip().lower()
186
+ if name and name != "vanilla" and len(name) >= 4:
187
+ tokens.add(name)
188
+
189
+ provider = (model.get("provider") or "")
190
+ for part in provider.replace("/", " ").replace(",", " ").split():
191
+ token = part.strip().lower()
192
+ if len(token) >= 5 and token not in ("neon", "vanilla", "brainforge"):
193
+ tokens.add(token)
194
+
195
+ mid = (model.get("id") or "")
196
+ if is_neon_character_model_id(mid):
197
+ base = mid.split(":", 2)[1] if mid.count(":") >= 2 else ""
198
+ for segment in base.replace("@", "/").split("/"):
199
+ token = segment.strip().lower()
200
+ if len(token) >= 5:
201
+ tokens.add(token)
202
+
203
+ return any(token in source_lower for token in tokens)
204
+
205
+
206
+ def _deprioritize_neon_mismatch(
207
+ recommended_id: str,
208
+ source_text: str,
209
+ models: list[dict[str, Any]],
210
+ ) -> str:
211
+ """Swap named Neon picks that don't match source for a general model."""
212
+ if len(models) <= 1:
213
+ return recommended_id
214
+
215
+ model_by_id = {m["id"]: m for m in models if (m.get("id") or "").strip()}
216
+ rec = model_by_id.get(recommended_id)
217
+ if not rec:
218
+ return recommended_id
219
+
220
+ kind = (rec.get("kind") or "provider").strip()
221
+ if kind != "neon_character" or is_vanilla_neon_model_id(recommended_id):
222
+ return recommended_id
223
+
224
+ if _source_mentions_neon_character(source_text, rec):
225
+ return recommended_id
226
+
227
+ LOG.warning(
228
+ "Neon character %s does not match source description; preferring general model",
229
+ recommended_id,
230
+ )
231
+
232
+ for m in models:
233
+ if (m.get("kind") or "provider") == "provider":
234
+ return m["id"]
235
+ for m in models:
236
+ if is_vanilla_neon_model_id(m.get("id")):
237
+ return m["id"]
238
+ return recommended_id
239
+
240
+
241
+ async def _meta_suggest_call(model_id: str, user_prompt: str) -> str:
242
+ """Run the suggestion meta-LLM via chat_completion (Neon + external)."""
243
+ resolved = settings.resolve_model(model_id)
244
+ if not resolved:
245
+ return ""
246
+
247
+ messages = [
248
+ {"role": "system", "content": _SUGGEST_SYSTEM_DIRECTIVE},
249
+ {"role": "user", "content": user_prompt},
250
+ ]
251
+ try:
252
+ result = await chat_completion(
253
+ resolved=resolved,
254
+ messages=messages,
255
+ temperature=0.2,
256
+ max_tokens=256,
257
+ timeout=45,
258
+ )
259
+ except Exception as exc:
260
+ LOG.exception("suggest_model meta-LLM call failed: %s", exc)
261
+ return ""
262
+
263
+ if result.get("error"):
264
+ LOG.warning("suggest_model meta-LLM error: %s", result.get("response"))
265
+ return ""
266
+
267
+ return strip_thinking(result.get("response", ""))
268
+
269
+
270
+ async def suggest_model_for_persona(
271
+ *,
272
+ orchestrator_model_id: str,
273
+ persona_name: str,
274
+ source_text: str = "",
275
+ role_prompt: str = "",
276
+ available_models: list[dict[str, Any]],
277
+ panel_context: list[dict[str, Any]] | None = None,
278
+ ) -> dict[str, Any]:
279
+ """Return {recommended_model_id, rationale} or {error: str}."""
280
+ source = _truncate(source_text, _SOURCE_TEXT_MAX_CHARS)
281
+ prompt_text = _truncate(role_prompt, _ROLE_PROMPT_MAX_CHARS)
282
+
283
+ if not source and not prompt_text:
284
+ return {
285
+ "error": (
286
+ "Enter a description or role prompt for a model to be suggested."
287
+ ),
288
+ }
289
+
290
+ models = [m for m in available_models if (m.get("id") or "").strip()]
291
+ if not models:
292
+ return {"error": "No models available to recommend from."}
293
+
294
+ if len(models) == 1:
295
+ only = models[0]
296
+ return {
297
+ "recommended_model_id": only["id"],
298
+ "rationale": "Only one model is available in the builder.",
299
+ }
300
+
301
+ panel = panel_context or []
302
+ user_prompt = SUGGEST_MODEL_PROMPT.format(
303
+ persona_name=(persona_name or "Unnamed").strip(),
304
+ source_text=source or "(not provided — rely on role prompt below)",
305
+ role_prompt=prompt_text or "(not provided — rely on description above)",
306
+ models_block=_models_block(models),
307
+ panel_block=_panel_block(panel),
308
+ )
309
+
310
+ meta_candidates = _meta_model_candidates(orchestrator_model_id, models)
311
+ if not meta_candidates:
312
+ return {
313
+ "error": "Model suggestion unavailable — no LLM configured to run the analysis.",
314
+ }
315
+
316
+ recommended: str | None = None
317
+ rationale = ""
318
+ for meta_model_id in meta_candidates:
319
+ raw = await _meta_suggest_call(meta_model_id, user_prompt)
320
+ recommended, rationale = _parse_suggest_response(raw, models)
321
+ if recommended and _validate_model_id(recommended, models):
322
+ break
323
+ recommended = None
324
+ rationale = ""
325
+
326
+ validated = _validate_model_id(recommended, models)
327
+ if not validated:
328
+ LOG.warning(
329
+ "suggest_model returned invalid id %r; valid=%s",
330
+ recommended,
331
+ [m.get("id") for m in models[:5]],
332
+ )
333
+ return {
334
+ "error": "Model suggestion unavailable — please pick manually.",
335
+ }
336
+
337
+ validated = _deprioritize_neon_mismatch(validated, source, models)
338
+
339
+ if not settings.resolve_model(validated):
340
+ LOG.warning("suggest_model picked unresolvable id %s", validated)
341
+ return {
342
+ "error": "Suggested model is no longer available — please pick manually.",
343
+ }
344
+
345
+ if not rationale:
346
+ rationale = "Recommended based on persona fit."
347
+
348
+ return {
349
+ "recommended_model_id": validated,
350
+ "rationale": rationale,
351
+ }
backend/app/services/persona.py CHANGED
@@ -4,9 +4,33 @@ import logging
4
 
5
  from app.clients.llm_router import chat_completion
6
  from app.config import settings
 
7
 
8
  LOG = logging.getLogger(__name__)
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  # ---------------------------------------------------------------------------
11
  # Structured input prompts
12
  # ---------------------------------------------------------------------------
@@ -16,15 +40,17 @@ STRUCTURED_AI_COMPLETED_PROMPT = (
16
  "statement, a profile, and optionally writing/speech samples. Some fields may be sparse or "
17
  "missing. Write a complete, vivid 3-5 sentence role prompt that an LLM can use to "
18
  "convincingly embody this persona in a conversation.\n\n"
19
- "If any fields are sparse, infer plausible personality traits, speech patterns, interests, "
20
- "and conversational style from whatever clues are available. Fill in realistic detail so "
21
- "the role prompt is rich and actionable — never produce a vague or skeletal prompt.\n\n"
22
- "Cover: personality, tone and speech patterns, background/expertise, interests and "
23
- "motivations, and how they would naturally interact in a casual conversation.\n\n"
 
24
  "The name is: {name}\n"
25
  "The identity statement is: {identity}\n"
26
  "The profile is: {profile}\n"
27
  "Here are the writing and/or speech samples: {samples}"
 
28
  )
29
 
30
  STRUCTURED_EXACT_PROMPT = (
@@ -41,6 +67,7 @@ STRUCTURED_EXACT_PROMPT = (
41
  "The identity statement is: {identity}\n"
42
  "The profile is: {profile}\n"
43
  "Here are the writing and/or speech samples: {samples}"
 
44
  )
45
 
46
  # ---------------------------------------------------------------------------
@@ -52,15 +79,17 @@ FREEFORM_AI_COMPLETED_PROMPT = (
52
  "detailed (with writing samples, background, etc.) or very brief (just a name or a short "
53
  "description). Regardless of how much is provided, write a complete, vivid 3-5 sentence "
54
  "role prompt that an LLM can use to convincingly embody this persona in a conversation.\n\n"
55
- "If the input is sparse, infer plausible personality traits, speech patterns, interests, "
56
- "and conversational style from whatever clues are available (the name, any title or "
57
- "occupation, context, etc.). Fill in realistic detail so the role prompt is rich and "
58
- "actionable — never produce a vague or skeletal prompt.\n\n"
59
- "Cover: personality, tone and speech patterns, background/expertise, interests and "
60
- "motivations, and how they would naturally interact in a casual conversation.\n\n"
 
61
  "The persona's name is: {name}\n\n"
62
  "Here is everything provided about this persona:\n"
63
  "---\n{text}\n---"
 
64
  )
65
 
66
  FREEFORM_EXACT_PROMPT = (
@@ -75,20 +104,20 @@ FREEFORM_EXACT_PROMPT = (
75
  "The persona's name is: {name}\n\n"
76
  "Here is everything provided about this persona:\n"
77
  "---\n{text}\n---"
 
78
  )
79
 
80
 
81
  async def _call_llm(model_id: str, prompt_text: str) -> dict:
82
  resolved = settings.resolve_model(model_id)
83
  if not resolved:
84
- return {"role_prompt": "", "error": f"Unknown model: {model_id}"}
 
 
 
85
 
86
  messages = [
87
- {"role": "system", "content": (
88
- "You are a helpful assistant that creates character prompts. "
89
- "Respond ONLY with the finished role prompt text. Do NOT include your reasoning, "
90
- "thought process, analysis, draft notes, or any meta-commentary."
91
- )},
92
  {"role": "user", "content": prompt_text},
93
  ]
94
 
@@ -106,18 +135,34 @@ async def _call_llm(model_id: str, prompt_text: str) -> dict:
106
  return {
107
  "role_prompt": result["response"],
108
  "elapsed_seconds": result["elapsed_seconds"],
 
109
  }
110
 
111
 
 
 
 
 
 
 
 
 
 
 
112
  async def generate_role_prompt(
113
- model_id: str,
114
  name: str,
115
  profile: str,
116
  identity: str,
117
  samples: str,
118
  role_style: str = "exact",
 
 
119
  ) -> dict:
120
- """Use the selected LLM to distill structured persona inputs into a role prompt."""
 
 
 
 
121
  template = STRUCTURED_AI_COMPLETED_PROMPT if role_style == "ai_completed" else STRUCTURED_EXACT_PROMPT
122
  prompt_text = template.format(
123
  name=name or "(not provided)",
@@ -125,19 +170,24 @@ async def generate_role_prompt(
125
  profile=profile or "(not provided)",
126
  samples=samples or "(not provided)",
127
  )
128
- return await _call_llm(model_id, prompt_text)
129
 
130
 
131
  async def generate_role_prompt_freeform(
132
- model_id: str,
133
  name: str,
134
  text: str,
135
  role_style: str = "ai_completed",
 
 
136
  ) -> dict:
137
- """Use the selected LLM to distill a single freeform text block into a role prompt."""
 
 
 
 
138
  template = FREEFORM_AI_COMPLETED_PROMPT if role_style == "ai_completed" else FREEFORM_EXACT_PROMPT
139
  prompt_text = template.format(
140
  name=name or "(not provided)",
141
  text=text or "(not provided)",
142
  )
143
- return await _call_llm(model_id, prompt_text)
 
4
 
5
  from app.clients.llm_router import chat_completion
6
  from app.config import settings
7
+ from app.services.model_picker import pick_general_purpose_model
8
 
9
  LOG = logging.getLogger(__name__)
10
 
11
+ _ROLE_WRITER_SYSTEM = (
12
+ "You are a helpful assistant that creates role prompts for LLM participants. "
13
+ "Respond ONLY with the finished role prompt text — no reasoning, analysis, "
14
+ "draft notes, questions to the user, or meta-commentary.\n\n"
15
+ "STRICT RULES:\n"
16
+ "- Do NOT invent a personal name unless one is explicitly provided in the name field.\n"
17
+ "- Do NOT invent hobbies, side interests, employers, blogs, websites, or domains "
18
+ "not stated in the user's input.\n"
19
+ "- Do NOT add conversational filler or questions (e.g. 'What do you think?').\n"
20
+ "- Write in second person ('You are…') so another LLM can embody the persona."
21
+ )
22
+
23
+ _ANTI_INVENTION_AI = (
24
+ "\n\nIMPORTANT: You may elaborate on tone, speech patterns, and professional style "
25
+ "implied by the stated identity, but stay strictly within the user's described domain. "
26
+ "Do NOT add unrelated interests, fictional backstory, or new subject areas."
27
+ )
28
+
29
+ _ANTI_INVENTION_EXACT = (
30
+ "\n\nIMPORTANT: Use ONLY the information explicitly provided. Do not invent names "
31
+ "(unless given in the name field), hobbies, employers, blogs, or other facts."
32
+ )
33
+
34
  # ---------------------------------------------------------------------------
35
  # Structured input prompts
36
  # ---------------------------------------------------------------------------
 
40
  "statement, a profile, and optionally writing/speech samples. Some fields may be sparse or "
41
  "missing. Write a complete, vivid 3-5 sentence role prompt that an LLM can use to "
42
  "convincingly embody this persona in a conversation.\n\n"
43
+ "If any fields are sparse, infer plausible personality traits, speech patterns, and "
44
+ "conversational style from whatever clues are available — but only within the professional "
45
+ "or personal domain the user described. Fill in realistic detail so the role prompt is "
46
+ "rich and actionable — never produce a vague or skeletal prompt.\n\n"
47
+ "Cover: personality, tone and speech patterns, background/expertise, and how they would "
48
+ "naturally interact in a group discussion.\n\n"
49
  "The name is: {name}\n"
50
  "The identity statement is: {identity}\n"
51
  "The profile is: {profile}\n"
52
  "Here are the writing and/or speech samples: {samples}"
53
+ + _ANTI_INVENTION_AI
54
  )
55
 
56
  STRUCTURED_EXACT_PROMPT = (
 
67
  "The identity statement is: {identity}\n"
68
  "The profile is: {profile}\n"
69
  "Here are the writing and/or speech samples: {samples}"
70
+ + _ANTI_INVENTION_EXACT
71
  )
72
 
73
  # ---------------------------------------------------------------------------
 
79
  "detailed (with writing samples, background, etc.) or very brief (just a name or a short "
80
  "description). Regardless of how much is provided, write a complete, vivid 3-5 sentence "
81
  "role prompt that an LLM can use to convincingly embody this persona in a conversation.\n\n"
82
+ "If the input is sparse, infer plausible personality traits, speech patterns, and "
83
+ "conversational style from whatever clues are available (the name, any title or "
84
+ "occupation, context, etc.) — but only within the domain the user described. Fill in "
85
+ "realistic detail so the role prompt is rich and actionable — never produce a vague or "
86
+ "skeletal prompt.\n\n"
87
+ "Cover: personality, tone and speech patterns, background/expertise, and how they would "
88
+ "naturally interact in a group discussion.\n\n"
89
  "The persona's name is: {name}\n\n"
90
  "Here is everything provided about this persona:\n"
91
  "---\n{text}\n---"
92
+ + _ANTI_INVENTION_AI
93
  )
94
 
95
  FREEFORM_EXACT_PROMPT = (
 
104
  "The persona's name is: {name}\n\n"
105
  "Here is everything provided about this persona:\n"
106
  "---\n{text}\n---"
107
+ + _ANTI_INVENTION_EXACT
108
  )
109
 
110
 
111
  async def _call_llm(model_id: str, prompt_text: str) -> dict:
112
  resolved = settings.resolve_model(model_id)
113
  if not resolved:
114
+ return {
115
+ "role_prompt": "",
116
+ "error": f"No neutral model available to generate the role prompt.",
117
+ }
118
 
119
  messages = [
120
+ {"role": "system", "content": _ROLE_WRITER_SYSTEM},
 
 
 
 
121
  {"role": "user", "content": prompt_text},
122
  ]
123
 
 
135
  return {
136
  "role_prompt": result["response"],
137
  "elapsed_seconds": result["elapsed_seconds"],
138
+ "writer_model_id": model_id,
139
  }
140
 
141
 
142
+ async def _resolve_writer_model(
143
+ orchestrator_model_id: str | None,
144
+ extra_model_ids: list[str] | None,
145
+ ) -> str | None:
146
+ return pick_general_purpose_model(
147
+ orchestrator_model_id,
148
+ extra_model_ids=extra_model_ids,
149
+ )
150
+
151
+
152
  async def generate_role_prompt(
 
153
  name: str,
154
  profile: str,
155
  identity: str,
156
  samples: str,
157
  role_style: str = "exact",
158
+ orchestrator_model_id: str | None = None,
159
+ extra_model_ids: list[str] | None = None,
160
  ) -> dict:
161
+ """Distill structured persona inputs into a role prompt via a neutral writer model."""
162
+ writer_id = await _resolve_writer_model(orchestrator_model_id, extra_model_ids)
163
+ if not writer_id:
164
+ return {"role_prompt": "", "error": "No model available to generate the role prompt."}
165
+
166
  template = STRUCTURED_AI_COMPLETED_PROMPT if role_style == "ai_completed" else STRUCTURED_EXACT_PROMPT
167
  prompt_text = template.format(
168
  name=name or "(not provided)",
 
170
  profile=profile or "(not provided)",
171
  samples=samples or "(not provided)",
172
  )
173
+ return await _call_llm(writer_id, prompt_text)
174
 
175
 
176
  async def generate_role_prompt_freeform(
 
177
  name: str,
178
  text: str,
179
  role_style: str = "ai_completed",
180
+ orchestrator_model_id: str | None = None,
181
+ extra_model_ids: list[str] | None = None,
182
  ) -> dict:
183
+ """Distill a freeform text block into a role prompt via a neutral writer model."""
184
+ writer_id = await _resolve_writer_model(orchestrator_model_id, extra_model_ids)
185
+ if not writer_id:
186
+ return {"role_prompt": "", "error": "No model available to generate the role prompt."}
187
+
188
  template = FREEFORM_AI_COMPLETED_PROMPT if role_style == "ai_completed" else FREEFORM_EXACT_PROMPT
189
  prompt_text = template.format(
190
  name=name or "(not provided)",
191
  text=text or "(not provided)",
192
  )
193
+ return await _call_llm(writer_id, prompt_text)
backend/app/services/prompts/__init__.py CHANGED
@@ -38,6 +38,7 @@ from app.services.prompts.closure import (
38
  CONTRIBUTION_SUMMARY_PROMPT,
39
  )
40
  from app.services.prompts.auto_select import AUTO_SELECT_PARTICIPANTS_PROMPT
 
41
  from app.services.prompts.credential_intake import (
42
  CREDENTIAL_INTAKE_EMPTY_TRANSCRIPT,
43
  CREDENTIAL_INTAKE_TURN_PROMPT,
@@ -67,6 +68,7 @@ __all__ = [
67
  "NO_CONSENSUS_REPORT_PROMPT",
68
  "CONTRIBUTION_SUMMARY_PROMPT",
69
  "AUTO_SELECT_PARTICIPANTS_PROMPT",
 
70
  "CREDENTIAL_INTAKE_TURN_PROMPT",
71
  "CREDENTIAL_INTAKE_EMPTY_TRANSCRIPT",
72
  ]
 
38
  CONTRIBUTION_SUMMARY_PROMPT,
39
  )
40
  from app.services.prompts.auto_select import AUTO_SELECT_PARTICIPANTS_PROMPT
41
+ from app.services.prompts.model_recommend import SUGGEST_MODEL_PROMPT
42
  from app.services.prompts.credential_intake import (
43
  CREDENTIAL_INTAKE_EMPTY_TRANSCRIPT,
44
  CREDENTIAL_INTAKE_TURN_PROMPT,
 
68
  "NO_CONSENSUS_REPORT_PROMPT",
69
  "CONTRIBUTION_SUMMARY_PROMPT",
70
  "AUTO_SELECT_PARTICIPANTS_PROMPT",
71
+ "SUGGEST_MODEL_PROMPT",
72
  "CREDENTIAL_INTAKE_TURN_PROMPT",
73
  "CREDENTIAL_INTAKE_EMPTY_TRANSCRIPT",
74
  ]
backend/app/services/prompts/model_recommend.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt for the Expert Persona "Suggest a model" feature.
2
+
3
+ The meta-LLM reads the user's original description (primary) and an
4
+ optional generated role prompt (secondary) to pick the best-fit model
5
+ from the builder's live model list.
6
+ """
7
+
8
+ SUGGEST_MODEL_PROMPT = (
9
+ "You are helping a user pick an LLM to power one participant in a "
10
+ "Collaborative Conversational AI (CCAI) group discussion.\n\n"
11
+ "CCAI design principle: model diversity across the panel matters. "
12
+ "When several models are similarly good fits, prefer one that "
13
+ "diversifies the panel (different model families / kinds).\n\n"
14
+ "Persona name: {persona_name}\n\n"
15
+ "User's original description (authoritative — weight this most heavily):\n"
16
+ "<<<\n{source_text}\n>>>\n\n"
17
+ "Generated role prompt (secondary — may contain LLM additions; treat "
18
+ "details NOT present in the user's description as low-trust):\n"
19
+ "<<<\n{role_prompt}\n>>>\n\n"
20
+ "Available models (pick ONLY from this list — use the exact `id` "
21
+ "values):\n"
22
+ "{models_block}\n\n"
23
+ "{panel_block}"
24
+ "Recommend the single most suitable model for embodying this persona.\n\n"
25
+ "Selection rules:\n"
26
+ "- Weight the user's core professional identity and task over incidental "
27
+ "phrases or hobbies. Do NOT match on a single off-topic keyword.\n"
28
+ "- Choose by capability and reasoning fit, NOT by keyword overlap with "
29
+ "model names or provider labels.\n"
30
+ "- For general professional personas, prefer kind=provider general "
31
+ "instruct models.\n"
32
+ "- Use kind=neon_character only when the user's description clearly "
33
+ "calls for that specific domain, voice, or character type.\n"
34
+ "- When panel context is provided, spread recommendations across model "
35
+ "families when alternatives fit equally well.\n\n"
36
+ "IMPORTANT: Your entire reply must be EXACTLY two lines and nothing else "
37
+ "(no preamble, no analysis, no markdown):\n"
38
+ "recommended_model_id: <exact id from the list above>\n"
39
+ "rationale: <one or two plain sentences>\n\n"
40
+ "The recommended_model_id MUST be copied exactly from the id= field "
41
+ "in the list above. Do not invent ids."
42
+ )
backend/tests/test_model_picker.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for neutral general-purpose model selection."""
2
+ from unittest.mock import patch
3
+
4
+ from app.services.model_picker import (
5
+ is_neon_character_model_id,
6
+ is_vanilla_neon_model_id,
7
+ pick_general_purpose_model,
8
+ )
9
+
10
+
11
+ def test_is_neon_character_model_id():
12
+ assert is_neon_character_model_id("neon:BrainForge/X@1:vanilla")
13
+ assert not is_neon_character_model_id("gpt-4o-mini")
14
+
15
+
16
+ def test_is_vanilla_neon_model_id():
17
+ assert is_vanilla_neon_model_id("neon:BrainForge/X@1:vanilla")
18
+ assert not is_vanilla_neon_model_id("neon:BrainForge/X@1:NucleotidingsAI")
19
+
20
+
21
+ def test_pick_prefers_non_neon_orchestrator():
22
+ with patch("app.services.model_picker.settings") as mock_settings:
23
+ mock_settings.orchestrator_model = "gpt-4o-mini"
24
+ mock_settings.providers = []
25
+ mock_settings.resolve_model.side_effect = lambda mid: (
26
+ {"model_id": mid} if mid == "gpt-4o-mini" else None
27
+ )
28
+ assert pick_general_purpose_model() == "gpt-4o-mini"
29
+
30
+
31
+ def test_pick_provider_when_orchestrator_unresolvable():
32
+ with patch("app.services.model_picker.settings") as mock_settings:
33
+ mock_settings.orchestrator_model = "gpt-4o-mini"
34
+ mock_settings.providers = [{
35
+ "models": [{"id": "gemini-2.0-flash", "name": "Gemini 2.0 Flash"}],
36
+ }]
37
+ mock_settings.resolve_model.side_effect = lambda mid: (
38
+ {"model_id": mid} if mid == "gemini-2.0-flash" else None
39
+ )
40
+ assert pick_general_purpose_model() == "gemini-2.0-flash"
41
+
42
+
43
+ def test_pick_vanilla_before_named_neon_character():
44
+ vanilla = "neon:BrainForge/NucleotidingsLLM@2026.05.23:vanilla"
45
+ named = "neon:BrainForge/NucleotidingsLLM@2026.05.23:NucleotidingsAI"
46
+ with patch("app.services.model_picker.settings") as mock_settings:
47
+ mock_settings.orchestrator_model = "gpt-4o-mini"
48
+ mock_settings.providers = []
49
+ mock_settings.resolve_model.side_effect = lambda mid: (
50
+ {"model_id": mid, "is_neon": mid.startswith("neon:")}
51
+ if mid in {vanilla, named}
52
+ else None
53
+ )
54
+ assert pick_general_purpose_model(extra_model_ids=[named, vanilla]) == vanilla
backend/tests/test_model_recommend.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for model recommendation validation helpers."""
2
+
3
+ from app.services.model_recommend import (
4
+ _deprioritize_neon_mismatch,
5
+ _models_block,
6
+ _parse_suggest_response,
7
+ _validate_model_id,
8
+ )
9
+ from app.services.prompts.model_recommend import SUGGEST_MODEL_PROMPT
10
+
11
+ MODELS = [
12
+ {
13
+ "id": "neon:BrainForge/NucleotidingsLLM@2026.05.23:vanilla",
14
+ "name": "vanilla",
15
+ "provider": "Neon / NucleotidingsLLM",
16
+ "kind": "neon_character",
17
+ },
18
+ {
19
+ "id": "neon:BrainForge/NucleotidingsLLM@2026.05.23:NucleotidingsAI",
20
+ "name": "NucleotidingsAI",
21
+ "provider": "Neon / NucleotidingsLLM",
22
+ "kind": "neon_character",
23
+ },
24
+ {
25
+ "id": "neon:BrainForge/LogisticsLLM@2026.01.20:vanilla",
26
+ "name": "vanilla",
27
+ "provider": "Neon / LogisticsLLM",
28
+ "kind": "neon_character",
29
+ },
30
+ ]
31
+
32
+
33
+ def test_validate_model_id_accepts_known_id():
34
+ models = [
35
+ {"id": "gpt-4o", "name": "GPT-4o", "provider": "OpenAI"},
36
+ {"id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash", "provider": "Google Gemini"},
37
+ ]
38
+ assert _validate_model_id("gpt-4o", models) == "gpt-4o"
39
+
40
+
41
+ def test_validate_model_id_rejects_unknown():
42
+ models = [{"id": "gpt-4o", "name": "GPT-4o", "provider": "OpenAI"}]
43
+ assert _validate_model_id("made-up", models) is None
44
+ assert _validate_model_id(None, models) is None
45
+
46
+
47
+ def test_validate_model_id_strips_whitespace():
48
+ models = [{"id": "gpt-4o", "name": "GPT-4o", "provider": "OpenAI"}]
49
+ assert _validate_model_id(" gpt-4o ", models) == "gpt-4o"
50
+
51
+
52
+ def test_models_block_includes_kind():
53
+ block = _models_block(MODELS[:1])
54
+ assert "kind=neon_character" in block
55
+ assert "id=neon:BrainForge/NucleotidingsLLM@2026.05.23:vanilla" in block
56
+
57
+
58
+ def test_suggest_prompt_includes_source_and_role():
59
+ formatted = SUGGEST_MODEL_PROMPT.format(
60
+ persona_name="Marketer",
61
+ source_text="B2B demand generation specialist",
62
+ role_prompt="You are a lifecycle marketer...",
63
+ models_block="1. id=gpt-4o | name=GPT-4o | family=OpenAI | kind=provider",
64
+ panel_block="",
65
+ )
66
+ assert "User's original description (authoritative" in formatted
67
+ assert "B2B demand generation specialist" in formatted
68
+ assert "Generated role prompt (secondary" in formatted
69
+ assert "kind=provider" in formatted
70
+
71
+
72
+ def test_parse_line_format():
73
+ raw = (
74
+ "recommended_model_id: neon:BrainForge/LogisticsLLM@2026.01.20:vanilla\n"
75
+ "rationale: Good fit for structured reasoning."
76
+ )
77
+ rid, rat = _parse_suggest_response(raw, MODELS)
78
+ assert rid == "neon:BrainForge/LogisticsLLM@2026.01.20:vanilla"
79
+ assert "reasoning" in rat
80
+
81
+
82
+ def test_parse_prose_mentions_provider_token():
83
+ raw = (
84
+ "The Neon NucleotidingsLLM is designed for handling nuclear and "
85
+ "complex scientific information, which suits this persona well."
86
+ )
87
+ rid, _rat = _parse_suggest_response(raw, MODELS)
88
+ assert rid == "neon:BrainForge/NucleotidingsLLM@2026.05.23:vanilla"
89
+
90
+
91
+ def test_deprioritize_neon_mismatch_swaps_named_character():
92
+ b2b_source = (
93
+ "Senior B2B demand generation marketer focused on lifecycle campaigns "
94
+ "and pipeline analytics."
95
+ )
96
+ bad_pick = "neon:BrainForge/NucleotidingsLLM@2026.05.23:NucleotidingsAI"
97
+ adjusted = _deprioritize_neon_mismatch(bad_pick, b2b_source, MODELS)
98
+ assert adjusted != bad_pick
99
+ assert adjusted.endswith(":vanilla")
100
+
101
+
102
+ def test_deprioritize_neon_keeps_vanilla():
103
+ b2b_source = "B2B marketer"
104
+ vanilla = "neon:BrainForge/NucleotidingsLLM@2026.05.23:vanilla"
105
+ assert _deprioritize_neon_mismatch(vanilla, b2b_source, MODELS) == vanilla
106
+
107
+
108
+ def test_deprioritize_neon_keeps_when_source_matches_domain():
109
+ nuclear_source = "Nuclear energy policy analyst using NucleotidingsLLM data."
110
+ named = "neon:BrainForge/NucleotidingsLLM@2026.05.23:NucleotidingsAI"
111
+ assert _deprioritize_neon_mismatch(named, nuclear_source, MODELS) == named
112
+
113
+
114
+ def test_suggest_requires_source_or_role():
115
+ import asyncio
116
+
117
+ from app.services.model_recommend import suggest_model_for_persona
118
+
119
+ result = asyncio.run(
120
+ suggest_model_for_persona(
121
+ orchestrator_model_id="gpt-4o",
122
+ persona_name="Test",
123
+ source_text="",
124
+ role_prompt="",
125
+ available_models=MODELS,
126
+ )
127
+ )
128
+ assert "error" in result
129
+ assert "description or role prompt" in result["error"]
docker-compose.yml CHANGED
@@ -7,6 +7,9 @@ services:
7
  # or the environment so local devs can sidestep port conflicts.
8
  - "${CCAI_HOST_PORT:-7860}:7860"
9
  env_file:
 
 
 
10
  - .env
11
  environment:
12
  CORS_ORIGINS: "http://localhost:7860,http://localhost:3000"
 
7
  # or the environment so local devs can sidestep port conflicts.
8
  - "${CCAI_HOST_PORT:-7860}:7860"
9
  env_file:
10
+ # Optional cross-project secrets file (set SHARED_ENV_FILE in shell).
11
+ - path: ${SHARED_ENV_FILE}
12
+ required: false
13
  - .env
14
  environment:
15
  CORS_ORIGINS: "http://localhost:7860,http://localhost:3000"
frontend/package-lock.json CHANGED
@@ -16771,22 +16771,6 @@
16771
  }
16772
  }
16773
  },
16774
- "node_modules/tailwindcss/node_modules/yaml": {
16775
- "version": "2.9.0",
16776
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
16777
- "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
16778
- "extraneous": true,
16779
- "license": "ISC",
16780
- "bin": {
16781
- "yaml": "bin.mjs"
16782
- },
16783
- "engines": {
16784
- "node": ">= 14.6"
16785
- },
16786
- "funding": {
16787
- "url": "https://github.com/sponsors/eemeli"
16788
- }
16789
- },
16790
  "node_modules/tapable": {
16791
  "version": "2.3.3",
16792
  "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
 
16771
  }
16772
  }
16773
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16774
  "node_modules/tapable": {
16775
  "version": "2.3.3",
16776
  "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
frontend/src/App.js CHANGED
@@ -48,12 +48,16 @@ function appendInlineChatNote(setMessages, text, extra = {}) {
48
  export default function App() {
49
  // Persistent state
50
  const persisted = useMemo(() => storage.loadState(), []);
 
 
 
 
51
  const [theme, setTheme] = useState(() => persisted.theme
52
  || (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
53
  );
54
  const [expertPersonas, setExpertPersonas] = useState(persisted.expert_personas || []);
55
- const [selectedIds, setSelectedIds] = useState(persisted.participants_selected || []);
56
- const [enabledMap, setEnabledMap] = useState(persisted.participants_enabled || {});
57
  const [modelAssignments, setModelAssignments] = useState(persisted.model_assignments || {});
58
  const [orchestratorModel, setOrchestratorModelState] = useState(persisted.orchestrator_model_id);
59
  const [summarizerModel, setSummarizerModelState] = useState(persisted.summarizer_model_id);
@@ -216,7 +220,7 @@ export default function App() {
216
  const list = [];
217
  for (const p of providers) {
218
  for (const m of p.models) {
219
- list.push({ id: m.id, name: m.name, provider: p.name });
220
  }
221
  }
222
  for (const nm of neonModels) {
@@ -226,12 +230,22 @@ export default function App() {
226
  id: `neon:${nm.model_id}:${p.persona_name}`,
227
  name: p.persona_name,
228
  provider: `Neon / ${nm.name.split('/').pop()}`,
 
229
  });
230
  }
231
  }
232
  return list;
233
  }, [providers, neonModels]);
234
 
 
 
 
 
 
 
 
 
 
235
  // Map neon:model@ver:persona ids -> HANA system_prompt (from /api/models).
236
  const neonPromptByModelId = useMemo(() => {
237
  const map = {};
@@ -280,6 +294,23 @@ export default function App() {
280
  return humanCatalogEntry ? [humanCatalogEntry, ...fromCatalog] : fromCatalog;
281
  }, [selectedIds, allCatalogParticipants, humanCatalogEntry]);
282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  const enabledSelectedCount = useMemo(() => {
284
  return selectedParticipants.filter(p => enabledMap[p.participant_id] !== false).length;
285
  }, [selectedParticipants, enabledMap]);
@@ -1047,14 +1078,20 @@ export default function App() {
1047
  // available in the catalog.
1048
  const autoSelectReady = autoSelectMode
1049
  && Object.keys(allCatalogParticipants).length >= 2;
1050
- const startDisabled = isRunning
1051
- || (!autoSelectMode && enabledSelectedCount < 2)
1052
- || (autoSelectMode && !autoSelectReady);
 
1053
  const startDisabledReason = autoSelectMode
1054
  ? (!autoSelectReady ? 'No candidate participants available for auto-select.' : '')
1055
  : enabledSelectedCount < 2
1056
  ? 'Add at least 2 active participants to start.'
1057
  : '';
 
 
 
 
 
1058
 
1059
  return (
1060
  <div className="app">
@@ -1127,12 +1164,14 @@ export default function App() {
1127
  disabled={startDisabled}
1128
  isRunning={isRunning}
1129
  disabledReason={startDisabledReason}
 
1130
  activeQuestion={activeQuestion}
1131
  />
1132
  <ChatArea
1133
  messages={messages}
1134
  systemMessages={systemMessages}
1135
  isRunning={isRunning}
 
1136
  statusText={statusText}
1137
  pause={pause}
1138
  onContinuePause={handleContinuePause}
@@ -1164,7 +1203,9 @@ export default function App() {
1164
  onSave={handleSaveExpert}
1165
  onDelete={handleDeleteExpert}
1166
  allModels={allModelsFlat}
1167
- defaultModelId={orchestratorModel || ''}
 
 
1168
  />
1169
  {tableOpen && (
1170
  <ChatTableView
 
48
  export default function App() {
49
  // Persistent state
50
  const persisted = useMemo(() => storage.loadState(), []);
51
+ const initialParticipants = useMemo(
52
+ () => storage.resolveInitialParticipants(persisted),
53
+ [persisted],
54
+ );
55
  const [theme, setTheme] = useState(() => persisted.theme
56
  || (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
57
  );
58
  const [expertPersonas, setExpertPersonas] = useState(persisted.expert_personas || []);
59
+ const [selectedIds, setSelectedIds] = useState(initialParticipants.selectedIds);
60
+ const [enabledMap, setEnabledMap] = useState(initialParticipants.enabledMap);
61
  const [modelAssignments, setModelAssignments] = useState(persisted.model_assignments || {});
62
  const [orchestratorModel, setOrchestratorModelState] = useState(persisted.orchestrator_model_id);
63
  const [summarizerModel, setSummarizerModelState] = useState(persisted.summarizer_model_id);
 
220
  const list = [];
221
  for (const p of providers) {
222
  for (const m of p.models) {
223
+ list.push({ id: m.id, name: m.name, provider: p.name, kind: 'provider' });
224
  }
225
  }
226
  for (const nm of neonModels) {
 
230
  id: `neon:${nm.model_id}:${p.persona_name}`,
231
  name: p.persona_name,
232
  provider: `Neon / ${nm.name.split('/').pop()}`,
233
+ kind: 'neon_character',
234
  });
235
  }
236
  }
237
  return list;
238
  }, [providers, neonModels]);
239
 
240
+ // Default for new Expert Personas: orchestrator if it's in the builder
241
+ // list, otherwise the first model the user can actually pick.
242
+ const expertDefaultModelId = useMemo(() => {
243
+ if (orchestratorModel && allModelsFlat.some(m => m.id === orchestratorModel)) {
244
+ return orchestratorModel;
245
+ }
246
+ return allModelsFlat[0]?.id || '';
247
+ }, [orchestratorModel, allModelsFlat]);
248
+
249
  // Map neon:model@ver:persona ids -> HANA system_prompt (from /api/models).
250
  const neonPromptByModelId = useMemo(() => {
251
  const map = {};
 
294
  return humanCatalogEntry ? [humanCatalogEntry, ...fromCatalog] : fromCatalog;
295
  }, [selectedIds, allCatalogParticipants, humanCatalogEntry]);
296
 
297
+ // Other panel members sent to the Expert Persona "Suggest a model"
298
+ // action so recommendations can favor model-family diversity.
299
+ const expertPanelContext = useMemo(() => {
300
+ const editingId = expertEditing?.participant_id;
301
+ return selectedParticipants
302
+ .filter(p => p.kind !== 'human' && p.participant_id !== editingId)
303
+ .map(p => {
304
+ const mid = modelAssignments[p.participant_id] || p.model_id || '';
305
+ const m = allModelsFlat.find(x => x.id === mid);
306
+ return {
307
+ name: p.name,
308
+ model_id: mid,
309
+ provider: m?.provider || '',
310
+ };
311
+ });
312
+ }, [selectedParticipants, modelAssignments, expertEditing, allModelsFlat]);
313
+
314
  const enabledSelectedCount = useMemo(() => {
315
  return selectedParticipants.filter(p => enabledMap[p.participant_id] !== false).length;
316
  }, [selectedParticipants, enabledMap]);
 
1078
  // available in the catalog.
1079
  const autoSelectReady = autoSelectMode
1080
  && Object.keys(allCatalogParticipants).length >= 2;
1081
+ const hasEnoughParticipantsToStart = autoSelectMode
1082
+ ? autoSelectReady
1083
+ : enabledSelectedCount >= 2;
1084
+ const startDisabled = isRunning || !hasEnoughParticipantsToStart;
1085
  const startDisabledReason = autoSelectMode
1086
  ? (!autoSelectReady ? 'No candidate participants available for auto-select.' : '')
1087
  : enabledSelectedCount < 2
1088
  ? 'Add at least 2 active participants to start.'
1089
  : '';
1090
+ const startDisabledTooltip = autoSelectMode
1091
+ ? (!autoSelectReady ? 'No candidate participants available for auto-select.' : '')
1092
+ : enabledSelectedCount < 2
1093
+ ? 'Select at least 2 participants.'
1094
+ : '';
1095
 
1096
  return (
1097
  <div className="app">
 
1164
  disabled={startDisabled}
1165
  isRunning={isRunning}
1166
  disabledReason={startDisabledReason}
1167
+ disabledTooltip={startDisabledTooltip}
1168
  activeQuestion={activeQuestion}
1169
  />
1170
  <ChatArea
1171
  messages={messages}
1172
  systemMessages={systemMessages}
1173
  isRunning={isRunning}
1174
+ hasEnoughParticipantsToStart={hasEnoughParticipantsToStart}
1175
  statusText={statusText}
1176
  pause={pause}
1177
  onContinuePause={handleContinuePause}
 
1203
  onSave={handleSaveExpert}
1204
  onDelete={handleDeleteExpert}
1205
  allModels={allModelsFlat}
1206
+ defaultModelId={expertDefaultModelId}
1207
+ panelContext={expertPanelContext}
1208
+ orchestratorModelId={orchestratorModel || undefined}
1209
  />
1210
  {tableOpen && (
1211
  <ChatTableView
frontend/src/components/ChatArea.js CHANGED
@@ -5,6 +5,70 @@ import OrchestratorMessage from './OrchestratorMessage';
5
  import FailsafePauseBanner from './FailsafePauseBanner';
6
  import HumanInputSlot from './HumanInputSlot';
7
  import HumanTurnIndicator from './HumanTurnIndicator';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  /**
10
  * Renders the conversation: a mix of participant bubbles, orchestrator
@@ -21,6 +85,7 @@ export default function ChatArea({
21
  messages,
22
  systemMessages,
23
  isRunning,
 
24
  statusText,
25
  pause,
26
  onContinuePause,
@@ -54,6 +119,37 @@ export default function ChatArea({
54
  return m;
55
  }, [participants]);
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  const hasContent = (messages?.length || 0) + (systemMessages?.length || 0) > 0;
58
  const chatEnded = (systemMessages || []).some(s => s.text === 'End of Chat');
59
 
@@ -70,7 +166,46 @@ export default function ChatArea({
70
  <div className="chat-area">
71
  {!hasContent && !isRunning && (
72
  <div className="chat-empty">
73
- Add at least 2 participants from the header dropdown, then start a conversation.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  </div>
75
  )}
76
  {(messages || []).map((msg, i) => {
@@ -123,51 +258,63 @@ export default function ChatArea({
123
  </div>
124
  )}
125
  {chatEnded && (
126
- <div className="chat-end-downloads" role="group" aria-label="Conversation downloads">
127
- <button
128
- type="button"
129
- className="btn-sm btn-outline chat-end-download-btn"
130
- onClick={onShowTableView}
131
- title="Open the conversation summary table"
132
- >
133
- <Table2 size={14} />
134
- Summary table…
135
- </button>
136
- <button
137
- type="button"
138
- className="btn-sm btn-outline chat-end-download-btn"
139
- onClick={onDownloadChatTxt}
140
- >
141
- <FileText size={14} />
142
- Chat as .txt
143
- </button>
144
- <button
145
- type="button"
146
- className="btn-sm btn-outline chat-end-download-btn"
147
- onClick={onDownloadChatMd}
148
- >
149
- <FileCode size={14} />
150
- Chat as .md
151
- </button>
152
- <button
153
- type="button"
154
- className="btn-sm btn-outline chat-end-download-btn"
155
- onClick={onDownloadCsvTable}
156
- title="Download the summary table as CSV"
157
- >
158
- <FileSpreadsheet size={14} />
159
- Summary table as .csv
160
- </button>
161
- <button
162
- type="button"
163
- className="btn-sm btn-outline chat-end-download-btn"
164
- onClick={onDownloadApiLog}
165
- disabled={!hasApiLog}
166
- title="Download the full backend API call history for this session"
167
- >
168
- <History size={14} />
169
- Full API history
170
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
171
  </div>
172
  )}
173
  <FailsafePauseBanner pause={pause} onContinue={onContinuePause} />
 
5
  import FailsafePauseBanner from './FailsafePauseBanner';
6
  import HumanInputSlot from './HumanInputSlot';
7
  import HumanTurnIndicator from './HumanTurnIndicator';
8
+ import { DEFAULT_DEMO_PERSONAS, DEFAULT_PARTICIPANT_IDS } from '../utils/storage';
9
+
10
+ /** Update when the Neon contact destination changes on the redesigned site. */
11
+ const NEON_CONTACT_URL = 'https://www.neon.ai/contact';
12
+
13
+ const [ELENA_ID, MARCUS_ID, AMIRA_ID] = DEFAULT_PARTICIPANT_IDS;
14
+
15
+ /** Dialogue-only turns for the empty-state sample; speaker ids come from the demo trio. */
16
+ const SAMPLE_PREVIEW_TURNS = [
17
+ {
18
+ kind: 'orchestrator',
19
+ text:
20
+ 'Question: Should a company build its own AI system or rely on a third-party provider? '
21
+ + 'Each participant: share your initial view.',
22
+ },
23
+ {
24
+ kind: 'participant',
25
+ speakerId: ELENA_ID,
26
+ text:
27
+ 'Third-party APIs keep upfront cost low, but per-seat and per-token fees compound fast at scale — '
28
+ + 'above a few million in annual AI spend, owning the stack often wins on total cost over three to five years.',
29
+ },
30
+ {
31
+ kind: 'participant',
32
+ speakerId: MARCUS_ID,
33
+ text:
34
+ 'Build gives you control over models, prompts, and integrations; buy gets you to production in weeks. '
35
+ + "I'd only build if the product is the AI itself, not if AI is supporting something else.",
36
+ },
37
+ {
38
+ kind: 'participant',
39
+ speakerId: AMIRA_ID,
40
+ addressedTo: MARCUS_ID,
41
+ replyingTo: [MARCUS_ID],
42
+ text:
43
+ 'Where data lives matters more than who hosts the model. A vendor with strong certifications can beat a sloppy '
44
+ + 'in-house deployment — but regulated workloads need clarity on retention, subprocessors, and whether prompts leave your boundary.',
45
+ },
46
+ {
47
+ kind: 'orchestrator',
48
+ text:
49
+ 'No single answer fits every company — the panel leans toward buy for speed and lower early risk, build when AI is '
50
+ + 'core IP or scale makes vendor fees dominate, with privacy and compliance as the tiebreaker.',
51
+ },
52
+ ];
53
+
54
+ function buildSamplePreviewMessages(nameById) {
55
+ return SAMPLE_PREVIEW_TURNS.map((turn) => {
56
+ if (turn.kind === 'orchestrator') {
57
+ return { kind: 'orchestrator', text: turn.text };
58
+ }
59
+ const speaker_id = turn.speakerId;
60
+ return {
61
+ kind: 'participant',
62
+ role: 'participant',
63
+ speaker_id,
64
+ speaker_name: nameById[speaker_id] || '',
65
+ text: turn.text,
66
+ ...(turn.addressedTo
67
+ ? { addressed_to: turn.addressedTo, replying_to: turn.replyingTo }
68
+ : {}),
69
+ };
70
+ });
71
+ }
72
 
73
  /**
74
  * Renders the conversation: a mix of participant bubbles, orchestrator
 
85
  messages,
86
  systemMessages,
87
  isRunning,
88
+ hasEnoughParticipantsToStart,
89
  statusText,
90
  pause,
91
  onContinuePause,
 
119
  return m;
120
  }, [participants]);
121
 
122
+ const defaultDemoNameById = useMemo(() => {
123
+ const m = {};
124
+ DEFAULT_DEMO_PERSONAS.forEach((p) => {
125
+ m[p.participant_id] = p.name;
126
+ });
127
+ return m;
128
+ }, []);
129
+
130
+ /** Names for sample bubbles: live roster when present, else catalog mirror in storage. */
131
+ const previewNameById = useMemo(() => {
132
+ const m = { ...defaultDemoNameById };
133
+ DEFAULT_PARTICIPANT_IDS.forEach((id) => {
134
+ if (participantNameById[id]) m[id] = participantNameById[id];
135
+ });
136
+ return m;
137
+ }, [defaultDemoNameById, participantNameById]);
138
+
139
+ /** Palette index: match sidebar roster order, fall back to default trio order. */
140
+ const previewSpeakerIdxFor = useMemo(() => {
141
+ const map = {};
142
+ DEFAULT_PARTICIPANT_IDS.forEach((id, defaultIdx) => {
143
+ map[id] = speakerIdxFor[id] ?? defaultIdx;
144
+ });
145
+ return map;
146
+ }, [speakerIdxFor]);
147
+
148
+ const samplePreviewMessages = useMemo(
149
+ () => buildSamplePreviewMessages(previewNameById),
150
+ [previewNameById],
151
+ );
152
+
153
  const hasContent = (messages?.length || 0) + (systemMessages?.length || 0) > 0;
154
  const chatEnded = (systemMessages || []).some(s => s.text === 'End of Chat');
155
 
 
166
  <div className="chat-area">
167
  {!hasContent && !isRunning && (
168
  <div className="chat-empty">
169
+ <div className="chat-empty-copy">
170
+ <p className="chat-empty-lead">
171
+ Watch a panel of AI experts debate a question, challenge each other, and reason toward a more considered answer.
172
+ </p>
173
+ <p className="chat-empty-instruction">
174
+ {hasEnoughParticipantsToStart
175
+ ? 'Three expert personas are ready to go — press Start Chat, or add, remove, or edit them to fit your question.'
176
+ : 'Add at least 2 participants from the header dropdown, then start a conversation.'}
177
+ </p>
178
+ </div>
179
+ <div className="chat-empty-sample" aria-hidden="true">
180
+ <p className="chat-empty-sample-label">Sample preview — not a live result</p>
181
+ <div className="chat-empty-sample-panel">
182
+ {samplePreviewMessages.map((msg, i) => {
183
+ if (msg.kind === 'orchestrator') {
184
+ return (
185
+ <OrchestratorMessage
186
+ key={`preview-orch-${i}`}
187
+ message={{ text: msg.text }}
188
+ messageIdx={i}
189
+ />
190
+ );
191
+ }
192
+ const prev = i > 0 ? samplePreviewMessages[i - 1] : null;
193
+ const prevParticipant =
194
+ prev?.kind === 'participant' ? prev : null;
195
+ return (
196
+ <MessageBubble
197
+ key={`preview-${msg.speaker_id}-${i}`}
198
+ message={msg}
199
+ idx={previewSpeakerIdxFor[msg.speaker_id] ?? 0}
200
+ messageIdx={i}
201
+ prevMessage={prevParticipant}
202
+ participantNameById={previewNameById}
203
+ showResponseTime={false}
204
+ />
205
+ );
206
+ })}
207
+ </div>
208
+ </div>
209
  </div>
210
  )}
211
  {(messages || []).map((msg, i) => {
 
258
  </div>
259
  )}
260
  {chatEnded && (
261
+ <div className="chat-end-bar">
262
+ <div className="chat-end-downloads" role="group" aria-label="Conversation downloads">
263
+ <button
264
+ type="button"
265
+ className="btn-sm btn-outline chat-end-download-btn"
266
+ onClick={onShowTableView}
267
+ title="Open the conversation summary table"
268
+ >
269
+ <Table2 size={14} />
270
+ Summary table…
271
+ </button>
272
+ <button
273
+ type="button"
274
+ className="btn-sm btn-outline chat-end-download-btn"
275
+ onClick={onDownloadChatTxt}
276
+ >
277
+ <FileText size={14} />
278
+ Chat as .txt
279
+ </button>
280
+ <button
281
+ type="button"
282
+ className="btn-sm btn-outline chat-end-download-btn"
283
+ onClick={onDownloadChatMd}
284
+ >
285
+ <FileCode size={14} />
286
+ Chat as .md
287
+ </button>
288
+ <button
289
+ type="button"
290
+ className="btn-sm btn-outline chat-end-download-btn"
291
+ onClick={onDownloadCsvTable}
292
+ title="Download the summary table as CSV"
293
+ >
294
+ <FileSpreadsheet size={14} />
295
+ Summary table as .csv
296
+ </button>
297
+ <button
298
+ type="button"
299
+ className="btn-sm btn-outline chat-end-download-btn"
300
+ onClick={onDownloadApiLog}
301
+ disabled={!hasApiLog}
302
+ title="Download the full backend API call history for this session"
303
+ >
304
+ <History size={14} />
305
+ Full API history
306
+ </button>
307
+ </div>
308
+ <p className="chat-end-cta">
309
+ Want a panel like this running on your own infrastructure?{' '}
310
+ <a
311
+ href={NEON_CONTACT_URL}
312
+ target="_blank"
313
+ rel="noopener noreferrer"
314
+ >
315
+ Talk to Neon
316
+ </a>
317
+ </p>
318
  </div>
319
  )}
320
  <FailsafePauseBanner pause={pause} onContinue={onContinuePause} />
frontend/src/components/ChatControls.js CHANGED
@@ -20,6 +20,7 @@ const ChatControls = forwardRef(function ChatControls({
20
  disabled,
21
  isRunning,
22
  disabledReason,
 
23
  activeQuestion,
24
  }, ref) {
25
  const demoIndexRef = useRef(0);
@@ -68,7 +69,7 @@ const ChatControls = forwardRef(function ChatControls({
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);
@@ -90,6 +91,7 @@ const ChatControls = forwardRef(function ChatControls({
90
  };
91
 
92
  const canStart = !disabled && !!resolveQuestion();
 
93
 
94
  return (
95
  <div className="chat-controls">
@@ -110,31 +112,51 @@ const ChatControls = forwardRef(function ChatControls({
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>
 
20
  disabled,
21
  isRunning,
22
  disabledReason,
23
+ disabledTooltip,
24
  activeQuestion,
25
  }, ref) {
26
  const demoIndexRef = useRef(0);
 
69
  };
70
 
71
  const handleKeyDown = (e) => {
72
+ if (mode !== 'demo') return;
73
  if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
74
  setMode('custom');
75
  setUserText(e.key);
 
91
  };
92
 
93
  const canStart = !disabled && !!resolveQuestion();
94
+ const startButtonTitle = !canStart && disabledTooltip ? disabledTooltip : undefined;
95
 
96
  return (
97
  <div className="chat-controls">
 
112
  </>
113
  ) : (
114
  <>
115
+ <div className="chat-controls-row">
116
+ <input
117
+ type="text"
118
+ className="chat-controls-question"
119
+ value={inputValue}
120
+ placeholder={
121
+ disabled && disabledReason
122
+ ? disabledReason
123
+ : demoQuestions.length === 0
124
+ ? 'Loading demo questions…'
125
+ : ''
 
 
 
126
  }
127
+ onChange={handleChange}
128
+ onKeyDown={(e) => {
129
+ handleKeyDown(e);
130
+ if (e.key === 'Enter' && canStart) {
131
+ e.preventDefault();
132
+ handleStart();
133
+ }
134
+ }}
135
+ />
136
+ <span
137
+ className={
138
+ 'chat-start-btn-wrap'
139
+ + (startButtonTitle ? ' chat-start-btn-wrap-disabled' : '')
140
+ }
141
+ title={startButtonTitle}
142
+ >
143
+ <button
144
+ type="button"
145
+ className="btn-primary chat-controls-start"
146
+ disabled={!canStart}
147
+ onClick={handleStart}
148
+ aria-disabled={!canStart}
149
+ >
150
+ <Play size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
151
+ Start Chat
152
+ </button>
153
+ </span>
154
+ </div>
155
+ {startButtonTitle && (
156
+ <span className="chat-start-hint" role="status">
157
+ {startButtonTitle}
158
+ </span>
159
+ )}
160
  </>
161
  )}
162
  </div>
frontend/src/components/ExpertPersonaModal.js CHANGED
@@ -1,6 +1,6 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
- import { Upload, Save, Trash2 } from 'lucide-react';
3
- import { generateRole, generateRoleFreeform } from '../utils/api';
4
 
5
  /**
6
  * Single source of truth for creating Expert Personas. Replaces the
@@ -19,6 +19,8 @@ export default function ExpertPersonaModal({
19
  onDelete,
20
  allModels, // [{ id, name, provider }]
21
  defaultModelId,
 
 
22
  }) {
23
  const [activeTab, setActiveTab] = useState('freeform');
24
  const [name, setName] = useState('');
@@ -31,8 +33,51 @@ export default function ExpertPersonaModal({
31
  const [generatedPrompt, setGeneratedPrompt] = useState('');
32
  const [busy, setBusy] = useState(false);
33
  const [error, setError] = useState('');
 
 
 
34
  const fileInputRef = useRef(null);
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  useEffect(() => {
37
  if (!isOpen) return;
38
  if (initial) {
@@ -43,7 +88,7 @@ export default function ExpertPersonaModal({
43
  setSamples(initial.samples || '');
44
  setFreeText(initial.freeform || '');
45
  setRoleStyle(initial.role_style || 'ai_completed');
46
- setModelId(initial.model_id || defaultModelId || '');
47
  setGeneratedPrompt(initial.role_prompt || '');
48
  } else {
49
  setActiveTab('freeform');
@@ -53,11 +98,14 @@ export default function ExpertPersonaModal({
53
  setSamples('');
54
  setFreeText('');
55
  setRoleStyle('ai_completed');
56
- setModelId(defaultModelId || '');
57
  setGeneratedPrompt('');
58
  }
59
  setError('');
60
- }, [isOpen, initial, defaultModelId]);
 
 
 
61
 
62
  if (!isOpen) return null;
63
 
@@ -75,30 +123,30 @@ export default function ExpertPersonaModal({
75
 
76
  const handleGenerate = async () => {
77
  setError('');
78
- if (!modelId) {
79
- setError('Pick a model to power this persona first.');
80
- return;
81
- }
82
  if (!name.trim()) {
83
  setError('Persona needs a name.');
84
  return;
85
  }
 
 
 
 
86
  setBusy(true);
87
  try {
88
  const result = activeTab === 'freeform'
89
  ? await generateRoleFreeform({
90
- model_id: modelId,
91
  name: name.trim(),
92
  text: freeText,
93
  role_style: roleStyle,
 
94
  })
95
  : await generateRole({
96
- model_id: modelId,
97
  name: name.trim(),
98
  profile,
99
  identity,
100
  samples,
101
  role_style: roleStyle,
 
102
  });
103
  setGeneratedPrompt(result.role_prompt || '');
104
  } catch (err) {
@@ -108,6 +156,48 @@ export default function ExpertPersonaModal({
108
  }
109
  };
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  const canSave = name.trim() && modelId && generatedPrompt.trim();
112
  const handleSave = () => {
113
  if (!canSave) return;
@@ -147,54 +237,120 @@ export default function ExpertPersonaModal({
147
  onChange={e => setName(e.target.value)}
148
  />
149
  </div>
150
- <div className="ccai-expert-field">
151
  <label>Powered by LLM</label>
152
- <select
153
- value={modelId}
154
- onChange={e => setModelId(e.target.value)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  >
156
- <option value="">Pick a model...</option>
157
- {(allModels || []).map(m => (
158
- <option key={m.id} value={m.id}>
159
- {m.name} {m.provider ? `(${m.provider})` : ''}
160
- </option>
161
- ))}
162
- </select>
163
  </div>
 
 
 
164
  </div>
165
 
166
- <div className="ccai-tab-row">
167
- <button
168
- className={'ccai-tab-btn' + (activeTab === 'freeform' ? ' ccai-tab-btn-active' : '')}
169
- onClick={() => setActiveTab('freeform')}
170
- >
171
- Freeform
172
- </button>
173
- <button
174
- className={'ccai-tab-btn' + (activeTab === 'structured' ? ' ccai-tab-btn-active' : '')}
175
- onClick={() => setActiveTab('structured')}
176
- >
177
- Structured
178
- </button>
179
- <div className="ccai-tab-spacer" />
180
- <label className="ccai-role-style">
181
- <input
182
- type="radio"
183
- name="role-style"
184
- checked={roleStyle === 'ai_completed'}
185
- onChange={() => setRoleStyle('ai_completed')}
186
- />
187
- AI-completed
188
- </label>
189
- <label className="ccai-role-style">
190
- <input
191
- type="radio"
192
- name="role-style"
193
- checked={roleStyle === 'exact'}
194
- onChange={() => setRoleStyle('exact')}
195
- />
196
- Exact (no inferring)
197
- </label>
198
  </div>
199
 
200
  {activeTab === 'freeform' ? (
@@ -256,22 +412,25 @@ export default function ExpertPersonaModal({
256
  <button
257
  className="btn-secondary"
258
  onClick={handleGenerate}
259
- disabled={busy || !modelId || !name.trim()}
260
  >
261
  {busy ? 'Generating role prompt...' : 'Generate role prompt'}
262
  </button>
263
  </div>
264
 
265
- {generatedPrompt && (
266
- <div className="ccai-expert-prompt">
267
- <label>Generated role prompt (editable)</label>
268
- <textarea
269
- value={generatedPrompt}
270
- onChange={e => setGeneratedPrompt(e.target.value)}
271
- rows={6}
272
- />
273
- </div>
274
- )}
 
 
 
275
 
276
  {error && <div className="ccai-expert-error">{error}</div>}
277
 
 
1
+ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
+ import { Upload, Save, Trash2, Wand2 } from 'lucide-react';
3
+ import { generateRole, generateRoleFreeform, suggestModel } from '../utils/api';
4
 
5
  /**
6
  * Single source of truth for creating Expert Personas. Replaces the
 
19
  onDelete,
20
  allModels, // [{ id, name, provider }]
21
  defaultModelId,
22
+ panelContext, // [{ name, model_id, provider }] — other panel members
23
+ orchestratorModelId, // optional override for the meta-LLM call
24
  }) {
25
  const [activeTab, setActiveTab] = useState('freeform');
26
  const [name, setName] = useState('');
 
33
  const [generatedPrompt, setGeneratedPrompt] = useState('');
34
  const [busy, setBusy] = useState(false);
35
  const [error, setError] = useState('');
36
+ const [suggestBusy, setSuggestBusy] = useState(false);
37
+ const [suggestion, setSuggestion] = useState(null);
38
+ const [suggestMessage, setSuggestMessage] = useState('');
39
  const fileInputRef = useRef(null);
40
 
41
+ const rolePromptText = useMemo(
42
+ () => (generatedPrompt || '').trim(),
43
+ [generatedPrompt],
44
+ );
45
+
46
+ const composeSourceText = useCallback(() => {
47
+ if (activeTab === 'freeform') {
48
+ return freeText.trim();
49
+ }
50
+ return [identity, profile, samples]
51
+ .map(s => (s || '').trim())
52
+ .filter(Boolean)
53
+ .join('\n\n');
54
+ }, [activeTab, freeText, identity, profile, samples]);
55
+
56
+ const hasDescriptionContent = useMemo(() => {
57
+ if (activeTab === 'freeform') {
58
+ return Boolean(freeText.trim());
59
+ }
60
+ return Boolean(identity.trim() || profile.trim() || samples.trim());
61
+ }, [activeTab, freeText, identity, profile, samples]);
62
+
63
+ const modelNameForId = useCallback((id) => {
64
+ const m = (allModels || []).find(x => x.id === id);
65
+ return m ? m.name : id;
66
+ }, [allModels]);
67
+
68
+ const modelProviderForId = useCallback((id) => {
69
+ const m = (allModels || []).find(x => x.id === id);
70
+ return m?.provider || '';
71
+ }, [allModels]);
72
+
73
+ const resolveModelId = useCallback((preferred) => {
74
+ const models = allModels || [];
75
+ if (preferred && models.some(m => m.id === preferred)) {
76
+ return preferred;
77
+ }
78
+ return models[0]?.id || '';
79
+ }, [allModels]);
80
+
81
  useEffect(() => {
82
  if (!isOpen) return;
83
  if (initial) {
 
88
  setSamples(initial.samples || '');
89
  setFreeText(initial.freeform || '');
90
  setRoleStyle(initial.role_style || 'ai_completed');
91
+ setModelId(resolveModelId(initial.model_id || defaultModelId));
92
  setGeneratedPrompt(initial.role_prompt || '');
93
  } else {
94
  setActiveTab('freeform');
 
98
  setSamples('');
99
  setFreeText('');
100
  setRoleStyle('ai_completed');
101
+ setModelId(resolveModelId(defaultModelId));
102
  setGeneratedPrompt('');
103
  }
104
  setError('');
105
+ setSuggestion(null);
106
+ setSuggestMessage('');
107
+ setSuggestBusy(false);
108
+ }, [isOpen, initial, defaultModelId, resolveModelId]);
109
 
110
  if (!isOpen) return null;
111
 
 
123
 
124
  const handleGenerate = async () => {
125
  setError('');
 
 
 
 
126
  if (!name.trim()) {
127
  setError('Persona needs a name.');
128
  return;
129
  }
130
+ if (!hasDescriptionContent) {
131
+ setError('Add a description before generating a role prompt.');
132
+ return;
133
+ }
134
  setBusy(true);
135
  try {
136
  const result = activeTab === 'freeform'
137
  ? await generateRoleFreeform({
 
138
  name: name.trim(),
139
  text: freeText,
140
  role_style: roleStyle,
141
+ orchestrator_model_id: orchestratorModelId || undefined,
142
  })
143
  : await generateRole({
 
144
  name: name.trim(),
145
  profile,
146
  identity,
147
  samples,
148
  role_style: roleStyle,
149
+ orchestrator_model_id: orchestratorModelId || undefined,
150
  });
151
  setGeneratedPrompt(result.role_prompt || '');
152
  } catch (err) {
 
156
  }
157
  };
158
 
159
+ const handleSuggestModel = async () => {
160
+ setError('');
161
+ setSuggestion(null);
162
+ setSuggestMessage('');
163
+ const sourceText = composeSourceText();
164
+ if (!sourceText && !rolePromptText) {
165
+ setSuggestMessage(
166
+ 'Enter a description or role prompt for a model to be suggested.',
167
+ );
168
+ return;
169
+ }
170
+ if (!(allModels || []).length) {
171
+ setSuggestMessage('No models available to suggest from.');
172
+ return;
173
+ }
174
+ setSuggestBusy(true);
175
+ try {
176
+ const result = await suggestModel({
177
+ persona_name: name.trim() || 'Unnamed',
178
+ source_text: sourceText,
179
+ role_prompt: rolePromptText,
180
+ available_models: allModels,
181
+ panel_context: panelContext || [],
182
+ orchestrator_model_id: orchestratorModelId || undefined,
183
+ });
184
+ setSuggestion({
185
+ modelId: result.recommended_model_id,
186
+ rationale: result.rationale || '',
187
+ });
188
+ } catch (err) {
189
+ setSuggestMessage(err.message || String(err));
190
+ } finally {
191
+ setSuggestBusy(false);
192
+ }
193
+ };
194
+
195
+ const handleAcceptSuggestion = () => {
196
+ if (!suggestion?.modelId) return;
197
+ setModelId(suggestion.modelId);
198
+ setSuggestion(null);
199
+ };
200
+
201
  const canSave = name.trim() && modelId && generatedPrompt.trim();
202
  const handleSave = () => {
203
  if (!canSave) return;
 
237
  onChange={e => setName(e.target.value)}
238
  />
239
  </div>
240
+ <div className="ccai-expert-field ccai-expert-model-field">
241
  <label>Powered by LLM</label>
242
+ <div className="ccai-expert-model-row">
243
+ <select
244
+ value={modelId}
245
+ onChange={e => setModelId(e.target.value)}
246
+ >
247
+ <option value="">Pick a model...</option>
248
+ {(allModels || []).map(m => (
249
+ <option key={m.id} value={m.id}>
250
+ {m.name} {m.provider ? `(${m.provider})` : ''}
251
+ </option>
252
+ ))}
253
+ </select>
254
+ <button
255
+ type="button"
256
+ className="btn-sm btn-outline ccai-suggest-model-btn"
257
+ onClick={handleSuggestModel}
258
+ disabled={suggestBusy || !(allModels || []).length}
259
+ title="Analyze the role prompt and recommend a model"
260
+ >
261
+ <Wand2 size={12} />
262
+ {suggestBusy ? 'Suggesting...' : 'Suggest a model'}
263
+ </button>
264
+ </div>
265
+ {suggestMessage && (
266
+ <div className="ccai-expert-suggest-message">{suggestMessage}</div>
267
+ )}
268
+ {suggestion && (
269
+ <div className="ccai-model-suggestion">
270
+ <div className="ccai-model-suggestion-title">
271
+ Suggested: {modelNameForId(suggestion.modelId)}
272
+ {modelProviderForId(suggestion.modelId)
273
+ ? ` (${modelProviderForId(suggestion.modelId)})`
274
+ : ''}
275
+ </div>
276
+ {suggestion.rationale && (
277
+ <p className="ccai-model-suggestion-rationale">{suggestion.rationale}</p>
278
+ )}
279
+ <div className="ccai-model-suggestion-actions">
280
+ <button
281
+ type="button"
282
+ className="btn-sm btn-primary"
283
+ onClick={handleAcceptSuggestion}
284
+ >
285
+ Use this model
286
+ </button>
287
+ <button
288
+ type="button"
289
+ className="btn-sm btn-secondary"
290
+ onClick={() => setSuggestion(null)}
291
+ >
292
+ Dismiss
293
+ </button>
294
+ </div>
295
+ </div>
296
+ )}
297
+ </div>
298
+ </div>
299
+
300
+ <div className="ccai-expert-input-mode">
301
+ <div className="ccai-tab-row">
302
+ <button
303
+ type="button"
304
+ className={'ccai-tab-btn' + (activeTab === 'freeform' ? ' ccai-tab-btn-active' : '')}
305
+ onClick={() => setActiveTab('freeform')}
306
+ title="One text area for background, bio, and writing samples."
307
+ >
308
+ Freeform
309
+ </button>
310
+ <button
311
+ type="button"
312
+ className={'ccai-tab-btn' + (activeTab === 'structured' ? ' ccai-tab-btn-active' : '')}
313
+ onClick={() => setActiveTab('structured')}
314
+ title="Separate fields for identity, profile, and writing samples."
315
  >
316
+ Structured
317
+ </button>
 
 
 
 
 
318
  </div>
319
+ <p className="ccai-expert-field-hint">
320
+ Freeform = one box for everything; Structured = separate fields for identity, profile, and samples.
321
+ </p>
322
  </div>
323
 
324
+ <div className="ccai-expert-role-style">
325
+ <div className="ccai-tab-row ccai-tab-row-compact">
326
+ <label
327
+ className="ccai-role-style"
328
+ title="Fills in tone and style from your notes; stays within what you described."
329
+ >
330
+ <input
331
+ type="radio"
332
+ name="role-style"
333
+ checked={roleStyle === 'ai_completed'}
334
+ onChange={() => setRoleStyle('ai_completed')}
335
+ />
336
+ AI-completed
337
+ </label>
338
+ <label
339
+ className="ccai-role-style"
340
+ title="Reorganizes your text only; won't add traits or facts you didn't provide."
341
+ >
342
+ <input
343
+ type="radio"
344
+ name="role-style"
345
+ checked={roleStyle === 'exact'}
346
+ onChange={() => setRoleStyle('exact')}
347
+ />
348
+ Exact (no inferring)
349
+ </label>
350
+ </div>
351
+ <p className="ccai-expert-field-hint">
352
+ AI-completed fills in tone and style from your notes (no new facts); Exact uses only what you typed.
353
+ </p>
 
 
354
  </div>
355
 
356
  {activeTab === 'freeform' ? (
 
412
  <button
413
  className="btn-secondary"
414
  onClick={handleGenerate}
415
+ disabled={busy || !name.trim() || !hasDescriptionContent}
416
  >
417
  {busy ? 'Generating role prompt...' : 'Generate role prompt'}
418
  </button>
419
  </div>
420
 
421
+ <div className="ccai-expert-prompt">
422
+ <label>Role prompt</label>
423
+ <textarea
424
+ value={generatedPrompt}
425
+ onChange={e => {
426
+ setGeneratedPrompt(e.target.value);
427
+ if (suggestMessage) setSuggestMessage('');
428
+ if (suggestion) setSuggestion(null);
429
+ }}
430
+ rows={6}
431
+ placeholder="Generate a role prompt above, or write one here. Suggest a model analyzes this text."
432
+ />
433
+ </div>
434
 
435
  {error && <div className="ccai-expert-error">{error}</div>}
436
 
frontend/src/components/Header.js CHANGED
@@ -4,6 +4,56 @@ import AuthBadge from './AuthBadge';
4
  import ParticipantDropdown from './ParticipantDropdown';
5
  import DownloadMenu from './DownloadMenu';
6
  import DevMenu from './DevMenu';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  /**
9
  * Header bar: brand on the left; on the right, auth badge, participant
@@ -97,41 +147,20 @@ export default function Header({
97
  autoSelectMode={autoSelectMode}
98
  onToggleAutoSelectMode={onToggleAutoSelectMode}
99
  />
100
- <button
101
- type="button"
102
- className={
103
- 'btn-sm btn-outline ccai-human-add-btn'
104
- + (humanParticipant ? ' ccai-human-add-btn-active' : '')
105
- }
106
- onClick={onOpenHumanModal}
107
- title={humanParticipant
108
- ? `Edit ${humanParticipant.name}'s credential summary`
109
- : 'Add a human participant to the conversation'}
110
- >
111
- {humanParticipant ? (
112
- <>
113
- <UserCheck size={14} style={{ marginRight: 4 }} />
114
- {humanParticipant.name}
115
- </>
116
- ) : (
117
- <>
118
- <UserPlus size={14} style={{ marginRight: 4 }} />
119
- Add a Human Participant
120
- </>
121
- )}
122
- </button>
123
- <button
124
- type="button"
125
- className="btn-sm btn-outline ccai-table-view-btn"
126
- onClick={onShowTableView}
127
- disabled={!hasChat}
128
- title={hasChat
129
- ? 'Open the conversation summary table'
130
- : 'Start a chat to view the summary table'}
131
- >
132
- <Table2 size={14} style={{ marginRight: 4 }} />
133
- Table View
134
- </button>
135
  <DownloadMenu
136
  hasChat={hasChat}
137
  hasApiLog={hasApiLog}
 
4
  import ParticipantDropdown from './ParticipantDropdown';
5
  import DownloadMenu from './DownloadMenu';
6
  import DevMenu from './DevMenu';
7
+ import HeaderMoreMenu from './HeaderMoreMenu';
8
+
9
+ function HumanParticipantButton({ humanParticipant, onOpenHumanModal, className }) {
10
+ return (
11
+ <button
12
+ type="button"
13
+ className={
14
+ 'btn-sm btn-outline ccai-human-add-btn header-actions-desktop'
15
+ + (className ? ` ${className}` : '')
16
+ + (humanParticipant ? ' ccai-human-add-btn-active' : '')
17
+ }
18
+ onClick={onOpenHumanModal}
19
+ title={humanParticipant
20
+ ? `Edit ${humanParticipant.name}'s credential summary`
21
+ : 'Add a human participant to the conversation'}
22
+ >
23
+ {humanParticipant ? (
24
+ <>
25
+ <UserCheck size={14} style={{ marginRight: 4 }} />
26
+ {humanParticipant.name}
27
+ </>
28
+ ) : (
29
+ <>
30
+ <UserPlus size={14} style={{ marginRight: 4 }} />
31
+ Add a Human Participant
32
+ </>
33
+ )}
34
+ </button>
35
+ );
36
+ }
37
+
38
+ function TableViewButton({ hasChat, onShowTableView, className }) {
39
+ return (
40
+ <button
41
+ type="button"
42
+ className={
43
+ 'btn-sm btn-outline ccai-table-view-btn header-actions-desktop'
44
+ + (className ? ` ${className}` : '')
45
+ }
46
+ onClick={onShowTableView}
47
+ disabled={!hasChat}
48
+ title={hasChat
49
+ ? 'Open the conversation summary table'
50
+ : 'Start a chat to view the summary table'}
51
+ >
52
+ <Table2 size={14} style={{ marginRight: 4 }} />
53
+ Table View
54
+ </button>
55
+ );
56
+ }
57
 
58
  /**
59
  * Header bar: brand on the left; on the right, auth badge, participant
 
147
  autoSelectMode={autoSelectMode}
148
  onToggleAutoSelectMode={onToggleAutoSelectMode}
149
  />
150
+ <HumanParticipantButton
151
+ humanParticipant={humanParticipant}
152
+ onOpenHumanModal={onOpenHumanModal}
153
+ />
154
+ <TableViewButton
155
+ hasChat={hasChat}
156
+ onShowTableView={onShowTableView}
157
+ />
158
+ <HeaderMoreMenu
159
+ humanParticipant={humanParticipant}
160
+ onOpenHumanModal={onOpenHumanModal}
161
+ hasChat={hasChat}
162
+ onShowTableView={onShowTableView}
163
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  <DownloadMenu
165
  hasChat={hasChat}
166
  hasApiLog={hasApiLog}
frontend/src/components/HeaderMoreMenu.js ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { MoreHorizontal, UserPlus, UserCheck, Table2 } from 'lucide-react';
3
+
4
+ /**
5
+ * Mobile header overflow for wide labeled actions (human participant,
6
+ * table view). Mirrors DownloadMenu's open/close pattern.
7
+ */
8
+ export default function HeaderMoreMenu({
9
+ humanParticipant,
10
+ onOpenHumanModal,
11
+ hasChat,
12
+ onShowTableView,
13
+ }) {
14
+ const [open, setOpen] = useState(false);
15
+ const wrapRef = useRef(null);
16
+
17
+ useEffect(() => {
18
+ function handleClickOutside(e) {
19
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) {
20
+ setOpen(false);
21
+ }
22
+ }
23
+ document.addEventListener('mousedown', handleClickOutside);
24
+ return () => document.removeEventListener('mousedown', handleClickOutside);
25
+ }, []);
26
+
27
+ const fire = (fn) => () => { fn?.(); setOpen(false); };
28
+
29
+ return (
30
+ <div className="dev-wrap header-actions-mobile" ref={wrapRef}>
31
+ <div className="dev-dropdown-header">
32
+ <button
33
+ type="button"
34
+ className="icon-btn"
35
+ onClick={() => setOpen(o => !o)}
36
+ title="More actions"
37
+ aria-label="More actions"
38
+ aria-expanded={open}
39
+ >
40
+ <MoreHorizontal size={16} />
41
+ </button>
42
+ {open && (
43
+ <div className="dev-panel">
44
+ <button
45
+ type="button"
46
+ className={
47
+ 'dev-panel-row'
48
+ + (humanParticipant ? ' ccai-human-add-btn-active' : '')
49
+ }
50
+ onClick={fire(onOpenHumanModal)}
51
+ title={humanParticipant
52
+ ? `Edit ${humanParticipant.name}'s credential summary`
53
+ : 'Add a human participant to the conversation'}
54
+ >
55
+ {humanParticipant ? (
56
+ <>
57
+ <UserCheck size={14} className="dev-check-icon" />
58
+ {humanParticipant.name}
59
+ </>
60
+ ) : (
61
+ <>
62
+ <UserPlus size={14} className="dev-check-icon" />
63
+ Add a Human Participant
64
+ </>
65
+ )}
66
+ </button>
67
+ <button
68
+ type="button"
69
+ className="dev-panel-row"
70
+ disabled={!hasChat}
71
+ onClick={fire(onShowTableView)}
72
+ title={hasChat
73
+ ? 'Open the conversation summary table'
74
+ : 'Start a chat to view the summary table'}
75
+ >
76
+ <Table2 size={14} className="dev-check-icon" />
77
+ Table View
78
+ </button>
79
+ </div>
80
+ )}
81
+ </div>
82
+ </div>
83
+ );
84
+ }
frontend/src/components/ParticipantDropdown.js CHANGED
@@ -5,7 +5,7 @@ import { Users, Plus, ChevronDown, Wand2, Check } from 'lucide-react';
5
  * Header dropdown that lists every available participant the user can
6
  * pull into the conversation. Three sections:
7
  * - Neon (HANA personas, vanilla/RAG already filtered server-side)
8
- * - Extra (the four bundled non-Neon-LLM personas)
9
  * - Expert (user-created, stored in localStorage)
10
  *
11
  * A top-of-list "Select N Automatically" toggle defers the choice to
@@ -45,6 +45,11 @@ export default function ParticipantDropdown({
45
  // orchestrator picks at start time, so user picks are ignored.
46
  const checkboxDisabledForAuto = !!autoSelectMode;
47
 
 
 
 
 
 
48
  return (
49
  <div className="ccai-dropdown-wrap" ref={ref}>
50
  <button
@@ -65,7 +70,14 @@ export default function ParticipantDropdown({
65
  <ChevronDown size={12} />
66
  </button>
67
  {open && (
68
- <div className="ccai-dropdown-panel">
 
 
 
 
 
 
 
69
  <div className="ccai-dropdown-section">
70
  <button
71
  type="button"
@@ -86,8 +98,9 @@ export default function ParticipantDropdown({
86
  <div className="ccai-dropdown-autoselect-help">
87
  {autoSelectMode
88
  ? 'The orchestrator will pick the most relevant participants for your question when you start the chat.'
89
- : 'Or pick manually from the lists below.'}
90
  </div>
 
91
  </div>
92
  <div className="ccai-dropdown-divider" />
93
  <div
@@ -164,20 +177,31 @@ export default function ParticipantDropdown({
164
  onToggle={() => onToggleParticipant(p, 'expert')}
165
  />
166
  ))}
167
- <button
168
- className="ccai-dropdown-create-btn"
169
- onClick={() => { setOpen(false); onOpenExpertModal(null); }}
170
- >
171
- <Plus size={12} />
172
- Create Expert Persona...
173
- </button>
174
  </div>
175
- </div>
 
176
  )}
177
  </div>
178
  );
179
  }
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  function DropdownItem({ participant, checked, disabledForAdd, autoSelectActive, onToggle }) {
182
  return (
183
  <label
 
5
  * Header dropdown that lists every available participant the user can
6
  * pull into the conversation. Three sections:
7
  * - Neon (HANA personas, vanilla/RAG already filtered server-side)
8
+ * - Extra (bundled catalog personas, provider + Neon LLMs)
9
  * - Expert (user-created, stored in localStorage)
10
  *
11
  * A top-of-list "Select N Automatically" toggle defers the choice to
 
45
  // orchestrator picks at start time, so user picks are ignored.
46
  const checkboxDisabledForAuto = !!autoSelectMode;
47
 
48
+ const openCreateExpertModal = () => {
49
+ setOpen(false);
50
+ onOpenExpertModal(null);
51
+ };
52
+
53
  return (
54
  <div className="ccai-dropdown-wrap" ref={ref}>
55
  <button
 
70
  <ChevronDown size={12} />
71
  </button>
72
  {open && (
73
+ <>
74
+ <button
75
+ type="button"
76
+ className="ccai-dropdown-backdrop"
77
+ aria-label="Close participants menu"
78
+ onClick={() => setOpen(false)}
79
+ />
80
+ <div className="ccai-dropdown-panel">
81
  <div className="ccai-dropdown-section">
82
  <button
83
  type="button"
 
98
  <div className="ccai-dropdown-autoselect-help">
99
  {autoSelectMode
100
  ? 'The orchestrator will pick the most relevant participants for your question when you start the chat.'
101
+ : 'Create or pick manually from the lists below.'}
102
  </div>
103
+ <CreateExpertPersonaButton onClick={openCreateExpertModal} />
104
  </div>
105
  <div className="ccai-dropdown-divider" />
106
  <div
 
177
  onToggle={() => onToggleParticipant(p, 'expert')}
178
  />
179
  ))}
180
+ <CreateExpertPersonaButton onClick={openCreateExpertModal} />
 
 
 
 
 
 
181
  </div>
182
+ </div>
183
+ </>
184
  )}
185
  </div>
186
  );
187
  }
188
 
189
+ function CreateExpertPersonaButton({ onClick, className }) {
190
+ return (
191
+ <button
192
+ type="button"
193
+ className={
194
+ 'ccai-dropdown-create-btn'
195
+ + (className ? ` ${className}` : '')
196
+ }
197
+ onClick={onClick}
198
+ >
199
+ <Plus size={12} />
200
+ Create Expert Persona...
201
+ </button>
202
+ );
203
+ }
204
+
205
  function DropdownItem({ participant, checked, disabledForAdd, autoSelectActive, onToggle }) {
206
  return (
207
  <label
frontend/src/components/ParticipantSidebar.js CHANGED
@@ -69,6 +69,7 @@ export default function ParticipantSidebar({
69
 
70
  function ParticipantCard({ participant, enabled, modelOverride, neonPromptByModelId, onToggleEnabled, onRemove }) {
71
  const [open, setOpen] = useState(false);
 
72
  const isHuman = participant.kind === 'human';
73
 
74
  const effectiveModelId = modelOverride
@@ -79,6 +80,16 @@ function ParticipantCard({ participant, enabled, modelOverride, neonPromptByMode
79
  || participant.role_prompt
80
  || '';
81
 
 
 
 
 
 
 
 
 
 
 
82
  return (
83
  <div
84
  className={
@@ -87,15 +98,27 @@ function ParticipantCard({ participant, enabled, modelOverride, neonPromptByMode
87
  + (isHuman ? ' ccai-participant-card-human' : '')
88
  }
89
  >
90
- <div className="ccai-participant-row">
 
 
 
 
 
91
  <button
92
  className="ccai-accordion-chevron"
93
- onClick={() => setOpen(o => !o)}
94
  aria-label="Toggle participant details"
 
95
  >
96
  {open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
97
  </button>
98
- <div className="ccai-participant-name">
 
 
 
 
 
 
99
  {isHuman && (
100
  <User
101
  size={12}
@@ -161,9 +184,25 @@ function ParticipantCard({ participant, enabled, modelOverride, neonPromptByMode
161
  </div>
162
  <div className="ccai-participant-field">
163
  <div className="ccai-participant-field-label">Persona prompt</div>
164
- <pre className="ccai-participant-prompt">
 
 
 
 
 
 
 
165
  {personaPrompt || '(no prompt set)'}
166
  </pre>
 
 
 
 
 
 
 
 
 
167
  </div>
168
  </>
169
  )}
 
69
 
70
  function ParticipantCard({ participant, enabled, modelOverride, neonPromptByModelId, onToggleEnabled, onRemove }) {
71
  const [open, setOpen] = useState(false);
72
+ const [promptExpanded, setPromptExpanded] = useState(false);
73
  const isHuman = participant.kind === 'human';
74
 
75
  const effectiveModelId = modelOverride
 
80
  || participant.role_prompt
81
  || '';
82
 
83
+ const PROMPT_PREVIEW_CHARS = 280;
84
+ const promptIsLong = personaPrompt.length > PROMPT_PREVIEW_CHARS;
85
+
86
+ const handleToggleOpen = () => {
87
+ setOpen((wasOpen) => {
88
+ if (wasOpen) setPromptExpanded(false);
89
+ return !wasOpen;
90
+ });
91
+ };
92
+
93
  return (
94
  <div
95
  className={
 
98
  + (isHuman ? ' ccai-participant-card-human' : '')
99
  }
100
  >
101
+ <div
102
+ className={
103
+ 'ccai-participant-row'
104
+ + (open ? ' ccai-participant-row--expanded' : '')
105
+ }
106
+ >
107
  <button
108
  className="ccai-accordion-chevron"
109
+ onClick={handleToggleOpen}
110
  aria-label="Toggle participant details"
111
+ aria-expanded={open}
112
  >
113
  {open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
114
  </button>
115
+ <div
116
+ className={
117
+ 'ccai-participant-name'
118
+ + (open ? ' ccai-participant-name--expanded' : '')
119
+ }
120
+ title={!open ? participant.name : undefined}
121
+ >
122
  {isHuman && (
123
  <User
124
  size={12}
 
184
  </div>
185
  <div className="ccai-participant-field">
186
  <div className="ccai-participant-field-label">Persona prompt</div>
187
+ <pre
188
+ className={
189
+ 'ccai-participant-prompt'
190
+ + (promptIsLong && !promptExpanded
191
+ ? ' ccai-participant-prompt--preview'
192
+ : '')
193
+ }
194
+ >
195
  {personaPrompt || '(no prompt set)'}
196
  </pre>
197
+ {promptIsLong && (
198
+ <button
199
+ type="button"
200
+ className="ccai-participant-prompt-toggle"
201
+ onClick={() => setPromptExpanded(v => !v)}
202
+ >
203
+ {promptExpanded ? 'Show less' : 'Show full prompt'}
204
+ </button>
205
+ )}
206
  </div>
207
  </>
208
  )}
frontend/src/styles/ccai.css CHANGED
@@ -10,6 +10,10 @@
10
 
11
  .ccai-dropdown-wrap { position: relative; }
12
 
 
 
 
 
13
  .ccai-dropdown-trigger {
14
  display: inline-flex;
15
  align-items: center;
@@ -165,6 +169,14 @@
165
  gap: 6px;
166
  }
167
 
 
 
 
 
 
 
 
 
168
  .ccai-participant-row-secondary {
169
  justify-content: flex-end;
170
  }
@@ -199,6 +211,14 @@
199
  min-width: 0;
200
  }
201
 
 
 
 
 
 
 
 
 
202
  .ccai-participant-controls {
203
  display: flex;
204
  align-items: center;
@@ -244,8 +264,31 @@
244
  padding: 8px 10px;
245
  white-space: pre-wrap;
246
  word-break: break-word;
247
- max-height: 160px;
248
- overflow-y: auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  }
250
 
251
  /* On/off slider switch */
@@ -348,6 +391,55 @@
348
  font-size: 13px;
349
  }
350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  .ccai-tab-row {
352
  display: flex;
353
  align-items: center;
@@ -356,6 +448,25 @@
356
  padding-bottom: 6px;
357
  }
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  .ccai-tab-btn {
360
  padding: 6px 12px;
361
  border: 1px solid transparent;
@@ -420,6 +531,32 @@
420
  gap: 4px;
421
  }
422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  .ccai-expert-prompt label {
424
  font-size: 11px;
425
  font-weight: 600;
@@ -819,6 +956,10 @@
819
 
820
  .ccai-orchestrator-msg-body p:last-child { margin-bottom: 0; }
821
 
 
 
 
 
822
  .ccai-orchestrator-msg-report {
823
  background: var(--accent-light);
824
  border-style: solid;
@@ -1056,6 +1197,33 @@
1056
  }
1057
 
1058
  @media (max-width: 600px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1059
  .ccai-table-card {
1060
  max-height: 100vh;
1061
  border-radius: 0;
 
10
 
11
  .ccai-dropdown-wrap { position: relative; }
12
 
13
+ .ccai-dropdown-backdrop {
14
+ display: none;
15
+ }
16
+
17
  .ccai-dropdown-trigger {
18
  display: inline-flex;
19
  align-items: center;
 
169
  gap: 6px;
170
  }
171
 
172
+ .ccai-participant-row--expanded {
173
+ align-items: flex-start;
174
+ }
175
+
176
+ .ccai-participant-row--expanded .ccai-accordion-chevron {
177
+ margin-top: 1px;
178
+ }
179
+
180
  .ccai-participant-row-secondary {
181
  justify-content: flex-end;
182
  }
 
211
  min-width: 0;
212
  }
213
 
214
+ .ccai-participant-name--expanded {
215
+ white-space: normal;
216
+ overflow: visible;
217
+ text-overflow: unset;
218
+ line-height: 1.35;
219
+ word-break: break-word;
220
+ }
221
+
222
  .ccai-participant-controls {
223
  display: flex;
224
  align-items: center;
 
264
  padding: 8px 10px;
265
  white-space: pre-wrap;
266
  word-break: break-word;
267
+ }
268
+
269
+ .ccai-participant-prompt--preview {
270
+ display: -webkit-box;
271
+ -webkit-box-orient: vertical;
272
+ -webkit-line-clamp: 5;
273
+ overflow: hidden;
274
+ }
275
+
276
+ .ccai-participant-prompt-toggle {
277
+ align-self: flex-start;
278
+ margin: 0;
279
+ padding: 0;
280
+ border: none;
281
+ background: none;
282
+ font-size: 11px;
283
+ font-weight: 600;
284
+ color: var(--accent-primary, #6366f1);
285
+ cursor: pointer;
286
+ text-decoration: underline;
287
+ text-underline-offset: 2px;
288
+ }
289
+
290
+ .ccai-participant-prompt-toggle:hover {
291
+ color: var(--text-primary);
292
  }
293
 
294
  /* On/off slider switch */
 
391
  font-size: 13px;
392
  }
393
 
394
+ .ccai-expert-model-field {
395
+ min-width: 0;
396
+ }
397
+
398
+ .ccai-expert-model-row {
399
+ display: flex;
400
+ flex-direction: column;
401
+ gap: 8px;
402
+ }
403
+
404
+ .ccai-expert-model-row select {
405
+ width: 100%;
406
+ }
407
+
408
+ .ccai-suggest-model-btn {
409
+ display: inline-flex;
410
+ align-items: center;
411
+ gap: 6px;
412
+ align-self: flex-start;
413
+ white-space: nowrap;
414
+ }
415
+
416
+ .ccai-model-suggestion {
417
+ margin-top: 4px;
418
+ padding: 10px 12px;
419
+ border: 1px solid var(--accent-primary);
420
+ border-radius: 8px;
421
+ background: var(--accent-light);
422
+ }
423
+
424
+ .ccai-model-suggestion-title {
425
+ font-size: 13px;
426
+ font-weight: 600;
427
+ color: var(--text-primary);
428
+ }
429
+
430
+ .ccai-model-suggestion-rationale {
431
+ margin: 6px 0 0;
432
+ font-size: 12px;
433
+ line-height: 1.45;
434
+ color: var(--text-secondary);
435
+ }
436
+
437
+ .ccai-model-suggestion-actions {
438
+ display: flex;
439
+ gap: 8px;
440
+ margin-top: 10px;
441
+ }
442
+
443
  .ccai-tab-row {
444
  display: flex;
445
  align-items: center;
 
448
  padding-bottom: 6px;
449
  }
450
 
451
+ .ccai-tab-row-compact {
452
+ border-bottom: none;
453
+ padding-bottom: 0;
454
+ }
455
+
456
+ .ccai-expert-input-mode,
457
+ .ccai-expert-role-style {
458
+ display: flex;
459
+ flex-direction: column;
460
+ gap: 6px;
461
+ }
462
+
463
+ .ccai-expert-field-hint {
464
+ margin: 0;
465
+ font-size: 11px;
466
+ line-height: 1.45;
467
+ color: var(--text-tertiary);
468
+ }
469
+
470
  .ccai-tab-btn {
471
  padding: 6px 12px;
472
  border: 1px solid transparent;
 
531
  gap: 4px;
532
  }
533
 
534
+ .ccai-expert-prompt-header {
535
+ display: flex;
536
+ align-items: center;
537
+ justify-content: space-between;
538
+ gap: 8px;
539
+ }
540
+
541
+ .ccai-expert-prompt-header label {
542
+ font-size: 11px;
543
+ font-weight: 600;
544
+ color: var(--text-tertiary);
545
+ text-transform: uppercase;
546
+ letter-spacing: 0.04em;
547
+ }
548
+
549
+ .ccai-expert-suggest-message {
550
+ margin-top: 4px;
551
+ padding: 8px 12px;
552
+ border: 1px solid var(--border-primary);
553
+ border-radius: 8px;
554
+ background: var(--bg-secondary);
555
+ color: var(--text-secondary);
556
+ font-size: 12px;
557
+ line-height: 1.45;
558
+ }
559
+
560
  .ccai-expert-prompt label {
561
  font-size: 11px;
562
  font-weight: 600;
 
956
 
957
  .ccai-orchestrator-msg-body p:last-child { margin-bottom: 0; }
958
 
959
+ .ccai-orchestrator-msg-body :where(p, li, ol, ul, blockquote, td, th) {
960
+ color: inherit;
961
+ }
962
+
963
  .ccai-orchestrator-msg-report {
964
  background: var(--accent-light);
965
  border-style: solid;
 
1197
  }
1198
 
1199
  @media (max-width: 600px) {
1200
+ .ccai-dropdown-backdrop {
1201
+ display: block;
1202
+ position: fixed;
1203
+ inset: 0;
1204
+ border: none;
1205
+ padding: 0;
1206
+ margin: 0;
1207
+ background: rgba(0, 0, 0, 0.45);
1208
+ z-index: 59;
1209
+ cursor: default;
1210
+ }
1211
+
1212
+ .ccai-dropdown-panel {
1213
+ position: fixed;
1214
+ left: 0;
1215
+ right: 0;
1216
+ bottom: 0;
1217
+ top: auto;
1218
+ width: 100%;
1219
+ max-width: none;
1220
+ max-height: min(85vh, calc(100dvh - 64px));
1221
+ border-radius: 16px 16px 0 0;
1222
+ border-bottom: none;
1223
+ padding-bottom: max(10px, env(safe-area-inset-bottom));
1224
+ box-shadow: var(--shadow-lg);
1225
+ }
1226
+
1227
  .ccai-table-card {
1228
  max-height: 100vh;
1229
  border-radius: 0;
frontend/src/styles/components.css CHANGED
@@ -445,16 +445,75 @@
445
  gap: 16px;
446
  padding: 16px 0;
447
  overflow-y: auto;
 
448
  }
449
 
450
  .chat-empty {
451
- flex: 1;
452
  display: flex;
 
453
  align-items: center;
454
- justify-content: center;
 
 
 
 
455
  color: var(--text-muted);
456
  font-size: 15px;
457
  font-style: italic;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  }
459
 
460
  /* ── Message Bubbles ───────────────────────────────────────────── */
@@ -514,6 +573,27 @@
514
  word-break: break-word;
515
  }
516
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  .message-bubble.bubble-a {
518
  background: var(--speaker-a-bg);
519
  color: var(--text-primary);
@@ -593,38 +673,109 @@
593
  /* Download strip rendered below the "End of Chat" marker. Mirrors the
594
  items in the header DownloadMenu so the user doesn't have to scroll
595
  back up to the top of a finished conversation to export it. */
 
 
 
 
 
 
 
 
 
596
  .chat-end-downloads {
597
  display: flex;
598
  flex-wrap: wrap;
599
  justify-content: center;
600
  gap: 8px;
601
- padding: 12px 0 4px;
602
  }
603
 
604
  .chat-end-download-btn {
605
  white-space: nowrap;
606
  }
607
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  /* ── Chat Controls ─────────────────────────────────────────────── */
609
 
610
  .chat-controls {
611
  display: flex;
612
- gap: 12px;
613
  align-items: stretch;
 
 
614
  padding: 12px 0;
 
615
  }
616
 
617
- .chat-controls input {
618
- flex: 1;
619
- padding: 10px 14px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  border: 1px solid var(--border-primary);
621
  border-radius: 8px;
622
  background: var(--bg-primary);
623
  color: var(--text-primary);
624
- font-size: 14px;
 
 
 
625
  }
626
 
627
- .chat-controls input:focus {
 
 
 
 
 
 
628
  outline: none;
629
  border-color: var(--accent-primary);
630
  box-shadow: 0 0 0 3px var(--accent-light);
@@ -1216,11 +1367,9 @@
1216
  padding: 12px 16px 16px;
1217
  }
1218
  .chat-controls {
1219
- flex-wrap: wrap;
1220
  gap: 8px;
1221
  }
1222
- .chat-controls input {
1223
- flex-basis: 100%;
1224
  font-size: 13px;
1225
  }
1226
  .message-row {
@@ -1334,11 +1483,19 @@
1334
  }
1335
  /* On phones the download strip stacks so each label is fully
1336
  readable; on wider viewports it stays inline-wrap. */
 
 
 
 
 
1337
  .chat-end-downloads {
1338
  flex-direction: column;
1339
  align-items: stretch;
1340
  gap: 6px;
1341
  }
 
 
 
1342
  .chat-end-download-btn {
1343
  justify-content: center;
1344
  }
 
445
  gap: 16px;
446
  padding: 16px 0;
447
  overflow-y: auto;
448
+ min-height: 0;
449
  }
450
 
451
  .chat-empty {
452
+ flex: 1 1 auto;
453
  display: flex;
454
+ flex-direction: column;
455
  align-items: center;
456
+ justify-content: flex-start;
457
+ width: 100%;
458
+ min-height: min-content;
459
+ gap: 1.5rem;
460
+ padding: 0.25rem 0 1.5rem;
461
  color: var(--text-muted);
462
  font-size: 15px;
463
  font-style: italic;
464
+ pointer-events: none;
465
+ }
466
+
467
+ .chat-empty-copy {
468
+ display: flex;
469
+ flex-direction: column;
470
+ align-items: center;
471
+ text-align: center;
472
+ gap: 1.25rem;
473
+ flex-shrink: 0;
474
+ }
475
+
476
+ .chat-empty-lead,
477
+ .chat-empty-instruction {
478
+ margin: 0;
479
+ white-space: nowrap;
480
+ }
481
+
482
+ .chat-empty-lead {
483
+ font-weight: 700;
484
+ }
485
+
486
+ .chat-empty-sample {
487
+ width: min(100%, 42rem);
488
+ flex-shrink: 0;
489
+ opacity: 0.72;
490
+ border: 1px dashed var(--border-primary);
491
+ border-radius: 12px;
492
+ background: var(--bg-secondary);
493
+ padding: 12px 14px 10px;
494
+ font-style: normal;
495
+ }
496
+
497
+ .chat-empty-sample-label {
498
+ margin: 0 0 10px;
499
+ font-size: 11px;
500
+ font-weight: 600;
501
+ font-style: normal;
502
+ letter-spacing: 0.04em;
503
+ text-transform: uppercase;
504
+ text-align: center;
505
+ color: var(--text-tertiary);
506
+ }
507
+
508
+ .chat-empty-sample-panel {
509
+ display: flex;
510
+ flex-direction: column;
511
+ gap: 4px;
512
+ }
513
+
514
+ .chat-empty-sample-panel .message-row,
515
+ .chat-empty-sample-panel .ccai-orchestrator-msg {
516
+ animation: none;
517
  }
518
 
519
  /* ── Message Bubbles ───────────────────────────────────────────── */
 
573
  word-break: break-word;
574
  }
575
 
576
+ /* Live participant bubbles use light pastel backgrounds (set inline in
577
+ MessageBubble.js). Keep body text dark so it stays readable in both
578
+ light and dark app themes — do not inherit --text-primary, which is
579
+ light-colored in dark mode. */
580
+ .ccai-bubble {
581
+ color: #111827;
582
+ font-style: normal;
583
+ }
584
+
585
+ .ccai-bubble :where(p, li, ol, ul, blockquote, td, th, h1, h2, h3, h4, h5, h6) {
586
+ color: inherit;
587
+ }
588
+
589
+ .ccai-bubble a {
590
+ color: #4338CA;
591
+ }
592
+
593
+ .ccai-bubble .message-elapsed {
594
+ color: #6B7280;
595
+ }
596
+
597
  .message-bubble.bubble-a {
598
  background: var(--speaker-a-bg);
599
  color: var(--text-primary);
 
673
  /* Download strip rendered below the "End of Chat" marker. Mirrors the
674
  items in the header DownloadMenu so the user doesn't have to scroll
675
  back up to the top of a finished conversation to export it. */
676
+ .chat-end-bar {
677
+ display: flex;
678
+ flex-wrap: wrap;
679
+ justify-content: center;
680
+ align-items: center;
681
+ gap: 12px 20px;
682
+ padding: 12px 0 4px;
683
+ }
684
+
685
  .chat-end-downloads {
686
  display: flex;
687
  flex-wrap: wrap;
688
  justify-content: center;
689
  gap: 8px;
 
690
  }
691
 
692
  .chat-end-download-btn {
693
  white-space: nowrap;
694
  }
695
 
696
+ .chat-end-cta {
697
+ margin: 0;
698
+ font-size: 13px;
699
+ line-height: 1.45;
700
+ color: var(--text-secondary);
701
+ text-align: center;
702
+ max-width: 20rem;
703
+ }
704
+
705
+ .chat-end-cta a {
706
+ color: var(--accent-primary);
707
+ font-weight: 600;
708
+ text-decoration: none;
709
+ }
710
+
711
+ .chat-end-cta a:hover {
712
+ text-decoration: underline;
713
+ }
714
+
715
  /* ── Chat Controls ─────────────────────────────────────────────── */
716
 
717
  .chat-controls {
718
  display: flex;
719
+ flex-direction: column;
720
  align-items: stretch;
721
+ gap: 6px;
722
+ width: 100%;
723
  padding: 12px 0;
724
+ flex-shrink: 0;
725
  }
726
 
727
+ .chat-controls-row {
728
+ display: flex;
729
+ flex-wrap: nowrap;
730
+ align-items: stretch;
731
+ gap: 8px;
732
+ width: 100%;
733
+ min-width: 0;
734
+ }
735
+
736
+ .chat-start-btn-wrap {
737
+ display: inline-flex;
738
+ flex: 0 0 auto;
739
+ align-self: stretch;
740
+ }
741
+
742
+ .chat-start-btn-wrap-disabled {
743
+ cursor: not-allowed;
744
+ }
745
+
746
+ .chat-start-btn-wrap button:disabled {
747
+ pointer-events: none;
748
+ }
749
+
750
+ .chat-start-hint {
751
+ font-size: 11px;
752
+ line-height: 1.3;
753
+ color: var(--text-secondary);
754
+ text-align: right;
755
+ }
756
+
757
+ .chat-controls-question {
758
+ flex: 1 1 0;
759
+ min-width: 0;
760
+ width: 0;
761
+ padding: 9px 12px;
762
  border: 1px solid var(--border-primary);
763
  border-radius: 8px;
764
  background: var(--bg-primary);
765
  color: var(--text-primary);
766
+ font-size: clamp(12px, calc(0.62vw + 5px), 14px);
767
+ line-height: 1.25;
768
+ font-family: inherit;
769
+ letter-spacing: -0.01em;
770
  }
771
 
772
+ .chat-controls-start {
773
+ height: 100%;
774
+ padding: 0 16px;
775
+ white-space: nowrap;
776
+ }
777
+
778
+ .chat-controls-question:focus {
779
  outline: none;
780
  border-color: var(--accent-primary);
781
  box-shadow: 0 0 0 3px var(--accent-light);
 
1367
  padding: 12px 16px 16px;
1368
  }
1369
  .chat-controls {
 
1370
  gap: 8px;
1371
  }
1372
+ .chat-controls-question {
 
1373
  font-size: 13px;
1374
  }
1375
  .message-row {
 
1483
  }
1484
  /* On phones the download strip stacks so each label is fully
1485
  readable; on wider viewports it stays inline-wrap. */
1486
+ .chat-end-bar {
1487
+ flex-direction: column;
1488
+ align-items: stretch;
1489
+ gap: 10px;
1490
+ }
1491
  .chat-end-downloads {
1492
  flex-direction: column;
1493
  align-items: stretch;
1494
  gap: 6px;
1495
  }
1496
+ .chat-end-cta {
1497
+ max-width: none;
1498
+ }
1499
  .chat-end-download-btn {
1500
  justify-content: center;
1501
  }
frontend/src/styles/layout.css CHANGED
@@ -72,6 +72,14 @@
72
  flex-shrink: 0;
73
  }
74
 
 
 
 
 
 
 
 
 
75
  .icon-btn {
76
  display: flex;
77
  align-items: center;
@@ -117,7 +125,7 @@
117
  flex: 1;
118
  min-height: 0;
119
  padding: 20px 24px;
120
- overflow-y: auto;
121
  display: flex;
122
  flex-direction: column;
123
  gap: 0;
@@ -144,6 +152,32 @@
144
  }
145
  }
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  /* ── Phone ────────────────────────────────────────────────────── */
148
  @media (max-width: 480px) {
149
  .app-header {
 
72
  flex-shrink: 0;
73
  }
74
 
75
+ .header-actions-mobile {
76
+ display: none;
77
+ }
78
+
79
+ .header-actions-desktop {
80
+ display: inline-flex;
81
+ }
82
+
83
  .icon-btn {
84
  display: flex;
85
  align-items: center;
 
125
  flex: 1;
126
  min-height: 0;
127
  padding: 20px 24px;
128
+ overflow: hidden;
129
  display: flex;
130
  flex-direction: column;
131
  gap: 0;
 
152
  }
153
  }
154
 
155
+ /* ── Small tablet / large phone (<600px) ──────────────────────── */
156
+ @media (max-width: 600px) {
157
+ .app-header {
158
+ flex-wrap: wrap;
159
+ gap: 8px;
160
+ }
161
+ .header-left {
162
+ flex: 1 1 auto;
163
+ min-width: 0;
164
+ }
165
+ .header-right {
166
+ flex: 1 1 100%;
167
+ flex-wrap: wrap;
168
+ justify-content: flex-end;
169
+ row-gap: 6px;
170
+ max-width: 100%;
171
+ min-width: 0;
172
+ }
173
+ .header-actions-desktop {
174
+ display: none !important;
175
+ }
176
+ .header-actions-mobile {
177
+ display: block;
178
+ }
179
+ }
180
+
181
  /* ── Phone ────────────────────────────────────────────────────── */
182
  @media (max-width: 480px) {
183
  .app-header {
frontend/src/utils/api.js CHANGED
@@ -30,11 +30,25 @@ export async function fetchDemoQuestions() {
30
  return resp.json();
31
  }
32
 
33
- export async function generateRole({ model_id, name, profile, identity, samples, role_style }) {
 
 
 
 
 
 
 
34
  const resp = await fetch(`${API_BASE}/api/chat/generate-role`, {
35
  method: 'POST',
36
  headers: { 'Content-Type': 'application/json' },
37
- body: JSON.stringify({ model_id, name, profile, identity, samples, role_style }),
 
 
 
 
 
 
 
38
  });
39
  if (!resp.ok) {
40
  const err = await resp.json().catch(() => ({ detail: resp.statusText }));
@@ -43,11 +57,21 @@ export async function generateRole({ model_id, name, profile, identity, samples,
43
  return resp.json();
44
  }
45
 
46
- export async function generateRoleFreeform({ model_id, name, text, role_style }) {
 
 
 
 
 
47
  const resp = await fetch(`${API_BASE}/api/chat/generate-role-freeform`, {
48
  method: 'POST',
49
  headers: { 'Content-Type': 'application/json' },
50
- body: JSON.stringify({ model_id, name, text, role_style }),
 
 
 
 
 
51
  });
52
  if (!resp.ok) {
53
  const err = await resp.json().catch(() => ({ detail: resp.statusText }));
@@ -56,6 +80,47 @@ export async function generateRoleFreeform({ model_id, name, text, role_style })
56
  return resp.json();
57
  }
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  /**
60
  * Start a CCAI conversation and consume the SSE stream.
61
  *
 
30
  return resp.json();
31
  }
32
 
33
+ export async function generateRole({
34
+ name,
35
+ profile,
36
+ identity,
37
+ samples,
38
+ role_style,
39
+ orchestrator_model_id,
40
+ }) {
41
  const resp = await fetch(`${API_BASE}/api/chat/generate-role`, {
42
  method: 'POST',
43
  headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({
45
+ name,
46
+ profile,
47
+ identity,
48
+ samples,
49
+ role_style,
50
+ orchestrator_model_id,
51
+ }),
52
  });
53
  if (!resp.ok) {
54
  const err = await resp.json().catch(() => ({ detail: resp.statusText }));
 
57
  return resp.json();
58
  }
59
 
60
+ export async function generateRoleFreeform({
61
+ name,
62
+ text,
63
+ role_style,
64
+ orchestrator_model_id,
65
+ }) {
66
  const resp = await fetch(`${API_BASE}/api/chat/generate-role-freeform`, {
67
  method: 'POST',
68
  headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({
70
+ name,
71
+ text,
72
+ role_style,
73
+ orchestrator_model_id,
74
+ }),
75
  });
76
  if (!resp.ok) {
77
  const err = await resp.json().catch(() => ({ detail: resp.statusText }));
 
80
  return resp.json();
81
  }
82
 
83
+ /**
84
+ * Suggest an LLM for an Expert Persona from the builder's live model list.
85
+ *
86
+ * Body: { persona_name, source_text, role_prompt, available_models,
87
+ * panel_context, orchestrator_model_id }
88
+ *
89
+ * Returns: { recommended_model_id, rationale }
90
+ */
91
+ export async function suggestModel({
92
+ persona_name,
93
+ source_text,
94
+ role_prompt,
95
+ available_models,
96
+ panel_context,
97
+ orchestrator_model_id,
98
+ }) {
99
+ const resp = await fetch(`${API_BASE}/api/chat/suggest-model`, {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({
103
+ persona_name,
104
+ source_text,
105
+ role_prompt,
106
+ available_models,
107
+ panel_context,
108
+ orchestrator_model_id,
109
+ }),
110
+ });
111
+ if (!resp.ok) {
112
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
113
+ const detail = err.detail;
114
+ const message = typeof detail === 'string'
115
+ ? detail
116
+ : Array.isArray(detail)
117
+ ? detail.map(d => d.msg || JSON.stringify(d)).join('; ')
118
+ : 'Model suggestion failed';
119
+ throw new Error(message);
120
+ }
121
+ return resp.json();
122
+ }
123
+
124
  /**
125
  * Start a CCAI conversation and consume the SSE stream.
126
  *
frontend/src/utils/storage.js CHANGED
@@ -9,11 +9,51 @@
9
  const NS = 'ccai-vibe-demo';
10
  const SCHEMA_VERSION = 1;
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  const DEFAULTS = {
13
  schema_version: SCHEMA_VERSION,
14
  expert_personas: [],
15
- participants_selected: [],
16
- participants_enabled: {},
17
  model_assignments: {},
18
  orchestrator_model_id: null,
19
  summarizer_model_id: null,
 
9
  const NS = 'ccai-vibe-demo';
10
  const SCHEMA_VERSION = 1;
11
 
12
+ /** Demo trio — ids/names mirror backend/app/services/extra_personas.py */
13
+ export const DEFAULT_DEMO_PERSONAS = [
14
+ {
15
+ participant_id: 'extra_elena_financial_strategist',
16
+ name: 'Elena — Financial Strategist',
17
+ },
18
+ {
19
+ participant_id: 'extra_marcus_technology_strategist',
20
+ name: 'Marcus — Technology Strategist',
21
+ },
22
+ {
23
+ participant_id: 'extra_amira_security_advisor',
24
+ name: 'Dr. Amira — Security & Privacy Advisor',
25
+ },
26
+ ];
27
+
28
+ /** Default panel for first load / empty saved selection (demo trio). */
29
+ export const DEFAULT_PARTICIPANT_IDS = DEFAULT_DEMO_PERSONAS.map(
30
+ (p) => p.participant_id,
31
+ );
32
+
33
+ export const DEFAULT_PARTICIPANTS_ENABLED = Object.fromEntries(
34
+ DEFAULT_PARTICIPANT_IDS.map((id) => [id, true]),
35
+ );
36
+
37
+ /** Use saved selection when non-empty; otherwise the demo default trio. */
38
+ export function resolveInitialParticipants(persisted) {
39
+ const saved = persisted?.participants_selected;
40
+ if (Array.isArray(saved) && saved.length > 0) {
41
+ return {
42
+ selectedIds: saved,
43
+ enabledMap: persisted.participants_enabled || {},
44
+ };
45
+ }
46
+ return {
47
+ selectedIds: [...DEFAULT_PARTICIPANT_IDS],
48
+ enabledMap: { ...DEFAULT_PARTICIPANTS_ENABLED },
49
+ };
50
+ }
51
+
52
  const DEFAULTS = {
53
  schema_version: SCHEMA_VERSION,
54
  expert_personas: [],
55
+ participants_selected: [...DEFAULT_PARTICIPANT_IDS],
56
+ participants_enabled: { ...DEFAULT_PARTICIPANTS_ENABLED },
57
  model_assignments: {},
58
  orchestrator_model_id: null,
59
  summarizer_model_id: null,
mock_backend.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mock CCAI backend for UI rendering/screenshots ONLY.
2
+
3
+ Serves the exact JSON shapes the frontend expects (derived from the real
4
+ backend's api/personas.py, api/models.py, extra_personas.py, demo_questions.json)
5
+ plus a canned SSE panel for /api/chat/start so the multi-persona discussion UI
6
+ can be screenshotted without any real LLM/HANA access. Not used in production.
7
+ """
8
+ import json, time
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+
11
+ PROVIDERS = [
12
+ {"id": "openai", "name": "OpenAI", "models": [
13
+ {"id": "gpt-5.4", "name": "GPT-5.4", "params": "Undisclosed"},
14
+ {"id": "gpt-4.1", "name": "GPT-4.1", "params": "Undisclosed"},
15
+ {"id": "gpt-4o", "name": "GPT-4o", "params": "~200B"},
16
+ {"id": "gpt-4o-mini", "name": "GPT-4o Mini", "params": "~8B"},
17
+ {"id": "o4-mini", "name": "o4-Mini", "params": "Undisclosed"},
18
+ ]},
19
+ {"id": "gemini", "name": "Google Gemini", "models": [
20
+ {"id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash", "params": "Undisclosed"},
21
+ {"id": "gemini-2.5-pro", "name": "Gemini 2.5 Pro", "params": "Undisclosed"},
22
+ ]},
23
+ {"id": "mistral", "name": "Mistral", "models": [
24
+ {"id": "devstral-2512", "name": "Devstral2", "params": "123B"},
25
+ {"id": "mistral-small-2603", "name": "Mistral Small 4", "params": "119B"},
26
+ ]},
27
+ {"id": "meta", "name": "Meta Llama", "models": [
28
+ {"id": "meta-llama/Llama-3.3-70B-Instruct-Turbo", "name": "Llama 3.3 70B Turbo", "params": "70B"},
29
+ ]},
30
+ {"id": "deepseek", "name": "DeepSeek", "models": [
31
+ {"id": "accounts/fireworks/models/deepseek-v3p1", "name": "DeepSeek V3.1", "params": "671B"},
32
+ ]},
33
+ ]
34
+
35
+ NEON_MODELS = [
36
+ {"model_id": "BrainForge/Security@2026.03.18", "name": "BrainForge/Security",
37
+ "personas": [
38
+ {"persona_name": "Athena", "enabled": True, "system_prompt": "You are Athena, a strategic advisor."},
39
+ {"persona_name": "Vanilla", "enabled": True, "system_prompt": "Plain assistant."},
40
+ ]},
41
+ ]
42
+
43
+ NEON_PERSONAS = [
44
+ {"participant_id": "neon:BrainForge/Security@2026.03.18:Athena", "kind": "neon",
45
+ "name": "Athena (Strategic Advisor)", "model_display": "Security",
46
+ "default_model_id": "neon:BrainForge/Security@2026.03.18:Athena",
47
+ "description": "Strategic advisor persona", "role_prompt": "You are Athena."},
48
+ ]
49
+
50
+ EXTRA = [
51
+ {"participant_id": "extra_pragmatic_generalist", "name": "Pragmatic Finance Expert",
52
+ "default_model_id": "gpt-5.4", "model_display": "gpt-5.4", "kind": "extra", "role_prompt": "..."},
53
+ {"participant_id": "extra_skeptical_critic", "name": "Skeptical Philosopher",
54
+ "default_model_id": "gemini-2.5-flash", "model_display": "gemini-2.5-flash", "kind": "extra", "role_prompt": "..."},
55
+ {"participant_id": "extra_empathetic_humanist", "name": "Empathetic Historian",
56
+ "default_model_id": "devstral-2512", "model_display": "devstral-2512", "kind": "extra", "role_prompt": "..."},
57
+ {"participant_id": "extra_data_driven_analyst", "name": "Data-Driven Geologist",
58
+ "default_model_id": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
59
+ "model_display": "Llama-3.3-70B-Instruct-Turbo", "kind": "extra", "role_prompt": "..."},
60
+ {"participant_id": "extra_elena_financial_strategist", "name": "Elena — Financial Strategist",
61
+ "default_model_id": "gpt-4.1", "model_display": "gpt-4.1", "kind": "extra", "role_prompt": "..."},
62
+ {"participant_id": "extra_marcus_technology_strategist", "name": "Marcus — Technology Strategist",
63
+ "default_model_id": "mistral-small-2603",
64
+ "model_display": "mistral-small-2603", "kind": "extra", "role_prompt": "..."},
65
+ {"participant_id": "extra_amira_security_advisor", "name": "Dr. Amira — Security & Privacy Advisor",
66
+ "default_model_id": "neon:BrainForge/Security@2026.05.13:CybersecurityExpert",
67
+ "model_display": "CybersecurityExpert", "kind": "extra", "role_prompt": "..."},
68
+ ]
69
+
70
+ DEMO_QUESTIONS = json.load(open("backend/app/data/demo_questions.json"))["questions"]
71
+
72
+ FORMATS = {
73
+ "structures": [
74
+ {"id": "collaborative", "name": "Collaborative Discussion", "description": "Structured group reasoning toward consensus."},
75
+ {"id": "roberts_rules", "name": "Robert's Rules", "description": "Formal motion/second/vote procedure."},
76
+ ],
77
+ "decisions": [
78
+ {"id": "consensus", "name": "Consensus", "description": "Seek agreement; majority report with dissent."},
79
+ {"id": "majority", "name": "Majority Vote", "description": "Simple majority."},
80
+ {"id": "ranked_choice", "name": "Ranked Choice", "description": "Ranked-choice tally."},
81
+ ],
82
+ "default_structure_id": "collaborative",
83
+ "default_decision_id": "consensus",
84
+ }
85
+
86
+ def sse(event, data):
87
+ return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()
88
+
89
+ class H(BaseHTTPRequestHandler):
90
+ def log_message(self, *a): pass
91
+ def _json(self, obj, code=200):
92
+ body = json.dumps(obj).encode()
93
+ self.send_response(code)
94
+ self.send_header("Content-Type", "application/json")
95
+ self.send_header("Access-Control-Allow-Origin", "*")
96
+ self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
97
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
98
+ self.send_header("Cache-Control", "no-store")
99
+ self.end_headers()
100
+ self.wfile.write(body)
101
+ def do_OPTIONS(self):
102
+ self.send_response(204)
103
+ self.send_header("Access-Control-Allow-Origin", "*")
104
+ self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
105
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
106
+ self.end_headers()
107
+ def do_GET(self):
108
+ p = self.path.split("?")[0]
109
+ if p == "/api/models": return self._json({"neon_models": NEON_MODELS, "providers": PROVIDERS})
110
+ if p == "/api/personas": return self._json({"neon": NEON_PERSONAS, "extra": EXTRA})
111
+ if p == "/api/demo-questions": return self._json({"questions": DEMO_QUESTIONS})
112
+ if p == "/api/chat/orchestrator": return self._json({"model_id": "gpt-4o-mini"})
113
+ if p == "/api/chat/speed-priority": return self._json({"enabled": False})
114
+ if p == "/api/chat/conversation-formats": return self._json(FORMATS)
115
+ if p == "/api/auth/status": return self._json({"logged_in": False, "is_org_member": False, "remaining_conversations": 30})
116
+ if p == "/api/rate-limit/status": return self._json({"remaining": 30, "daily_limit": 30})
117
+ return self._json({}, 404)
118
+ def do_PUT(self):
119
+ self._read()
120
+ if self.path.startswith("/api/chat/orchestrator"): return self._json({"model_id": "gpt-4o-mini"})
121
+ if self.path.startswith("/api/chat/speed-priority"): return self._json({"enabled": False})
122
+ return self._json({})
123
+ def do_PATCH(self):
124
+ self._read(); return self._json({})
125
+ def _read(self):
126
+ n = int(self.headers.get("Content-Length", 0) or 0)
127
+ return self.rfile.read(n) if n else b""
128
+ def do_POST(self):
129
+ raw = self._read()
130
+ try: body = json.loads(raw or b"{}")
131
+ except Exception: body = {}
132
+ if self.path.startswith("/api/chat/suggest-model"):
133
+ return self._json({"recommended_model_id": "gemini-2.5-flash",
134
+ "rationale": "A philosophy-leaning critic benefits from a model with strong reasoning and concise argumentation; Gemini 2.5 Flash also diversifies the panel away from the GPT family already present."})
135
+ if self.path.startswith("/api/chat/generate-role-freeform") or self.path.startswith("/api/chat/generate-role"):
136
+ return self._json({"role_prompt": "You are " + (body.get("name") or "an expert") + ". (mock-generated role prompt for UI rendering)"})
137
+ if self.path.startswith("/api/chat/auto-select-participants"):
138
+ cands = body.get("candidates", [])
139
+ return self._json({"selected": [c["participant_id"] for c in cands[:body.get("count",3)]],
140
+ "rationale": "Picked the most topically relevant participants (mock)."})
141
+ if self.path.startswith("/api/chat/start"):
142
+ return self._sse_panel(body)
143
+ return self._json({})
144
+ def _sse_panel(self, body):
145
+ self.send_response(200)
146
+ self.send_header("Content-Type", "text/event-stream")
147
+ self.send_header("Access-Control-Allow-Origin", "*")
148
+ self.send_header("Cache-Control", "no-cache")
149
+ self.end_headers()
150
+ parts = body.get("participants", [])
151
+ if len(parts) < 2:
152
+ parts = [{"participant_id": "extra_pragmatic_generalist", "name": "Pragmatic Finance Expert"},
153
+ {"participant_id": "extra_skeptical_critic", "name": "Skeptical Philosopher"},
154
+ {"participant_id": "extra_empathetic_humanist", "name": "Empathetic Historian"}]
155
+ q = body.get("question", "the question")
156
+ sid = "mock-" + str(int(time.time()))
157
+ roster = [{"participant_id": p["participant_id"], "name": p["name"],
158
+ "model_display": p.get("name")} for p in parts]
159
+ self.wfile.write(sse("session", {"session_id": sid, "participants": roster})); self.wfile.flush()
160
+ self.wfile.write(sse("status", {"message": "Phase 1: Initial Opinions"})); self.wfile.flush()
161
+ op = {
162
+ parts[0]["name"]: f"On '{q[:50]}...', my first take is to weigh cost against benefit. The numbers have to clear a return-on-investment bar before anything else.",
163
+ parts[1]["name"]: "I'd challenge the framing. Before we optimize, what assumption are we all making that nobody has examined? Let's pressure-test the premise.",
164
+ }
165
+ if len(parts) > 2:
166
+ op[parts[2]["name"]] = "I want to center the human stakes. Whoever is on the receiving end of this decision matters as much as the spreadsheet."
167
+ for i, p in enumerate(parts):
168
+ mid = f"m{i}"
169
+ txt = op.get(p["name"], "Here is my initial opinion, grounded in my area of expertise.")
170
+ self.wfile.write(sse("message", {"message_id": mid, "role": "participant",
171
+ "speaker_id": p["participant_id"], "speaker_name": p["name"],
172
+ "text": txt, "phase": "initial_opinions", "elapsed_seconds": 2.1 + i,
173
+ "timestamp": time.time()})); self.wfile.flush()
174
+ self.wfile.write(sse("orchestrator", {"message_id": "o1", "role": "orchestrator",
175
+ "kind": "status", "text": "All participants have given initial opinions. Moving to the critique phase, where each will respond to the others.",
176
+ "timestamp": time.time()})); self.wfile.flush()
177
+ self.wfile.write(sse("message", {"message_id": "m10", "role": "participant",
178
+ "speaker_id": parts[1]["participant_id"], "speaker_name": parts[1]["name"],
179
+ "text": "Responding directly to the finance framing: ROI is necessary but not sufficient. A positive ROI on paper can still be the wrong call if it erodes trust.",
180
+ "addressed_to": parts[0]["participant_id"], "replying_to": [parts[0]["participant_id"]],
181
+ "phase": "critique", "elapsed_seconds": 3.4, "timestamp": time.time()})); self.wfile.flush()
182
+ self.wfile.write(sse("orchestrator", {"message_id": "o2", "role": "orchestrator",
183
+ "kind": "majority_report",
184
+ "text": "**Majority Report.** The panel converges on a staged approach: validate ROI on a small pilot first, while explicitly protecting the human/trust factors the Historian raised. One participant dissents, preferring a faster full commitment.",
185
+ "timestamp": time.time()})); self.wfile.flush()
186
+ self.wfile.write(sse("system", {"text": "End of Chat"})); self.wfile.flush()
187
+ self.wfile.write(sse("done", {})); self.wfile.flush()
188
+
189
+ if __name__ == "__main__":
190
+ print("Mock backend on :8000")
191
+ ThreadingHTTPServer(("127.0.0.1", 8000), H).serve_forever()
scripts/dev-local.sh ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5
+ export PATH="/opt/homebrew/bin:/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
6
+
7
+ echo "Starting CCAI-Vibe-Demo local dev (hot reload enabled)"
8
+ echo "Project root: $ROOT"
9
+ echo ""
10
+
11
+ # Cross-project secrets (shared.env) — loaded by backend/app/config.py before .env
12
+ if [[ -n "${SHARED_ENV_PATH:-}" && -f "${SHARED_ENV_PATH}" ]]; then
13
+ echo "Using shared secrets: $SHARED_ENV_PATH"
14
+ elif [[ -f "$HOME/.secrets/shared.env" ]]; then
15
+ export SHARED_ENV_PATH="$HOME/.secrets/shared.env"
16
+ echo "Using shared secrets: $SHARED_ENV_PATH"
17
+ elif [[ -f "$HOME/Downloads/shared.env" ]]; then
18
+ export SHARED_ENV_PATH="$HOME/Downloads/shared.env"
19
+ echo "Using shared secrets: $SHARED_ENV_PATH"
20
+ fi
21
+
22
+ if [[ ! -f "$ROOT/.env" ]]; then
23
+ cp "$ROOT/.env.example" "$ROOT/.env"
24
+ echo "Created .env from .env.example — project overrides only; API keys can live in shared.env."
25
+ fi
26
+
27
+ if [[ ! -f "$ROOT/frontend/.env.development" ]]; then
28
+ echo "REACT_APP_API_URL=http://localhost:8000" > "$ROOT/frontend/.env.development"
29
+ fi
30
+
31
+ cleanup() {
32
+ echo ""
33
+ echo "Stopping dev servers..."
34
+ jobs -p | xargs kill 2>/dev/null || true
35
+ }
36
+ trap cleanup EXIT INT TERM
37
+
38
+ cd "$ROOT/backend"
39
+ PYTHON="${PYTHON:-/opt/homebrew/bin/python3.12}"
40
+ if [[ ! -d venv ]]; then
41
+ "$PYTHON" -m venv venv
42
+ source venv/bin/activate
43
+ pip install -r requirements.txt
44
+ else
45
+ source venv/bin/activate
46
+ fi
47
+
48
+ echo "→ Backend: http://localhost:8000 (uvicorn --reload)"
49
+ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 &
50
+ BACKEND_PID=$!
51
+
52
+ cd "$ROOT/frontend"
53
+ if [[ ! -d node_modules ]]; then
54
+ npm install
55
+ fi
56
+
57
+ echo "→ Frontend: http://localhost:3000 (react-scripts start)"
58
+ BROWSER=none npm start &
59
+ FRONTEND_PID=$!
60
+
61
+ wait $BACKEND_PID $FRONTEND_PID