MikelWL commited on
Commit
eab0119
·
1 Parent(s): 88a62f1

DB-canonical personas: CRUD + shared settings

Browse files
README.md CHANGED
@@ -109,10 +109,9 @@ After the conversation completes, the app runs post-conversation analysis and po
109
 
110
  ## 5. Personas
111
 
112
- - Surveyor definitions: `data/surveyor_personas.yaml`
113
- - Patient definitions: `data/patient_personas.yaml`
114
-
115
- Edit the YAML, then restart the backend to apply changes.
116
 
117
  ---
118
 
 
109
 
110
  ## 5. Personas
111
 
112
+ - Personas are **DB-canonical** (SQLite at `DB_PATH`) and shared across users on Hugging Face Spaces.
113
+ - On startup, the app seeds a small immutable default set (3 surveyors + 3 patients).
114
+ - Create/duplicate/edit/delete **user personas** in the **Configuration** panel (defaults remain view-only).
 
115
 
116
  ---
117
 
backend/api/analysis_routes.py CHANGED
@@ -13,7 +13,7 @@ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
13
  from pydantic import BaseModel, Field
14
 
15
  from .conversation_service import run_resource_agent_analysis
16
- from .storage_service import get_run_store
17
  from config.settings import get_settings
18
  from backend.storage import RunRecord
19
 
@@ -145,13 +145,14 @@ async def _analyze_from_text(
145
  text=msg["content"],
146
  ))
147
 
 
148
  resources = await run_resource_agent_analysis(
149
  transcript=transcript,
150
  llm_backend=settings.llm.backend,
151
  host=settings.llm.host,
152
  model=settings.llm.model,
153
  settings=settings,
154
- analysis_attributes=analysis_attributes,
155
  )
156
 
157
  persisted = False
@@ -172,7 +173,7 @@ async def _analyze_from_text(
172
  "source_name": source_name,
173
  },
174
  "analysis": {
175
- "analysis_attributes": analysis_attributes,
176
  },
177
  }
178
  record = RunRecord(
 
13
  from pydantic import BaseModel, Field
14
 
15
  from .conversation_service import run_resource_agent_analysis
16
+ from .storage_service import get_run_store, get_persona_store
17
  from config.settings import get_settings
18
  from backend.storage import RunRecord
19
 
 
145
  text=msg["content"],
146
  ))
147
 
148
+ effective_analysis_attributes = await get_persona_store().get_setting("analysis_attributes")
149
  resources = await run_resource_agent_analysis(
150
  transcript=transcript,
151
  llm_backend=settings.llm.backend,
152
  host=settings.llm.host,
153
  model=settings.llm.model,
154
  settings=settings,
155
+ analysis_attributes=effective_analysis_attributes,
156
  )
157
 
158
  persisted = False
 
173
  "source_name": source_name,
174
  },
175
  "analysis": {
176
+ "analysis_attributes": effective_analysis_attributes,
177
  },
178
  }
