MikelWL commited on
Commit
402f4b1
·
1 Parent(s): 8b79f2d

Refactor: replace surveyor knobs with attributes list

Browse files
backend/api/conversation_routes.py CHANGED
@@ -2,7 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
5
- from typing import Dict, Optional, Any
6
 
7
  from fastapi import APIRouter, HTTPException
8
  from pydantic import BaseModel, Field
@@ -26,9 +26,9 @@ class StartConversationRequest(BaseModel):
26
  default=None,
27
  description="Extra instructions appended to the patient system prompt for this run",
28
  )
29
- surveyor_knobs: Optional[Dict[str, Any]] = Field(
30
  default=None,
31
- description="Structured surveyor configuration knobs (compiled into the surveyor system prompt)",
32
  )
33
  surveyor_question_bank: Optional[str] = Field(
34
  default=None,
@@ -57,7 +57,7 @@ async def start_conversation(request: StartConversationRequest) -> Dict[str, str
57
  model=request.model,
58
  surveyor_prompt_addition=request.surveyor_prompt_addition,
59
  patient_prompt_addition=request.patient_prompt_addition,
60
- surveyor_knobs=request.surveyor_knobs,
61
  surveyor_question_bank=request.surveyor_question_bank,
62
  )
63
  if success:
 
2
 
3
  from __future__ import annotations
4
 
5
+ from typing import Dict, Optional, Any, List
6
 
7
  from fastapi import APIRouter, HTTPException
8
  from pydantic import BaseModel, Field
 
26
  default=None,
27
  description="Extra instructions appended to the patient system prompt for this run",
28
  )
29
+ surveyor_attributes: Optional[List[str]] = Field(
30
  default=None,
31
+ description="Plain-language surveyor attributes (bullet lines) compiled into the surveyor system prompt",
32
  )
33
  surveyor_question_bank: Optional[str] = Field(
34
  default=None,
 
57
  model=request.model,
58
  surveyor_prompt_addition=request.surveyor_prompt_addition,
59
  patient_prompt_addition=request.patient_prompt_addition,
60
+ surveyor_attributes=request.surveyor_attributes,
61
  surveyor_question_bank=request.surveyor_question_bank,
62
  )
63
  if success:
backend/api/conversation_service.py CHANGED
@@ -42,7 +42,7 @@ from backend.core.persona_system import PersonaSystem # noqa: E402
42
  from .conversation_ws import ConnectionManager # noqa: E402
43
  from .storage_service import get_run_store # noqa: E402
44
  from backend.storage import RunRecord # noqa: E402
45
- from backend.core.surveyor_knobs import compile_surveyor_overlay, compile_question_bank_overlay # noqa: E402
46
 
47
  # Setup logging
48
  logger = logging.getLogger(__name__)
@@ -272,7 +272,7 @@ class ConversationInfo:
272
  stop_requested: bool = False
273
  surveyor_prompt_addition: Optional[str] = None
274
  patient_prompt_addition: Optional[str] = None
275
- surveyor_knobs: Optional[Dict[str, Any]] = None
276
  surveyor_question_bank: Optional[str] = None
277
 
278
 
@@ -291,7 +291,7 @@ class HumanChatInfo:
291
  stop_requested: bool = False
292
  surveyor_prompt_addition: Optional[str] = None
293
  patient_prompt_addition: Optional[str] = None
294
- surveyor_knobs: Optional[Dict[str, Any]] = None
295
  surveyor_question_bank: Optional[str] = None
296
  asked_question_ids: List[str] = field(default_factory=list)
297
  lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@@ -334,7 +334,7 @@ class ConversationService:
334
  model: Optional[str] = None,
335
  surveyor_prompt_addition: Optional[str] = None,
336
  patient_prompt_addition: Optional[str] = None,
337
- surveyor_knobs: Optional[Dict[str, Any]] = None,
338
  surveyor_question_bank: Optional[str] = None,
339
  ) -> bool:
340
  """Start a new human-to-surveyor chat session."""
@@ -361,7 +361,7 @@ class ConversationService:
361
  llm_backend=resolved_backend,
362
  surveyor_prompt_addition=surveyor_prompt_addition,
363
  patient_prompt_addition=patient_prompt_addition,
364
- surveyor_knobs=surveyor_knobs if isinstance(surveyor_knobs, dict) else None,
365
  surveyor_question_bank=surveyor_question_bank if isinstance(surveyor_question_bank, str) and surveyor_question_bank.strip() else None,
366
  status=ConversationStatus.STARTING,
367
  created_at=datetime.now(),
@@ -511,14 +511,14 @@ class ConversationService:
511
  user_prompt=user_prompt,
512
  )
513
 
514
- overlay = compile_surveyor_overlay(chat_info.surveyor_knobs)
515
- if overlay:
516
- system_prompt = (system_prompt + "\n\n" + overlay).strip()
517
-
518
  qb = compile_question_bank_overlay(chat_info.surveyor_question_bank)
519
  if qb:
520
  system_prompt = (system_prompt + "\n\n" + qb).strip()
521
 
 
 
 
 
522
  patient_persona = self.persona_system.get_persona(chat_info.patient_persona_id) or {}
523
  try:
524
  patient_context = self.persona_system.prompt_builder.build_system_prompt(patient_persona)
@@ -578,7 +578,7 @@ class ConversationService:
578
  model: Optional[str] = None,
579
  surveyor_prompt_addition: Optional[str] = None,
580
  patient_prompt_addition: Optional[str] = None,
581
- surveyor_knobs: Optional[Dict[str, Any]] = None,
582
  surveyor_question_bank: Optional[str] = None) -> bool:
