DB-canonical personas: CRUD + shared settings
Browse files- README.md +3 -4
- backend/api/analysis_routes.py +4 -3
- backend/api/conversation_service.py +44 -18
- backend/api/main.py +2 -1
- backend/api/persona_routes.py +178 -13
- backend/api/storage_service.py +51 -6
- backend/core/conversation_manager.py +2 -2
- backend/core/default_personas.py +117 -0
- backend/core/persona_seed.py +28 -0
- backend/core/persona_system.py +18 -43
- backend/storage/__init__.py +10 -3
- backend/storage/models.py +22 -0
- backend/storage/sqlite_persona_store.py +441 -0
- data/patient_personas.yaml +0 -48
- data/surveyor_personas.yaml +0 -70
- docs/development.md +1 -3
- docs/overview.md +6 -8
- docs/persistence.md +4 -4
- docs/persona-knobs.md +3 -5
- docs/roadmap.md +1 -1
- frontend/pages/config_view.py +293 -40
- frontend/pages/main_page.py +1 -29
- frontend/react_gradio_hybrid.py +2 -7
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 |
-
-
|
| 113 |
-
-
|
| 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=
|
| 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":
|
| 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
|
| 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 =
|
| 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=
|
| 395 |
-
patient_system_prompt=
|
| 396 |
-
analysis_attributes=
|
| 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=
|
| 750 |
-
patient_system_prompt=
|
| 751 |
-
analysis_attributes=
|
| 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": {
|
| 1032 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 11 |
from .conversation_service import get_conversation_service
|
|
|
|
|
|
|
| 12 |
|
| 13 |
router = APIRouter(prefix="", tags=["personas"])
|
| 14 |
|
| 15 |
-
persona_system =
|
| 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 |
-
|
| 82 |
-
|
|
|
|
| 83 |
raise HTTPException(status_code=404, detail=f"Persona {persona_id} not found")
|
| 84 |
-
kind =
|
| 85 |
return PersonaDetailResponse(
|
| 86 |
-
id=
|
| 87 |
-
name=
|
| 88 |
kind=kind,
|
| 89 |
-
description=
|
| 90 |
-
is_default=
|
| 91 |
-
attributes=
|
| 92 |
-
question_bank_items=
|
| 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 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 =
|
| 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
|
| 123 |
-
"""Initialize persona system.
|
| 124 |
-
|
| 125 |
-
|
| 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 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 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 |
-
"""
|
| 331 |
-
|
| 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__ = [
|
| 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
|
| 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 |
-
- **
|
| 23 |
-
|
|
|
|
| 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 |
-
-
|
| 57 |
-
-
|
| 58 |
|
| 59 |
-
|
| 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`
|
| 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 |
-
|
| 288 |
|
| 289 |
-
-
|
| 290 |
-
- DB
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
| 55 |
-
|
| 56 |
-
return (typeof store.surveyor === 'string') ? store.surveyor : '';
|
| 57 |
});
|
| 58 |
const [patientSystemPrompt, setPatientSystemPrompt] = React.useState(() => {
|
| 59 |
-
|
| 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 = () =>
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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">
|
|
|
|
|
|
|
| 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=
|
| 250 |
value={attr || ''}
|
| 251 |
-
|
|
|
|
|
|
|
| 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">
|
|
|
|
|
|
|
| 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=
|
| 270 |
value={q.text || ''}
|
| 271 |
-
|
|
|
|
|
|
|
| 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">
|
|
|
|
|
|
|
| 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=
|
| 334 |
value={attr || ''}
|
| 335 |
-
|
|
|
|
|
|
|
| 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 |
})
|