179
  record = RunRecord(
backend/api/conversation_service.py CHANGED
@@ -38,9 +38,10 @@ for path in (BACKEND_DIR, PROJECT_ROOT):
38
  from config.settings import AppSettings, get_settings # noqa: E402
39
  from backend.core.conversation_manager import ConversationManager # noqa: E402
40
  from backend.core.llm_client import create_llm_client # noqa: E402
41
- 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_attributes_overlay, compile_question_bank_overlay # noqa: E402
46
  from backend.core.patient_knobs import compile_patient_attributes_overlay # noqa: E402
@@ -326,7 +327,7 @@ class ConversationService:
326
  settings: Shared application settings (optional)
327
  """
328
  self.websocket_manager = websocket_manager
329
- self.persona_system = PersonaSystem()
330
  self.active_conversations: Dict[str, ConversationInfo] = {}
331
  self.active_human_chats: Dict[str, HumanChatInfo] = {}
332
  self.transcripts: Dict[str, List[Dict[str, Any]]] = {}
@@ -359,10 +360,10 @@ class ConversationService:
359
  patient_persona_id: str,
360
  host: Optional[str] = None,
361
  model: Optional[str] = None,
362
- patient_attributes: Optional[List[str]] = None,
363
- surveyor_system_prompt: Optional[str] = None,
364
- patient_system_prompt: Optional[str] = None,
365
- analysis_attributes: Optional[List[str]] = None,
366
  surveyor_attributes: Optional[List[str]] = None,
367
  surveyor_question_bank: Optional[str] = None,
368
  ai_role: Optional[str] = None,
@@ -384,6 +385,15 @@ class ConversationService:
384
 
385
  resolved_ai_role = ai_role if ai_role in ("surveyor", "patient") else "surveyor"
386
 
 
 
 
 
 
 
 
 
 
387
  chat_info = HumanChatInfo(
388
  conversation_id=conversation_id,
389
  surveyor_persona_id=surveyor_persona_id,
@@ -391,9 +401,9 @@ class ConversationService:
391
  host=resolved_host,
392
  model=resolved_model,
393
  llm_backend=resolved_backend,
394
- surveyor_system_prompt=(surveyor_system_prompt or "").strip() or DEFAULT_SURVEYOR_SYSTEM_PROMPT,
395
- patient_system_prompt=(patient_system_prompt or "").strip() or DEFAULT_PATIENT_SYSTEM_PROMPT,
396
- analysis_attributes=[s.strip() for s in (analysis_attributes or []) if isinstance(s, str) and s.strip()],
397
  patient_attributes=self._persona_attributes(patient_persona),
398
  surveyor_attributes=self._persona_attributes(surveyor_persona),
399
  surveyor_question_bank=self._persona_question_bank(surveyor_persona),
@@ -699,10 +709,10 @@ class ConversationService:
699
  patient_persona_id: str,
700
  host: Optional[str] = None,
701
  model: Optional[str] = None,
702
- patient_attributes: Optional[List[str]] = None,
703
- surveyor_system_prompt: Optional[str] = None,
704
- patient_system_prompt: Optional[str] = None,
705
- analysis_attributes: Optional[List[str]] = None,
706
  surveyor_attributes: Optional[List[str]] = None,
707
  surveyor_question_bank: Optional[str] = None) -> bool:
708
  """Start a new AI-to-AI conversation.
@@ -738,6 +748,14 @@ class ConversationService:
738
  resolved_model = model or self.settings.llm.model
739
  resolved_backend = self.settings.llm.backend
740
 
 
 
 
 
 
 
 
 
741
  # Create conversation info
742
  conv_info = ConversationInfo(
743
  conversation_id=conversation_id,
@@ -746,9 +764,9 @@ class ConversationService:
746
  host=resolved_host,
747
  model=resolved_model,
748
  llm_backend=resolved_backend,
749
- surveyor_system_prompt=(surveyor_system_prompt or "").strip() or DEFAULT_SURVEYOR_SYSTEM_PROMPT,
750
- patient_system_prompt=(patient_system_prompt or "").strip() or DEFAULT_PATIENT_SYSTEM_PROMPT,
751
- analysis_attributes=[s.strip() for s in (analysis_attributes or []) if isinstance(s, str) and s.strip()],
752
  patient_attributes=self._persona_attributes(patient_persona),
753
  surveyor_attributes=self._persona_attributes(surveyor_persona),
754
  surveyor_question_bank=self._persona_question_bank(surveyor_persona),
@@ -1028,8 +1046,16 @@ class ConversationService:
1028
  surveyor_persona = self.persona_system.get_persona(conv_info.surveyor_persona_id) or {}
1029
  patient_persona = self.persona_system.get_persona(conv_info.patient_persona_id) or {}
1030
  persona_snapshots = {
1031
- "surveyor": {"persona_id": conv_info.surveyor_persona_id, "persona_version_id": None, "snapshot": surveyor_persona},
1032
- "patient": {"persona_id": conv_info.patient_persona_id, "persona_version_id": None, "snapshot": patient_persona},
 
 
 
 
 
 
 
 
1033
  }
1034
  except Exception:
1035
  persona_snapshots = {}
 
38
  from config.settings import AppSettings, get_settings # noqa: E402
39
  from backend.core.conversation_manager import ConversationManager # noqa: E402
40
  from backend.core.llm_client import create_llm_client # noqa: E402
41
+ from backend.core.persona_system import get_persona_system # noqa: E402
42
  from .conversation_ws import ConnectionManager # noqa: E402
43
  from .storage_service import get_run_store # noqa: E402
44
+ from .storage_service import get_persona_store # noqa: E402
45
  from backend.storage import RunRecord # noqa: E402
46
  from backend.core.surveyor_knobs import compile_surveyor_attributes_overlay, compile_question_bank_overlay # noqa: E402
47
  from backend.core.patient_knobs import compile_patient_attributes_overlay # noqa: E402
 
327
  settings: Shared application settings (optional)
328
  """
329
  self.websocket_manager = websocket_manager
330
+ self.persona_system = get_persona_system()
331
  self.active_conversations: Dict[str, ConversationInfo] = {}
332
  self.active_human_chats: Dict[str, HumanChatInfo] = {}
333
  self.transcripts: Dict[str, List[Dict[str, Any]]] = {}
 
360
  patient_persona_id: str,
361
  host: Optional[str] = None,
362
  model: Optional[str] = None,
363
+ patient_attributes: Optional[List[str]] = None, # deprecated (persona content is DB-canonical)
364
+ surveyor_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
365
+ patient_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
366
+ analysis_attributes: Optional[List[str]] = None, # deprecated (DB-canonical)
367
  surveyor_attributes: Optional[List[str]] = None,
368
  surveyor_question_bank: Optional[str] = None,
369
  ai_role: Optional[str] = None,
 
385
 
386
  resolved_ai_role = ai_role if ai_role in ("surveyor", "patient") else "surveyor"
387
 
388
+ # DB-canonical settings (shared on HF)
389
+ store = get_persona_store()
390
+ sp = await store.get_setting("surveyor_system_prompt")
391
+ pp = await store.get_setting("patient_system_prompt")
392
+ ap = await store.get_setting("analysis_attributes")
393
+ resolved_surveyor_prompt = sp if isinstance(sp, str) and sp.strip() else DEFAULT_SURVEYOR_SYSTEM_PROMPT
394
+ resolved_patient_prompt = pp if isinstance(pp, str) and pp.strip() else DEFAULT_PATIENT_SYSTEM_PROMPT
395
+ resolved_analysis_attrs = [s.strip() for s in ap if isinstance(ap, str) and s.strip()] if isinstance(ap, list) else []
396
+
397
  chat_info = HumanChatInfo(
398
  conversation_id=conversation_id,
399
  surveyor_persona_id=surveyor_persona_id,
 
401
  host=resolved_host,
402
  model=resolved_model,
403
  llm_backend=resolved_backend,
404
+ surveyor_system_prompt=resolved_surveyor_prompt,
405
+ patient_system_prompt=resolved_patient_prompt,
406
+ analysis_attributes=resolved_analysis_attrs,
407
  patient_attributes=self._persona_attributes(patient_persona),
408
  surveyor_attributes=self._persona_attributes(surveyor_persona),
409
  surveyor_question_bank=self._persona_question_bank(surveyor_persona),
 
709
  patient_persona_id: str,
710
  host: Optional[str] = None,
711
  model: Optional[str] = None,
712
+ patient_attributes: Optional[List[str]] = None, # deprecated (DB-canonical)
713
+ surveyor_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
714
+ patient_system_prompt: Optional[str] = None, # deprecated (DB-canonical)
715
+ analysis_attributes: Optional[List[str]] = None, # deprecated (DB-canonical)
716
  surveyor_attributes: Optional[List[str]] = None,
717
  surveyor_question_bank: Optional[str] = None) -> bool:
718
  """Start a new AI-to-AI conversation.
 
748
  resolved_model = model or self.settings.llm.model
749
  resolved_backend = self.settings.llm.backend
750
 
751
+ store = get_persona_store()
752
+ sp = await store.get_setting("surveyor_system_prompt")
753
+ pp = await store.get_setting("patient_system_prompt")
754
+ ap = await store.get_setting("analysis_attributes")
755
+ resolved_surveyor_prompt = sp if isinstance(sp, str) and sp.strip() else DEFAULT_SURVEYOR_SYSTEM_PROMPT
756
+ resolved_patient_prompt = pp if isinstance(pp, str) and pp.strip() else DEFAULT_PATIENT_SYSTEM_PROMPT
757
+ resolved_analysis_attrs = [s.strip() for s in ap if isinstance(ap, str) and s.strip()] if isinstance(ap, list) else []
758
+
759
  # Create conversation info
760
  conv_info = ConversationInfo(
761
  conversation_id=conversation_id,
 
764
  host=resolved_host,
765
  model=resolved_model,
766
  llm_backend=resolved_backend,
767
+ surveyor_system_prompt=resolved_surveyor_prompt,
768
+ patient_system_prompt=resolved_patient_prompt,
769
+ analysis_attributes=resolved_analysis_attrs,
770
  patient_attributes=self._persona_attributes(patient_persona),
771
  surveyor_attributes=self._persona_attributes(surveyor_persona),
772
  surveyor_question_bank=self._persona_question_bank(surveyor_persona),
 
1046
  surveyor_persona = self.persona_system.get_persona(conv_info.surveyor_persona_id) or {}
1047
  patient_persona = self.persona_system.get_persona(conv_info.patient_persona_id) or {}
1048
  persona_snapshots = {
1049
+ "surveyor": {
1050
+ "persona_id": conv_info.surveyor_persona_id,
1051
+ "persona_version_id": surveyor_persona.get("version_id"),
1052
+ "snapshot": surveyor_persona,
1053
+ },
1054
+ "patient": {
1055
+ "persona_id": conv_info.patient_persona_id,
1056
+ "persona_version_id": patient_persona.get("version_id"),
1057
+ "snapshot": patient_persona,
1058
+ },
1059
  }
1060
  except Exception:
1061
  persona_snapshots = {}
backend/api/main.py CHANGED
@@ -35,7 +35,7 @@ from .analysis_routes import router as analysis_router # noqa: E402
35
  from .run_routes import router as run_router # noqa: E402
36
  from .run_export_routes import router as run_export_router # noqa: E402
37
  from .conversation_service import initialize_conversation_service # noqa: E402
38
- from .storage_service import initialize_run_store # noqa: E402
39
  from backend.core.auth import COOKIE_NAME, INTERNAL_HEADER, get_app_password, verify_session_token # noqa: E402
40
 
41
  # Load application settings
@@ -108,6 +108,7 @@ async def startup_event():
108
  # Initialize conversation service with WebSocket manager and settings
109
  initialize_conversation_service(manager, settings)
110
  await initialize_run_store(settings)
 
111
 
112
  logger.info("API startup complete")
113
 
 
35
  from .run_routes import router as run_router # noqa: E402
36
  from .run_export_routes import router as run_export_router # noqa: E402
37
  from .conversation_service import initialize_conversation_service # noqa: E402
38
+ from .storage_service import initialize_run_store, seed_personas_and_settings # noqa: E402
39
  from backend.core.auth import COOKIE_NAME, INTERNAL_HEADER, get_app_password, verify_session_token # noqa: E402
40
 
41
  # Load application settings
 
108
  # Initialize conversation service with WebSocket manager and settings
109
  initialize_conversation_service(manager, settings)
110
  await initialize_run_store(settings)
111
+ await seed_personas_and_settings(overwrite_defaults=True)
112
 
113
  logger.info("API startup complete")
114
 
backend/api/persona_routes.py CHANGED
@@ -5,21 +5,24 @@ from __future__ import annotations
5
  from typing import Dict, List, Optional, Any
6
 
7
  from fastapi import APIRouter, HTTPException
8
- from pydantic import BaseModel
9
 
10
- from backend.core.persona_system import PersonaSystem
 
11
  from .conversation_service import get_conversation_service
 
 
12
 
13
  router = APIRouter(prefix="", tags=["personas"])
14
 
15
- persona_system = PersonaSystem()
16
 
17
 
18
  class PersonaResponse(BaseModel):
19
  id: str
20
  name: str
21
  role: str
22
- description: str
23
  is_default: bool = False
24
 
25
 
@@ -33,6 +36,38 @@ class PersonaDetailResponse(BaseModel):
33
  question_bank_items: List[Any] = []
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  @router.get("/personas")
37
  async def list_personas() -> Dict[str, List[PersonaResponse]]:
38
  try:
@@ -78,20 +113,150 @@ async def api_health() -> Dict[str, str]:
78
  @router.get("/personas/{persona_id}")
79
  async def get_persona(persona_id: str) -> PersonaDetailResponse:
80
  try:
81
- persona = persona_system.get_persona(persona_id)
82
- if not persona:
 
83
  raise HTTPException(status_code=404, detail=f"Persona {persona_id} not found")
84
- kind = (persona.get("kind") or persona.get("role") or "").strip().lower() or "unknown"
85
  return PersonaDetailResponse(
86
- id=persona.get("id", persona_id),
87
- name=persona.get("name", "Unknown"),
88
  kind=kind,
89
- description=persona.get("description", "") or "",
90
- is_default=bool(persona.get("is_default", False)),
91
- attributes=persona.get("attributes") or [],
92
- question_bank_items=persona.get("question_bank_items") or [],
93
  )
94
  except HTTPException:
95
  raise
96
  except Exception as e:
97
  raise HTTPException(status_code=500, detail=f"Internal error retrieving persona: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  from typing import Dict, List, Optional, Any
6
 
7
  from fastapi import APIRouter, HTTPException
8
+ from pydantic import BaseModel, Field
9
 
10
+ from backend.core.persona_system import get_persona_system
11
+ from .storage_service import get_persona_store, refresh_persona_cache
12
  from .conversation_service import get_conversation_service
13
+ from backend.core.universal_prompts import DEFAULT_PATIENT_SYSTEM_PROMPT, DEFAULT_SURVEYOR_SYSTEM_PROMPT
14
+ from backend.core.analysis_knobs import DEFAULT_ANALYSIS_ATTRIBUTES
15
 
16
  router = APIRouter(prefix="", tags=["personas"])
17
 
18
+ persona_system = get_persona_system()
19
 
20
 
21
  class PersonaResponse(BaseModel):
22
  id: str
23
  name: str
24
  role: str
25
+ description: str = ""
26
  is_default: bool = False
27
 
28
 
 
36
  question_bank_items: List[Any] = []
37
 
38
 
39
+ class CreatePersonaRequest(BaseModel):
40
+ kind: str = Field(..., description="surveyor|patient")
41
+ name: str = Field(..., description="Display name")
42
+ clone_from_persona_id: Optional[str] = Field(default=None, description="Optional persona id to clone from")
43
+
44
+
45
+ class UpdatePersonaRequest(BaseModel):
46
+ attributes: List[str] = Field(default_factory=list)
47
+ question_bank_items: List[str] = Field(default_factory=list)
48
+
49
+
50
+ class SettingsResponse(BaseModel):
51
+ surveyor_system_prompt: str
52
+ patient_system_prompt: str
53
+ analysis_attributes: List[str]
54
+
55
+
56
+ class UpdateSettingsRequest(BaseModel):
57
+ surveyor_system_prompt: Optional[str] = None
58
+ patient_system_prompt: Optional[str] = None
59
+ analysis_attributes: Optional[List[str]] = None
60
+
61
+
62
+ @router.get("/settings/defaults")
63
+ async def get_settings_defaults() -> SettingsResponse:
64
+ return SettingsResponse(
65
+ surveyor_system_prompt=DEFAULT_SURVEYOR_SYSTEM_PROMPT,
66
+ patient_system_prompt=DEFAULT_PATIENT_SYSTEM_PROMPT,
67
+ analysis_attributes=list(DEFAULT_ANALYSIS_ATTRIBUTES),
68
+ )
69
+
70
+
71
  @router.get("/personas")
72
  async def list_personas() -> Dict[str, List[PersonaResponse]]:
73
  try:
 
113
  @router.get("/personas/{persona_id}")
114
  async def get_persona(persona_id: str) -> PersonaDetailResponse:
115
  try:
116
+ store = get_persona_store()
117
+ record = await store.get_persona(persona_id, include_deleted=False)
118
+ if not record:
119
  raise HTTPException(status_code=404, detail=f"Persona {persona_id} not found")
120
+ kind = record.kind
121
  return PersonaDetailResponse(
122
+ id=record.persona_id,
123
+ name=record.name,
124
  kind=kind,
125
+ description="",
126
+ is_default=record.is_default,
127
+ attributes=record.attributes,
128
+ question_bank_items=record.question_bank_items,
129
  )
130
  except HTTPException:
131
  raise
132
  except Exception as e:
133
  raise HTTPException(status_code=500, detail=f"Internal error retrieving persona: {str(e)}")
134
+
135
+
136
+ @router.post("/personas")
137
+ async def create_persona(payload: CreatePersonaRequest) -> PersonaDetailResponse:
138
+ try:
139
+ kind = (payload.kind or "").strip().lower()
140
+ if kind not in ("surveyor", "patient"):
141
+ raise HTTPException(status_code=400, detail="kind must be surveyor|patient")
142
+ name = (payload.name or "").strip()
143
+ if not name:
144
+ raise HTTPException(status_code=400, detail="name is required")
145
+
146
+ store = get_persona_store()
147
+ attributes: List[str] = []
148
+ question_bank_items: List[str] = []
149
+ if payload.clone_from_persona_id:
150
+ src = await store.get_persona(payload.clone_from_persona_id, include_deleted=False)
151
+ if not src:
152
+ raise HTTPException(status_code=404, detail="clone_from_persona_id not found")
153
+ if src.kind != kind:
154
+ raise HTTPException(status_code=400, detail="clone_from_persona_id kind mismatch")
155
+ attributes = src.attributes
156
+ question_bank_items = src.question_bank_items
157
+
158
+ persona_id, _ = await store.create_persona(
159
+ kind=kind,
160
+ name=name,
161
+ attributes=attributes,
162
+ question_bank_items=question_bank_items,
163
+ is_default=False,
164
+ )
165
+ await refresh_persona_cache()
166
+ record = await store.get_persona(persona_id)
167
+ if not record:
168
+ raise HTTPException(status_code=500, detail="Failed to create persona")
169
+ return PersonaDetailResponse(
170
+ id=record.persona_id,
171
+ name=record.name,
172
+ kind=record.kind,
173
+ description="",
174
+ is_default=record.is_default,
175
+ attributes=record.attributes,
176
+ question_bank_items=record.question_bank_items,
177
+ )
178
+ except HTTPException:
179
+ raise
180
+ except Exception as e:
181
+ raise HTTPException(status_code=500, detail=f"Internal error creating persona: {str(e)}")
182
+
183
+
184
+ @router.put("/personas/{persona_id}")
185
+ async def update_persona(persona_id: str, payload: UpdatePersonaRequest) -> PersonaDetailResponse:
186
+ try:
187
+ store = get_persona_store()
188
+ record = await store.get_persona(persona_id, include_deleted=True)
189
+ if not record:
190
+ raise HTTPException(status_code=404, detail="persona not found")
191
+ if record.is_default:
192
+ raise HTTPException(status_code=403, detail="default persona is immutable")
193
+
194
+ await store.update_persona(
195
+ persona_id=persona_id,
196
+ name=None, # name is set on create; not editable in v1
197
+ attributes=payload.attributes,
198
+ question_bank_items=payload.question_bank_items,
199
+ overwrite_defaults=False,
200
+ )
201
+ await refresh_persona_cache()
202
+ updated = await store.get_persona(persona_id)
203
+ if not updated:
204
+ raise HTTPException(status_code=404, detail="persona not found")
205
+ return PersonaDetailResponse(
206
+ id=updated.persona_id,
207
+ name=updated.name,
208
+ kind=updated.kind,
209
+ description="",
210
+ is_default=updated.is_default,
211
+ attributes=updated.attributes,
212
+ question_bank_items=updated.question_bank_items,
213
+ )
214
+ except HTTPException:
215
+ raise
216
+ except PermissionError as e:
217
+ raise HTTPException(status_code=403, detail=str(e))
218
+ except Exception as e:
219
+ raise HTTPException(status_code=500, detail=f"Internal error updating persona: {str(e)}")
220
+
221
+
222
+ @router.delete("/personas/{persona_id}")
223
+ async def delete_persona(persona_id: str) -> Dict[str, Any]:
224
+ try:
225
+ store = get_persona_store()
226
+ await store.soft_delete_persona(persona_id)
227
+ await refresh_persona_cache()
228
+ return {"ok": True}
229
+ except PermissionError as e:
230
+ raise HTTPException(status_code=403, detail=str(e))
231
+ except ValueError:
232
+ raise HTTPException(status_code=404, detail="persona not found")
233
+ except Exception as e:
234
+ raise HTTPException(status_code=500, detail=f"Internal error deleting persona: {str(e)}")
235
+
236
+
237
+ @router.get("/settings")
238
+ async def get_settings() -> SettingsResponse:
239
+ store = get_persona_store()
240
+ sp = await store.get_setting("surveyor_system_prompt")
241
+ pp = await store.get_setting("patient_system_prompt")
242
+ ap = await store.get_setting("analysis_attributes")
243
+ return SettingsResponse(
244
+ surveyor_system_prompt=sp if isinstance(sp, str) else "",
245
+ patient_system_prompt=pp if isinstance(pp, str) else "",
246
+ analysis_attributes=[s for s in ap if isinstance(ap, str)] if isinstance(ap, list) else [],
247
+ )
248
+
249
+
250
+ @router.put("/settings")
251
+ async def update_settings(payload: UpdateSettingsRequest) -> SettingsResponse:
252
+ store = get_persona_store()
253
+ if payload.surveyor_system_prompt is not None:
254
+ await store.upsert_setting("surveyor_system_prompt", str(payload.surveyor_system_prompt))
255
+ if payload.patient_system_prompt is not None:
256
+ await store.upsert_setting("patient_system_prompt", str(payload.patient_system_prompt))
257
+ if payload.analysis_attributes is not None:
258
+ await store.upsert_setting(
259
+ "analysis_attributes",
260
+ [s.strip() for s in (payload.analysis_attributes or []) if isinstance(s, str) and s.strip()],
261
+ )
262
+ return await get_settings()
backend/api/storage_service.py CHANGED
@@ -4,11 +4,14 @@ import logging
4
  from typing import Optional
5
 
6
  from config.settings import AppSettings, get_settings
7
- from backend.storage import SQLiteRunStore
 
 
8
 
9
  logger = logging.getLogger(__name__)
10
 
11
  run_store: Optional[SQLiteRunStore] = None
 
12
 
13
 
14
  def get_run_store() -> SQLiteRunStore:
@@ -17,11 +20,53 @@ def get_run_store() -> SQLiteRunStore:
17
  return run_store
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  async def initialize_run_store(settings: Optional[AppSettings] = None) -> None:
21
- global run_store
22
  resolved = settings or get_settings()
23
- store = SQLiteRunStore(resolved.db.path)
24
- await store.init()
25
- run_store = store
26
- logger.info("RunStore initialized")
 
 
 
 
 
27
 
 
 
4
  from typing import Optional
5
 
6
  from config.settings import AppSettings, get_settings
7
+ from backend.storage import SQLiteRunStore, SQLitePersonaStore
8
+ from backend.core.persona_seed import seed_defaults_overwrite
9
+ from backend.core.persona_system import get_persona_system
10
 
11
  logger = logging.getLogger(__name__)
12
 
13
  run_store: Optional[SQLiteRunStore] = None
14
+ persona_store: Optional[SQLitePersonaStore] = None
15
 
16
 
17
  def get_run_store() -> SQLiteRunStore:
 
20
  return run_store
21
 
22
 
23
+ def get_persona_store() -> SQLitePersonaStore:
24
+ if persona_store is None:
25
+ raise RuntimeError("PersonaStore not initialized")
26
+ return persona_store
27
+
28
+
29
+ async def seed_personas_and_settings(*, overwrite_defaults: bool = False) -> None:
30
+ store = get_persona_store()
31
+ if overwrite_defaults:
32
+ await seed_defaults_overwrite(store=store)
33
+ else:
34
+ # Best-effort: seed only if missing would require extra checks; keep overwrite behavior explicit.
35
+ await seed_defaults_overwrite(store=store)
36
+
37
+ await refresh_persona_cache()
38
+
39
+
40
+ async def refresh_persona_cache() -> None:
41
+ store = get_persona_store()
42
+ records = await store.list_persona_records(include_deleted=False)
43
+ personas: list = []
44
+ for rec in records:
45
+ personas.append(
46
+ {
47
+ "id": rec.persona_id,
48
+ "kind": rec.kind,
49
+ "name": rec.name,
50
+ "is_default": rec.is_default,
51
+ "version_id": rec.current_version_id,
52
+ "attributes": rec.attributes,
53
+ "question_bank_items": rec.question_bank_items,
54
+ }
55
+ )
56
+ get_persona_system().set_personas(personas)
57
+
58
+
59
  async def initialize_run_store(settings: Optional[AppSettings] = None) -> None:
60
+ global run_store, persona_store
61
  resolved = settings or get_settings()
62
+ db_path = resolved.db.path
63
+
64
+ rs = SQLiteRunStore(db_path)
65
+ await rs.init()
66
+ run_store = rs
67
+
68
+ ps = SQLitePersonaStore(db_path)
69
+ await ps.init()
70
+ persona_store = ps
71
 
72
+ logger.info("RunStore + PersonaStore initialized")
backend/core/conversation_manager.py CHANGED
@@ -30,7 +30,7 @@ from pathlib import Path
30
  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_attributes_overlay, compile_question_bank_overlay
35
  from backend.core.patient_knobs import compile_patient_attributes_overlay
36
  from backend.core.universal_prompts import DEFAULT_PATIENT_SYSTEM_PROMPT, DEFAULT_SURVEYOR_SYSTEM_PROMPT
@@ -104,7 +104,7 @@ class ConversationManager:
104
  llm_parameters: Additional keyword arguments for the LLM client
105
  """
106
  # Initialize systems
107
- self.persona_system = PersonaSystem()
108
  client_kwargs = {"host": host, "model": model}
109
  if llm_parameters:
110
  client_kwargs.update(llm_parameters)
 
30
  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 get_persona_system
34
  from backend.core.surveyor_knobs import compile_surveyor_attributes_overlay, compile_question_bank_overlay
35
  from backend.core.patient_knobs import compile_patient_attributes_overlay
36
  from backend.core.universal_prompts import DEFAULT_PATIENT_SYSTEM_PROMPT, DEFAULT_SURVEYOR_SYSTEM_PROMPT
 
104
  llm_parameters: Additional keyword arguments for the LLM client
105
  """
106
  # Initialize systems
107
+ self.persona_system = get_persona_system()
108
  client_kwargs = {"host": host, "model": model}
109
  if llm_parameters:
110
  client_kwargs.update(llm_parameters)
backend/core/default_personas.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List
4
+
5
+
6
+ DEFAULT_SURVEYOR_PERSONAS: List[Dict] = [
7
+ {
8
+ "persona_id": "professional_healthcare_001",
9
+ "kind": "surveyor",
10
+ "name": "Dr. Sarah Mitchell",
11
+ "attributes": [
12
+ "Senior healthcare survey specialist with 15 years of clinical research experience.",
13
+ "Professional, warm tone; patient and measured pace.",
14
+ "Use plain language first; explain medical terms briefly if needed.",
15
+ "Be empathetic; acknowledge emotions before moving on.",
16
+ "Ask exactly one question per turn; avoid multi-part questions.",
17
+ "Avoid giving medical advice; focus on gathering information.",
18
+ "If the patient seems confused, rephrase the question simply.",
19
+ ],
20
+ "question_bank_items": [
21
+ "Can you tell me about a recent healthcare appointment you had and what it was for?",
22
+ "Were you able to attend that appointment as planned? If not, what got in the way?",
23
+ "How easy or difficult was it to get the care you needed (scheduling, transportation, timing)?",
24
+ "Were your questions answered in a way you could understand?",
25
+ "Did you feel listened to and respected during the visit?",
26
+ "What is one thing that would have improved your experience?",
27
+ ],
28
+ },
29
+ {
30
+ "persona_id": "friendly_researcher_001",
31
+ "kind": "surveyor",
32
+ "name": "Alex Thompson",
33
+ "attributes": [
34
+ "Clinical research assistant conducting patient experience surveys.",
35
+ "Warm, approachable, encouraging tone; conversational style.",
36
+ "Avoid jargon; keep questions short and easy to answer.",
37
+ "Gently probe for specifics when answers are vague.",
38
+ "Ask exactly one question per turn; avoid multi-part questions.",
39
+ "Avoid giving medical advice; focus on listening and clarifying.",
40
+ ],
41
+ "question_bank_items": [
42
+ "What’s been going on with your health recently?",
43
+ "Can you describe a recent interaction you had with a doctor or clinic?",
44
+ "Was it easy or hard to get that care (scheduling, travel, cost, timing)?",
45
+ "Did you feel listened to and understood?",
46
+ "Were any instructions or next steps clear to you?",
47
+ "If you could change one thing about that experience, what would it be?",
48
+ ],
49
+ },
50
+ {
51
+ "persona_id": "professional_healthcare_002",
52
+ "kind": "surveyor",
53
+ "name": "Nurse Jordan Lee",
54
+ "attributes": [
55
+ "Nurse survey interviewer focused on care coordination and follow-up.",
56
+ "Efficient and structured while remaining empathetic.",
57
+ "Summarize briefly to confirm understanding before the next question.",
58
+ "Ask exactly one question per turn; avoid multi-part questions.",
59
+ "If the patient mentions a barrier, ask for one concrete example.",
60
+ "Avoid giving medical advice; focus on experience, access, and process.",
61
+ ],
62
+ "question_bank_items": [
63
+ "What type of care have you needed recently (check-up, follow-up, urgent issue, other)?",
64
+ "Were you able to get an appointment when you needed it?",
65
+ "Did anything make it difficult to follow through on care or instructions?",
66
+ "How well did different parts of your care feel coordinated (calls, referrals, follow-ups)?",
67
+ "Did you have the support you needed (family, clinic resources, community support)?",
68
+ "What is one practical change that would make care easier for you?",
69
+ ],
70
+ },
71
+ ]
72
+
73
+
74
+ DEFAULT_PATIENT_PERSONAS: List[Dict] = [
75
+ {
76
+ "persona_id": "cooperative_senior_001",
77
+ "kind": "patient",
78
+ "name": "Margaret Thompson",
79
+ "attributes": [
80
+ "72 years old, retired teacher.",
81
+ "Managing Type 2 diabetes and high blood pressure for years.",
82
+ "Friendly and cooperative; wants to help with the survey.",
83
+ "Sometimes needs medical terms repeated or clarified.",
84
+ "Tends to give direct answers plus a couple relevant details.",
85
+ "Worries about health occasionally but is generally doing okay.",
86
+ ],
87
+ "question_bank_items": [],
88
+ },
89
+ {
90
+ "persona_id": "anxious_parent_001",
91
+ "kind": "patient",
92
+ "name": "Jennifer Chen",
93
+ "attributes": [
94
+ "38 years old, working parent.",
95
+ "Primary concern is a 6-year-old child with recurring asthma.",
96
+ "Anxious and protective; somewhat skeptical of surveys.",
97
+ "Often asks why information is needed and how it will be used.",
98
+ "Can be defensive about care decisions but is not hostile.",
99
+ "Gives detailed answers when asked about the child's condition.",
100
+ ],
101
+ "question_bank_items": [],
102
+ },
103
+ {
104
+ "persona_id": "busy_professional_001",
105
+ "kind": "patient",
106
+ "name": "David Rodriguez",
107
+ "attributes": [
108
+ "45 years old financial executive with a demanding schedule.",
109
+ "Work-related stress; irregular sleep.",
110
+ "Occasional back pain from long sitting.",
111
+ "Pressed for time; gives brief, direct answers unless prompted.",
112
+ "Not rude, but can sound impatient; appreciates efficiency.",
113
+ ],
114
+ "question_bank_items": [],
115
+ },
116
+ ]
117
+
backend/core/persona_seed.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List
4
+
5
+ from backend.core.default_personas import DEFAULT_PATIENT_PERSONAS, DEFAULT_SURVEYOR_PERSONAS
6
+ from backend.core.universal_prompts import DEFAULT_PATIENT_SYSTEM_PROMPT, DEFAULT_SURVEYOR_SYSTEM_PROMPT
7
+ from backend.core.analysis_knobs import DEFAULT_ANALYSIS_ATTRIBUTES
8
+ from backend.storage.sqlite_persona_store import SQLitePersonaStore
9
+
10
+
11
+ async def seed_defaults_overwrite(*, store: SQLitePersonaStore) -> None:
12
+ # Universal prompts
13
+ await store.upsert_setting("surveyor_system_prompt", DEFAULT_SURVEYOR_SYSTEM_PROMPT)
14
+ await store.upsert_setting("patient_system_prompt", DEFAULT_PATIENT_SYSTEM_PROMPT)
15
+
16
+ # Analysis agent defaults are managed separately (config UI already supports apply defaults),
17
+ # but keep a stable key for future shared settings.
18
+ await store.upsert_setting("analysis_attributes", DEFAULT_ANALYSIS_ATTRIBUTES)
19
+
20
+ # Personas (overwrite by writing a new version each startup)
21
+ for p in (DEFAULT_SURVEYOR_PERSONAS + DEFAULT_PATIENT_PERSONAS):
22
+ await store.upsert_default_persona(
23
+ persona_id=p["persona_id"],
24
+ kind=p["kind"],
25
+ name=p["name"],
26
+ attributes=p.get("attributes") or [],
27
+ question_bank_items=p.get("question_bank_items") or [],
28
+ )
backend/core/persona_system.py CHANGED
@@ -22,7 +22,6 @@ Example:
22
 
23
  from __future__ import annotations
24
 
25
- import yaml
26
  from typing import Dict, List, Optional, Any, Tuple
27
  from pathlib import Path
28
  from datetime import datetime
@@ -119,47 +118,25 @@ class PersonaPromptBuilder:
119
  class PersonaSystem:
120
  """Main persona management system."""
121
 
122
- def __init__(self, personas_dir: Optional[Path] = None):
123
- """Initialize persona system.
124
-
125
- Args:
126
- personas_dir: Directory containing persona YAML files
127
  """
128
- if personas_dir is None:
129
- # Default to data directory
130
- personas_dir = Path(__file__).parent.parent.parent / "data"
131
-
132
- self.personas_dir = Path(personas_dir)
133
  self.personas: Dict[str, Dict[str, Any]] = {}
134
  self.prompt_builder = PersonaPromptBuilder()
135
-
136
- # Load personas
137
- self._load_personas()
138
-
139
- def _load_personas(self):
140
- """Load all persona definitions from YAML files."""
141
- if not self.personas_dir.exists():
142
- logger.warning(f"Personas directory not found: {self.personas_dir}")
143
- return
144
-
145
- for yaml_file in self.personas_dir.glob("*.yaml"):
146
- try:
147
- with open(yaml_file, 'r') as f:
148
- data = yaml.safe_load(f)
149
-
150
- if "personas" in data:
151
- for persona in data["personas"]:
152
- persona_id = persona.get("id")
153
- if persona_id:
154
- self.personas[persona_id] = persona
155
- logger.info(f"Loaded persona: {persona_id}")
156
- else:
157
- logger.warning(f"Persona missing ID in {yaml_file}")
158
-
159
- except Exception as e:
160
- logger.error(f"Failed to load personas from {yaml_file}: {e}")
161
-
162
- logger.info(f"Loaded {len(self.personas)} personas total")
163
 
164
  def get_persona(self, persona_id: str) -> Optional[Dict[str, Any]]:
165
  """Get persona by ID.
@@ -327,10 +304,8 @@ class PersonaSystem:
327
  return 1.0 # Default delay
328
 
329
  def reload_personas(self):
330
- """Reload persona definitions from files."""
331
- self.personas.clear()
332
- self._load_personas()
333
- logger.info("Personas reloaded")
334
 
335
 
336
  # Global persona system instance
 
22
 
23
  from __future__ import annotations
24
 
 
25
  from typing import Dict, List, Optional, Any, Tuple
26
  from pathlib import Path
27
  from datetime import datetime
 
118
  class PersonaSystem:
119
  """Main persona management system."""
120
 
121
+ def __init__(self):
122
+ """Initialize persona system (cache-only).
123
+
124
+ Personas are loaded from the DB-backed persona store during app startup.
 
125
  """
 
 
 
 
 
126
  self.personas: Dict[str, Dict[str, Any]] = {}
127
  self.prompt_builder = PersonaPromptBuilder()
128
+
129
+ def set_personas(self, personas: List[Dict[str, Any]]) -> None:
130
+ """Replace the in-memory persona cache with the provided records."""
131
+ cache: Dict[str, Dict[str, Any]] = {}
132
+ for persona in personas or []:
133
+ if not isinstance(persona, dict):
134
+ continue
135
+ pid = persona.get("id")
136
+ if isinstance(pid, str) and pid.strip():
137
+ cache[pid] = persona
138
+ self.personas = cache
139
+ logger.info(f"Persona cache updated: {len(self.personas)} personas")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
  def get_persona(self, persona_id: str) -> Optional[Dict[str, Any]]:
142
  """Get persona by ID.
 
304
  return 1.0 # Default delay
305
 
306
  def reload_personas(self):
307
+ """Deprecated: runtime personas are loaded from DB-backed store on startup."""
308
+ logger.info("reload_personas() is a no-op; personas are DB-backed")
 
 
309
 
310
 
311
  # Global persona system instance
backend/storage/__init__.py CHANGED
@@ -1,5 +1,12 @@
1
- from .models import RunRecord, RunSummary
 
2
  from .sqlite_run_store import SQLiteRunStore
3
 
4
- __all__ = ["RunRecord", "RunSummary", "SQLiteRunStore"]
5
-
 
 
 
 
 
 
 
1
+ from .models import RunRecord, RunSummary, PersonaRecord, PersonaSummary
2
+ from .sqlite_persona_store import SQLitePersonaStore
3
  from .sqlite_run_store import SQLiteRunStore
4
 
5
+ __all__ = [
6
+ "RunRecord",
7
+ "RunSummary",
8
+ "PersonaRecord",
9
+ "PersonaSummary",
10
+ "SQLiteRunStore",
11
+ "SQLitePersonaStore",
12
+ ]
backend/storage/models.py CHANGED
@@ -30,3 +30,25 @@ class RunRecord:
30
  title: Optional[str] = None
31
  input_summary: Optional[str] = None
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  title: Optional[str] = None
31
  input_summary: Optional[str] = None
32
 
33
+
34
+ @dataclass(frozen=True)
35
+ class PersonaSummary:
36
+ persona_id: str
37
+ kind: str
38
+ name: str
39
+ is_default: bool = False
40
+ is_deleted: bool = False
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class PersonaRecord:
45
+ persona_id: str
46
+ kind: str
47
+ name: str
48
+ is_default: bool
49
+ is_deleted: bool
50
+ current_version_id: str
51
+ created_at: str
52
+ updated_at: str
53
+ attributes: List[str]
54
+ question_bank_items: List[str]
backend/storage/sqlite_persona_store.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+ from uuid import uuid4
9
+
10
+ import aiosqlite
11
+
12
+ from .models import PersonaRecord, PersonaSummary
13
+
14
+
15
+ def _now_iso() -> str:
16
+ return datetime.now().isoformat()
17
+
18
+
19
+ def _clean_str_list(value: Any) -> List[str]:
20
+ if not isinstance(value, list):
21
+ return []
22
+ out: List[str] = []
23
+ for item in value:
24
+ if isinstance(item, str) and item.strip():
25
+ out.append(item.strip())
26
+ return out
27
+
28
+
29
+ class SQLitePersonaStore:
30
+ def __init__(self, db_path: str):
31
+ self.db_path = str(db_path)
32
+
33
+ async def init(self) -> None:
34
+ Path(self.db_path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
35
+ async with aiosqlite.connect(self.db_path) as db:
36
+ await db.execute("PRAGMA foreign_keys=ON;")
37
+
38
+ await db.execute(
39
+ """
40
+ CREATE TABLE IF NOT EXISTS personas (
41
+ persona_id TEXT PRIMARY KEY,
42
+ kind TEXT NOT NULL,
43
+ name TEXT NOT NULL,
44
+ is_default INTEGER NOT NULL DEFAULT 0,
45
+ is_deleted INTEGER NOT NULL DEFAULT 0,
46
+ current_version_id TEXT NOT NULL,
47
+ created_at TEXT NOT NULL,
48
+ updated_at TEXT NOT NULL
49
+ );
50
+ """
51
+ )
52
+ await db.execute(
53
+ "CREATE INDEX IF NOT EXISTS personas_kind ON personas(kind);"
54
+ )
55
+ await db.execute(
56
+ "CREATE INDEX IF NOT EXISTS personas_is_default ON personas(is_default);"
57
+ )
58
+ await db.execute(
59
+ """
60
+ CREATE TABLE IF NOT EXISTS persona_versions (
61
+ persona_id TEXT NOT NULL,
62
+ version_id TEXT NOT NULL,
63
+ created_at TEXT NOT NULL,
64
+ content_json TEXT NOT NULL,
65
+ PRIMARY KEY (persona_id, version_id),
66
+ FOREIGN KEY (persona_id) REFERENCES personas(persona_id) ON DELETE CASCADE
67
+ );
68
+ """
69
+ )
70
+ await db.execute(
71
+ """
72
+ CREATE TABLE IF NOT EXISTS app_settings (
73
+ key TEXT PRIMARY KEY,
74
+ value_json TEXT NOT NULL,
75
+ updated_at TEXT NOT NULL
76
+ );
77
+ """
78
+ )
79
+ await db.commit()
80
+
81
+ async def upsert_setting(self, key: str, value: Any) -> None:
82
+ if not isinstance(key, str) or not key.strip():
83
+ raise ValueError("key is required")
84
+ updated_at = _now_iso()
85
+ payload = json.dumps(value, ensure_ascii=False)
86
+ async with aiosqlite.connect(self.db_path) as db:
87
+ await db.execute("PRAGMA foreign_keys=ON;")
88
+ await db.execute(
89
+ """
90
+ INSERT INTO app_settings (key, value_json, updated_at)
91
+ VALUES (?, ?, ?)
92
+ ON CONFLICT(key) DO UPDATE SET
93
+ value_json=excluded.value_json,
94
+ updated_at=excluded.updated_at;
95
+ """,
96
+ (key, payload, updated_at),
97
+ )
98
+ await db.commit()
99
+
100
+ async def get_setting(self, key: str) -> Optional[Any]:
101
+ if not isinstance(key, str) or not key.strip():
102
+ return None
103
+ async with aiosqlite.connect(self.db_path) as db:
104
+ await db.execute("PRAGMA foreign_keys=ON;")
105
+ cur = await db.execute(
106
+ "SELECT value_json FROM app_settings WHERE key = ?;",
107
+ (key,),
108
+ )
109
+ row = await cur.fetchone()
110
+ if not row:
111
+ return None
112
+ try:
113
+ return json.loads(row[0])
114
+ except Exception:
115
+ return None
116
+
117
+ async def list_personas(
118
+ self,
119
+ *,
120
+ kind: Optional[str] = None,
121
+ include_deleted: bool = False,
122
+ ) -> List[PersonaSummary]:
123
+ async with aiosqlite.connect(self.db_path) as db:
124
+ await db.execute("PRAGMA foreign_keys=ON;")
125
+ where: List[str] = []
126
+ args: List[Any] = []
127
+ if kind:
128
+ where.append("kind = ?")
129
+ args.append(kind)
130
+ if not include_deleted:
131
+ where.append("is_deleted = 0")
132
+ clause = ("WHERE " + " AND ".join(where)) if where else ""
133
+ cur = await db.execute(
134
+ f"""
135
+ SELECT persona_id, kind, name, is_default, is_deleted
136
+ FROM personas
137
+ {clause}
138
+ ORDER BY is_default DESC, name ASC;
139
+ """,
140
+ tuple(args),
141
+ )
142
+ rows = await cur.fetchall()
143
+ return [
144
+ PersonaSummary(
145
+ persona_id=row[0],
146
+ kind=row[1],
147
+ name=row[2],
148
+ is_default=bool(row[3]),
149
+ is_deleted=bool(row[4]),
150
+ )
151
+ for row in rows
152
+ ]
153
+
154
+ async def list_persona_records(
155
+ self,
156
+ *,
157
+ kind: Optional[str] = None,
158
+ include_deleted: bool = False,
159
+ ) -> List[PersonaRecord]:
160
+ async with aiosqlite.connect(self.db_path) as db:
161
+ await db.execute("PRAGMA foreign_keys=ON;")
162
+ where: List[str] = []
163
+ args: List[Any] = []
164
+ if kind:
165
+ where.append("p.kind = ?")
166
+ args.append(kind)
167
+ if not include_deleted:
168
+ where.append("p.is_deleted = 0")
169
+ clause = ("WHERE " + " AND ".join(where)) if where else ""
170
+ cur = await db.execute(
171
+ f"""
172
+ SELECT
173
+ p.persona_id, p.kind, p.name, p.is_default, p.is_deleted,
174
+ p.current_version_id, p.created_at, p.updated_at,
175
+ v.content_json
176
+ FROM personas p
177
+ JOIN persona_versions v
178
+ ON v.persona_id = p.persona_id AND v.version_id = p.current_version_id
179
+ {clause}
180
+ ORDER BY p.is_default DESC, p.name ASC;
181
+ """,
182
+ tuple(args),
183
+ )
184
+ rows = await cur.fetchall()
185
+
186
+ out: List[PersonaRecord] = []
187
+ for row in rows:
188
+ content: Dict[str, Any] = {}
189
+ if isinstance(row[8], str):
190
+ try:
191
+ content = json.loads(row[8]) or {}
192
+ except Exception:
193
+ content = {}
194
+ out.append(
195
+ PersonaRecord(
196
+ persona_id=row[0],
197
+ kind=row[1],
198
+ name=row[2],
199
+ is_default=bool(row[3]),
200
+ is_deleted=bool(row[4]),
201
+ current_version_id=row[5],
202
+ created_at=row[6],
203
+ updated_at=row[7],
204
+ attributes=_clean_str_list(content.get("attributes")),
205
+ question_bank_items=_clean_str_list(content.get("question_bank_items")),
206
+ )
207
+ )
208
+ return out
209
+
210
+ async def get_persona(self, persona_id: str, *, include_deleted: bool = False) -> Optional[PersonaRecord]:
211
+ if not isinstance(persona_id, str) or not persona_id.strip():
212
+ return None
213
+ async with aiosqlite.connect(self.db_path) as db:
214
+ await db.execute("PRAGMA foreign_keys=ON;")
215
+ cur = await db.execute(
216
+ """
217
+ SELECT persona_id, kind, name, is_default, is_deleted, current_version_id, created_at, updated_at
218
+ FROM personas
219
+ WHERE persona_id = ?;
220
+ """,
221
+ (persona_id,),
222
+ )
223
+ row = await cur.fetchone()
224
+ if not row:
225
+ return None
226
+ if (not include_deleted) and bool(row[4]):
227
+ return None
228
+ current_version_id = row[5]
229
+ cur = await db.execute(
230
+ """
231
+ SELECT content_json
232
+ FROM persona_versions
233
+ WHERE persona_id = ? AND version_id = ?;
234
+ """,
235
+ (persona_id, current_version_id),
236
+ )
237
+ vrow = await cur.fetchone()
238
+ content: Dict[str, Any] = {}
239
+ if vrow and isinstance(vrow[0], str):
240
+ try:
241
+ content = json.loads(vrow[0]) or {}
242
+ except Exception:
243
+ content = {}
244
+ attrs = _clean_str_list(content.get("attributes"))
245
+ qb = _clean_str_list(content.get("question_bank_items"))
246
+ return PersonaRecord(
247
+ persona_id=row[0],
248
+ kind=row[1],
249
+ name=row[2],
250
+ is_default=bool(row[3]),
251
+ is_deleted=bool(row[4]),
252
+ current_version_id=row[5],
253
+ created_at=row[6],
254
+ updated_at=row[7],
255
+ attributes=attrs,
256
+ question_bank_items=qb,
257
+ )
258
+
259
+ async def create_persona(
260
+ self,
261
+ *,
262
+ persona_id: Optional[str] = None,
263
+ kind: str,
264
+ name: str,
265
+ attributes: Optional[List[str]] = None,
266
+ question_bank_items: Optional[List[str]] = None,
267
+ is_default: bool = False,
268
+ ) -> Tuple[str, str]:
269
+ if kind not in ("surveyor", "patient"):
270
+ raise ValueError("kind must be 'surveyor' or 'patient'")
271
+ if not isinstance(name, str) or not name.strip():
272
+ raise ValueError("name is required")
273
+ if persona_id is not None:
274
+ if not isinstance(persona_id, str) or not persona_id.strip():
275
+ raise ValueError("persona_id must be a non-empty string when provided")
276
+ persona_id = persona_id.strip()
277
+ else:
278
+ persona_id = str(uuid4())
279
+ version_id = str(uuid4())
280
+ created_at = _now_iso()
281
+ updated_at = created_at
282
+ content = {
283
+ "attributes": _clean_str_list(attributes),
284
+ "question_bank_items": _clean_str_list(question_bank_items) if kind == "surveyor" else [],
285
+ }
286
+ async with aiosqlite.connect(self.db_path) as db:
287
+ await db.execute("PRAGMA foreign_keys=ON;")
288
+ await db.execute("BEGIN;")
289
+ try:
290
+ await db.execute(
291
+ """
292
+ INSERT INTO personas (
293
+ persona_id, kind, name, is_default, is_deleted, current_version_id, created_at, updated_at
294
+ ) VALUES (?, ?, ?, ?, 0, ?, ?, ?);
295
+ """,
296
+ (persona_id, kind, name.strip(), 1 if is_default else 0, version_id, created_at, updated_at),
297
+ )
298
+ await db.execute(
299
+ """
300
+ INSERT INTO persona_versions (persona_id, version_id, created_at, content_json)
301
+ VALUES (?, ?, ?, ?);
302
+ """,
303
+ (persona_id, version_id, created_at, json.dumps(content, ensure_ascii=False)),
304
+ )
305
+ await db.commit()
306
+ except Exception:
307
+ await db.execute("ROLLBACK;")
308
+ raise
309
+ return persona_id, version_id
310
+
311
+ async def upsert_default_persona(
312
+ self,
313
+ *,
314
+ persona_id: str,
315
+ kind: str,
316
+ name: str,
317
+ attributes: List[str],
318
+ question_bank_items: List[str],
319
+ ) -> str:
320
+ if kind not in ("surveyor", "patient"):
321
+ raise ValueError("kind must be 'surveyor' or 'patient'")
322
+ if not isinstance(persona_id, str) or not persona_id.strip():
323
+ raise ValueError("persona_id is required")
324
+ if not isinstance(name, str) or not name.strip():
325
+ raise ValueError("name is required")
326
+
327
+ persona_id = persona_id.strip()
328
+ version_id = str(uuid4())
329
+ now = _now_iso()
330
+ content = {
331
+ "attributes": _clean_str_list(attributes),
332
+ "question_bank_items": _clean_str_list(question_bank_items) if kind == "surveyor" else [],
333
+ }
334
+
335
+ async with aiosqlite.connect(self.db_path) as db:
336
+ await db.execute("PRAGMA foreign_keys=ON;")
337
+ await db.execute("BEGIN;")
338
+ try:
339
+ cur = await db.execute(
340
+ "SELECT persona_id FROM personas WHERE persona_id = ?;",
341
+ (persona_id,),
342
+ )
343
+ row = await cur.fetchone()
344
+ if row:
345
+ await db.execute(
346
+ """
347
+ INSERT INTO persona_versions (persona_id, version_id, created_at, content_json)
348
+ VALUES (?, ?, ?, ?);
349
+ """,
350
+ (persona_id, version_id, now, json.dumps(content, ensure_ascii=False)),
351
+ )
352
+ await db.execute(
353
+ """
354
+ UPDATE personas
355
+ SET kind = ?, name = ?, is_default = 1, is_deleted = 0, current_version_id = ?, updated_at = ?
356
+ WHERE persona_id = ?;
357
+ """,
358
+ (kind, name.strip(), version_id, now, persona_id),
359
+ )
360
+ else:
361
+ await db.execute(
362
+ """
363
+ INSERT INTO personas (
364
+ persona_id, kind, name, is_default, is_deleted, current_version_id, created_at, updated_at
365
+ ) VALUES (?, ?, ?, 1, 0, ?, ?, ?);
366
+ """,
367
+ (persona_id, kind, name.strip(), version_id, now, now),
368
+ )
369
+ await db.execute(
370
+ """
371
+ INSERT INTO persona_versions (persona_id, version_id, created_at, content_json)
372
+ VALUES (?, ?, ?, ?);
373
+ """,
374
+ (persona_id, version_id, now, json.dumps(content, ensure_ascii=False)),
375
+ )
376
+ await db.commit()
377
+ except Exception:
378
+ await db.execute("ROLLBACK;")
379
+ raise
380
+ return version_id
381
+
382
+ async def update_persona(
383
+ self,
384
+ *,
385
+ persona_id: str,
386
+ name: Optional[str] = None,
387
+ attributes: Optional[List[str]] = None,
388
+ question_bank_items: Optional[List[str]] = None,
389
+ overwrite_defaults: bool = False,
390
+ ) -> str:
391
+ record = await self.get_persona(persona_id, include_deleted=True)
392
+ if not record:
393
+ raise ValueError("persona not found")
394
+ if record.is_default and not overwrite_defaults:
395
+ raise PermissionError("default persona is immutable")
396
+ version_id = str(uuid4())
397
+ created_at = _now_iso()
398
+ updated_at = created_at
399
+ next_name = (name.strip() if isinstance(name, str) and name.strip() else record.name)
400
+ content = {
401
+ "attributes": _clean_str_list(attributes) if attributes is not None else record.attributes,
402
+ "question_bank_items": _clean_str_list(question_bank_items) if record.kind == "surveyor" and question_bank_items is not None else record.question_bank_items,
403
+ }
404
+ async with aiosqlite.connect(self.db_path) as db:
405
+ await db.execute("PRAGMA foreign_keys=ON;")
406
+ await db.execute("BEGIN;")
407
+ try:
408
+ await db.execute(
409
+ """
410
+ INSERT INTO persona_versions (persona_id, version_id, created_at, content_json)
411
+ VALUES (?, ?, ?, ?);
412
+ """,
413
+ (persona_id, version_id, created_at, json.dumps(content, ensure_ascii=False)),
414
+ )
415
+ await db.execute(
416
+ """
417
+ UPDATE personas
418
+ SET name = ?, current_version_id = ?, updated_at = ?, is_deleted = 0
419
+ WHERE persona_id = ?;
420
+ """,
421
+ (next_name, version_id, updated_at, persona_id),
422
+ )
423
+ await db.commit()
424
+ except Exception:
425
+ await db.execute("ROLLBACK;")
426
+ raise
427
+ return version_id
428
+
429
+ async def soft_delete_persona(self, persona_id: str) -> None:
430
+ record = await self.get_persona(persona_id, include_deleted=True)
431
+ if not record:
432
+ raise ValueError("persona not found")
433
+ if record.is_default:
434
+ raise PermissionError("default persona is immutable")
435
+ async with aiosqlite.connect(self.db_path) as db:
436
+ await db.execute("PRAGMA foreign_keys=ON;")
437
+ await db.execute(
438
+ "UPDATE personas SET is_deleted = 1, updated_at = ? WHERE persona_id = ?;",
439
+ (_now_iso(), persona_id),
440
+ )
441
+ await db.commit()
data/patient_personas.yaml DELETED
@@ -1,48 +0,0 @@
1
- # Default Patient Personas (v2: attributes-only)
2
- #
3
- # These are immutable baseline personas shipped with the app.
4
- # Persona behavior is defined by:
5
- # - a universal patient system prompt (type-wide)
6
- # - persona-specific attributes
7
- #
8
- # No persona-specific system_prompt exists in v2 defaults.
9
-
10
- personas:
11
- - id: "cooperative_senior_001"
12
- kind: "patient"
13
- is_default: true
14
- name: "Margaret Thompson"
15
- description: "Friendly senior; cooperative; sometimes needs clarification."
16
- attributes:
17
- - "72 years old, retired teacher."
18
- - "Managing Type 2 diabetes and high blood pressure for years."
19
- - "Friendly and cooperative; wants to help with the survey."
20
- - "Sometimes needs medical terms repeated or clarified."
21
- - "Tends to give direct answers plus a couple relevant details."
22
- - "Worries about health occasionally but is generally doing okay."
23
-
24
- - id: "anxious_parent_001"
25
- kind: "patient"
26
- is_default: true
27
- name: "Jennifer Chen"
28
- description: "Anxious parent; detail-seeking; skeptical but not hostile."
29
- attributes:
30
- - "38 years old, working parent."
31
- - "Primary concern is a 6-year-old child with recurring asthma."
32
- - "Anxious and protective; somewhat skeptical of surveys."
33
- - "Often asks why information is needed and how it will be used."
34
- - "Can be defensive about care decisions but is not hostile."
35
- - "Gives detailed answers when asked about the child's condition."
36
-
37
- - id: "busy_professional_001"
38
- kind: "patient"
39
- is_default: true
40
- name: "David Rodriguez"
41
- description: "Busy professional; brief answers; time-conscious."
42
- attributes:
43
- - "45 years old financial executive with a demanding schedule."
44
- - "Work-related stress; irregular sleep."
45
- - "Occasional back pain from long sitting."
46
- - "Pressed for time; gives brief, direct answers unless prompted."
47
- - "Not rude, but can sound impatient; appreciates efficiency."
48
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
data/surveyor_personas.yaml DELETED
@@ -1,70 +0,0 @@
1
- # Default Surveyor Personas (v2: attributes-only)
2
- #
3
- # These are immutable baseline personas shipped with the app.
4
- # Persona behavior is defined by:
5
- # - a universal surveyor system prompt (type-wide)
6
- # - persona-specific attributes (+ optional question bank)
7
- #
8
- # No persona-specific system_prompt exists in v2 defaults.
9
-
10
- personas:
11
- - id: "professional_healthcare_001"
12
- kind: "surveyor"
13
- is_default: true
14
- name: "Dr. Sarah Mitchell"
15
- description: "Professional, warm, clinically fluent."
16
- attributes:
17
- - "Senior healthcare survey specialist with 15 years of clinical research experience."
18
- - "Professional, warm tone; patient and measured pace."
19
- - "Use plain language first; explain medical terms briefly if needed."
20
- - "Be empathetic; acknowledge emotions before moving on."
21
- - "Ask exactly one question per turn; avoid multi-part questions."
22
- - "Avoid giving medical advice; focus on gathering information."
23
- - "If the patient seems confused, rephrase the question simply."
24
- question_bank_items:
25
- - "Can you tell me about a recent healthcare appointment you had and what it was for?"
26
- - "Were you able to attend that appointment as planned? If not, what got in the way?"
27
- - "How easy or difficult was it to get the care you needed (scheduling, transportation, timing)?"
28
- - "Were your questions answered in a way you could understand?"
29
- - "Did you feel listened to and respected during the visit?"
30
- - "What is one thing that would have improved your experience?"
31
-
32
- - id: "friendly_researcher_001"
33
- kind: "surveyor"
34
- is_default: true
35
- name: "Alex Thompson"
36
- description: "Friendly, encouraging, plain-language interviewer."
37
- attributes:
38
- - "Clinical research assistant conducting patient experience surveys."
39
- - "Warm, approachable, encouraging tone; conversational style."
40
- - "Avoid jargon; keep questions short and easy to answer."
41
- - "Gently probe for specifics when answers are vague."
42
- - "Ask exactly one question per turn; avoid multi-part questions."
43
- - "Avoid giving medical advice; focus on listening and clarifying."
44
- question_bank_items:
45
- - "What’s been going on with your health recently?"
46
- - "Can you describe a recent interaction you had with a doctor or clinic?"
47
- - "Was it easy or hard to get that care (scheduling, travel, cost, timing)?"
48
- - "Did you feel listened to and understood?"
49
- - "Were any instructions or next steps clear to you?"
50
- - "If you could change one thing about that experience, what would it be?"
51
-
52
- - id: "professional_healthcare_002"
53
- kind: "surveyor"
54
- is_default: true
55
- name: "Nurse Jordan Lee"
56
- description: "Efficient, structured, empathetic."
57
- attributes:
58
- - "Nurse survey interviewer focused on care coordination and follow-up."
59
- - "Efficient and structured while remaining empathetic."
60
- - "Summarize briefly to confirm understanding before the next question."
61
- - "Ask exactly one question per turn; avoid multi-part questions."
62
- - "If the patient mentions a barrier, ask for one concrete example."
63
- - "Avoid giving medical advice; focus on experience, access, and process."
64
- question_bank_items:
65
- - "What type of care have you needed recently (check-up, follow-up, urgent issue, other)?"
66
- - "Were you able to get an appointment when you needed it?"
67
- - "Did anything make it difficult to follow through on care or instructions?"
68
- - "How well did different parts of your care feel coordinated (calls, referrals, follow-ups)?"
69
- - "Did you have the support you needed (family, clinic resources, community support)?"
70
- - "What is one practical change that would make care easier for you?"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/development.md CHANGED
@@ -64,7 +64,6 @@ The UI includes a **Configuration** view used to edit persona-specific settings
64
 
65
  ## Making Changes Safely
66
 
67
- - Prefer editing personas via YAML (`data/`) and restart the backend to reload.
68
  - All configuration flows through `config/settings.py`; add new settings there and reference them via `get_settings()`.
69
  - When adding LLM providers, implement a new client in `backend/core/llm_client.py` and hook it into the existing factory.
70
  - Keep WebSocket message schemas stable (`backend/api/conversation_ws.py`); update both backend and frontend consumers if you change them.
@@ -74,8 +73,7 @@ The UI includes a **Configuration** view used to edit persona-specific settings
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: edit **Surveyors Attributes** and/or **Question bank** in **Configuration** (for a given surveyor persona), then select that surveyor persona in **AI-to-AI** and run a session. Confirm the completed run’s `config.personas.surveyor_attributes` and `config.personas.surveyor_question_bank` are present in `/api/runs/{run_id}`.
78
- - For question bank (surveyor): after selecting a surveyor persona that has a question bank configured, 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
 
81
  ## Notes on Persistence (HF)
 
64
 
65
  ## Making Changes Safely
66
 
 
67
  - All configuration flows through `config/settings.py`; add new settings there and reference them via `get_settings()`.
68
  - When adding LLM providers, implement a new client in `backend/core/llm_client.py` and hook it into the existing factory.
69
  - Keep WebSocket message schemas stable (`backend/api/conversation_ws.py`); update both backend and frontend consumers if you change them.
 
73
  - No automated test suite yet. Add lightweight `pytest` modules under `tests/` as you extend functionality.
74
  - Manually verify through the primary web UI (`frontend/react_gradio_hybrid.py`).
75
  - For persistence + history: complete a run, confirm it appears in **History**, restart the container, and confirm it still appears and exports download.
76
+ - For persona config: edit the shared system prompt and/or a user persona in **Configuration**, click **Save**, and confirm the change is reflected in subsequent runs.
 
77
  - If you need to debug the conversation loop, instrument `backend/core/conversation_manager.py` or launch a shell and run it directly.
78
 
79
  ## Notes on Persistence (HF)
docs/overview.md CHANGED
@@ -19,8 +19,9 @@ The AI Survey Simulator orchestrates AI-to-AI healthcare survey conversations so
19
  - **LLM Backend (Ollama by default)**
20
  The backend uses `LLM_HOST`/`LLM_MODEL` from `.env` to reach a local Ollama server. Other providers can be integrated by extending `llm_client.py`.
21
 
22
- - **Data Assets (`data/`)**
23
- Persona definitions live in YAML files (`patient_personas.yaml`, `surveyor_personas.yaml`). Update these to add or refine personas.
 
24
 
25
  ## Runtime Flow
26
 
@@ -53,10 +54,10 @@ These are forwarded across the `/ws/frontend/...` bridge to the backend socket.
53
 
54
  The UI includes a **Configuration** view (same page, no reload) that lets you:
55
 
56
- - Select surveyor + patient personas (loaded from `GET /api/personas`)
57
- - Add optional prompt additions for each role (sent with `start_conversation`)
58
 
59
- These settings are currently stored in the browser (local-only) and apply to the next run.
60
 
61
  ## Export & Persistence Notes
62
 
@@ -93,9 +94,6 @@ frontend/
93
  gradio_app.py # legacy/optional local UI
94
  react_gradio_hybrid.py # primary demo UI (web)
95
  websocket_manager.py
96
- data/
97
- patient_personas.yaml
98
- surveyor_personas.yaml
99
  config/
100
  settings.py # Shared configuration loader
101
  .env.example
 
19
  - **LLM Backend (Ollama by default)**
20
  The backend uses `LLM_HOST`/`LLM_MODEL` from `.env` to reach a local Ollama server. Other providers can be integrated by extending `llm_client.py`.
21
 
22
+ - **Persistence (`DB_PATH`)**
23
+ Sealed runs and app-wide configuration (personas + shared prompts/settings) are stored in SQLite at `DB_PATH` (recommended on HF: under `/data`).
24
+ Default personas are seeded on startup.
25
 
26
  ## Runtime Flow
27
 
 
54
 
55
  The UI includes a **Configuration** view (same page, no reload) that lets you:
56
 
57
+ - Edit app-wide surveyor/patient system prompts and analysis attributes (`GET/PUT /api/settings`)
58
+ - Create/edit/delete user personas (`GET/POST/PUT/DELETE /api/personas`)
59
 
60
+ Persona selection for actually running conversations is done in the **AI-to-AI** and **Human-to-AI** panels.
61
 
62
  ## Export & Persistence Notes
63
 
 
94
  gradio_app.py # legacy/optional local UI
95
  react_gradio_hybrid.py # primary demo UI (web)
96
  websocket_manager.py
 
 
 
97
  config/
98
  settings.py # Shared configuration loader
99
  .env.example
docs/persistence.md CHANGED
@@ -278,16 +278,16 @@ End-of-analysis save flow:
278
 
279
  ### Personas
280
 
281
- - `GET /api/personas` (already exists for current YAML personas; will evolve to use DB + defaults)
282
  - `POST /api/personas`
283
  - `PUT /api/personas/{persona_id}` (creates a new version)
284
  - `DELETE /api/personas/{persona_id}` (soft delete)
285
  - (optional) `GET /api/personas/{persona_id}/versions`
286
 
287
- Important: keep existing YAML personas as “defaults”:
288
 
289
- - On first run, load YAML defaults if DB is empty (or treat YAML as seed data).
290
- - DB becomes the writable source; YAML remains the baseline defaults.
291
 
292
  ---
293
 
 
278
 
279
  ### Personas
280
 
281
+ - `GET /api/personas`
282
  - `POST /api/personas`
283
  - `PUT /api/personas/{persona_id}` (creates a new version)
284
  - `DELETE /api/personas/{persona_id}` (soft delete)
285
  - (optional) `GET /api/personas/{persona_id}/versions`
286
 
287
+ Defaults:
288
 
289
+ - Default personas are seeded by the backend on startup.
290
+ - The DB is the runtime source of truth for both defaults and user-created personas.
291
 
292
  ---
293
 
docs/persona-knobs.md CHANGED
@@ -1,4 +1,4 @@
1
- # Persona Controls (v2) — Surveyor + Patient (local-first)
2
 
3
  This document defines the **core, behavior-changing configuration controls** we support for personas.
4
 
@@ -14,7 +14,7 @@ The guiding principle is: **every UI control must have a guaranteed effect** on
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
 
@@ -22,7 +22,7 @@ For v2, we:
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
 
@@ -43,7 +43,6 @@ Personas can express style *within* these constraints, but do not change the con
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:
@@ -64,7 +63,6 @@ Patient structured controls are intentionally minimal for now; the synthetic pat
64
 
65
  ### Identity
66
  - `name` (required): display name.
67
- - `description` (optional): short one-liner shown in selectors.
68
 
69
  ### Attributes (plain bullet lines)
70
  A list of short rules the patient should follow during the session, compiled into the patient prompt so the control always matters.
 
1
+ # Persona Controls (v2) — Surveyor + Patient
2
 
3
  This document defines the **core, behavior-changing configuration controls** we support for personas.
4
 
 
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 structured fields do not materially affect generation unless they are duplicated into that prompt.
18
 
19
  For v2, we:
20
 
 
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 structured field first-class in the UI.
26
 
27
  ---
28
 
 
43
 
44
  ### Identity
45
  - `name` (required): display name.
 
46
 
47
  ### Attributes (plain bullet lines)
48
  A list of short, testable rules, for example:
 
63
 
64
  ### Identity
65
  - `name` (required): display name.
 
66
 
67
  ### Attributes (plain bullet lines)
68
  A list of short rules the patient should follow during the session, compiled into the patient prompt so the control always matters.
docs/roadmap.md CHANGED
@@ -10,7 +10,7 @@ _Last updated: 2026-01-22_
10
  - Bottom-up findings (emergent themes)
11
  - Top-down coding (care experience rubric + codebook categories)
12
  - FastAPI backend with conversation management service
13
- - Personas defined via YAML and loaded dynamically
14
  - Ollama integration with fallback to `/api/generate`
15
  - Hosted LLM support via OpenRouter (`LLM_BACKEND=openrouter`)
16
  - Hugging Face Spaces (Docker) deployment
 
10
  - Bottom-up findings (emergent themes)
11
  - Top-down coding (care experience rubric + codebook categories)
12
  - FastAPI backend with conversation management service
13
+ - Personas persisted in DB (seeded defaults + user-created)
14
  - Ollama integration with fallback to `/api/generate`
15
  - Hosted LLM support via OpenRouter (`LLM_BACKEND=openrouter`)
16
  - Hugging Face Spaces (Docker) deployment
frontend/pages/config_view.py CHANGED
@@ -51,30 +51,50 @@ You will also be given persona-specific attributes.`;
51
  const [surveyorQuestionItems, setSurveyorQuestionItems] = React.useState([]);
52
  const [surveyorAttributeItems, setSurveyorAttributeItems] = React.useState([]);
53
  const [patientAttributeItems, setPatientAttributeItems] = React.useState([]);
54
- const [surveyorSystemPrompt, setSurveyorSystemPrompt] = React.useState(() => {
55
- const store = (existing && existing.system_prompts && typeof existing.system_prompts === 'object') ? existing.system_prompts : {};
56
- return (typeof store.surveyor === 'string') ? store.surveyor : '';
57
  });
58
  const [patientSystemPrompt, setPatientSystemPrompt] = React.useState(() => {
59
- const store = (existing && existing.system_prompts && typeof existing.system_prompts === 'object') ? existing.system_prompts : {};
60
- return (typeof store.patient === 'string') ? store.patient : '';
61
  });
62
  const [surveyorPersonaDetail, setSurveyorPersonaDetail] = React.useState(null);
63
  const [patientPersonaDetail, setPatientPersonaDetail] = React.useState(null);
64
  const [analysisAttributeItems, setAnalysisAttributeItems] = React.useState(() => {
65
- const store = (existing && existing.analysis_agent && typeof existing.analysis_agent === 'object') ? existing.analysis_agent : {};
66
- const attrs = Array.isArray(store.attributes) ? store.attributes : null;
67
- if (attrs && attrs.length) {
68
- return attrs
69
- .map((x) => (typeof x === 'string' ? x.trim() : ''))
70
- .filter((x) => x.length > 0);
71
- }
72
  return [];
73
  });
74
  const [savedAt, setSavedAt] = React.useState(existing?.saved_at || null);
75
  const [saveFlash, setSaveFlash] = React.useState(false);
76
- const applySurveyorDefaults = () => setSurveyorSystemPrompt(DEFAULT_SURVEYOR_SYSTEM_PROMPT);
77
- const applyPatientDefaults = () => setPatientSystemPrompt(DEFAULT_PATIENT_SYSTEM_PROMPT);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  const addAnalysisAttribute = () => {
80
  setAnalysisAttributeItems((prev) => ([...(prev || []), '' ]));
@@ -89,8 +109,33 @@ You will also be given persona-specific attributes.`;
89
  };
90
 
91
  const applyAnalysisDefaults = () => {
92
- setAnalysisAttributeItems([...(ANALYSIS_DEFAULT_ATTRIBUTES || [])]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  };
 
 
 
 
 
94
 
95
  React.useEffect(() => {
96
  authedFetch('/api/personas')
@@ -104,6 +149,18 @@ You will also be given persona-specific attributes.`;
104
  .catch(() => {});
105
  }, []);
106
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  React.useEffect(() => {
108
  authedFetch(`/api/personas/${selectedSurveyorId}`)
109
  .then(r => r.json())
@@ -140,27 +197,84 @@ You will also be given persona-specific attributes.`;
140
 
141
  const onSave = () => {
142
  const prev = loadConfig() || {};
143
-
144
  const cfg = Object.assign({}, prev, {
145
  editor: {
146
  surveyor_persona_id: selectedSurveyorId,
147
  patient_persona_id: selectedPatientId,
148
  },
149
- system_prompts: {
150
- surveyor: (surveyorSystemPrompt || '').trim(),
151
- patient: (patientSystemPrompt || '').trim(),
152
- },
153
- analysis_agent: {
154
- attributes: (analysisAttributeItems || [])
155
- .map((x) => (typeof x === 'string' ? x.trim() : ''))
156
- .filter((x) => x.length > 0),
157
- },
158
  saved_at: new Date().toISOString()
159
  });
160
  saveConfig(cfg);
161
- setSavedAt(cfg.saved_at);
162
- setSaveFlash(true);
163
- setTimeout(() => setSaveFlash(false), 1000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  };
165
 
166
  const TabNav = () => {
@@ -186,7 +300,7 @@ You will also be given persona-specific attributes.`;
186
  <div>
187
  <h2 className="text-xl font-bold text-slate-800">Configuration</h2>
188
  <p className="text-slate-600 mt-1 text-sm">
189
- Use this panel to edit persona-specific settings in this browser. Persona selection for runs is done in the AI-to-AI / Human-to-AI panels.
190
  </p>
191
  </div>
192
  </div>
@@ -220,7 +334,7 @@ You will also be given persona-specific attributes.`;
220
  <div className="grid grid-cols-2 gap-6 items-end">
221
  <div>
222
  <label className="block text-sm font-semibold text-slate-700 mb-2">Review surveyor persona</label>
223
- <div className="flex">
224
  <select
225
  className="w-full md:w-1/4 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
226
  value={selectedSurveyorId}
@@ -230,6 +344,41 @@ You will also be given persona-specific attributes.`;
230
  <option key={p.id} value={p.id}>{p.name}</option>
231
  ))}
232
  </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  </div>
234
  </div>
235
  <div />
@@ -237,7 +386,9 @@ You will also be given persona-specific attributes.`;
237
 
238
  <div className="grid grid-cols-2 gap-6">
239
  <div>
240
- <label className="block text-sm font-semibold text-slate-700 mb-2">Attributes (read-only)</label>
 
 
241
  <div className="space-y-2">
242
  {(surveyorAttributeItems || []).length === 0 ? (
243
  <div className="text-sm text-slate-500">No attributes.</div>
@@ -246,18 +397,41 @@ You will also be given persona-specific attributes.`;
246
  <div key={idx} className="flex items-start gap-2">
247
  <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{String(idx + 1).padStart(2, '0')}</div>
248
  <input
249
- className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-slate-50 text-slate-600"
250
  value={attr || ''}
251
- disabled
 
 
252
  />
 
 
 
 
 
 
 
 
 
 
253
  </div>
254
  ))
255
  )}
 
 
 
 
 
 
 
 
 
256
  </div>
257
  </div>
258
 
259
  <div>
260
- <label className="block text-sm font-semibold text-slate-700 mb-2">Question bank (read-only)</label>
 
 
261
  <div className="space-y-2">
262
  {(surveyorQuestionItems || []).length === 0 ? (
263
  <div className="text-sm text-slate-500">No questions.</div>
@@ -266,13 +440,34 @@ You will also be given persona-specific attributes.`;
266
  <div key={q.id} className="flex items-start gap-2">
267
  <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{q.id}</div>
268
  <input
269
- className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-slate-50 text-slate-600"
270
  value={q.text || ''}
271
- disabled
 
 
272
  />
 
 
 
 
 
 
 
 
 
 
273
  </div>
274
  ))
275
  )}
 
 
 
 
 
 
 
 
 
276
  </div>
277
  </div>
278
  </div>
@@ -304,7 +499,7 @@ You will also be given persona-specific attributes.`;
304
  <div className="grid grid-cols-2 gap-6 items-end">
305
  <div>
306
  <label className="block text-sm font-semibold text-slate-700 mb-2">Review patient persona</label>
307
- <div className="flex">
308
  <select
309
  className="w-full md:w-1/4 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
310
  value={selectedPatientId}
@@ -314,6 +509,41 @@ You will also be given persona-specific attributes.`;
314
  <option key={p.id} value={p.id}>{p.name}</option>
315
  ))}
316
  </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  </div>
318
  </div>
319
  <div />
@@ -321,7 +551,9 @@ You will also be given persona-specific attributes.`;
321
 
322
  <div className="grid grid-cols-2 gap-6">
323
  <div>
324
- <label className="block text-sm font-semibold text-slate-700 mb-2">Attributes (read-only)</label>
 
 
325
  <div className="space-y-2">
326
  {(patientAttributeItems || []).length === 0 ? (
327
  <div className="text-sm text-slate-500">No attributes.</div>
@@ -330,13 +562,34 @@ You will also be given persona-specific attributes.`;
330
  <div key={idx} className="flex items-start gap-2">
331
  <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{String(idx + 1).padStart(2, '0')}</div>
332
  <input
333
- className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-slate-50 text-slate-600"
334
  value={attr || ''}
335
- disabled
 
 
336
  />
 
 
 
 
 
 
 
 
 
 
337
  </div>
338
  ))
339
  )}
 
 
 
 
 
 
 
 
 