583
  """Start a new AI-to-AI conversation.
584
 
@@ -623,7 +623,7 @@ class ConversationService:
623
  llm_backend=resolved_backend,
624
  surveyor_prompt_addition=surveyor_prompt_addition,
625
  patient_prompt_addition=patient_prompt_addition,
626
- surveyor_knobs=surveyor_knobs if isinstance(surveyor_knobs, dict) else None,
627
  surveyor_question_bank=surveyor_question_bank if isinstance(surveyor_question_bank, str) and surveyor_question_bank.strip() else None,
628
  status=ConversationStatus.STARTING,
629
  created_at=datetime.now()
@@ -647,7 +647,7 @@ class ConversationService:
647
  llm_parameters=llm_parameters,
648
  surveyor_prompt_addition=surveyor_prompt_addition,
649
  patient_prompt_addition=patient_prompt_addition,
650
- surveyor_knobs=surveyor_knobs if isinstance(surveyor_knobs, dict) else None,
651
  surveyor_question_bank=surveyor_question_bank if isinstance(surveyor_question_bank, str) else None,
652
  )
653
 
@@ -914,7 +914,7 @@ class ConversationService:
914
  "patient_persona_id": conv_info.patient_persona_id,
915
  "surveyor_prompt_addition": getattr(conv_info, "surveyor_prompt_addition", None),
916
  "patient_prompt_addition": getattr(conv_info, "patient_prompt_addition", None),
917
- "surveyor_knobs": getattr(conv_info, "surveyor_knobs", None),
918
  "surveyor_question_bank": getattr(conv_info, "surveyor_question_bank", None),
919
  "asked_question_ids": getattr(manager, "asked_question_ids", None) if hasattr(manager, "asked_question_ids") else None,
920
  },
 
42
  from .conversation_ws import ConnectionManager # noqa: E402
43
  from .storage_service import get_run_store # noqa: E402
44
  from backend.storage import RunRecord # noqa: E402
45
+ from backend.core.surveyor_knobs import compile_surveyor_attributes_overlay, compile_question_bank_overlay # noqa: E402
46
 
47
  # Setup logging
48
  logger = logging.getLogger(__name__)
 
272
  stop_requested: bool = False
273
  surveyor_prompt_addition: Optional[str] = None
274
  patient_prompt_addition: Optional[str] = None
275
+ surveyor_attributes: List[str] = field(default_factory=list)
276
  surveyor_question_bank: Optional[str] = None
277
 
278
 
 
291
  stop_requested: bool = False
292
  surveyor_prompt_addition: Optional[str] = None
293
  patient_prompt_addition: Optional[str] = None
294
+ surveyor_attributes: List[str] = field(default_factory=list)
295
  surveyor_question_bank: Optional[str] = None
296
  asked_question_ids: List[str] = field(default_factory=list)
297
  lock: asyncio.Lock = field(default_factory=asyncio.Lock)
 
334
  model: Optional[str] = None,
335
  surveyor_prompt_addition: Optional[str] = None,
336
  patient_prompt_addition: Optional[str] = None,
337
+ surveyor_attributes: Optional[List[str]] = None,
338
  surveyor_question_bank: Optional[str] = None,
339
  ) -> bool:
340
  """Start a new human-to-surveyor chat session."""
 
361
  llm_backend=resolved_backend,
362
  surveyor_prompt_addition=surveyor_prompt_addition,
363
  patient_prompt_addition=patient_prompt_addition,
364
+ surveyor_attributes=[s.strip() for s in (surveyor_attributes or []) if isinstance(s, str) and s.strip()],
365
  surveyor_question_bank=surveyor_question_bank if isinstance(surveyor_question_bank, str) and surveyor_question_bank.strip() else None,
366
  status=ConversationStatus.STARTING,
367
  created_at=datetime.now(),
 
511
  user_prompt=user_prompt,
512
  )
513
 
 
 
 
 
514
  qb = compile_question_bank_overlay(chat_info.surveyor_question_bank)
515
  if qb:
516
  system_prompt = (system_prompt + "\n\n" + qb).strip()
517
 
518
+ attrs = compile_surveyor_attributes_overlay(chat_info.surveyor_attributes)
519
+ if attrs:
520
+ system_prompt = (system_prompt + "\n\n" + attrs).strip()
521
+
522
  patient_persona = self.persona_system.get_persona(chat_info.patient_persona_id) or {}
523
  try:
524
  patient_context = self.persona_system.prompt_builder.build_system_prompt(patient_persona)
 
578
  model: Optional[str] = None,
579
  surveyor_prompt_addition: Optional[str] = None,
580
  patient_prompt_addition: Optional[str] = None,
581
+ surveyor_attributes: Optional[List[str]] = None,
582
  surveyor_question_bank: Optional[str] = None) -> bool:
583
  """Start a new AI-to-AI conversation.
584
 
 
623
  llm_backend=resolved_backend,
624
  surveyor_prompt_addition=surveyor_prompt_addition,
625
  patient_prompt_addition=patient_prompt_addition,
626
+ surveyor_attributes=[s.strip() for s in (surveyor_attributes or []) if isinstance(s, str) and s.strip()],
627
  surveyor_question_bank=surveyor_question_bank if isinstance(surveyor_question_bank, str) and surveyor_question_bank.strip() else None,
628
  status=ConversationStatus.STARTING,
629
  created_at=datetime.now()
 
647
  llm_parameters=llm_parameters,
648
  surveyor_prompt_addition=surveyor_prompt_addition,
649
  patient_prompt_addition=patient_prompt_addition,
650
+ surveyor_attributes=[s.strip() for s in (surveyor_attributes or []) if isinstance(s, str) and s.strip()],
651
  surveyor_question_bank=surveyor_question_bank if isinstance(surveyor_question_bank, str) else None,
652
  )
653
 
 
914
  "patient_persona_id": conv_info.patient_persona_id,
915
  "surveyor_prompt_addition": getattr(conv_info, "surveyor_prompt_addition", None),
916
  "patient_prompt_addition": getattr(conv_info, "patient_prompt_addition", None),
917
+ "surveyor_attributes": getattr(conv_info, "surveyor_attributes", None),
918
  "surveyor_question_bank": getattr(conv_info, "surveyor_question_bank", None),
919
  "asked_question_ids": getattr(manager, "asked_question_ids", None) if hasattr(manager, "asked_question_ids") else None,
920
  },
backend/api/conversation_ws.py CHANGED
@@ -335,7 +335,7 @@ async def handle_start_conversation(data: dict, conversation_id: str):
335
  model = data.get("model")
336
  surveyor_prompt_addition = data.get("surveyor_prompt_addition")
337
  patient_prompt_addition = data.get("patient_prompt_addition")
338
- surveyor_knobs = data.get("surveyor_knobs")
339
  surveyor_question_bank = data.get("surveyor_question_bank")
340
 
341
  if not surveyor_persona_id or not patient_persona_id:
@@ -355,7 +355,7 @@ async def handle_start_conversation(data: dict, conversation_id: str):
355
  model=model,
356
  surveyor_prompt_addition=surveyor_prompt_addition,
357
  patient_prompt_addition=patient_prompt_addition,
358
- surveyor_knobs=surveyor_knobs,
359
  surveyor_question_bank=surveyor_question_bank,
360
  )
361
 
@@ -390,7 +390,7 @@ async def handle_start_human_chat(data: dict, conversation_id: str):
390
  model = data.get("model")
391
  surveyor_prompt_addition = data.get("surveyor_prompt_addition")
392
  patient_prompt_addition = data.get("patient_prompt_addition")
393
- surveyor_knobs = data.get("surveyor_knobs")
394
  surveyor_question_bank = data.get("surveyor_question_bank")
395
 
396
  if not surveyor_persona_id or not patient_persona_id:
@@ -409,7 +409,7 @@ async def handle_start_human_chat(data: dict, conversation_id: str):
409
  model=model,
410
  surveyor_prompt_addition=surveyor_prompt_addition,
411
  patient_prompt_addition=patient_prompt_addition,
412
- surveyor_knobs=surveyor_knobs,
413
  surveyor_question_bank=surveyor_question_bank,
414
  )
415
 
 
335
  model = data.get("model")
336
  surveyor_prompt_addition = data.get("surveyor_prompt_addition")
337
  patient_prompt_addition = data.get("patient_prompt_addition")
338
+ surveyor_attributes = data.get("surveyor_attributes")
339
  surveyor_question_bank = data.get("surveyor_question_bank")
340
 
341
  if not surveyor_persona_id or not patient_persona_id:
 
355
  model=model,
356
  surveyor_prompt_addition=surveyor_prompt_addition,
357
  patient_prompt_addition=patient_prompt_addition,
358
+ surveyor_attributes=surveyor_attributes,
359
  surveyor_question_bank=surveyor_question_bank,
360
  )
361
 
 
390
  model = data.get("model")
391
  surveyor_prompt_addition = data.get("surveyor_prompt_addition")
392
  patient_prompt_addition = data.get("patient_prompt_addition")
393
+ surveyor_attributes = data.get("surveyor_attributes")
394
  surveyor_question_bank = data.get("surveyor_question_bank")
395
 
396
  if not surveyor_persona_id or not patient_persona_id:
 
409
  model=model,
410
  surveyor_prompt_addition=surveyor_prompt_addition,
411
  patient_prompt_addition=patient_prompt_addition,
412
+ surveyor_attributes=surveyor_attributes,
413
  surveyor_question_bank=surveyor_question_bank,
414
  )
415
 
backend/core/conversation_manager.py CHANGED
@@ -31,7 +31,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
31
 
32
  from backend.core.llm_client import create_llm_client
33
  from backend.core.persona_system import PersonaSystem
34
- from backend.core.surveyor_knobs import compile_surveyor_overlay, compile_question_bank_overlay
35
 
36
  SURVEYOR_MAX_TOKENS = 140
37
  PATIENT_MAX_TOKENS = 240
@@ -82,7 +82,7 @@ class ConversationManager:
82
  patient_persona: dict = None,
83
  surveyor_prompt_addition: Optional[str] = None,
84
  patient_prompt_addition: Optional[str] = None,
85
- surveyor_knobs: Optional[Dict[str, Any]] = None,
86
  surveyor_question_bank: Optional[str] = None,
