UI: move persona selectors into panels + add AI role toggle
Browse files- backend/api/conversation_service.py +95 -19
- backend/api/conversation_ws.py +2 -0
- frontend/pages/main_page.py +34 -89
- frontend/pages/shared_views.py +138 -43
- frontend/react_gradio_hybrid.py +1 -0
backend/api/conversation_service.py
CHANGED
|
@@ -50,6 +50,7 @@ logger = logging.getLogger(__name__)
|
|
| 50 |
_SENTENCE_SEGMENTER = pysbd.Segmenter(language="en", clean=False)
|
| 51 |
|
| 52 |
SURVEYOR_MAX_TOKENS = 140
|
|
|
|
| 53 |
|
| 54 |
SURVEYOR_STYLE_GUIDANCE = (
|
| 55 |
"Constraints:\n"
|
|
@@ -59,6 +60,13 @@ SURVEYOR_STYLE_GUIDANCE = (
|
|
| 59 |
"- Avoid long explanations or multi-paragraph monologues.\n"
|
| 60 |
)
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
def _split_sentences(text: str) -> List[str]:
|
| 64 |
normalized = " ".join((text or "").split())
|
|
@@ -291,6 +299,7 @@ class HumanChatInfo:
|
|
| 291 |
patient_prompt_addition: Optional[str] = None
|
| 292 |
surveyor_attributes: List[str] = field(default_factory=list)
|
| 293 |
surveyor_question_bank: Optional[str] = None
|
|
|
|
| 294 |
asked_question_ids: List[str] = field(default_factory=list)
|
| 295 |
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
| 296 |
client: Any = None
|
|
@@ -333,6 +342,7 @@ class ConversationService:
|
|
| 333 |
patient_prompt_addition: Optional[str] = None,
|
| 334 |
surveyor_attributes: Optional[List[str]] = None,
|
| 335 |
surveyor_question_bank: Optional[str] = None,
|
|
|
|
| 336 |
) -> bool:
|
| 337 |
"""Start a new human-to-surveyor chat session."""
|
| 338 |
if conversation_id in self.active_conversations or conversation_id in self.active_human_chats:
|
|
@@ -349,6 +359,8 @@ class ConversationService:
|
|
| 349 |
resolved_model = model or self.settings.llm.model
|
| 350 |
resolved_backend = self.settings.llm.backend
|
| 351 |
|
|
|
|
|
|
|
| 352 |
chat_info = HumanChatInfo(
|
| 353 |
conversation_id=conversation_id,
|
| 354 |
surveyor_persona_id=surveyor_persona_id,
|
|
@@ -359,6 +371,7 @@ class ConversationService:
|
|
| 359 |
patient_prompt_addition=patient_prompt_addition,
|
| 360 |
surveyor_attributes=[s.strip() for s in (surveyor_attributes or []) if isinstance(s, str) and s.strip()],
|
| 361 |
surveyor_question_bank=surveyor_question_bank if isinstance(surveyor_question_bank, str) and surveyor_question_bank.strip() else None,
|
|
|
|
| 362 |
status=ConversationStatus.STARTING,
|
| 363 |
created_at=datetime.now(),
|
| 364 |
)
|
|
@@ -374,25 +387,26 @@ class ConversationService:
|
|
| 374 |
await self._send_status_update(conversation_id, ConversationStatus.STARTING)
|
| 375 |
await self._send_status_update(conversation_id, ConversationStatus.RUNNING)
|
| 376 |
|
| 377 |
-
#
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
|
|
|
| 396 |
|
| 397 |
return True
|
| 398 |
|
|
@@ -410,6 +424,38 @@ class ConversationService:
|
|
| 410 |
patient_persona = self.persona_system.get_persona(chat_info.patient_persona_id) or {}
|
| 411 |
surveyor_persona = self.persona_system.get_persona(chat_info.surveyor_persona_id) or {}
|
| 412 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
patient_label = patient_persona.get("name", "Patient")
|
| 414 |
await self._append_and_broadcast_transcript(
|
| 415 |
conversation_id=conversation_id,
|
|
@@ -563,6 +609,36 @@ class ConversationService:
|
|
| 563 |
|
| 564 |
return cleaned or "I apologize—I'm having trouble responding right now. Could you repeat that?"
|
| 565 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
async def start_conversation(self,
|
| 567 |
conversation_id: str,
|
| 568 |
surveyor_persona_id: str,
|
|
|
|
| 50 |
_SENTENCE_SEGMENTER = pysbd.Segmenter(language="en", clean=False)
|
| 51 |
|
| 52 |
SURVEYOR_MAX_TOKENS = 140
|
| 53 |
+
PATIENT_MAX_TOKENS = 220
|
| 54 |
|
| 55 |
SURVEYOR_STYLE_GUIDANCE = (
|
| 56 |
"Constraints:\n"
|
|
|
|
| 60 |
"- Avoid long explanations or multi-paragraph monologues.\n"
|
| 61 |
)
|
| 62 |
|
| 63 |
+
PATIENT_STYLE_GUIDANCE = (
|
| 64 |
+
"Constraints:\n"
|
| 65 |
+
"- Be concise (1–3 short sentences).\n"
|
| 66 |
+
"- Answer the question as the patient; avoid medical advice.\n"
|
| 67 |
+
"- Stay consistent with your persona and scenario.\n"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
|
| 71 |
def _split_sentences(text: str) -> List[str]:
|
| 72 |
normalized = " ".join((text or "").split())
|
|
|
|
| 299 |
patient_prompt_addition: Optional[str] = None
|
| 300 |
surveyor_attributes: List[str] = field(default_factory=list)
|
| 301 |
surveyor_question_bank: Optional[str] = None
|
| 302 |
+
ai_role: str = "surveyor" # "surveyor" or "patient"
|
| 303 |
asked_question_ids: List[str] = field(default_factory=list)
|
| 304 |
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
| 305 |
client: Any = None
|
|
|
|
| 342 |
patient_prompt_addition: Optional[str] = None,
|
| 343 |
surveyor_attributes: Optional[List[str]] = None,
|
| 344 |
surveyor_question_bank: Optional[str] = None,
|
| 345 |
+
ai_role: Optional[str] = None,
|
| 346 |
) -> bool:
|
| 347 |
"""Start a new human-to-surveyor chat session."""
|
| 348 |
if conversation_id in self.active_conversations or conversation_id in self.active_human_chats:
|
|
|
|
| 359 |
resolved_model = model or self.settings.llm.model
|
| 360 |
resolved_backend = self.settings.llm.backend
|
| 361 |
|
| 362 |
+
resolved_ai_role = ai_role if ai_role in ("surveyor", "patient") else "surveyor"
|
| 363 |
+
|
| 364 |
chat_info = HumanChatInfo(
|
| 365 |
conversation_id=conversation_id,
|
| 366 |
surveyor_persona_id=surveyor_persona_id,
|
|
|
|
| 371 |
patient_prompt_addition=patient_prompt_addition,
|
| 372 |
surveyor_attributes=[s.strip() for s in (surveyor_attributes or []) if isinstance(s, str) and s.strip()],
|
| 373 |
surveyor_question_bank=surveyor_question_bank if isinstance(surveyor_question_bank, str) and surveyor_question_bank.strip() else None,
|
| 374 |
+
ai_role=resolved_ai_role,
|
| 375 |
status=ConversationStatus.STARTING,
|
| 376 |
created_at=datetime.now(),
|
| 377 |
)
|
|
|
|
| 387 |
await self._send_status_update(conversation_id, ConversationStatus.STARTING)
|
| 388 |
await self._send_status_update(conversation_id, ConversationStatus.RUNNING)
|
| 389 |
|
| 390 |
+
# If the AI is the surveyor, we can optionally generate an initial greeting + first question.
|
| 391 |
+
if chat_info.ai_role == "surveyor":
|
| 392 |
+
try:
|
| 393 |
+
greeting = await self._generate_human_chat_surveyor_message(
|
| 394 |
+
chat_info,
|
| 395 |
+
transcript=[],
|
| 396 |
+
user_prompt=(
|
| 397 |
+
"Please greet the patient briefly and ask your first survey question.\n\n"
|
| 398 |
+
f"{SURVEYOR_STYLE_GUIDANCE}"
|
| 399 |
+
),
|
| 400 |
+
)
|
| 401 |
+
await self._append_and_broadcast_transcript(
|
| 402 |
+
conversation_id=conversation_id,
|
| 403 |
+
role="surveyor",
|
| 404 |
+
persona=surveyor_persona.get("name", "Surveyor"),
|
| 405 |
+
content=greeting,
|
| 406 |
+
)
|
| 407 |
+
except Exception as e:
|
| 408 |
+
logger.error(f"Failed to generate human-chat greeting: {e}")
|
| 409 |
+
# It's OK to proceed without a greeting.
|
| 410 |
|
| 411 |
return True
|
| 412 |
|
|
|
|
| 424 |
patient_persona = self.persona_system.get_persona(chat_info.patient_persona_id) or {}
|
| 425 |
surveyor_persona = self.persona_system.get_persona(chat_info.surveyor_persona_id) or {}
|
| 426 |
|
| 427 |
+
transcript = self.transcripts.get(conversation_id, [])
|
| 428 |
+
|
| 429 |
+
if chat_info.ai_role == "patient":
|
| 430 |
+
# Human is the surveyor; AI responds as the patient.
|
| 431 |
+
await self._append_and_broadcast_transcript(
|
| 432 |
+
conversation_id=conversation_id,
|
| 433 |
+
role="surveyor",
|
| 434 |
+
persona=f"{surveyor_persona.get('name', 'Surveyor')} (Human)",
|
| 435 |
+
content=text,
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
transcript = self.transcripts.get(conversation_id, [])
|
| 439 |
+
last_surveyor_msg = next((m for m in reversed(transcript) if m.get("role") == "surveyor"), None)
|
| 440 |
+
last_text = (last_surveyor_msg or {}).get("content", text)
|
| 441 |
+
reply = await self._generate_human_chat_patient_message(
|
| 442 |
+
chat_info,
|
| 443 |
+
transcript=transcript,
|
| 444 |
+
user_prompt=(
|
| 445 |
+
f"The interviewer just said: '{last_text}'. "
|
| 446 |
+
"Please respond naturally as your persona would.\n\n"
|
| 447 |
+
f"{PATIENT_STYLE_GUIDANCE}"
|
| 448 |
+
),
|
| 449 |
+
)
|
| 450 |
+
await self._append_and_broadcast_transcript(
|
| 451 |
+
conversation_id=conversation_id,
|
| 452 |
+
role="patient",
|
| 453 |
+
persona=patient_persona.get("name", "Patient"),
|
| 454 |
+
content=reply,
|
| 455 |
+
)
|
| 456 |
+
return
|
| 457 |
+
|
| 458 |
+
# Default: Human is the patient; AI responds as the surveyor.
|
| 459 |
patient_label = patient_persona.get("name", "Patient")
|
| 460 |
await self._append_and_broadcast_transcript(
|
| 461 |
conversation_id=conversation_id,
|
|
|
|
| 609 |
|
| 610 |
return cleaned or "I apologize—I'm having trouble responding right now. Could you repeat that?"
|
| 611 |
|
| 612 |
+
async def _generate_human_chat_patient_message(
|
| 613 |
+
self,
|
| 614 |
+
chat_info: HumanChatInfo,
|
| 615 |
+
*,
|
| 616 |
+
transcript: List[Dict[str, Any]],
|
| 617 |
+
user_prompt: str,
|
| 618 |
+
) -> str:
|
| 619 |
+
conversation_history = [
|
| 620 |
+
{"role": "assistant" if msg.get("role") == "patient" else "user", "content": msg.get("content", "")}
|
| 621 |
+
for msg in (transcript or [])
|
| 622 |
+
]
|
| 623 |
+
|
| 624 |
+
system_prompt, prompt_with_history = self.persona_system.build_conversation_prompt(
|
| 625 |
+
persona_id=chat_info.patient_persona_id,
|
| 626 |
+
conversation_history=conversation_history,
|
| 627 |
+
user_prompt=user_prompt,
|
| 628 |
+
)
|
| 629 |
+
|
| 630 |
+
system_prompt = (system_prompt or "").strip()
|
| 631 |
+
if chat_info.patient_prompt_addition:
|
| 632 |
+
system_prompt = (system_prompt + "\n\nAdditional instructions:\n" + chat_info.patient_prompt_addition).strip()
|
| 633 |
+
|
| 634 |
+
response = await chat_info.client.generate(
|
| 635 |
+
prompt=prompt_with_history,
|
| 636 |
+
system_prompt=system_prompt,
|
| 637 |
+
max_tokens=PATIENT_MAX_TOKENS,
|
| 638 |
+
temperature=0.7,
|
| 639 |
+
)
|
| 640 |
+
return (response or "").strip() or "I'm sorry—I'm having trouble responding right now."
|
| 641 |
+
|
| 642 |
async def start_conversation(self,
|
| 643 |
conversation_id: str,
|
| 644 |
surveyor_persona_id: str,
|
backend/api/conversation_ws.py
CHANGED
|
@@ -386,6 +386,7 @@ async def handle_start_human_chat(data: dict, conversation_id: str):
|
|
| 386 |
patient_persona_id = data.get("patient_persona_id")
|
| 387 |
host = data.get("host")
|
| 388 |
model = data.get("model")
|
|
|
|
| 389 |
patient_prompt_addition = data.get("patient_prompt_addition")
|
| 390 |
surveyor_attributes = data.get("surveyor_attributes")
|
| 391 |
surveyor_question_bank = data.get("surveyor_question_bank")
|
|
@@ -407,6 +408,7 @@ async def handle_start_human_chat(data: dict, conversation_id: str):
|
|
| 407 |
patient_prompt_addition=patient_prompt_addition,
|
| 408 |
surveyor_attributes=surveyor_attributes,
|
| 409 |
surveyor_question_bank=surveyor_question_bank,
|
|
|
|
| 410 |
)
|
| 411 |
|
| 412 |
if success:
|
|
|
|
| 386 |
patient_persona_id = data.get("patient_persona_id")
|
| 387 |
host = data.get("host")
|
| 388 |
model = data.get("model")
|
| 389 |
+
ai_role = data.get("ai_role")
|
| 390 |
patient_prompt_addition = data.get("patient_prompt_addition")
|
| 391 |
surveyor_attributes = data.get("surveyor_attributes")
|
| 392 |
surveyor_question_bank = data.get("surveyor_question_bank")
|
|
|
|
| 408 |
patient_prompt_addition=patient_prompt_addition,
|
| 409 |
surveyor_attributes=surveyor_attributes,
|
| 410 |
surveyor_question_bank=surveyor_question_bank,
|
| 411 |
+
ai_role=ai_role,
|
| 412 |
)
|
| 413 |
|
| 414 |
if success:
|
frontend/pages/main_page.py
CHANGED
|
@@ -115,7 +115,10 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
|
|
| 115 |
const bucket = (run && run[kind] && typeof run[kind] === 'object') ? run[kind] : {};
|
| 116 |
return {
|
| 117 |
surveyor_persona_id: bucket.surveyor_persona_id || cfg?.surveyor_persona_id || 'friendly_researcher_001',
|
| 118 |
-
patient_persona_id: bucket.patient_persona_id || cfg?.patient_persona_id || 'cooperative_senior_001'
|
|
|
|
|
|
|
|
|
|
| 119 |
};
|
| 120 |
}
|
| 121 |
|
|
@@ -274,6 +277,7 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
|
|
| 274 |
const [mainPatientId, setMainPatientId] = useState(() => getRunPersonaSelection(loadConfig() || {}, 'ai_to_ai').patient_persona_id);
|
| 275 |
const [humanSurveyorId, setHumanSurveyorId] = useState(() => getRunPersonaSelection(loadConfig() || {}, 'human_to_ai').surveyor_persona_id);
|
| 276 |
const [humanPatientId, setHumanPatientId] = useState(() => getRunPersonaSelection(loadConfig() || {}, 'human_to_ai').patient_persona_id);
|
|
|
|
| 277 |
|
| 278 |
const [historyBusy, setHistoryBusy] = useState(false);
|
| 279 |
const [historyError, setHistoryError] = useState(null);
|
|
@@ -712,6 +716,7 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
|
|
| 712 |
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
| 713 |
wsRef.current.send(JSON.stringify({
|
| 714 |
type: 'start_human_chat',
|
|
|
|
| 715 |
surveyor_persona_id: surveyorId,
|
| 716 |
patient_persona_id: patientId,
|
| 717 |
surveyor_question_bank: surveyorQuestionBank || undefined,
|
|
@@ -1035,78 +1040,6 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
|
|
| 1035 |
<div className="bg-white rounded-lg shadow-lg p-4 mb-6">
|
| 1036 |
<div className="flex items-center justify-between gap-4">
|
| 1037 |
<PageNav active={activePage} onChange={setActivePage} />
|
| 1038 |
-
{activePage === 'main' && (
|
| 1039 |
-
<div className="flex items-end gap-3">
|
| 1040 |
-
<div>
|
| 1041 |
-
<div className="text-xs font-semibold text-slate-600 mb-1">Surveyor</div>
|
| 1042 |
-
<select
|
| 1043 |
-
className="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
|
| 1044 |
-
value={mainSurveyorId}
|
| 1045 |
-
onChange={(e) => {
|
| 1046 |
-
const next = e.target.value;
|
| 1047 |
-
setMainSurveyorId(next);
|
| 1048 |
-
persistRunSelection('ai_to_ai', { surveyor_persona_id: next });
|
| 1049 |
-
}}
|
| 1050 |
-
>
|
| 1051 |
-
{(personaCatalog.surveyors.length ? personaCatalog.surveyors : [{id: 'friendly_researcher_001', name: 'Alex Thompson'}]).map(p => (
|
| 1052 |
-
<option key={p.id} value={p.id}>{p.name} ({p.id})</option>
|
| 1053 |
-
))}
|
| 1054 |
-
</select>
|
| 1055 |
-
</div>
|
| 1056 |
-
<div>
|
| 1057 |
-
<div className="text-xs font-semibold text-slate-600 mb-1">Patient</div>
|
| 1058 |
-
<select
|
| 1059 |
-
className="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
|
| 1060 |
-
value={mainPatientId}
|
| 1061 |
-
onChange={(e) => {
|
| 1062 |
-
const next = e.target.value;
|
| 1063 |
-
setMainPatientId(next);
|
| 1064 |
-
persistRunSelection('ai_to_ai', { patient_persona_id: next });
|
| 1065 |
-
}}
|
| 1066 |
-
>
|
| 1067 |
-
{(personaCatalog.patients.length ? personaCatalog.patients : [{id: 'cooperative_senior_001', name: 'Margaret Thompson'}]).map(p => (
|
| 1068 |
-
<option key={p.id} value={p.id}>{p.name} ({p.id})</option>
|
| 1069 |
-
))}
|
| 1070 |
-
</select>
|
| 1071 |
-
</div>
|
| 1072 |
-
</div>
|
| 1073 |
-
)}
|
| 1074 |
-
{activePage === 'human' && (
|
| 1075 |
-
<div className="flex items-end gap-3">
|
| 1076 |
-
<div>
|
| 1077 |
-
<div className="text-xs font-semibold text-slate-600 mb-1">Surveyor</div>
|
| 1078 |
-
<select
|
| 1079 |
-
className="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
|
| 1080 |
-
value={humanSurveyorId}
|
| 1081 |
-
onChange={(e) => {
|
| 1082 |
-
const next = e.target.value;
|
| 1083 |
-
setHumanSurveyorId(next);
|
| 1084 |
-
persistRunSelection('human_to_ai', { surveyor_persona_id: next });
|
| 1085 |
-
}}
|
| 1086 |
-
>
|
| 1087 |
-
{(personaCatalog.surveyors.length ? personaCatalog.surveyors : [{id: 'friendly_researcher_001', name: 'Alex Thompson'}]).map(p => (
|
| 1088 |
-
<option key={p.id} value={p.id}>{p.name} ({p.id})</option>
|
| 1089 |
-
))}
|
| 1090 |
-
</select>
|
| 1091 |
-
</div>
|
| 1092 |
-
<div>
|
| 1093 |
-
<div className="text-xs font-semibold text-slate-600 mb-1">Patient scenario</div>
|
| 1094 |
-
<select
|
| 1095 |
-
className="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
|
| 1096 |
-
value={humanPatientId}
|
| 1097 |
-
onChange={(e) => {
|
| 1098 |
-
const next = e.target.value;
|
| 1099 |
-
setHumanPatientId(next);
|
| 1100 |
-
persistRunSelection('human_to_ai', { patient_persona_id: next });
|
| 1101 |
-
}}
|
| 1102 |
-
>
|
| 1103 |
-
{(personaCatalog.patients.length ? personaCatalog.patients : [{id: 'cooperative_senior_001', name: 'Margaret Thompson'}]).map(p => (
|
| 1104 |
-
<option key={p.id} value={p.id}>{p.name} ({p.id})</option>
|
| 1105 |
-
))}
|
| 1106 |
-
</select>
|
| 1107 |
-
</div>
|
| 1108 |
-
</div>
|
| 1109 |
-
)}
|
| 1110 |
{(activePage === 'main' || activePage === 'human' || activePage === 'analyze' || activePage === 'history') && activeStatus === 'complete' && activeResources && (
|
| 1111 |
<div className="flex items-center gap-2">
|
| 1112 |
<button
|
|
@@ -1263,26 +1196,38 @@ def get_main_page_html(auth_enabled: bool = False) -> str:
|
|
| 1263 |
</div>
|
| 1264 |
) : (
|
| 1265 |
<div className="grid grid-cols-[2fr_1fr_2fr] gap-6 items-start">
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1274 |
activeMessages={activeMessages}
|
| 1275 |
highlightedEvidence={highlightedEvidence}
|
| 1276 |
renderHighlightedText={renderHighlightedText}
|
| 1277 |
/>
|
| 1278 |
-
|
| 1279 |
-
|
| 1280 |
-
|
| 1281 |
-
|
| 1282 |
-
|
| 1283 |
-
|
| 1284 |
-
|
| 1285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1286 |
loadTextFile={loadTextFile}
|
| 1287 |
runTextAnalysis={runTextAnalysis}
|
| 1288 |
analysisStatus={analysisStatus}
|
|
|
|
| 115 |
const bucket = (run && run[kind] && typeof run[kind] === 'object') ? run[kind] : {};
|
| 116 |
return {
|
| 117 |
surveyor_persona_id: bucket.surveyor_persona_id || cfg?.surveyor_persona_id || 'friendly_researcher_001',
|
| 118 |
+
patient_persona_id: bucket.patient_persona_id || cfg?.patient_persona_id || 'cooperative_senior_001',
|
| 119 |
+
ai_role: (kind === 'human_to_ai')
|
| 120 |
+
? (bucket.ai_role || 'surveyor')
|
| 121 |
+
: undefined
|
| 122 |
};
|
| 123 |
}
|
| 124 |
|
|
|
|
| 277 |
const [mainPatientId, setMainPatientId] = useState(() => getRunPersonaSelection(loadConfig() || {}, 'ai_to_ai').patient_persona_id);
|
| 278 |
const [humanSurveyorId, setHumanSurveyorId] = useState(() => getRunPersonaSelection(loadConfig() || {}, 'human_to_ai').surveyor_persona_id);
|
| 279 |
const [humanPatientId, setHumanPatientId] = useState(() => getRunPersonaSelection(loadConfig() || {}, 'human_to_ai').patient_persona_id);
|
| 280 |
+
const [humanAiRole, setHumanAiRole] = useState(() => getRunPersonaSelection(loadConfig() || {}, 'human_to_ai').ai_role || 'surveyor');
|
| 281 |
|
| 282 |
const [historyBusy, setHistoryBusy] = useState(false);
|
| 283 |
const [historyError, setHistoryError] = useState(null);
|
|
|
|
| 716 |
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
| 717 |
wsRef.current.send(JSON.stringify({
|
| 718 |
type: 'start_human_chat',
|
| 719 |
+
ai_role: humanAiRole || 'surveyor',
|
| 720 |
surveyor_persona_id: surveyorId,
|
| 721 |
patient_persona_id: patientId,
|
| 722 |
surveyor_question_bank: surveyorQuestionBank || undefined,
|
|
|
|
| 1040 |
<div className="bg-white rounded-lg shadow-lg p-4 mb-6">
|
| 1041 |
<div className="flex items-center justify-between gap-4">
|
| 1042 |
<PageNav active={activePage} onChange={setActivePage} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1043 |
{(activePage === 'main' || activePage === 'human' || activePage === 'analyze' || activePage === 'history') && activeStatus === 'complete' && activeResources && (
|
| 1044 |
<div className="flex items-center gap-2">
|
| 1045 |
<button
|
|
|
|
| 1196 |
</div>
|
| 1197 |
) : (
|
| 1198 |
<div className="grid grid-cols-[2fr_1fr_2fr] gap-6 items-start">
|
| 1199 |
+
{(activePage === 'human') ? (
|
| 1200 |
+
<HumanChatPanel
|
| 1201 |
+
humanChatActive={humanChatActive}
|
| 1202 |
+
aiRole={humanAiRole}
|
| 1203 |
+
setAiRole={(next) => { setHumanAiRole(next); persistRunSelection('human_to_ai', { ai_role: next }); }}
|
| 1204 |
+
personaCatalog={personaCatalog}
|
| 1205 |
+
surveyorId={humanSurveyorId}
|
| 1206 |
+
setSurveyorId={(next) => { setHumanSurveyorId(next); persistRunSelection('human_to_ai', { surveyor_persona_id: next }); }}
|
| 1207 |
+
patientId={humanPatientId}
|
| 1208 |
+
setPatientId={(next) => { setHumanPatientId(next); persistRunSelection('human_to_ai', { patient_persona_id: next }); }}
|
| 1209 |
+
humanDraft={humanDraft}
|
| 1210 |
+
setHumanDraft={setHumanDraft}
|
| 1211 |
+
sendHumanChatMessage={sendHumanChatMessage}
|
| 1212 |
+
transcriptContainerRef={transcriptContainerRef}
|
| 1213 |
+
onTranscriptScroll={onTranscriptScroll}
|
| 1214 |
activeMessages={activeMessages}
|
| 1215 |
highlightedEvidence={highlightedEvidence}
|
| 1216 |
renderHighlightedText={renderHighlightedText}
|
| 1217 |
/>
|
| 1218 |
+
) : (
|
| 1219 |
+
<ConversationUploadPanel
|
| 1220 |
+
activePage={activePage}
|
| 1221 |
+
conversationActive={conversationActive}
|
| 1222 |
+
personaCatalog={personaCatalog}
|
| 1223 |
+
surveyorId={mainSurveyorId}
|
| 1224 |
+
setSurveyorId={(next) => { setMainSurveyorId(next); persistRunSelection('ai_to_ai', { surveyor_persona_id: next }); }}
|
| 1225 |
+
patientId={mainPatientId}
|
| 1226 |
+
setPatientId={(next) => { setMainPatientId(next); persistRunSelection('ai_to_ai', { patient_persona_id: next }); }}
|
| 1227 |
+
analysisBusy={analysisBusy}
|
| 1228 |
+
analysisText={analysisText}
|
| 1229 |
+
setAnalysisText={setAnalysisText}
|
| 1230 |
+
analysisSourceName={analysisSourceName}
|
| 1231 |
loadTextFile={loadTextFile}
|
| 1232 |
runTextAnalysis={runTextAnalysis}
|
| 1233 |
analysisStatus={analysisStatus}
|
frontend/pages/shared_views.py
CHANGED
|
@@ -1,13 +1,18 @@
|
|
| 1 |
def get_shared_views_js() -> str:
|
| 2 |
return r"""
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
loadTextFile,
|
| 12 |
runTextAnalysis,
|
| 13 |
analysisStatus,
|
|
@@ -20,13 +25,44 @@ def get_shared_views_js() -> str:
|
|
| 20 |
} = props;
|
| 21 |
|
| 22 |
return (
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
{activePage === 'analyze' && (
|
| 32 |
<div className="mb-4 space-y-3">
|
|
@@ -93,13 +129,20 @@ def get_shared_views_js() -> str:
|
|
| 93 |
);
|
| 94 |
}
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
onTranscriptScroll,
|
| 104 |
activeMessages,
|
| 105 |
highlightedEvidence,
|
|
@@ -113,28 +156,80 @@ def get_shared_views_js() -> str:
|
|
| 113 |
}
|
| 114 |
};
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
<button
|
| 139 |
type="button"
|
| 140 |
onClick={sendHumanChatMessage}
|
|
|
|
| 1 |
def get_shared_views_js() -> str:
|
| 2 |
return r"""
|
| 3 |
+
function ConversationUploadPanel(props) {
|
| 4 |
+
const {
|
| 5 |
+
activePage,
|
| 6 |
+
conversationActive,
|
| 7 |
+
personaCatalog,
|
| 8 |
+
surveyorId,
|
| 9 |
+
setSurveyorId,
|
| 10 |
+
patientId,
|
| 11 |
+
setPatientId,
|
| 12 |
+
analysisBusy,
|
| 13 |
+
analysisText,
|
| 14 |
+
setAnalysisText,
|
| 15 |
+
analysisSourceName,
|
| 16 |
loadTextFile,
|
| 17 |
runTextAnalysis,
|
| 18 |
analysisStatus,
|
|
|
|
| 25 |
} = props;
|
| 26 |
|
| 27 |
return (
|
| 28 |
+
<div className="bg-white rounded-lg shadow-lg p-6">
|
| 29 |
+
<div className="flex items-center gap-2 mb-4">
|
| 30 |
+
<span className="text-2xl">{activePage === 'analyze' ? '🧾' : '💬'}</span>
|
| 31 |
+
<h2 className="text-xl font-bold text-slate-800">{activePage === 'analyze' ? 'Upload Text' : 'AI-to-AI'}</h2>
|
| 32 |
+
{activePage === 'main' && conversationActive && <span className="ml-auto text-green-600 font-medium animate-pulse">● LIVE</span>}
|
| 33 |
+
{activePage === 'analyze' && analysisBusy && <span className="ml-auto text-purple-600 font-medium animate-pulse">● RUNNING</span>}
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
{activePage === 'main' && (
|
| 37 |
+
<div className="mb-4 grid grid-cols-2 gap-3">
|
| 38 |
+
<div>
|
| 39 |
+
<div className="text-xs font-semibold text-slate-600 mb-1">Surveyor</div>
|
| 40 |
+
<select
|
| 41 |
+
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
|
| 42 |
+
value={surveyorId}
|
| 43 |
+
disabled={conversationActive}
|
| 44 |
+
onChange={(e) => setSurveyorId(e.target.value)}
|
| 45 |
+
>
|
| 46 |
+
{(personaCatalog?.surveyors?.length ? personaCatalog.surveyors : [{id: 'friendly_researcher_001', name: 'Alex Thompson'}]).map(p => (
|
| 47 |
+
<option key={p.id} value={p.id}>{p.name} ({p.id})</option>
|
| 48 |
+
))}
|
| 49 |
+
</select>
|
| 50 |
+
</div>
|
| 51 |
+
<div>
|
| 52 |
+
<div className="text-xs font-semibold text-slate-600 mb-1">Patient scenario</div>
|
| 53 |
+
<select
|
| 54 |
+
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
|
| 55 |
+
value={patientId}
|
| 56 |
+
disabled={conversationActive}
|
| 57 |
+
onChange={(e) => setPatientId(e.target.value)}
|
| 58 |
+
>
|
| 59 |
+
{(personaCatalog?.patients?.length ? personaCatalog.patients : [{id: 'cooperative_senior_001', name: 'Margaret Thompson'}]).map(p => (
|
| 60 |
+
<option key={p.id} value={p.id}>{p.name} ({p.id})</option>
|
| 61 |
+
))}
|
| 62 |
+
</select>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
)}
|
| 66 |
|
| 67 |
{activePage === 'analyze' && (
|
| 68 |
<div className="mb-4 space-y-3">
|
|
|
|
| 129 |
);
|
| 130 |
}
|
| 131 |
|
| 132 |
+
function HumanChatPanel(props) {
|
| 133 |
+
const {
|
| 134 |
+
humanChatActive,
|
| 135 |
+
aiRole,
|
| 136 |
+
setAiRole,
|
| 137 |
+
personaCatalog,
|
| 138 |
+
surveyorId,
|
| 139 |
+
setSurveyorId,
|
| 140 |
+
patientId,
|
| 141 |
+
setPatientId,
|
| 142 |
+
humanDraft,
|
| 143 |
+
setHumanDraft,
|
| 144 |
+
sendHumanChatMessage,
|
| 145 |
+
transcriptContainerRef,
|
| 146 |
onTranscriptScroll,
|
| 147 |
activeMessages,
|
| 148 |
highlightedEvidence,
|
|
|
|
| 156 |
}
|
| 157 |
};
|
| 158 |
|
| 159 |
+
return (
|
| 160 |
+
<div className="bg-white rounded-lg shadow-lg p-6">
|
| 161 |
+
<div className="flex items-center gap-2 mb-4">
|
| 162 |
+
<span className="text-2xl">🧑💬</span>
|
| 163 |
+
<h2 className="text-xl font-bold text-slate-800">Human-to-AI</h2>
|
| 164 |
+
{humanChatActive && <span className="ml-auto text-green-600 font-medium animate-pulse">● LIVE</span>}
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<div className="mb-4 grid grid-cols-2 gap-3">
|
| 168 |
+
<div>
|
| 169 |
+
<div className="text-xs font-semibold text-slate-600 mb-1">AI role</div>
|
| 170 |
+
<div className="flex gap-2">
|
| 171 |
+
<button
|
| 172 |
+
type="button"
|
| 173 |
+
disabled={humanChatActive}
|
| 174 |
+
onClick={() => setAiRole('surveyor')}
|
| 175 |
+
className={`${aiRole === 'surveyor' ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-700 border-slate-300'} border px-3 py-2 rounded-lg text-sm font-semibold transition-colors disabled:opacity-50`}
|
| 176 |
+
>
|
| 177 |
+
Surveyor
|
| 178 |
+
</button>
|
| 179 |
+
<button
|
| 180 |
+
type="button"
|
| 181 |
+
disabled={humanChatActive}
|
| 182 |
+
onClick={() => setAiRole('patient')}
|
| 183 |
+
className={`${aiRole === 'patient' ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-700 border-slate-300'} border px-3 py-2 rounded-lg text-sm font-semibold transition-colors disabled:opacity-50`}
|
| 184 |
+
>
|
| 185 |
+
Patient
|
| 186 |
+
</button>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
<div className="grid grid-cols-2 gap-2">
|
| 190 |
+
<div>
|
| 191 |
+
<div className="text-xs font-semibold text-slate-600 mb-1">Surveyor</div>
|
| 192 |
+
<select
|
| 193 |
+
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
|
| 194 |
+
value={surveyorId}
|
| 195 |
+
disabled={humanChatActive}
|
| 196 |
+
onChange={(e) => setSurveyorId(e.target.value)}
|
| 197 |
+
>
|
| 198 |
+
{(personaCatalog?.surveyors?.length ? personaCatalog.surveyors : [{id: 'friendly_researcher_001', name: 'Alex Thompson'}]).map(p => (
|
| 199 |
+
<option key={p.id} value={p.id}>{p.name} ({p.id})</option>
|
| 200 |
+
))}
|
| 201 |
+
</select>
|
| 202 |
+
</div>
|
| 203 |
+
<div>
|
| 204 |
+
<div className="text-xs font-semibold text-slate-600 mb-1">Patient</div>
|
| 205 |
+
<select
|
| 206 |
+
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
|
| 207 |
+
value={patientId}
|
| 208 |
+
disabled={humanChatActive}
|
| 209 |
+
onChange={(e) => setPatientId(e.target.value)}
|
| 210 |
+
>
|
| 211 |
+
{(personaCatalog?.patients?.length ? personaCatalog.patients : [{id: 'cooperative_senior_001', name: 'Margaret Thompson'}]).map(p => (
|
| 212 |
+
<option key={p.id} value={p.id}>{p.name} ({p.id})</option>
|
| 213 |
+
))}
|
| 214 |
+
</select>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
|
| 219 |
+
<div className="mb-4">
|
| 220 |
+
{!humanChatActive ? (
|
| 221 |
+
<div className="text-sm text-slate-500">
|
| 222 |
+
Click “Start” to begin. {aiRole === 'patient' ? 'Type as the surveyor.' : 'Type as the patient.'} Use “End session” to run analysis.
|
| 223 |
+
</div>
|
| 224 |
+
) : (
|
| 225 |
+
<div className="flex items-end gap-3">
|
| 226 |
+
<textarea
|
| 227 |
+
className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white h-20"
|
| 228 |
+
placeholder={aiRole === 'patient' ? "Type your message as the surveyor… (Ctrl/⌘+Enter to send)" : "Type your message as the patient… (Ctrl/⌘+Enter to send)"}
|
| 229 |
+
value={humanDraft}
|
| 230 |
+
onChange={(e) => setHumanDraft(e.target.value)}
|
| 231 |
+
onKeyDown={onKeyDown}
|
| 232 |
+
/>
|
| 233 |
<button
|
| 234 |
type="button"
|
| 235 |
onClick={sendHumanChatMessage}
|
frontend/react_gradio_hybrid.py
CHANGED
|
@@ -185,6 +185,7 @@ async def frontend_websocket(websocket: WebSocket, conversation_id: str):
|
|
| 185 |
manager.send_message({
|
| 186 |
"type": "start_human_chat",
|
| 187 |
"content": "start",
|
|
|
|
| 188 |
"surveyor_persona_id": data.get("surveyor_persona_id", "friendly_researcher_001"),
|
| 189 |
"patient_persona_id": data.get("patient_persona_id", "cooperative_senior_001"),
|
| 190 |
"surveyor_question_bank": data.get("surveyor_question_bank"),
|
|
|
|
| 185 |
manager.send_message({
|
| 186 |
"type": "start_human_chat",
|
| 187 |
"content": "start",
|
| 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_question_bank": data.get("surveyor_question_bank"),
|