340
  </div>
341
  </div>
342
  <div />
 
51
  const [surveyorQuestionItems, setSurveyorQuestionItems] = React.useState([]);
52
  const [surveyorAttributeItems, setSurveyorAttributeItems] = React.useState([]);
53
  const [patientAttributeItems, setPatientAttributeItems] = React.useState([]);
54
+ const [surveyorSystemPrompt, setSurveyorSystemPrompt] = React.useState(() => {
55
+ return '';
 
56
  });
57
  const [patientSystemPrompt, setPatientSystemPrompt] = React.useState(() => {
58
+ return '';
 
59
  });
60
  const [surveyorPersonaDetail, setSurveyorPersonaDetail] = React.useState(null);
61
  const [patientPersonaDetail, setPatientPersonaDetail] = React.useState(null);
62
  const [analysisAttributeItems, setAnalysisAttributeItems] = React.useState(() => {
 
 
 
 
 
 
 
63
  return [];
64
  });
65
  const [savedAt, setSavedAt] = React.useState(existing?.saved_at || null);
66
  const [saveFlash, setSaveFlash] = React.useState(false);
67
+ const applySurveyorDefaults = () => {
68
+ authedFetch('/api/settings/defaults')
69
+ .then(r => r.json())
70
+ .then(data => {
71
+ if (typeof data?.surveyor_system_prompt === 'string') setSurveyorSystemPrompt(data.surveyor_system_prompt);
72
+ else setSurveyorSystemPrompt(DEFAULT_SURVEYOR_SYSTEM_PROMPT);
73
+ })
74
+ .catch(() => setSurveyorSystemPrompt(DEFAULT_SURVEYOR_SYSTEM_PROMPT));
75
+ };
76
+ const applyPatientDefaults = () => {
77
+ authedFetch('/api/settings/defaults')
78
+ .then(r => r.json())
79
+ .then(data => {
80
+ if (typeof data?.patient_system_prompt === 'string') setPatientSystemPrompt(data.patient_system_prompt);
81
+ else setPatientSystemPrompt(DEFAULT_PATIENT_SYSTEM_PROMPT);
82
+ })
83
+ .catch(() => setPatientSystemPrompt(DEFAULT_PATIENT_SYSTEM_PROMPT));
84
+ };
85
+
86
+ const refreshPersonas = () => {
87
+ return authedFetch('/api/personas')
88
+ .then(r => r.json())
89
+ .then(data => {
90
+ setPersonas({
91
+ surveyors: data.surveyors || [],
92
+ patients: data.patients || []
93
+ });
94
+ return data;
95
+ })
96
+ .catch(() => null);
97
+ };
98
 