87
  host: str = "http://localhost:11434",
88
  model: str = "llama3.2:latest",
@@ -132,7 +132,7 @@ class ConversationManager:
132
  self.turn_count = 0
133
  self.surveyor_prompt_addition = (surveyor_prompt_addition or "").strip()
134
  self.patient_prompt_addition = (patient_prompt_addition or "").strip()
135
- self.surveyor_knobs = surveyor_knobs if isinstance(surveyor_knobs, dict) else None
136
  self.surveyor_question_bank = (surveyor_question_bank or "").strip() or None
137
  self.asked_question_ids: List[str] = []
138
 
@@ -235,12 +235,12 @@ class ConversationManager:
235
  conversation_history=conversation_history,
236
  user_prompt=user_prompt
237
  )
238
- overlay = compile_surveyor_overlay(self.surveyor_knobs)
239
- if overlay:
240
- system_prompt = f"{system_prompt}\n\n{overlay}"
241
  qb = compile_question_bank_overlay(self.surveyor_question_bank)
242
  if qb:
243
  system_prompt = f"{system_prompt}\n\n{qb}"
 
 
 
244
  if self.surveyor_prompt_addition:
245
  system_prompt = f"{system_prompt}\n\nAdditional instructions:\n{self.surveyor_prompt_addition}"
246
 
 
31
 
32
  from backend.core.llm_client import create_llm_client
33
  from backend.core.persona_system import PersonaSystem
34
+ from backend.core.surveyor_knobs import compile_surveyor_attributes_overlay, compile_question_bank_overlay
35
 
36
  SURVEYOR_MAX_TOKENS = 140
37
  PATIENT_MAX_TOKENS = 240
 
82
  patient_persona: dict = None,
83
  surveyor_prompt_addition: Optional[str] = None,
84
  patient_prompt_addition: Optional[str] = None,
85
+ surveyor_attributes: Optional[List[str]] = None,
86
  surveyor_question_bank: Optional[str] = None,
87
  host: str = "http://localhost:11434",
88
  model: str = "llama3.2:latest",
 
132
  self.turn_count = 0
133
  self.surveyor_prompt_addition = (surveyor_prompt_addition or "").strip()
134
  self.patient_prompt_addition = (patient_prompt_addition or "").strip()
135
+ self.surveyor_attributes = [s.strip() for s in (surveyor_attributes or []) if isinstance(s, str) and s.strip()]
136
  self.surveyor_question_bank = (surveyor_question_bank or "").strip() or None
137
  self.asked_question_ids: List[str] = []
138
 
 
235
  conversation_history=conversation_history,
236
  user_prompt=user_prompt
237
  )
 
 
 
238
  qb = compile_question_bank_overlay(self.surveyor_question_bank)
239
  if qb:
240
  system_prompt = f"{system_prompt}\n\n{qb}"
241
+ attrs = compile_surveyor_attributes_overlay(self.surveyor_attributes)
242
+ if attrs:
243
+ system_prompt = f"{system_prompt}\n\n{attrs}"
244
  if self.surveyor_prompt_addition:
245
  system_prompt = f"{system_prompt}\n\nAdditional instructions:\n{self.surveyor_prompt_addition}"
246
 
backend/core/surveyor_knobs.py CHANGED
@@ -1,55 +1,34 @@
1
  from __future__ import annotations
2
 
3
- from typing import Any, Dict, List, Optional
4
 
5
 
6
- def _as_str_list(value: Any) -> List[str]:
7
  if not value:
8
  return []
 
 
 
9
  if isinstance(value, list):
10
  out: List[str] = []
11
  for item in value:
12
- if isinstance(item, str) and item.strip():
13
- out.append(item.strip())
 
 
14
  return out
15
  return []
16
 
17
 
18
- def compile_surveyor_overlay(knobs: Optional[Dict[str, Any]]) -> str:
19
- """Compile structured surveyor knobs into a deterministic prompt overlay."""
20
- if not isinstance(knobs, dict) or not knobs:
 
21
  return ""
22
-
23
- stance = knobs.get("stance")
24
- question_strategy = knobs.get("question_strategy")
25
- empathy_style = knobs.get("empathy_style")
26
- off_track_handling = knobs.get("off_track_handling")
27
-
28
- probing_policy = _as_str_list(knobs.get("probing_policy"))
29
- sensitivity_handling = _as_str_list(knobs.get("sensitivity_handling"))
30
-
31
  lines: List[str] = []
32
- lines.append("Surveyor configuration (structured knobs):")
33
-
34
- def add(label: str, value: Any):
35
- if isinstance(value, str) and value.strip():
36
- lines.append(f"- {label}: {value.strip()}")
37
-
38
- add("stance", stance)
39
- add("question_strategy", question_strategy)
40
- add("empathy_style", empathy_style)
41
- add("off_track_handling", off_track_handling)
42
-
43
- if probing_policy:
44
- lines.append("- probing_policy:")
45
- for item in probing_policy:
46
- lines.append(f" - {item}")
47
-
48
- if sensitivity_handling:
49
- lines.append("- sensitivity_handling:")
50
- for item in sensitivity_handling:
51
- lines.append(f" - {item}")
52
-
53
  return "\n".join(lines).strip()
54
 
55
 
 
1
  from __future__ import annotations
2
 
3
+ from typing import Any, List, Optional
4
 
5
 
6
+ def _clean_bullets(value: Any) -> List[str]:
7
  if not value:
8
  return []
9
+ if isinstance(value, str):
10
+ parts = [line.strip() for line in value.splitlines()]
11
+ return [p for p in parts if p and not p.startswith("#")]
12
  if isinstance(value, list):
13
  out: List[str] = []
14
  for item in value:
15
+ if isinstance(item, str):
16
+ s = item.strip()
17
+ if s and not s.startswith("#"):
18
+ out.append(s)
19
  return out
20
  return []
21
 
22
 
23
+ def compile_surveyor_attributes_overlay(attributes: Any) -> str:
24
+ """Compile surveyor attributes (plain bullet lines) into a prompt overlay."""
25
+ items = _clean_bullets(attributes)
26
+ if not items:
27
  return ""
 
 
 
 
 
 
 
 
 
28
  lines: List[str] = []