99
  const addAnalysisAttribute = () => {
100
  setAnalysisAttributeItems((prev) => ([...(prev || []), '' ]));
 
109
  };
110
 
111
  const applyAnalysisDefaults = () => {
112
+ authedFetch('/api/settings/defaults')
113
+ .then(r => r.json())
114
+ .then(data => {
115
+ const attrs = Array.isArray(data?.analysis_attributes) ? data.analysis_attributes : [];
116
+ const cleaned = attrs.map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0);
117
+ if (cleaned.length > 0) setAnalysisAttributeItems(cleaned);
118
+ else setAnalysisAttributeItems([...(ANALYSIS_DEFAULT_ATTRIBUTES || [])]);
119
+ })
120
+ .catch(() => setAnalysisAttributeItems([...(ANALYSIS_DEFAULT_ATTRIBUTES || [])]));
121
+ };
122
+
123
+ const addSurveyorAttribute = () => setSurveyorAttributeItems((prev) => ([...(prev || []), '' ]));
124
+ const updateSurveyorAttribute = (idx, text) => setSurveyorAttributeItems((prev) => (prev || []).map((v, i) => (i === idx ? text : v)));
125
+ const removeSurveyorAttribute = (idx) => setSurveyorAttributeItems((prev) => (prev || []).filter((_, i) => i !== idx));
126
+
127
+ const addSurveyorQuestion = () => {
128
+ const nextId = `q${String((surveyorQuestionItems || []).length + 1).padStart(2, '0')}`;
129
+ setSurveyorQuestionItems((prev) => ([...(prev || []), { id: nextId, text: '' }]));
130
+ };
131
+ const updateSurveyorQuestionText = (id, text) => {
132
+ setSurveyorQuestionItems((prev) => (prev || []).map((q) => (q.id === id ? Object.assign({}, q, { text }) : q)));
133
  };
134
+ const removeSurveyorQuestion = (id) => setSurveyorQuestionItems((prev) => (prev || []).filter((q) => q.id !== id));
135
+
136
+ const addPatientAttribute = () => setPatientAttributeItems((prev) => ([...(prev || []), '' ]));
137
+ const updatePatientAttribute = (idx, text) => setPatientAttributeItems((prev) => (prev || []).map((v, i) => (i === idx ? text : v)));
138
+ const removePatientAttribute = (idx) => setPatientAttributeItems((prev) => (prev || []).filter((_, i) => i !== idx));
139
 
140
  React.useEffect(() => {
141
  authedFetch('/api/personas')
 
149
  .catch(() => {});
150
  }, []);
151
 
152
+ React.useEffect(() => {
153
+ authedFetch('/api/settings')
154
+ .then(r => r.json())
155
+ .then(data => {
156
+ if (typeof data?.surveyor_system_prompt === 'string') setSurveyorSystemPrompt(data.surveyor_system_prompt);
157
+ if (typeof data?.patient_system_prompt === 'string') setPatientSystemPrompt(data.patient_system_prompt);
158
+ const attrs = Array.isArray(data?.analysis_attributes) ? data.analysis_attributes : [];
159
+ setAnalysisAttributeItems(attrs.map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0));
160
+ })
161
+ .catch(() => {});
162
+ }, []);
163
+
164
  React.useEffect(() => {
165
  authedFetch(`/api/personas/${selectedSurveyorId}`)
166
  .then(r => r.json())
 
197
 
198
  const onSave = () => {
199
  const prev = loadConfig() || {};
 
200
  const cfg = Object.assign({}, prev, {
201
  editor: {
202
  surveyor_persona_id: selectedSurveyorId,
203
  patient_persona_id: selectedPatientId,
204
  },
 
 
 
 
 
 
 
 
 
205
  saved_at: new Date().toISOString()
206
  });
207
  saveConfig(cfg);
208
+
209
+ const body = {
210
+ surveyor_system_prompt: (surveyorSystemPrompt || '').trim(),
211
+ patient_system_prompt: (patientSystemPrompt || '').trim(),
212
+ analysis_attributes: (analysisAttributeItems || []).map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0),
213
+ };
214
+
215
+ Promise.all([
216
+ authedFetch('/api/settings', {
217
+ method: 'PUT',
218
+ headers: { 'Content-Type': 'application/json' },
219
+ body: JSON.stringify(body)
220
+ }).catch(() => null),
221
+ (activePane === 'surveyors' && surveyorPersonaDetail && surveyorPersonaDetail.is_default === false)
222
+ ? authedFetch(`/api/personas/${selectedSurveyorId}`, {
223
+ method: 'PUT',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify({
226
+ attributes: (surveyorAttributeItems || []).map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0),
227
+ question_bank_items: (surveyorQuestionItems || []).map((q) => (q && typeof q.text === 'string' ? q.text.trim() : '')).filter((t) => t.length > 0),
228
+ })
229
+ }).catch(() => null)
230
+ : Promise.resolve(null),
231
+ (activePane === 'patients' && patientPersonaDetail && patientPersonaDetail.is_default === false)
232
+ ? authedFetch(`/api/personas/${selectedPatientId}`, {
233
+ method: 'PUT',
234
+ headers: { 'Content-Type': 'application/json' },
235
+ body: JSON.stringify({
236
+ attributes: (patientAttributeItems || []).map((x) => (typeof x === 'string' ? x.trim() : '')).filter((x) => x.length > 0),
237
+ question_bank_items: [],
238
+ })
239
+ }).catch(() => null)
240
+ : Promise.resolve(null),
241
+ ]).then(() => {
242
+ setSavedAt(cfg.saved_at);
243
+ setSaveFlash(true);
244
+ setTimeout(() => setSaveFlash(false), 1000);
245
+ });
246
+ };
247
+
248
+ const createPersona = async (kind, cloneFromId) => {
249
+ const base = (kind === 'surveyor') ? 'New surveyor persona name' : 'New patient persona name';
250
+ const suggested = cloneFromId
251
+ ? `Copy of ${(kind === 'surveyor' ? surveyorPersonaDetail?.name : patientPersonaDetail?.name) || 'Persona'}`
252
+ : '';
253
+ const name = window.prompt(base, suggested) || '';
254
+ if (!name.trim()) return null;
255
+ const res = await authedFetch('/api/personas', {
256
+ method: 'POST',
257
+ headers: { 'Content-Type': 'application/json' },
258
+ body: JSON.stringify({
259
+ kind,
260
+ name: name.trim(),
261
+ clone_from_persona_id: cloneFromId || undefined,
262
+ })
263
+ });
264
+ if (!res.ok) return null;
265
+ const data = await res.json();
266
+ await refreshPersonas();
267
+ return data?.id || null;
268
+ };
269
+
270
+ const deletePersona = async (personaId) => {
271
+ if (!personaId) return false;
272
+ const ok = window.confirm('Delete this persona? This cannot be undone.');
273
+ if (!ok) return false;
274
+ const res = await authedFetch(`/api/personas/${personaId}`, { method: 'DELETE' });
275
+ if (!res.ok) return false;
276
+ await refreshPersonas();
277
+ return true;
278
  };