29
+ lines.append("Surveyor attributes (follow these):")
30
+ for item in items:
31
+ lines.append(f"- {item}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  return "\n".join(lines).strip()
33
 
34
 
docs/README.md CHANGED
@@ -5,7 +5,7 @@ These short guides are all you need to extend the AI Survey Simulator:
5
  - `overview.md` — architecture summary, major components, and repository map.
6
  - `development.md` — setup, runtime instructions, and implementation guidelines.
7
  - `persistence.md` — persistent run history + persona CRUD design (HF `/data` now, Railway later).
8
- - `persona-knobs.md` — persona configuration knobs (core v1 vs deferred) for Surveyor + Patient.
9
  - `roadmap.md` — current status and prioritized future work.
10
 
11
  Keep documentation lean: update the relevant file when behavior changes or priorities shift.
 
5
  - `overview.md` — architecture summary, major components, and repository map.
6
  - `development.md` — setup, runtime instructions, and implementation guidelines.
7
  - `persistence.md` — persistent run history + persona CRUD design (HF `/data` now, Railway later).
8
+ - `persona-knobs.md` — persona configuration controls (v2) for Surveyor + Patient.
9
  - `roadmap.md` — current status and prioritized future work.
10
 
11
  Keep documentation lean: update the relevant file when behavior changes or priorities shift.
docs/development.md CHANGED
@@ -74,7 +74,7 @@ The UI also includes a **Configuration** view that lets you select personas and
74
  - No automated test suite yet. Add lightweight `pytest` modules under `tests/` as you extend functionality.
75
  - Manually verify through the primary web UI (`frontend/react_gradio_hybrid.py`).
76
  - For persistence + history: complete a run, confirm it appears in **History**, restart the container, and confirm it still appears and exports download.
77
- - For config knobs (surveyor v1): change surveyor settings in **Configuration**, run an AI↔AI session, and confirm behavior differs and the completed run’s `config.personas.surveyor_knobs` is present when fetched from `/api/runs/{run_id}`.
78
  - For question bank (surveyor): set a question bank in **Configuration → Surveyors**, complete a run, and confirm the surveyor sticks to one bank question per turn and the run’s `config.personas.asked_question_ids` is populated in `/api/runs/{run_id}`.
79
  - If you need to debug the conversation loop, instrument `backend/core/conversation_manager.py` or launch a shell and run it directly.
80
 
 
74
  - No automated test suite yet. Add lightweight `pytest` modules under `tests/` as you extend functionality.
75
  - Manually verify through the primary web UI (`frontend/react_gradio_hybrid.py`).
76
  - For persistence + history: complete a run, confirm it appears in **History**, restart the container, and confirm it still appears and exports download.
77
+ - For surveyor configuration: change **Surveyors Attributes** and/or **Question bank** in **Configuration**, run an AI↔AI session, and confirm the completed run’s `config.personas.surveyor_attributes` and `config.personas.surveyor_question_bank` are present when fetched from `/api/runs/{run_id}`.
78
  - For question bank (surveyor): set a question bank in **Configuration → Surveyors**, complete a run, and confirm the surveyor sticks to one bank question per turn and the run’s `config.personas.asked_question_ids` is populated in `/api/runs/{run_id}`.
79
  - If you need to debug the conversation loop, instrument `backend/core/conversation_manager.py` or launch a shell and run it directly.
80
 
docs/persona-knobs.md CHANGED
@@ -1,136 +1,94 @@
1
- # Persona “Knobs” (v1 Core) — Surveyor + Patient
2
 
3
- This document defines the **core, behavior-changing configuration knobs** we will support first for personas.
4
 
5
  Goal alignment:
6
 
7
  - **A) Surveyor quality**: reliably covers target questions with empathy, good flow, and appropriate probing.
8
  - **B) Validation harness**: synthetic patients exist to *stress test* the surveyor under realistic situations.
9
- - **C) Analysis**: handled separately (resource agent knobs are out of scope here).
10
 
11
- The guiding principle is: **every UI field must have a guaranteed effect** on generation (no decorative persona metadata).
12
 
13
  ---
14
 
15
- ## Key design decision: compile structured knobs into prompts
16
 
17
- Today, the LLM behavior is primarily driven by the persona’s `system_prompt`. Many existing YAML fields do not materially affect generation unless they are manually duplicated into that prompt.
18
 
19
- For v1, we will:
20
 
21
- 1. Define a small structured schema of core knobs (below).
22
- 2. **Compile** those knobs into a deterministic “persona overlay” section that is included in the model’s system prompt.
23
- 3. Keep a **system prompt preview** in the UI showing the exact final system prompt text that will be sent.
24
 
25
- This preserves flexibility (freeform prompt editing) while ensuring the structured knobs always matter.
26
-
27
- Non-goal: making every current YAML field first-class.
28
 
29
  ---
30
 
31
  ## What is “baked in” (global, not persona-specific)
32
 
33
- These are global constraints / scaffolding that apply to all personas and are not edited in the persona editor:
34
 
35
- - Turn-format constraints (e.g., “ask at most one question”, “1–2 sentences”) and token/temperature defaults.
36
  - Conversation scaffolding templates (greeting/follow-up framing; use of the last message).
37
- - Conversation-history inclusion and formatting (e.g., max history window).
38
- - Safety/compliance rules we want consistently enforced across personas.
39
 
40
- Personas can express style *within* these constraints, but do not change the constraints themselves in v1.
41
 
42
  ---
43
 
44
- ## Surveyor persona knobs (Core v1)
45
-
46
- These should be exposed as first-class fields and compiled into the surveyor system prompt.
47
 
48
  ### Identity
49
  - `name` (required): display name.
50
  - `description` (optional): short one-liner shown in selectors.
51
 
52
- ### Survey behavior
53
- - `stance` (dropdown): `empathetic_researcher | neutral_clinical | time_efficient`.
54
- - `question_strategy` (dropdown): `sequential | adaptive_followups | conversational_cover_all`.
55
- - `probing_policy` (multi-select):
56
- - `clarify_timeline`
57
- - `ask_examples`
58
- - `quantify_frequency_severity`
59
- - `reflect_emotion`
60
- - `summarize_and_confirm`
61
- - `empathy_style` (dropdown): `validating | neutral | supportive_brief`.
62
- - `off_track_handling` (dropdown): `gentle_redirect | one_vent_then_redirect | hard_redirect`.
63
- - `sensitivity_handling` (multi-select):
64
- - `acknowledge`
65
- - `offer_skip`
66
- - `normalize`
67
- - `avoid_advice`
68
-
69
- ### Freeform prompt
70
- - `system_prompt` (required): human-editable base prompt (still important).
71
- - `system_prompt_addition` (optional, per-run): already supported in the configuration UI; stays as run-level override.
72
 
73
  ---
74
 
75
- ## Patient persona knobs (Core v1)
76
 
77
- These should be exposed as first-class fields and compiled into the patient system prompt.
78
 
79
  ### Identity
80
  - `name` (required): display name.
81
  - `description` (optional): short one-liner shown in selectors.
82
 
83
- ### Scenario / health situation
84
- - `conditions` (list): e.g., `Type 2 diabetes`, `asthma`.
85
- - `medications` (list): e.g., `metformin`, `albuterol`.
86
- - `care_context` (short text): e.g., “primary care follow-up”, “recent ER visit”.
87
- - `recent_events` (short bullets): key timeline anchors used to answer questions realistically.
88
-
89
- ### Barriers / constraints
90
- - `barriers` (multi-select):
91
- - `cost`
92
- - `transport`
93
- - `time`
94
- - `insurance`
95
- - `digital_access`
96
- - `language`
97
- - `stigma`
98
-
99
- ### Conversational behavior
100
- - `cooperativeness` (ordinal): `forthcoming | mixed | guarded`.
101
- - `emotional_stance` (dropdown): `anxious | frustrated | neutral | optimistic`.
102
- - `verbosity` (dropdown): `brief | normal | detailed`.
103
- - `health_literacy` (dropdown): `low | medium | high`.
104
- - `recall_certainty` (dropdown): `confident | mixed | uncertain`.
105
- - `digression_tendency` (dropdown): `on_track | occasional_anecdotes | tangential`.
106
-
107
- ### Freeform prompt
108
- - `system_prompt` (required): human-editable base prompt.
109
- - `system_prompt_addition` (optional, per-run): already supported in the configuration UI; stays as run-level override.
110
 
111
  ---
112
 
113
- ## Secondary knobs (documented, not implemented in v1)
114
 
115
  These may be useful later but are intentionally deferred to avoid UI complexity without clear validation value.
116
 
117
  Surveyor:
118
- - terminology level (plain/mixed/clinical-with-explanations)
119
- - pacing (brief/normal/thorough) beyond global constraints
120
- - explicit consent/confidentiality micro-scripts as toggles
121
 
122
  Patient:
123
- - privacy skepticism toggle (“Why do you need this?”)
124
- - quirks (interrupts, under-reports symptoms, minimizes pain)
125
- - richer biography fields (occupation, family structure) unless used for study design
126
 
127
  ---
128
 
129
- ## Implementation notes (how these knobs become real)
130
 
131
  To prevent dead fields, the implementation should ensure:
132
 
133
- - All core knobs are included in the final system prompt text (compiled overlay).
134
- - The UI shows a prompt preview so users understand the effective configuration.
135
- - Persisted runs capture the persona snapshot and the prompt overlay used (already supported by run snapshots).
136
-
 
1
+ # Persona Controls (v2) — Surveyor + Patient (local-first)
2
 