279
 
280
  const TabNav = () => {
 
300
  <div>
301
  <h2 className="text-xl font-bold text-slate-800">Configuration</h2>
302
  <p className="text-slate-600 mt-1 text-sm">
303
+ Use this panel to edit shared app settings and personas. Persona selection for runs is done in the AI-to-AI / Human-to-AI panels.
304
  </p>
305
  </div>
306
  </div>
 
334
  <div className="grid grid-cols-2 gap-6 items-end">
335
  <div>
336
  <label className="block text-sm font-semibold text-slate-700 mb-2">Review surveyor persona</label>
337
+ <div className="flex items-center gap-2 flex-wrap">
338
  <select
339
  className="w-full md:w-1/4 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
340
  value={selectedSurveyorId}
 
344
  <option key={p.id} value={p.id}>{p.name}</option>
345
  ))}
346
  </select>
347
+ <button
348
+ type="button"
349
+ onClick={async () => {
350
+ const id = await createPersona('surveyor', null);
351
+ if (id) setSelectedSurveyorId(id);
352
+ }}
353
+ 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"
354
+ >
355
+ + New
356
+ </button>
357
+ <button
358
+ type="button"
359
+ onClick={async () => {
360
+ const id = await createPersona('surveyor', selectedSurveyorId);
361
+ if (id) setSelectedSurveyorId(id);
362
+ }}
363
+ 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"
364
+ >
365
+ Duplicate
366
+ </button>
367
+ <button
368
+ type="button"
369
+ disabled={!(surveyorPersonaDetail && surveyorPersonaDetail.is_default === false)}
370
+ onClick={async () => {
371
+ const ok = await deletePersona(selectedSurveyorId);
372
+ if (ok) {
373
+ const data = await refreshPersonas();
374
+ const fallback = (data?.surveyors && data.surveyors[0] && data.surveyors[0].id) ? data.surveyors[0].id : 'friendly_researcher_001';
375
+ setSelectedSurveyorId(fallback);
376
+ }
377
+ }}
378
+ className="bg-white hover:bg-red-50 text-red-700 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-red-200 disabled:opacity-50 disabled:hover:bg-white"
379
+ >
380
+ Delete
381
+ </button>
382
  </div>