3
+ This document defines the **core, behavior-changing configuration controls** we support for personas.
4
 
5
  Goal alignment:
6
 
7
  - **A) Surveyor quality**: reliably covers target questions with empathy, good flow, and appropriate probing.
8
  - **B) Validation harness**: synthetic patients exist to *stress test* the surveyor under realistic situations.
9
+ - **C) Analysis**: handled separately (resource agent controls are out of scope here).
10
 
11
+ The guiding principle is: **every UI control must have a guaranteed effect** on generation (no decorative controls).
12
 
13
  ---
14
 
15
+ ## Key design decision: compile attributes + question bank into prompts
16
 
17
+ Today, behavior is primarily driven by the persona’s `system_prompt`. Many YAML fields do not materially affect generation unless they are duplicated into that prompt.
18
 
19
+ For v2, we:
20
 
21
+ 1. Let the user define a small set of plain-language **Attributes** (bullet lines) for the surveyor.
22
+ 2. Let the user define a **Question bank** the surveyor must work through.
23
+ 3. **Compile** both into a deterministic prompt overlay included in the surveyor system prompt, so the controls always matter.
24
 
25
+ Non-goal: making every YAML field first-class in the UI.
 
 
26
 
27
  ---
28
 
29
  ## What is “baked in” (global, not persona-specific)
30
 
31
+ These are global constraints/scaffolding that apply to all personas and are not edited in the config UI:
32
 
33
+ - Turn-format constraints (e.g., 1–2 sentences, at most one question per turn).
34
  - Conversation scaffolding templates (greeting/follow-up framing; use of the last message).
35
+ - Conversation-history inclusion and formatting.
36
+ - Safety/compliance rules we want consistently enforced.
37
 
38
+ Personas can express style *within* these constraints, but do not change the constraints themselves in v2.
39
 
40
  ---
41
 
42
+ ## Surveyor controls (Core v2)
 
 
43
 
44
  ### Identity
45
  - `name` (required): display name.
46
  - `description` (optional): short one-liner shown in selectors.
47
 
48
+ ### Attributes (plain bullet lines)
49
+ A list of short, testable rules, for example:
50
+ - “Ask exactly one question per turn; no multi-part questions.
51
+ - “Reflect emotion briefly before redirecting.”
52
+ - “Avoid medical advice; suggest clinician follow-up when needed.”
53
+
54
+ ### Question bank (targets to cover)
55
+ A list of survey questions the surveyor must work through.
56
+
57
+ When a question bank is present, the surveyor outputs strict JSON including the chosen question id (e.g., `q01`) so asked questions can be tracked deterministically.
58
+
59
+ ### Surveyor prompt addition (optional, per-run)
60
+ Additional free-form instructions appended last (lowest priority).
 
 
 
 
 
 
 
61
 
62
  ---
63
 
64
+ ## Patient controls (Core v1, minimal)
65
 
66
+ Patient structured controls are intentionally minimal for now; the synthetic patient exists as a validation harness.
67
 
68
  ### Identity
69
  - `name` (required): display name.
70
  - `description` (optional): short one-liner shown in selectors.
71
 
72
+ ### Patient prompt addition (optional, per-run)
73
+ Patient prompt addition is supported as a run-level override.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  ---
76
 
77
+ ## Secondary controls (documented, not implemented yet)
78
 
79
  These may be useful later but are intentionally deferred to avoid UI complexity without clear validation value.
80
 
81
  Surveyor:
82
+ - richer structured knobs (stance/probing/off-track handling) if we find a strong need
 
 
83
 
84
  Patient:
85
+ - richer structured scenario/behavior fields once we define how to validate them
 
 
86
 
87
  ---
88
 
89
+ ## Implementation notes (how these controls become real)
90
 
91
  To prevent dead fields, the implementation should ensure:
92
 
93
+ - Core controls are included in the final system prompt text (compiled overlay).
94
+ - Persisted runs capture the config snapshot used (including attributes + question bank).
 
 
frontend/pages/config_view.py CHANGED
@@ -1,13 +1,13 @@
1
  def get_config_view_js() -> str:
2
  return r"""
3
- function ConfigView() {
4
- const existing = React.useMemo(() => loadConfig(), []);
5
- const [personas, setPersonas] = React.useState({ surveyors: [], patients: [] });
6
- const [activePane, setActivePane] = React.useState('surveyors'); // surveyors|patients|analysis|system
7
 
8
  const [selectedSurveyorId, setSelectedSurveyorId] = React.useState(existing?.surveyor_persona_id || 'friendly_researcher_001');
9
  const [selectedPatientId, setSelectedPatientId] = React.useState(existing?.patient_persona_id || 'cooperative_senior_001');
10
- const [surveyorQuestionItems, setSurveyorQuestionItems] = React.useState(() => {
11
  const items = Array.isArray(existing?.surveyor_question_bank_items) ? existing.surveyor_question_bank_items : null;
12
  if (items && items.length) {
13
  return items
@@ -18,18 +18,20 @@ def get_config_view_js() -> str:
18
  const raw = typeof existing?.surveyor_question_bank === 'string' ? existing.surveyor_question_bank : '';
19
  const lines = raw.split('\n').map((l) => (l || '').trim()).filter((l) => l.length > 0);
20
  return lines.map((text, idx) => ({ id: `q${String(idx + 1).padStart(2, '0')}`, text }));
21
- });
22
- const [surveyorPromptAddition, setSurveyorPromptAddition] = React.useState(existing?.surveyor_prompt_addition || '');
23
- const [patientPromptAddition, setPatientPromptAddition] = React.useState(existing?.patient_prompt_addition || '');
24
- const [surveyorKnobs, setSurveyorKnobs] = React.useState(existing?.surveyor_knobs || {
25
- stance: 'empathetic_researcher',
26
- question_strategy: 'conversational_cover_all',
27
- probing_policy: ['reflect_emotion', 'summarize_and_confirm'],
28
- empathy_style: 'validating',
29
- off_track_handling: 'gentle_redirect',
30
- sensitivity_handling: ['acknowledge', 'normalize', 'avoid_advice']
31
- });
32
- const [savedAt, setSavedAt] = React.useState(existing?.saved_at || null);
 
 
33
 
34
  const surveyorQuestionBankText = React.useMemo(() => {
35
  const lines = (surveyorQuestionItems || [])
@@ -38,7 +40,7 @@ def get_config_view_js() -> str:
38
  return lines.join('\n');
39
  }, [surveyorQuestionItems]);
40
 
41
- const addQuestion = () => {
42
  const nextId = `q${String((surveyorQuestionItems || []).length + 1).padStart(2, '0')}`;
43
  setSurveyorQuestionItems((prev) => ([...(prev || []), { id: nextId, text: '' }]));
44
  };
@@ -47,9 +49,21 @@ def get_config_view_js() -> str:
47
  setSurveyorQuestionItems((prev) => (prev || []).map((q) => (q.id === id ? Object.assign({}, q, { text }) : q)));
48
  };
49
 
50
- const removeQuestion = (id) => {
51
- setSurveyorQuestionItems((prev) => (prev || []).filter((q) => q.id !== id));
52
- };
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  React.useEffect(() => {
55
  authedFetch('/api/personas')
@@ -63,22 +77,24 @@ def get_config_view_js() -> str:
63
  .catch(() => {});
64
  }, []);
65
 
66
- const onSave = () => {
67
- const cfg = {
68
- surveyor_persona_id: selectedSurveyorId,
69
- patient_persona_id: selectedPatientId,
70
- surveyor_question_bank: surveyorQuestionBankText,
71
- surveyor_question_bank_items: (surveyorQuestionItems || [])
72
- .map((q) => ({ id: q.id, text: (q.text || '').trim() }))
73
- .filter((q) => q.id && q.text.length > 0),
74
- surveyor_prompt_addition: surveyorPromptAddition,
75
- patient_prompt_addition: patientPromptAddition,
76
- surveyor_knobs: surveyorKnobs,
77
- saved_at: new Date().toISOString()
78
- };
79
- localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
80
- setSavedAt(cfg.saved_at);
81
- };
 
 
82
 
83
  const TabNav = () => {
84
  const base = "px-4 py-2 rounded-lg text-sm font-semibold border transition-colors";
@@ -110,174 +126,120 @@ def get_config_view_js() -> str:
110
 
111
  <TabNav />
112
 
113
- {activePane === 'surveyors' ? (
114
- <div className="mt-6 grid grid-cols-2 gap-6">
115
- <div>
116
- <label className="block text-sm font-semibold text-slate-700 mb-2">Surveyor persona</label>
117
- <select
118
- className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
119
- value={selectedSurveyorId}
120
- onChange={(e) => setSelectedSurveyorId(e.target.value)}
121
- >
122
- {(personas.surveyors.length ? personas.surveyors : [{id: 'friendly_researcher_001', name: 'Alex Thompson'}]).map(p => (
123
- <option key={p.id} value={p.id}>{p.name} ({p.id})</option>
124
- ))}
125
- </select>
126
-
127
- <div className="mt-4">
128
- <label className="block text-sm font-semibold text-slate-700 mb-2">Surveyor stance</label>
129
- <select
130
- className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
131
- value={surveyorKnobs.stance || 'empathetic_researcher'}
132
- onChange={(e) => setSurveyorKnobs(prev => Object.assign({}, prev, { stance: e.target.value }))}
133
- >
134
- <option value="empathetic_researcher">Empathetic researcher</option>
135
- <option value="neutral_clinical">Neutral clinical</option>
136
- <option value="time_efficient">Time efficient</option>
137
- </select>
138
- </div>
139
-
140
- <div className="mt-4">
141
- <label className="block text-sm font-semibold text-slate-700 mb-2">Question strategy</label>
142
- <select
143
- className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
144
- value={surveyorKnobs.question_strategy || 'conversational_cover_all'}
145
- onChange={(e) => setSurveyorKnobs(prev => Object.assign({}, prev, { question_strategy: e.target.value }))}
146
- >
147
- <option value="sequential">Sequential</option>
148
- <option value="adaptive_followups">Adaptive with follow-ups</option>
149
- <option value="conversational_cover_all">Conversational but cover all</option>
150
- </select>
151
- </div>
152
 
153
- <div className="mt-4">
154
- <label className="block text-sm font-semibold text-slate-700 mb-2">Empathy style</label>
155
- <select
156
- className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
157
- value={surveyorKnobs.empathy_style || 'validating'}
158
- onChange={(e) => setSurveyorKnobs(prev => Object.assign({}, prev, { empathy_style: e.target.value }))}
159
- >
160
- <option value="validating">Validating</option>
161
- <option value="neutral">Neutral</option>
162
- <option value="supportive_brief">Supportive, brief</option>
163
- </select>
164
- </div>
165
 
166
- <div className="mt-4">
167
- <label className="block text-sm font-semibold text-slate-700 mb-2">Off-track handling</label>
168
- <select
169
- className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
170
- value={surveyorKnobs.off_track_handling || 'gentle_redirect'}
171
- onChange={(e) => setSurveyorKnobs(prev => Object.assign({}, prev, { off_track_handling: e.target.value }))}
172
- >
173
- <option value="gentle_redirect">Gently redirect</option>
174
- <option value="one_vent_then_redirect">Let them vent once, then redirect</option>
175
- <option value="hard_redirect">Hard redirect</option>
176
- </select>
177
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
- <div className="mt-4">
180
- <label className="block text-sm font-semibold text-slate-700 mb-2">Probing policy</label>
181
- {[
182
- { key: 'clarify_timeline', label: 'Clarify timeline' },
183
- { key: 'ask_examples', label: 'Ask examples' },
184
- { key: 'quantify_frequency_severity', label: 'Quantify frequency/severity' },
185
- { key: 'reflect_emotion', label: 'Reflect emotion' },
186
- { key: 'summarize_and_confirm', label: 'Summarize and confirm' }
187
- ].map((item) => (
188
- <label key={item.key} className="flex items-center gap-2 text-sm text-slate-700 mt-1">
189
- <input
190
- type="checkbox"
191
- checked={(surveyorKnobs.probing_policy || []).includes(item.key)}
192
- onChange={(e) => {
193
- const next = new Set(surveyorKnobs.probing_policy || []);
194
- if (e.target.checked) next.add(item.key); else next.delete(item.key);
195
- setSurveyorKnobs(prev => Object.assign({}, prev, { probing_policy: Array.from(next) }));
196
- }}
197
- />
198
- {item.label}
199
- </label>
200
- ))}
201
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
- <div className="mt-4">
204
- <label className="block text-sm font-semibold text-slate-700 mb-2">Sensitivity handling</label>
205
- {[
206
- { key: 'acknowledge', label: 'Acknowledge' },
207
- { key: 'offer_skip', label: 'Offer to skip' },
208
- { key: 'normalize', label: 'Normalize' },
209
- { key: 'avoid_advice', label: 'Avoid advice' }
210
- ].map((item) => (
211
- <label key={item.key} className="flex items-center gap-2 text-sm text-slate-700 mt-1">
212
- <input
213
- type="checkbox"
214
- checked={(surveyorKnobs.sensitivity_handling || []).includes(item.key)}
215
- onChange={(e) => {
216
- const next = new Set(surveyorKnobs.sensitivity_handling || []);
217
- if (e.target.checked) next.add(item.key); else next.delete(item.key);
218
- setSurveyorKnobs(prev => Object.assign({}, prev, { sensitivity_handling: Array.from(next) }));
219
- }}
220
- />
221
- {item.label}
222
- </label>
223
- ))}
224
- </div>
225
- </div>
226
-
227
- <div>
228
- <div className="flex flex-col gap-4">
229
- <div>
230
- <label className="block text-sm font-semibold text-slate-700 mb-2">Question bank</label>
231
- <div className="text-xs text-slate-500 mb-2">
232
- Questions the surveyor should aim to cover during the session.
233
- </div>
234
- <div className="space-y-2">
235
- {(surveyorQuestionItems || []).length === 0 ? (
236
- <div className="text-sm text-slate-500">No questions yet.</div>
237
- ) : (
238
- (surveyorQuestionItems || []).map((q) => (
239
- <div key={q.id} className="flex items-start gap-2">
240
- <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{q.id}</div>
241
- <input
242
- className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
243
- value={q.text || ''}
244
- onChange={(e) => updateQuestionText(q.id, e.target.value)}
245
- placeholder="Type a question..."
246
- />
247
- <button
248
- type="button"
249
- onClick={() => removeQuestion(q.id)}
250
- className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm"
251
- title="Remove question"
252
- >
253
-
254
- </button>
255
- </div>
256
- ))
257
- )}
258
- <button
259
- type="button"
260
- onClick={addQuestion}
261
- className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300"
262
- >
263
- + Add question
264
- </button>
265
- </div>
266
- </div>
267
-
268
- <div>
269
- <label className="block text-sm font-semibold text-slate-700 mb-2">Surveyor prompt addition (optional)</label>
270
- <textarea
271
- className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white h-40"
272
- placeholder="E.g., Ask only one short question per turn; avoid advice; acknowledge and probe..."
273
- value={surveyorPromptAddition}
274
- onChange={(e) => setSurveyorPromptAddition(e.target.value)}
275
- />
276
- </div>
277
- </div>
278
- </div>
279
- </div>
280
- ) : activePane === 'patients' ? (
281
  <div className="mt-6 grid grid-cols-2 gap-6">
282
  <div>
283
  <label className="block text-sm font-semibold text-slate-700 mb-2">Patient persona</label>
 
1
  def get_config_view_js() -> str:
2
  return r"""