383
  </div>
384
  <div />
 
386
 
387
  <div className="grid grid-cols-2 gap-6">
388
  <div>
389
+ <label className="block text-sm font-semibold text-slate-700 mb-2">
390
+ Attributes {surveyorPersonaDetail?.is_default === false ? '' : '(read-only)'}
391
+ </label>
392
  <div className="space-y-2">
393
  {(surveyorAttributeItems || []).length === 0 ? (
394
  <div className="text-sm text-slate-500">No attributes.</div>
 
397
  <div key={idx} className="flex items-start gap-2">
398
  <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{String(idx + 1).padStart(2, '0')}</div>
399
  <input
400
+ className={`flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm ${surveyorPersonaDetail?.is_default === false ? 'bg-white' : 'bg-slate-50 text-slate-600'}`}
401
  value={attr || ''}
402
+ onChange={(e) => updateSurveyorAttribute(idx, e.target.value)}
403
+ disabled={!(surveyorPersonaDetail?.is_default === false)}
404
+ placeholder="Type an attribute..."
405
  />
406
+ {surveyorPersonaDetail?.is_default === false && (
407
+ <button
408
+ type="button"
409
+ onClick={() => removeSurveyorAttribute(idx)}
410
+ className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm"
411
+ title="Remove attribute"
412
+ >
413
+
414
+ </button>
415
+ )}
416
  </div>
417
  ))
418
  )}
419
+ {surveyorPersonaDetail?.is_default === false && (
420
+ <button
421
+ type="button"
422
+ onClick={addSurveyorAttribute}
423
+ 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"
424
+ >
425
+ + Add attribute
426
+ </button>
427
+ )}
428
  </div>
429
  </div>
430
 
431
  <div>
432
+ <label className="block text-sm font-semibold text-slate-700 mb-2">
433
+ Question bank {surveyorPersonaDetail?.is_default === false ? '' : '(read-only)'}
434
+ </label>
435
  <div className="space-y-2">