3
+ function ConfigView() {
4
+ const existing = React.useMemo(() => loadConfig(), []);
5
+ const [personas, setPersonas] = React.useState({ surveyors: [], patients: [] });
6
+ const [activePane, setActivePane] = React.useState('surveyors'); // surveyors|patients|analysis|system
7
 
8
  const [selectedSurveyorId, setSelectedSurveyorId] = React.useState(existing?.surveyor_persona_id || 'friendly_researcher_001');
9
  const [selectedPatientId, setSelectedPatientId] = React.useState(existing?.patient_persona_id || 'cooperative_senior_001');
10
+ const [surveyorQuestionItems, setSurveyorQuestionItems] = React.useState(() => {
11
  const items = Array.isArray(existing?.surveyor_question_bank_items) ? existing.surveyor_question_bank_items : null;
12
  if (items && items.length) {
13
  return items
 
18
  const raw = typeof existing?.surveyor_question_bank === 'string' ? existing.surveyor_question_bank : '';
19
  const lines = raw.split('\n').map((l) => (l || '').trim()).filter((l) => l.length > 0);
20
  return lines.map((text, idx) => ({ id: `q${String(idx + 1).padStart(2, '0')}`, text }));
21
+ });
22
+ const legacySurveyorKnobsPresent = !!(existing && existing.surveyor_knobs);
23
+ const [surveyorAttributeItems, setSurveyorAttributeItems] = React.useState(() => {
24
+ const attrs = Array.isArray(existing?.surveyor_attributes) ? existing.surveyor_attributes : null;
25
+ if (attrs && attrs.length) {
26
+ return attrs
27
+ .map((x) => (typeof x === 'string' ? x.trim() : ''))
28
+ .filter((x) => x.length > 0);
29
+ }
30
+ return [];
31
+ });
32
+ const [surveyorPromptAddition, setSurveyorPromptAddition] = React.useState(existing?.surveyor_prompt_addition || '');
33
+ const [patientPromptAddition, setPatientPromptAddition] = React.useState(existing?.patient_prompt_addition || '');
34
+ const [savedAt, setSavedAt] = React.useState(existing?.saved_at || null);
35
 
36
  const surveyorQuestionBankText = React.useMemo(() => {
37
  const lines = (surveyorQuestionItems || [])
 
40
  return lines.join('\n');
41
  }, [surveyorQuestionItems]);
42
 
43
+ const addQuestion = () => {
44
  const nextId = `q${String((surveyorQuestionItems || []).length + 1).padStart(2, '0')}`;
45
  setSurveyorQuestionItems((prev) => ([...(prev || []), { id: nextId, text: '' }]));
46
  };
 
49
  setSurveyorQuestionItems((prev) => (prev || []).map((q) => (q.id === id ? Object.assign({}, q, { text }) : q)));
50
  };
51
 
52
+ const removeQuestion = (id) => {
53
+ setSurveyorQuestionItems((prev) => (prev || []).filter((q) => q.id !== id));
54
+ };
55
+
56
+ const addAttribute = () => {
57
+ setSurveyorAttributeItems((prev) => ([...(prev || []), '' ]));
58
+ };
59
+
60
+ const updateAttribute = (idx, text) => {
61
+ setSurveyorAttributeItems((prev) => (prev || []).map((v, i) => (i === idx ? text : v)));
62
+ };
63
+
64
+ const removeAttribute = (idx) => {
65
+ setSurveyorAttributeItems((prev) => (prev || []).filter((_, i) => i !== idx));
66
+ };
67
 
68
  React.useEffect(() => {
69
  authedFetch('/api/personas')
 
77
  .catch(() => {});
78
  }, []);
79
 
80
+ const onSave = () => {
81
+ const cfg = {
82
+ surveyor_persona_id: selectedSurveyorId,
83
+ patient_persona_id: selectedPatientId,
84
+ surveyor_question_bank: surveyorQuestionBankText,
85
+ surveyor_question_bank_items: (surveyorQuestionItems || [])
86
+ .map((q) => ({ id: q.id, text: (q.text || '').trim() }))
87
+ .filter((q) => q.id && q.text.length > 0),
88
+ surveyor_attributes: (surveyorAttributeItems || [])
89
+ .map((x) => (typeof x === 'string' ? x.trim() : ''))
90
+ .filter((x) => x.length > 0),
91
+ surveyor_prompt_addition: surveyorPromptAddition,
92
+ patient_prompt_addition: patientPromptAddition,
93
+ saved_at: new Date().toISOString()
94
+ };
95
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
96
+ setSavedAt(cfg.saved_at);
97
+ };
98
 
99
  const TabNav = () => {
100
  const base = "px-4 py-2 rounded-lg text-sm font-semibold border transition-colors";
 
126
 
127
  <TabNav />
128
 
129
+ {activePane === 'surveyors' ? (
130
+ <div className="mt-6 grid grid-cols-2 gap-6">
131
+ <div>
132
+ {legacySurveyorKnobsPresent ? (
133
+ <div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
134
+ Legacy surveyor knobs were removed. Please use “Attributes” instead and click “Save Configuration”.
135
+ </div>
136
+ ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
+ <label className="block text-sm font-semibold text-slate-700 mb-2">Surveyor persona</label>
139
+ <select
140
+ className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
141
+ value={selectedSurveyorId}
142
+ onChange={(e) => setSelectedSurveyorId(e.target.value)}
143
+ >
144
+ {(personas.surveyors.length ? personas.surveyors : [{id: 'friendly_researcher_001', name: 'Alex Thompson'}]).map(p => (
145
+ <option key={p.id} value={p.id}>{p.name} ({p.id})</option>
146
+ ))}
147
+ </select>
 
 
148
 
149
+ <div className="mt-4">
150
+ <label className="block text-sm font-semibold text-slate-700 mb-2">Question bank</label>
151
+ <div className="text-xs text-slate-500 mb-2">
152
+ Questions the surveyor should aim to cover during the session.
153
+ </div>
154
+ <div className="space-y-2">
155
+ {(surveyorQuestionItems || []).length === 0 ? (
156
+ <div className="text-sm text-slate-500">No questions yet.</div>
157
+ ) : (
158
+ (surveyorQuestionItems || []).map((q) => (
159
+ <div key={q.id} className="flex items-start gap-2">
160
+ <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{q.id}</div>
161
+ <input
162
+ className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
163
+ value={q.text || ''}
164
+ onChange={(e) => updateQuestionText(q.id, e.target.value)}
165
+ placeholder="Type a question..."
166
+ />
167
+ <button
168
+ type="button"
169
+ onClick={() => removeQuestion(q.id)}
170
+ className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm"
171
+ title="Remove question"
172
+ >
173
+
174
+ </button>
175
+ </div>
176
+ ))
177
+ )}
178
+ <button
179
+ type="button"
180
+ onClick={addQuestion}
181
+ className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300"
182
+ >
183
+ + Add question
184
+ </button>
185
+ </div>
186
+ </div>
187
+ </div>
188
 
189
+ <div>
190
+ <div className="flex flex-col gap-4">
191
+ <div>
192
+ <label className="block text-sm font-semibold text-slate-700 mb-2">Attributes</label>
193
+ <div className="text-xs text-slate-500 mb-2">
194
+ Plain-language rules the surveyor should follow during the session.
195
+ </div>
196
+ <div className="space-y-2">
197
+ {(surveyorAttributeItems || []).length === 0 ? (
198
+ <div className="text-sm text-slate-500">No attributes yet.</div>
199
+ ) : (
200
+ (surveyorAttributeItems || []).map((attr, idx) => (
201
+ <div key={idx} className="flex items-start gap-2">
202
+ <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{String(idx + 1).padStart(2, '0')}</div>
203
+ <input
204
+ className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
205
+ value={attr || ''}
206
+ onChange={(e) => updateAttribute(idx, e.target.value)}
207
+ placeholder="Type an attribute..."
208
+ />
209
+ <button
210
+ type="button"
211
+ onClick={() => removeAttribute(idx)}
212
+ className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm"
213
+ title="Remove attribute"
214
+ >
215
+
216
+ </button>
217
+ </div>
218
+ ))
219
+ )}
220
+ <button
221
+ type="button"
222
+ onClick={addAttribute}
223
+ className="bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-slate-300"
224
+ >
225
+ + Add attribute
226
+ </button>
227
+ </div>
228
+ </div>
229
 
230
+ <div>
231
+ <label className="block text-sm font-semibold text-slate-700 mb-2">Surveyor prompt addition (optional)</label>
232
+ <textarea
233
+ className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white h-40"
234
+ placeholder="Extra free-form instructions appended last (lowest priority)."
235
+ value={surveyorPromptAddition}
236
+ onChange={(e) => setSurveyorPromptAddition(e.target.value)}
237
+ />
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </div>
242
+ ) : activePane === 'patients' ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  <div className="mt-6 grid grid-cols-2 gap-6">
244
  <div>
245
  <label className="block text-sm font-semibold text-slate-700 mb-2">Patient persona</label>
frontend/pages/main_page.py CHANGED
@@ -44,6 +44,13 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
44
  return lines.join('\n').trim();
45
  }
46
 
 
 
 
 
 
 
 
47
  function loadSessionToken() {
48
  try {
49
  const raw = localStorage.getItem(SESSION_KEY);
@@ -538,6 +545,7 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
538
 
539
  const cfg = loadConfig() || {};
540
  const surveyorQuestionBank = getSurveyorQuestionBank(cfg);
 
541
  const surveyorId = cfg.surveyor_persona_id || 'friendly_researcher_001';
542
  const patientId = cfg.patient_persona_id || 'cooperative_senior_001';
543
 
@@ -548,9 +556,9 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
548
  surveyor_persona_id: surveyorId,
549
  patient_persona_id: patientId,
550
  surveyor_question_bank: surveyorQuestionBank || undefined,
 
551
  surveyor_prompt_addition: cfg.surveyor_prompt_addition || undefined,
552
  patient_prompt_addition: cfg.patient_prompt_addition || undefined,
553
- surveyor_knobs: cfg.surveyor_knobs || undefined
554
  }));
555
  }
556
  }, 500);
@@ -570,6 +578,7 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
570
 
571
  const cfg = loadConfig() || {};
572
  const surveyorQuestionBank = getSurveyorQuestionBank(cfg);
 
573
  const surveyorId = cfg.surveyor_persona_id || 'friendly_researcher_001';
574
  const patientId = cfg.patient_persona_id || 'cooperative_senior_001';
575
 
@@ -580,9 +589,9 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
580
  surveyor_persona_id: surveyorId,
581
  patient_persona_id: patientId,
582
  surveyor_question_bank: surveyorQuestionBank || undefined,
 
583
  surveyor_prompt_addition: cfg.surveyor_prompt_addition || undefined,
584
  patient_prompt_addition: cfg.patient_prompt_addition || undefined,
585
- surveyor_knobs: cfg.surveyor_knobs || undefined
586
  }));
587
  }
588
  }, 500);
 
44
  return lines.join('\n').trim();
45
  }
46
 
47
+ function getSurveyorAttributes(cfg) {
48
+ const attrs = (cfg && Array.isArray(cfg.surveyor_attributes)) ? cfg.surveyor_attributes : [];
49
+ return attrs
50
+ .map((x) => (typeof x === 'string') ? x.trim() : '')
51
+ .filter((x) => x.length > 0);
52
+ }
53
+
54
  function loadSessionToken() {
55
  try {
56
  const raw = localStorage.getItem(SESSION_KEY);
 
545
 
546
  const cfg = loadConfig() || {};
547
  const surveyorQuestionBank = getSurveyorQuestionBank(cfg);
548
+ const surveyorAttributes = getSurveyorAttributes(cfg);
549
  const surveyorId = cfg.surveyor_persona_id || 'friendly_researcher_001';
550
  const patientId = cfg.patient_persona_id || 'cooperative_senior_001';
551
 
 
556
  surveyor_persona_id: surveyorId,
557
  patient_persona_id: patientId,
558
  surveyor_question_bank: surveyorQuestionBank || undefined,
559
+ surveyor_attributes: surveyorAttributes.length ? surveyorAttributes : undefined,
560
  surveyor_prompt_addition: cfg.surveyor_prompt_addition || undefined,
561
  patient_prompt_addition: cfg.patient_prompt_addition || undefined,
 
562
  }));
563
  }
564
  }, 500);
 
578
 
579
  const cfg = loadConfig() || {};
580
  const surveyorQuestionBank = getSurveyorQuestionBank(cfg);
581
+ const surveyorAttributes = getSurveyorAttributes(cfg);
582
  const surveyorId = cfg.surveyor_persona_id || 'friendly_researcher_001';
583
  const patientId = cfg.patient_persona_id || 'cooperative_senior_001';
584
 
 
589
  surveyor_persona_id: surveyorId,
590
  patient_persona_id: patientId,
591
  surveyor_question_bank: surveyorQuestionBank || undefined,
592
+ surveyor_attributes: surveyorAttributes.length ? surveyorAttributes : undefined,
593
  surveyor_prompt_addition: cfg.surveyor_prompt_addition || undefined,
594
  patient_prompt_addition: cfg.patient_prompt_addition || undefined,
 
595
  }));
596
  }
597
  }, 500);
frontend/react_gradio_hybrid.py CHANGED
@@ -165,9 +165,9 @@ async def frontend_websocket(websocket: WebSocket, conversation_id: str):
165
  "surveyor_persona_id": data.get("surveyor_persona_id", "friendly_researcher_001"),
166
  "patient_persona_id": data.get("patient_persona_id", "cooperative_senior_001"),
167
  "surveyor_question_bank": data.get("surveyor_question_bank"),
 
168
  "surveyor_prompt_addition": data.get("surveyor_prompt_addition"),
169
  "patient_prompt_addition": data.get("patient_prompt_addition"),
170
- "surveyor_knobs": data.get("surveyor_knobs"),
171
  "host": settings.llm.host,
172
  "model": settings.llm.model,
173
  })
@@ -189,9 +189,9 @@ async def frontend_websocket(websocket: WebSocket, conversation_id: str):
189
  "surveyor_persona_id": data.get("surveyor_persona_id", "friendly_researcher_001"),
190
  "patient_persona_id": data.get("patient_persona_id", "cooperative_senior_001"),
191
  "surveyor_question_bank": data.get("surveyor_question_bank"),
 
192
  "surveyor_prompt_addition": data.get("surveyor_prompt_addition"),
193
  "patient_prompt_addition": data.get("patient_prompt_addition"),
194
- "surveyor_knobs": data.get("surveyor_knobs"),
195
  "host": settings.llm.host,
196
  "model": settings.llm.model,
197
  })
 
165
  "surveyor_persona_id": data.get("surveyor_persona_id", "friendly_researcher_001"),
166
  "patient_persona_id": data.get("patient_persona_id", "cooperative_senior_001"),
167
  "surveyor_question_bank": data.get("surveyor_question_bank"),
168
+ "surveyor_attributes": data.get("surveyor_attributes"),
169
  "surveyor_prompt_addition": data.get("surveyor_prompt_addition"),
170
  "patient_prompt_addition": data.get("patient_prompt_addition"),
 
171
  "host": settings.llm.host,
172
  "model": settings.llm.model,
173
  })
 
189
  "surveyor_persona_id": data.get("surveyor_persona_id", "friendly_researcher_001"),
190
  "patient_persona_id": data.get("patient_persona_id", "cooperative_senior_001"),
191
  "surveyor_question_bank": data.get("surveyor_question_bank"),
192
+ "surveyor_attributes": data.get("surveyor_attributes"),
193
  "surveyor_prompt_addition": data.get("surveyor_prompt_addition"),
194
  "patient_prompt_addition": data.get("patient_prompt_addition"),
 
195
  "host": settings.llm.host,
196
  "model": settings.llm.model,
197
  })