436
  {(surveyorQuestionItems || []).length === 0 ? (
437
  <div className="text-sm text-slate-500">No questions.</div>
 
440
  <div key={q.id} className="flex items-start gap-2">
441
  <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{q.id}</div>
442
  <input
443
+ className={`flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm ${surveyorPersonaDetail?.is_default === false ? 'bg-white' : 'bg-slate-50 text-slate-600'}`}
444
  value={q.text || ''}
445
+ onChange={(e) => updateSurveyorQuestionText(q.id, e.target.value)}
446
+ disabled={!(surveyorPersonaDetail?.is_default === false)}
447
+ placeholder="Type a question..."
448
  />
449
+ {surveyorPersonaDetail?.is_default === false && (
450
+ <button
451
+ type="button"
452
+ onClick={() => removeSurveyorQuestion(q.id)}
453
+ className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm"
454
+ title="Remove question"
455
+ >
456
+
457
+ </button>
458
+ )}
459
  </div>
460
  ))
461
  )}
462
+ {surveyorPersonaDetail?.is_default === false && (
463
+ <button
464
+ type="button"
465
+ onClick={addSurveyorQuestion}
466
+ 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"
467
+ >
468
+ + Add question
469
+ </button>
470
+ )}
471
  </div>
472
  </div>
473
  </div>
 
499
  <div className="grid grid-cols-2 gap-6 items-end">
500
  <div>
501
  <label className="block text-sm font-semibold text-slate-700 mb-2">Review patient persona</label>
502
+ <div className="flex items-center gap-2 flex-wrap">
503
  <select
504
  className="w-full md:w-1/4 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
505
  value={selectedPatientId}
 
509
  <option key={p.id} value={p.id}>{p.name}</option>
510
  ))}
511
  </select>
512
+ <button
513
+ type="button"
514
+ onClick={async () => {
515
+ const id = await createPersona('patient', null);
516
+ if (id) setSelectedPatientId(id);
517
+ }}
518
+ 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"
519
+ >
520
+ + New
521
+ </button>
522
+ <button
523
+ type="button"
524
+ onClick={async () => {
525
+ const id = await createPersona('patient', selectedPatientId);
526
+ if (id) setSelectedPatientId(id);
527
+ }}
528
+ 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"
529
+ >
530
+ Duplicate
531
+ </button>
532
+ <button
533
+ type="button"
534
+ disabled={!(patientPersonaDetail && patientPersonaDetail.is_default === false)}
535
+ onClick={async () => {
536
+ const ok = await deletePersona(selectedPatientId);
537
+ if (ok) {
538
+ const data = await refreshPersonas();
539
+ const fallback = (data?.patients && data.patients[0] && data.patients[0].id) ? data.patients[0].id : 'cooperative_senior_001';
540
+ setSelectedPatientId(fallback);
541
+ }
542
+ }}
543
+ className="bg-white hover:bg-red-50 text-red-700 px-3 py-2 rounded-lg text-sm font-semibold transition-all border border-red-200 disabled:opacity-50 disabled:hover:bg-white"
544
+ >
545
+ Delete
546
+ </button>
547
  </div>
548
  </div>
549
  <div />
 
551
 
552
  <div className="grid grid-cols-2 gap-6">
553
  <div>
554
+ <label className="block text-sm font-semibold text-slate-700 mb-2">
555
+ Attributes {patientPersonaDetail?.is_default === false ? '' : '(read-only)'}
556
+ </label>
557
  <div className="space-y-2">
558
  {(patientAttributeItems || []).length === 0 ? (
559
  <div className="text-sm text-slate-500">No attributes.</div>
 
562
  <div key={idx} className="flex items-start gap-2">
563
  <div className="mt-2 text-xs font-mono text-slate-500 w-10 shrink-0">{String(idx + 1).padStart(2, '0')}</div>
564
  <input
565
+ className={`flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm ${patientPersonaDetail?.is_default === false ? 'bg-white' : 'bg-slate-50 text-slate-600'}`}
566
  value={attr || ''}
567
+ onChange={(e) => updatePatientAttribute(idx, e.target.value)}
568
+ disabled={!(patientPersonaDetail?.is_default === false)}
569
+ placeholder="Type an attribute..."
570
  />
571
+ {patientPersonaDetail?.is_default === false && (
572
+ <button
573
+ type="button"
574
+ onClick={() => removePatientAttribute(idx)}
575
+ className="text-slate-600 hover:text-red-600 px-2 py-2 text-sm"
576
+ title="Remove attribute"
577
+ >
578
+
579
+ </button>
580
+ )}
581
  </div>
582
  ))
583
  )}
584
+ {patientPersonaDetail?.is_default === false && (
585
+ <button
586
+ type="button"
587
+ onClick={addPatientAttribute}
588
+ 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"
589
+ >
590
+ + Add attribute
591
+ </button>
592
+ )}
593
  </div>
594
  </div>
595
  <div />
frontend/pages/main_page.py CHANGED
@@ -57,10 +57,6 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
57
  return (cfg && cfg.analysis_agent && typeof cfg.analysis_agent === 'object') ? cfg.analysis_agent : {};
58
  }
59
 
60
- function getSystemPromptsStore(cfg) {
61
- return (cfg && cfg.system_prompts && typeof cfg.system_prompts === 'object') ? cfg.system_prompts : {};
62
- }
63
-
64
  function getSurveyorPersonaData(cfg, surveyorId) {
65
  if (!surveyorId) return null;
66
  const store = getSurveyorPersonaStore(cfg);
@@ -687,11 +683,6 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
687
  connectWebSocket('main');
688
  const surveyorId = mainSurveyorId || 'friendly_researcher_001';
689
  const patientId = mainPatientId || 'cooperative_senior_001';
690
- const cfg = loadConfig() || {};
691
- const systemPrompts = getSystemPromptsStore(cfg);
692
- const surveyorSystemPrompt = (typeof systemPrompts.surveyor === 'string') ? systemPrompts.surveyor : '';
693
- const patientSystemPrompt = (typeof systemPrompts.patient === 'string') ? systemPrompts.patient : '';
694
- const analysisAttributes = getAnalysisAttributesFor(cfg);
695
 
696
  setTimeout(() => {
697
  if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
@@ -699,9 +690,6 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
699
  type: 'start_conversation',
700
  surveyor_persona_id: surveyorId,
701
  patient_persona_id: patientId,
702
- surveyor_system_prompt: surveyorSystemPrompt || undefined,
703
- patient_system_prompt: patientSystemPrompt || undefined,
704
- analysis_attributes: analysisAttributes.length ? analysisAttributes : undefined,
705
  }));
706
  }
707
  }, 500);
@@ -720,11 +708,6 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
720
  connectWebSocket('human');
721
  const surveyorId = humanSurveyorId || 'friendly_researcher_001';
722
  const patientId = humanPatientId || 'cooperative_senior_001';
723
- const cfg = loadConfig() || {};
724
- const systemPrompts = getSystemPromptsStore(cfg);
725
- const surveyorSystemPrompt = (typeof systemPrompts.surveyor === 'string') ? systemPrompts.surveyor : '';
726
- const patientSystemPrompt = (typeof systemPrompts.patient === 'string') ? systemPrompts.patient : '';
727
- const analysisAttributes = getAnalysisAttributesFor(cfg);
728
 
729
  setTimeout(() => {
730
  if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
@@ -733,9 +716,6 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
733
  ai_role: humanAiRole || 'surveyor',
734
  surveyor_persona_id: surveyorId,
735
  patient_persona_id: patientId,
736
- surveyor_system_prompt: surveyorSystemPrompt || undefined,
737
- patient_system_prompt: patientSystemPrompt || undefined,
738
- analysis_attributes: analysisAttributes.length ? analysisAttributes : undefined,
739
  }));
740
  }
741
  }, 500);
@@ -795,11 +775,6 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
795
  fd.append('file', file);
796
  fd.append('conversation_id', conversationId);
797
  if (file.name) fd.append('source_name', file.name);
798
- {
799
- const cfg = loadConfig() || {};
800
- const analysisAttributes = getAnalysisAttributesFor(cfg);
801
- if (analysisAttributes.length) fd.append('analysis_attributes_json', JSON.stringify(analysisAttributes));
802
- }
803
 
804
  const res = await authedFetchForm('/api/analyze/file', fd);
805
  if (!res.ok) {
@@ -844,16 +819,13 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
844
  analysisConversationIdRef.current = conversationId;
845
 
846
  try {
847
- const cfg = loadConfig() || {};
848
- const analysisAttributes = getAnalysisAttributesFor(cfg);
849
  const res = await authedFetch('/api/analyze/text', {
850
  method: 'POST',
851
  headers: { 'Content-Type': 'application/json' },
852
  body: JSON.stringify({
853
  conversation_id: conversationId,
854
  source_name: analysisSourceName || undefined,
855
- text,
856
- analysis_attributes: analysisAttributes.length ? analysisAttributes : undefined,
857
  })
858
  });
859
  if (!res.ok) {
 
57
  return (cfg && cfg.analysis_agent && typeof cfg.analysis_agent === 'object') ? cfg.analysis_agent : {};
58
  }
59
 
 
 
 
 
60
  function getSurveyorPersonaData(cfg, surveyorId) {
61
  if (!surveyorId) return null;
62
  const store = getSurveyorPersonaStore(cfg);
 
683
  connectWebSocket('main');
684
  const surveyorId = mainSurveyorId || 'friendly_researcher_001';
685
  const patientId = mainPatientId || 'cooperative_senior_001';
 
 
 
 
 
686
 
687
  setTimeout(() => {
688
  if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
 
690
  type: 'start_conversation',
691
  surveyor_persona_id: surveyorId,
692
  patient_persona_id: patientId,
 
 
 
693
  }));
694
  }
695
  }, 500);
 
708
  connectWebSocket('human');
709
  const surveyorId = humanSurveyorId || 'friendly_researcher_001';
710
  const patientId = humanPatientId || 'cooperative_senior_001';
 
 
 
 
 
711
 
712
  setTimeout(() => {
713
  if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
 
716
  ai_role: humanAiRole || 'surveyor',
717
  surveyor_persona_id: surveyorId,
718
  patient_persona_id: patientId,
 
 
 
719
  }));
720
  }
721
  }, 500);
 
775
  fd.append('file', file);
776
  fd.append('conversation_id', conversationId);
777
  if (file.name) fd.append('source_name', file.name);
 
 
 
 
 
778
 
779
  const res = await authedFetchForm('/api/analyze/file', fd);
780
  if (!res.ok) {
 
819
  analysisConversationIdRef.current = conversationId;
820
 
821
  try {
 
 
822
  const res = await authedFetch('/api/analyze/text', {
823
  method: 'POST',
824
  headers: { 'Content-Type': 'application/json' },
825
  body: JSON.stringify({
826
  conversation_id: conversationId,
827
  source_name: analysisSourceName || undefined,
828
+ text
 
829
  })
830
  });
831
  if (!res.ok) {
frontend/react_gradio_hybrid.py CHANGED
@@ -21,7 +21,7 @@ from websocket_manager import WebSocketManager
21
  from backend.api.main import app as backend_app
22
  from backend.api.conversation_ws import manager as backend_ws_manager
23
  from backend.api.conversation_service import initialize_conversation_service
24
- from backend.api.storage_service import initialize_run_store
25
  from backend.core.auth import (
26
  COOKIE_NAME,
27
  INTERNAL_HEADER,
@@ -50,6 +50,7 @@ app.mount("/api", backend_app)
50
  async def initialize_backend_services():
51
  initialize_conversation_service(backend_ws_manager, settings)
52
  await initialize_run_store(settings)
 
53
 
54
 
55
  # Enable CORS for local development
@@ -164,9 +165,6 @@ async def frontend_websocket(websocket: WebSocket, conversation_id: str):
164
  "content": "start",
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_system_prompt": data.get("surveyor_system_prompt"),
168
- "patient_system_prompt": data.get("patient_system_prompt"),
169
- "analysis_attributes": data.get("analysis_attributes"),
170
  "host": settings.llm.host,
171
  "model": settings.llm.model,
172
  })
@@ -188,9 +186,6 @@ async def frontend_websocket(websocket: WebSocket, conversation_id: str):
188
  "ai_role": data.get("ai_role"),
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_system_prompt": data.get("surveyor_system_prompt"),
192
- "patient_system_prompt": data.get("patient_system_prompt"),
193
- "analysis_attributes": data.get("analysis_attributes"),
194
  "host": settings.llm.host,
195
  "model": settings.llm.model,
196
  })
 
21
  from backend.api.main import app as backend_app
22
  from backend.api.conversation_ws import manager as backend_ws_manager
23
  from backend.api.conversation_service import initialize_conversation_service
24
+ from backend.api.storage_service import initialize_run_store, seed_personas_and_settings
25
  from backend.core.auth import (
26
  COOKIE_NAME,
27
  INTERNAL_HEADER,
 
50
  async def initialize_backend_services():
51
  initialize_conversation_service(backend_ws_manager, settings)
52
  await initialize_run_store(settings)
53
+ await seed_personas_and_settings(overwrite_defaults=True)
54
 
55
 
56
  # Enable CORS for local development
 
165
  "content": "start",
166
  "surveyor_persona_id": data.get("surveyor_persona_id", "friendly_researcher_001"),
167
  "patient_persona_id": data.get("patient_persona_id", "cooperative_senior_001"),
 
 
 
168
  "host": settings.llm.host,
169
  "model": settings.llm.model,
170
  })
 
186
  "ai_role": data.get("ai_role"),
187
  "surveyor_persona_id": data.get("surveyor_persona_id", "friendly_researcher_001"),
188
  "patient_persona_id": data.get("patient_persona_id", "cooperative_senior_001"),
 
 
 
189
  "host": settings.llm.host,
190
  "model": settings.llm.model,
191
  })