MikelWL commited on
Commit
ca1ff64
·
1 Parent(s): bd67c7b

Add config view and shared-password gate

Browse files
README.md CHANGED
@@ -85,8 +85,9 @@ Backend listens on `http://localhost:8000`, Gradio on `http://localhost:7860`.
85
  ## 4. Use the App
86
 
87
  1. Open the UI URL.
88
- 2. Click **Start Conversation**. The UI auto-connects to the backend and refreshes once per second.
89
- 3. Click **Stop Conversation** when finished.
 
90
 
91
  After the conversation completes, the app runs post-conversation analysis and populates:
92
  - Bottom-up findings (emergent themes) with evidence
 
85
  ## 4. Use the App
86
 
87
  1. Open the UI URL.
88
+ 2. If `APP_PASSWORD` is set, enter it on the login page to unlock the app.
89
+ 3. Click **Start Conversation**.
90
+ 4. Click **Stop Conversation** when finished.
91
 
92
  After the conversation completes, the app runs post-conversation analysis and populates:
93
  - Bottom-up findings (emergent themes) with evidence
backend/api/main.py CHANGED
@@ -15,7 +15,7 @@ import logging
15
  import sys
16
  from pathlib import Path
17
 
18
- from fastapi import FastAPI, WebSocket
19
  from fastapi.middleware.cors import CORSMiddleware
20
  import uvicorn
21
 
@@ -30,6 +30,7 @@ from config.settings import get_settings # noqa: E402
30
  from .conversation_ws import websocket_endpoint, manager # noqa: E402
31
  from .routes import router as conversations_router # noqa: E402
32
  from .conversation_service import initialize_conversation_service # noqa: E402
 
33
 
34
  # Load application settings
35
  settings = get_settings()
@@ -61,6 +62,26 @@ app.add_middleware(
61
  # Include API routes
62
  app.include_router(conversations_router)
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  @app.on_event("startup")
66
  async def startup_event():
@@ -98,6 +119,15 @@ async def websocket_conversation_endpoint(websocket: WebSocket, conversation_id:
98
  websocket: WebSocket connection
99
  conversation_id: Unique identifier for the conversation
100
  """
 
 
 
 
 
 
 
 
 
101
  await websocket_endpoint(websocket, conversation_id)
102
 
103
 
 
15
  import sys
16
  from pathlib import Path
17
 
18
+ from fastapi import FastAPI, WebSocket, Request
19
  from fastapi.middleware.cors import CORSMiddleware
20
  import uvicorn
21
 
 
30
  from .conversation_ws import websocket_endpoint, manager # noqa: E402
31
  from .routes import router as conversations_router # noqa: E402
32
  from .conversation_service import initialize_conversation_service # noqa: E402
33
+ from backend.core.auth import COOKIE_NAME, INTERNAL_HEADER, get_app_password, verify_session_token # noqa: E402
34
 
35
  # Load application settings
36
  settings = get_settings()
 
62
  # Include API routes
63
  app.include_router(conversations_router)
64
 
65
+ @app.middleware("http")
66
+ async def auth_middleware(request: Request, call_next):
67
+ password = get_app_password()
68
+ if not password:
69
+ return await call_next(request)
70
+
71
+ path = request.url.path
72
+ if path in ("/health", "/docs", "/openapi.json"):
73
+ return await call_next(request)
74
+
75
+ if request.headers.get(INTERNAL_HEADER) == password:
76
+ return await call_next(request)
77
+
78
+ token = request.cookies.get(COOKIE_NAME)
79
+ if token and verify_session_token(token, password):
80
+ return await call_next(request)
81
+
82
+ from fastapi.responses import JSONResponse
83
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
84
+
85
 
86
  @app.on_event("startup")
87
  async def startup_event():
 
119
  websocket: WebSocket connection
120
  conversation_id: Unique identifier for the conversation
121
  """
122
+ password = get_app_password()
123
+ if password:
124
+ if websocket.headers.get(INTERNAL_HEADER) == password:
125
+ pass
126
+ else:
127
+ token = websocket.cookies.get(COOKIE_NAME)
128
+ if not (token and verify_session_token(token, password)):
129
+ await websocket.close(code=1008)
130
+ return
131
  await websocket_endpoint(websocket, conversation_id)
132
 
133
 
backend/core/auth.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import os
6
+ import time
7
+ from typing import Optional
8
+
9
+
10
+ COOKIE_NAME = "converta_session"
11
+ INTERNAL_HEADER = "x-internal-auth"
12
+
13
+
14
+ def get_app_password() -> Optional[str]:
15
+ value = os.environ.get("APP_PASSWORD")
16
+ if value is None:
17
+ return None
18
+ value = value.strip()
19
+ return value or None
20
+
21
+
22
+ def _b64url_encode(data: bytes) -> str:
23
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
24
+
25
+
26
+ def _b64url_decode(data: str) -> bytes:
27
+ padded = data + "=" * (-len(data) % 4)
28
+ return base64.urlsafe_b64decode(padded.encode("ascii"))
29
+
30
+
31
+ def _sign(payload_b64: str, secret: str) -> str:
32
+ sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
33
+ return _b64url_encode(sig)
34
+
35
+
36
+ def create_session_token(secret: str, ttl_seconds: int = 7 * 24 * 60 * 60) -> str:
37
+ now = int(time.time())
38
+ payload = {"iat": now, "exp": now + ttl_seconds}
39
+ payload_b64 = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
40
+ sig_b64 = _sign(payload_b64, secret)
41
+ return f"{payload_b64}.{sig_b64}"
42
+
43
+
44
+ def verify_session_token(token: str, secret: str) -> bool:
45
+ try:
46
+ payload_b64, sig_b64 = token.split(".", 1)
47
+ except ValueError:
48
+ return False
49
+
50
+ expected = _sign(payload_b64, secret)
51
+ if not hmac.compare_digest(expected, sig_b64):
52
+ return False
53
+
54
+ try:
55
+ payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
56
+ except Exception:
57
+ return False
58
+
59
+ exp = payload.get("exp")
60
+ if not isinstance(exp, int):
61
+ return False
62
+ if exp < int(time.time()):
63
+ return False
64
+
65
+ return True
66
+
67
+
68
+ def constant_time_equals(a: str, b: str) -> bool:
69
+ return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
70
+
docs/development.md CHANGED
@@ -18,6 +18,7 @@ Key environment variables (see `.env.example`):
18
  - `LLM_BACKEND` — `ollama` (local default) or `openrouter`
19
  - `LLM_HOST` / `LLM_MODEL` — target endpoint & model ID
20
  - `LLM_API_KEY`, `LLM_SITE_URL`, `LLM_APP_NAME` — required when using OpenRouter
 
21
  - `FRONTEND_BACKEND_BASE_URL` and `FRONTEND_WEBSOCKET_URL` — how the UI talks to FastAPI
22
  - `LOG_LEVEL` — INFO by default
23
 
@@ -59,6 +60,8 @@ The primary demo UI is served by `frontend/react_gradio_hybrid.py` and includes
59
 
60
  When running outside Docker, you typically run the backend and the web UI separately; when running in Docker/HF, the backend is mounted under `/api` inside the same server.
61
 
 
 
62
  ## Making Changes Safely
63
 
64
  - Prefer editing personas via YAML (`data/`) and restart the backend to reload.
@@ -72,6 +75,10 @@ When running outside Docker, you typically run the backend and the web UI separa
72
  - Manually verify conversations through the Gradio UI.
73
  - If you need to debug the conversation loop, instrument `backend/core/conversation_manager.py` or launch a shell and run it directly.
74
 
 
 
 
 
75
  ## Roadmap & Next Steps
76
 
77
  See `docs/roadmap.md` for current priorities, open questions, and suggested next features (persona selector UI, hosted LLM support, etc.).
 
18
  - `LLM_BACKEND` — `ollama` (local default) or `openrouter`
19
  - `LLM_HOST` / `LLM_MODEL` — target endpoint & model ID
20
  - `LLM_API_KEY`, `LLM_SITE_URL`, `LLM_APP_NAME` — required when using OpenRouter
21
+ - `APP_PASSWORD` — optional shared password gate (when set, the UI + API require login)
22
  - `FRONTEND_BACKEND_BASE_URL` and `FRONTEND_WEBSOCKET_URL` — how the UI talks to FastAPI
23
  - `LOG_LEVEL` — INFO by default
24
 
 
60
 
61
  When running outside Docker, you typically run the backend and the web UI separately; when running in Docker/HF, the backend is mounted under `/api` inside the same server.
62
 
63
+ The UI also includes a **Configuration** view that lets you select personas and add per-role prompt additions. These settings are currently stored in the browser (local-only).
64
+
65
  ## Making Changes Safely
66
 
67
  - Prefer editing personas via YAML (`data/`) and restart the backend to reload.
 
75
  - Manually verify conversations through the Gradio UI.
76
  - If you need to debug the conversation loop, instrument `backend/core/conversation_manager.py` or launch a shell and run it directly.
77
 
78
+ ## Notes on Persistence (HF)
79
+
80
+ Hugging Face Spaces provide a persistent volume (typically mounted at `/data` in Docker Spaces). This repo does not yet persist app data there; future work (persona editing + conversation history) should store shared artifacts under `/data` and treat repo YAML under `data/` as defaults.
81
+
82
  ## Roadmap & Next Steps
83
 
84
  See `docs/roadmap.md` for current priorities, open questions, and suggested next features (persona selector UI, hosted LLM support, etc.).
docs/hf.md CHANGED
@@ -10,6 +10,7 @@ In Space → Settings → Variables and secrets:
10
 
11
  **Secrets**
12
  - `LLM_API_KEY`: OpenRouter API key
 
13
 
14
  **Variables**
15
  - `LLM_BACKEND`: `openrouter`
@@ -32,6 +33,8 @@ Run the Docker image locally before pushing to HF:
32
 
33
  Then open `http://localhost:7860` and click **Start Conversation**.
34
 
 
 
35
  Note: the local Docker runner forces `FRONTEND_WEBSOCKET_URL` to use the mounted backend (`/api/ws/conversation`) so you don’t accidentally point at `localhost:8000`.
36
 
37
  If `7860` is already in use locally, run:
@@ -58,6 +61,8 @@ git push --force hf main
58
 
59
  - **UI loads but analysis never appears / shows backend connection errors**
60
  - Ensure `FRONTEND_WEBSOCKET_URL` is set to `ws://127.0.0.1:7860/api/ws/conversation`.
 
 
61
  - **Space crashes on startup**
62
  - Check Space → Logs for the Python traceback.
63
  - Confirm `PORT` is being respected (HF sets it automatically; we bind to `0.0.0.0:$PORT`).
 
10
 
11
  **Secrets**
12
  - `LLM_API_KEY`: OpenRouter API key
13
+ - `APP_PASSWORD`: shared password to unlock the Space UI (optional but recommended for public Spaces)
14
 
15
  **Variables**
16
  - `LLM_BACKEND`: `openrouter`
 
33
 
34
  Then open `http://localhost:7860` and click **Start Conversation**.
35
 
36
+ If `APP_PASSWORD` is set, you will first see a login page; enter the password to unlock the app.
37
+
38
  Note: the local Docker runner forces `FRONTEND_WEBSOCKET_URL` to use the mounted backend (`/api/ws/conversation`) so you don’t accidentally point at `localhost:8000`.
39
 
40
  If `7860` is already in use locally, run:
 
61
 
62
  - **UI loads but analysis never appears / shows backend connection errors**
63
  - Ensure `FRONTEND_WEBSOCKET_URL` is set to `ws://127.0.0.1:7860/api/ws/conversation`.
64
+ - **Space is public but you want to prevent casual usage**
65
+ - Set the `APP_PASSWORD` secret to enable the shared-password login gate.
66
  - **Space crashes on startup**
67
  - Check Space → Logs for the Python traceback.
68
  - Confirm `PORT` is being respected (HF sets it automatically; we bind to `0.0.0.0:$PORT`).
docs/overview.md CHANGED
@@ -24,7 +24,7 @@ The AI Survey Simulator orchestrates AI-to-AI healthcare survey conversations so
24
 
25
  ## Runtime Flow
26
 
27
- 1. Browser loads the Web UI and opens `ws://.../ws/frontend/{conversation_id}`.
28
  2. The Web UI server bridges that connection to the backend conversation socket at `/api/ws/conversation/{conversation_id}`.
29
  3. Backend spawns a `ConversationManager`, which alternates surveyor/patient turns using the configured LLM.
30
  4. Generated messages stream back to the browser over the bridged WebSocket connection.
@@ -32,6 +32,24 @@ The AI Survey Simulator orchestrates AI-to-AI healthcare survey conversations so
32
  - Bottom-up findings (emergent themes) with evidence pointers
33
  - Top-down coding (care experience rubric + codebook categories) with evidence pointers
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  ## Repository Map (Key Paths)
36
 
37
  ```
 
24
 
25
  ## Runtime Flow
26
 
27
+ 1. Browser loads the Web UI (may require login if `APP_PASSWORD` is set) and opens `ws://.../ws/frontend/{conversation_id}`.
28
  2. The Web UI server bridges that connection to the backend conversation socket at `/api/ws/conversation/{conversation_id}`.
29
  3. Backend spawns a `ConversationManager`, which alternates surveyor/patient turns using the configured LLM.
30
  4. Generated messages stream back to the browser over the bridged WebSocket connection.
 
32
  - Bottom-up findings (emergent themes) with evidence pointers
33
  - Top-down coding (care experience rubric + codebook categories) with evidence pointers
34
 
35
+ ## Configuration UI
36
+
37
+ The UI includes a **Configuration** view (same page, no reload) that lets you:
38
+
39
+ - Select surveyor + patient personas (loaded from `GET /api/personas`)
40
+ - Add optional prompt additions for each role (sent with `start_conversation`)
41
+
42
+ These settings are currently stored in the browser (local-only) and apply to the next run.
43
+
44
+ ## Access Control (Prototype)
45
+
46
+ If `APP_PASSWORD` is set, the Space is gated behind a simple login page:
47
+
48
+ - The browser receives a signed session cookie after login
49
+ - All `/api/*` endpoints and the backend WebSocket require either:
50
+ - that cookie, or
51
+ - an internal header (`x-internal-auth`) used by the UI server when bridging sockets
52
+
53
  ## Repository Map (Key Paths)
54
 
55
  ```
frontend/pages/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """HTML page renderers for the FastAPI-served UI."""
2
+
frontend/pages/config_view.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def get_config_view_js() -> str:
2
+ return r"""
3
+ function ConfigView() {
4
+ const existing = React.useMemo(() => loadConfig(), []);
5
+ const [personas, setPersonas] = React.useState({ surveyors: [], patients: [] });
6
+
7
+ const [selectedSurveyorId, setSelectedSurveyorId] = React.useState(existing?.surveyor_persona_id || 'friendly_researcher_001');
8
+ const [selectedPatientId, setSelectedPatientId] = React.useState(existing?.patient_persona_id || 'cooperative_senior_001');
9
+ const [surveyorPromptAddition, setSurveyorPromptAddition] = React.useState(existing?.surveyor_prompt_addition || '');
10
+ const [patientPromptAddition, setPatientPromptAddition] = React.useState(existing?.patient_prompt_addition || '');
11
+ const [savedAt, setSavedAt] = React.useState(existing?.saved_at || null);
12
+
13
+ React.useEffect(() => {
14
+ fetch('/api/personas')
15
+ .then(r => r.json())
16
+ .then(data => {
17
+ setPersonas({
18
+ surveyors: data.surveyors || [],
19
+ patients: data.patients || []
20
+ });
21
+ })
22
+ .catch(() => {});
23
+ }, []);
24
+
25
+ const onSave = () => {
26
+ const cfg = {
27
+ surveyor_persona_id: selectedSurveyorId,
28
+ patient_persona_id: selectedPatientId,
29
+ surveyor_prompt_addition: surveyorPromptAddition,
30
+ patient_prompt_addition: patientPromptAddition,
31
+ saved_at: new Date().toISOString()
32
+ };
33
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
34
+ setSavedAt(cfg.saved_at);
35
+ };
36
+
37
+ return (
38
+ <div className="bg-white rounded-lg shadow-lg p-6">
39
+ <div className="flex items-center justify-between gap-6 mb-4">
40
+ <div>
41
+ <h2 className="text-xl font-bold text-slate-800">Configuration</h2>
42
+ <p className="text-slate-600 mt-1 text-sm">
43
+ These settings apply to the next conversation run in this browser. Click “Save Configuration” to persist them.
44
+ </p>
45
+ </div>
46
+ </div>
47
+
48
+ <div className="grid grid-cols-2 gap-6">
49
+ <div>
50
+ <label className="block text-sm font-semibold text-slate-700 mb-2">Surveyor persona</label>
51
+ <select
52
+ className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
53
+ value={selectedSurveyorId}
54
+ onChange={(e) => setSelectedSurveyorId(e.target.value)}
55
+ >
56
+ {(personas.surveyors.length ? personas.surveyors : [{id: 'friendly_researcher_001', name: 'Alex Thompson'}]).map(p => (
57
+ <option key={p.id} value={p.id}>{p.name} ({p.id})</option>
58
+ ))}
59
+ </select>
60
+
61
+ <label className="block text-sm font-semibold text-slate-700 mt-4 mb-2">Surveyor prompt addition (optional)</label>
62
+ <textarea
63
+ className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white h-28"
64
+ placeholder="E.g., Ask only one short question per turn; avoid advice; acknowledge and probe..."
65
+ value={surveyorPromptAddition}
66
+ onChange={(e) => setSurveyorPromptAddition(e.target.value)}
67
+ />
68
+ </div>
69
+
70
+ <div>
71
+ <label className="block text-sm font-semibold text-slate-700 mb-2">Patient persona</label>
72
+ <select
73
+ className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
74
+ value={selectedPatientId}
75
+ onChange={(e) => setSelectedPatientId(e.target.value)}
76
+ >
77
+ {(personas.patients.length ? personas.patients : [{id: 'cooperative_senior_001', name: 'Margaret Thompson'}]).map(p => (
78
+ <option key={p.id} value={p.id}>{p.name} ({p.id})</option>
79
+ ))}
80
+ </select>
81
+
82
+ <label className="block text-sm font-semibold text-slate-700 mt-4 mb-2">Patient prompt addition (optional)</label>
83
+ <textarea
84
+ className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white h-28"
85
+ placeholder="E.g., Be brief; focus on routines; mention barriers; ask clarifying questions..."
86
+ value={patientPromptAddition}
87
+ onChange={(e) => setPatientPromptAddition(e.target.value)}
88
+ />
89
+ </div>
90
+ </div>
91
+
92
+ <div className="mt-6 flex items-center justify-between gap-4">
93
+ <div className="text-xs text-slate-500">
94
+ {savedAt ? `Saved: ${new Date(savedAt).toLocaleString()}` : 'Not saved yet.'}
95
+ </div>
96
+ <button
97
+ onClick={onSave}
98
+ className="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg font-semibold transition-all shadow"
99
+ >
100
+ Save Configuration
101
+ </button>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
106
+ """
frontend/pages/login_page.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def get_login_page_html(app_name: str = "ConverTA") -> str:
2
+ return f"""<!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>{app_name} — Login</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ </head>
10
+ <body class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 px-6 py-10">
11
+ <div class="max-w-md mx-auto">
12
+ <div class="flex items-center gap-3 mb-6 px-1">
13
+ <span class="text-3xl">🧠</span>
14
+ <h1 class="text-2xl font-extrabold text-slate-900 tracking-tight">{app_name}</h1>
15
+ </div>
16
+
17
+ <div class="bg-white rounded-lg shadow-lg p-6">
18
+ <h2 class="text-lg font-bold text-slate-900">Access required</h2>
19
+ <p class="text-sm text-slate-600 mt-1">
20
+ Enter the shared password to use the app.
21
+ </p>
22
+
23
+ <div class="mt-4">
24
+ <label class="block text-sm font-semibold text-slate-700 mb-2" for="pw">Password</label>
25
+ <input id="pw" type="password" class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white" autocomplete="current-password" />
26
+ <div id="err" class="text-sm text-red-600 mt-2 hidden"></div>
27
+ </div>
28
+
29
+ <div class="mt-6 flex items-center justify-end">
30
+ <button id="btn" class="bg-slate-900 hover:bg-slate-800 text-white px-4 py-2 rounded-lg text-sm font-semibold transition-all shadow">
31
+ Unlock
32
+ </button>
33
+ </div>
34
+ </div>
35
+ </div>
36
+
37
+ <script>
38
+ const pw = document.getElementById('pw');
39
+ const btn = document.getElementById('btn');
40
+ const err = document.getElementById('err');
41
+
42
+ function showError(msg) {{
43
+ err.textContent = msg;
44
+ err.classList.remove('hidden');
45
+ }}
46
+
47
+ async function submit() {{
48
+ err.classList.add('hidden');
49
+ btn.disabled = true;
50
+ btn.textContent = 'Checking...';
51
+ try {{
52
+ const res = await fetch('/login', {{
53
+ method: 'POST',
54
+ headers: {{ 'Content-Type': 'application/json' }},
55
+ body: JSON.stringify({{ password: pw.value }})
56
+ }});
57
+ if (res.ok) {{
58
+ window.location.reload();
59
+ return;
60
+ }}
61
+ if (res.status === 401) {{
62
+ showError('Incorrect password.');
63
+ return;
64
+ }}
65
+ showError('Login failed.');
66
+ }} catch (e) {{
67
+ showError('Login failed.');
68
+ }} finally {{
69
+ btn.disabled = false;
70
+ btn.textContent = 'Unlock';
71
+ }}
72
+ }}
73
+
74
+ btn.addEventListener('click', submit);
75
+ pw.addEventListener('keydown', (e) => {{
76
+ if (e.key === 'Enter') submit();
77
+ }});
78
+ pw.focus();
79
+ </script>
80
+ </body>
81
+ </html>
82
+ """
83
+
frontend/pages/main_page.py ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .config_view import get_config_view_js
2
+
3
+
4
+ def get_main_page_html() -> str:
5
+ html = r"""<!DOCTYPE html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="UTF-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
10
+ <title>ConverTA</title>
11
+ <script src="https://cdn.tailwindcss.com"></script>
12
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
13
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
14
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
15
+ </head>
16
+ <body>
17
+ <div id="root"></div>
18
+
19
+ <script type="text/babel">
20
+ const { useState, useEffect, useRef } = React;
21
+
22
+ const STORAGE_KEY = 'converta.config.v1';
23
+
24
+ function loadConfig() {
25
+ try {
26
+ const raw = localStorage.getItem(STORAGE_KEY);
27
+ if (!raw) return null;
28
+ return JSON.parse(raw);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function PageNav({ active, onChange }) {
35
+ const base = "px-4 py-2 rounded-lg text-sm font-semibold border transition-colors";
36
+ const activeCls = "bg-slate-900 text-white border-slate-900";
37
+ const inactiveCls = "bg-white text-slate-700 border-slate-300 hover:border-slate-400";
38
+ return (
39
+ <div className="flex gap-2">
40
+ <button type="button" onClick={() => onChange('main')} className={`${base} ${active === 'main' ? activeCls : inactiveCls}`}>Conversation</button>
41
+ <button type="button" onClick={() => onChange('config')} className={`${base} ${active === 'config' ? activeCls : inactiveCls}`}>Configuration</button>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ /*__CONFIG_VIEW__*/
47
+
48
+ function App() {
49
+ const [activePage, setActivePage] = useState('main');
50
+ const [conversationActive, setConversationActive] = useState(false);
51
+ const [messages, setMessages] = useState([]);
52
+ const [insights, setInsights] = useState([]);
53
+ const [routing, setRouting] = useState(null);
54
+ const [resources, setResources] = useState(null);
55
+ const [resourceAgentStatus, setResourceAgentStatus] = useState('idle'); // idle|running|complete|error
56
+ const [resourceAgentError, setResourceAgentError] = useState(null);
57
+ const [connectionStatus, setConnectionStatus] = useState('disconnected');
58
+ const [stats, setStats] = useState({ sent: 0, received: 0 });
59
+
60
+ const wsRef = useRef(null);
61
+ const conversationIdRef = useRef(null);
62
+ const transcriptContainerRef = useRef(null);
63
+ const stickToBottomRef = useRef(true);
64
+
65
+ useEffect(() => {
66
+ return () => {
67
+ if (wsRef.current) {
68
+ wsRef.current.close();
69
+ }
70
+ };
71
+ }, []);
72
+
73
+ useEffect(() => {
74
+ if (activePage !== 'main') return;
75
+ const el = transcriptContainerRef.current;
76
+ if (!el) return;
77
+ if (!stickToBottomRef.current) return;
78
+ el.scrollTop = el.scrollHeight;
79
+ }, [messages.length, activePage]);
80
+
81
+ const connectWebSocket = () => {
82
+ conversationIdRef.current = `react_conv_${Date.now()}`;
83
+ const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
84
+ const wsUrl = `${wsScheme}://${window.location.host}/ws/frontend/${conversationIdRef.current}`;
85
+
86
+ const websocket = new WebSocket(wsUrl);
87
+
88
+ websocket.onopen = () => {
89
+ setConnectionStatus('connected');
90
+ wsRef.current = websocket;
91
+ };
92
+
93
+ websocket.onmessage = (event) => {
94
+ const message = JSON.parse(event.data);
95
+ handleMessage(message);
96
+ };
97
+
98
+ websocket.onerror = () => {
99
+ setConnectionStatus('error');
100
+ };
101
+
102
+ websocket.onclose = () => {
103
+ setConnectionStatus('disconnected');
104
+ wsRef.current = null;
105
+ };
106
+ };
107
+
108
+ const handleMessage = (message) => {
109
+ if (message.type === 'conversation_message') {
110
+ const newMsg = {
111
+ role: message.role,
112
+ text: message.content,
113
+ time: new Date().toLocaleTimeString(),
114
+ persona: message.persona || message.role
115
+ };
116
+ setMessages(prev => [...prev, newMsg]);
117
+ }
118
+ else if (message.type === 'conversation_status') {
119
+ if (message.status === 'completed') {
120
+ setConversationActive(false);
121
+ }
122
+ }
123
+ else if (message.type === 'conversation_error') {
124
+ console.error('Conversation error:', message.error);
125
+ }
126
+ else if (message.type === 'stats') {
127
+ setStats(message.data);
128
+ }
129
+ else if (message.type === 'resource_agent_status') {
130
+ const status = message.status || 'unknown';
131
+ setResourceAgentStatus(status);
132
+ if (status === 'error') {
133
+ setResourceAgentError(message.error || 'Resource agent failed');
134
+ }
135
+ }
136
+ else if (message.type === 'resource_agent_result') {
137
+ setResources(message.data || null);
138
+ setResourceAgentStatus('complete');
139
+ setResourceAgentError(null);
140
+ }
141
+ };
142
+
143
+ const confidenceToPercent = (confidence) => {
144
+ if (typeof confidence !== 'number' || !Number.isFinite(confidence)) return null;
145
+ const normalized = confidence <= 1 ? confidence : (confidence <= 100 ? confidence / 100 : 1);
146
+ return Math.max(0, Math.min(100, Math.round(normalized * 100)));
147
+ };
148
+
149
+ const getEvidenceSnippet = (evidence) => {
150
+ const evidenceId = evidence?.evidence_id;
151
+ if (!evidenceId || !resources?.evidence_catalog) {
152
+ return { label: 'Unknown', snippet: '' };
153
+ }
154
+ const entry = resources.evidence_catalog[evidenceId];
155
+ if (!entry) {
156
+ return { label: evidenceId, snippet: '' };
157
+ }
158
+
159
+ const idx = entry.message_index;
160
+ const msg = messages[idx];
161
+ const label = msg ? `${msg.role === 'surveyor' ? 'Surveyor' : 'Patient'} (${msg.persona})` : `Message #${idx}`;
162
+ return { label, snippet: entry.text || '' };
163
+ };
164
+
165
+ const startConversation = () => {
166
+ setMessages([]);
167
+ setInsights([]);
168
+ setRouting(null);
169
+ setResources(null);
170
+ setResourceAgentStatus('idle');
171
+ setResourceAgentError(null);
172
+ setConversationActive(true);
173
+ stickToBottomRef.current = true;
174
+
175
+ connectWebSocket();
176
+
177
+ const cfg = loadConfig() || {};
178
+ const surveyorId = cfg.surveyor_persona_id || 'friendly_researcher_001';
179
+ const patientId = cfg.patient_persona_id || 'cooperative_senior_001';
180
+
181
+ setTimeout(() => {
182
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
183
+ wsRef.current.send(JSON.stringify({
184
+ type: 'start_conversation',
185
+ surveyor_persona_id: surveyorId,
186
+ patient_persona_id: patientId,
187
+ surveyor_prompt_addition: cfg.surveyor_prompt_addition || undefined,
188
+ patient_prompt_addition: cfg.patient_prompt_addition || undefined
189
+ }));
190
+ }
191
+ }, 500);
192
+ };
193
+
194
+ const stopConversation = () => {
195
+ if (wsRef.current) {
196
+ wsRef.current.send(JSON.stringify({ type: 'stop_conversation' }));
197
+ }
198
+ setConversationActive(false);
199
+ };
200
+
201
+ const onTranscriptScroll = () => {
202
+ const el = transcriptContainerRef.current;
203
+ if (!el) return;
204
+ const thresholdPx = 120;
205
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
206
+ stickToBottomRef.current = distanceFromBottom < thresholdPx;
207
+ };
208
+
209
+ const renderCareBox = (title, tone, data, defaultText) => {
210
+ const toneColor = {
211
+ positive: 'bg-green-50 border-green-200',
212
+ mixed: 'bg-yellow-50 border-yellow-200',
213
+ negative: 'bg-red-50 border-red-200',
214
+ neutral: 'bg-slate-50 border-slate-200'
215
+ }[tone] || 'bg-slate-50 border-slate-200';
216
+
217
+ const hasAny = (box) => {
218
+ if (!box || typeof box !== 'object') return false;
219
+ if (typeof box.confidence === 'number' && box.confidence > 0) return true;
220
+ if (typeof box.summary === 'string' && box.summary.trim().length > 0) return true;
221
+ if (Array.isArray(box.reasons) && box.reasons.length > 0) return true;
222
+ if (Array.isArray(box.evidence) && box.evidence.length > 0) return true;
223
+ return false;
224
+ };
225
+
226
+ const resolved = hasAny(data) ? data : null;
227
+
228
+ return (
229
+ <div className={`border rounded-lg p-3 ${toneColor}`}>
230
+ <div className="flex items-center justify-between">
231
+ <div className="font-semibold text-slate-900">{title}</div>
232
+ </div>
233
+ {!resolved ? (
234
+ <div className="text-sm text-slate-600 mt-2">{defaultText}</div>
235
+ ) : (
236
+ <>
237
+ {typeof resolved.confidence === 'number' && (
238
+ <div className="text-xs text-slate-500 mb-1">
239
+ Confidence: {confidenceToPercent(resolved.confidence)}%
240
+ </div>
241
+ )}
242
+ {resolved.summary && (
243
+ <div className="text-sm text-slate-700">
244
+ <span className="font-semibold text-slate-700">Summary:</span> {resolved.summary}
245
+ </div>
246
+ )}
247
+ {(resolved.reasons || []).length > 0 && (
248
+ <div className="mt-2 space-y-1">
249
+ {(resolved.reasons || []).slice(0, 5).map((reason, idx) => (
250
+ <div key={idx} className="text-xs text-slate-600">- {reason}</div>
251
+ ))}
252
+ </div>
253
+ )}
254
+ {((resolved.evidence || []).length > 0) && (
255
+ <details className="mt-2 bg-white/40 rounded-md px-2 py-1 border border-slate-200">
256
+ <summary className="cursor-pointer select-none text-xs font-semibold text-slate-700">
257
+ Evidence ({(resolved.evidence || []).slice(0, 3).length})
258
+ </summary>
259
+ <div className="mt-2 space-y-1">
260
+ {(resolved.evidence || []).slice(0, 3).map((ev, idx) => {
261
+ const { label, snippet } = getEvidenceSnippet(ev);
262
+ return (
263
+ <div key={idx} className="text-xs text-slate-600">
264
+ <span className="font-medium text-slate-700">{label}:</span> {snippet}
265
+ </div>
266
+ );
267
+ })}
268
+ </div>
269
+ </details>
270
+ )}
271
+ </>
272
+ )}
273
+ </div>
274
+ );
275
+ };
276
+
277
+ return (
278
+ <div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 px-6 py-4">
279
+ <div className="w-full">
280
+ <div className="flex items-center gap-3 mb-4 px-1">
281
+ <span className="text-3xl">🧠</span>
282
+ <h1 className="text-2xl font-extrabold text-slate-900 tracking-tight">ConverTA</h1>
283
+ </div>
284
+
285
+ <div className="bg-white rounded-lg shadow-lg p-4 mb-6">
286
+ <div className="flex items-center justify-between gap-4">
287
+ <PageNav active={activePage} onChange={setActivePage} />
288
+ {activePage === 'main' && (
289
+ !conversationActive ? (
290
+ <button onClick={startConversation} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-semibold flex items-center gap-2 transition-all shadow">
291
+ <span>⚡</span>
292
+ Start
293
+ </button>
294
+ ) : (
295
+ <button onClick={stopConversation} className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm font-semibold transition-all shadow">
296
+ ⏹️ Stop
297
+ </button>
298
+ )
299
+ )}
300
+ </div>
301
+ </div>
302
+
303
+ {activePage === 'config' ? (
304
+ <ConfigView />
305
+ ) : (
306
+ <div className="grid grid-cols-[2fr_1fr_2fr] gap-6 items-start">
307
+ <div className="bg-white rounded-lg shadow-lg p-6">
308
+ <div className="flex items-center gap-2 mb-4">
309
+ <span className="text-2xl">💬</span>
310
+ <h2 className="text-xl font-bold text-slate-800">Live Conversation</h2>
311
+ {conversationActive && <span className="ml-auto text-green-600 font-medium animate-pulse">● LIVE</span>}
312
+ </div>
313
+ <div ref={transcriptContainerRef} onScroll={onTranscriptScroll} className="space-y-3 h-96 overflow-y-auto bg-slate-50 p-4 rounded-lg">
314
+ {messages.length === 0 && (
315
+ <div className="text-center text-slate-400 py-20">
316
+ {conversationActive
317
+ ? '🔄 Waiting for the first messages...'
318
+ : '👋 Click "Start" to begin. This panel streams conversation utterances in real time.'}
319
+ </div>
320
+ )}
321
+ {messages.map((msg, idx) => (
322
+ <div key={idx} className={`p-4 rounded-lg shadow-sm ${msg.role === 'surveyor' ? 'bg-blue-50 border-l-4 border-blue-500' : 'bg-green-50 border-l-4 border-green-500'}`}>
323
+ <div className="flex items-center gap-2 mb-1">
324
+ <span className="font-semibold text-sm">
325
+ {msg.role === 'surveyor' ? '🔵' : '🟢'} {msg.persona}
326
+ </span>
327
+ <span className="text-xs text-slate-500">{msg.time}</span>
328
+ </div>
329
+ <p className="text-slate-700">{msg.text}</p>
330
+ </div>
331
+ ))}
332
+ </div>
333
+ </div>
334
+
335
+ <div className="bg-white rounded-lg shadow-lg p-6">
336
+ <div className="flex items-center gap-2 mb-4">
337
+ <span className="text-2xl">📊</span>
338
+ <h2 className="text-xl font-bold text-slate-800">Bottom-Up Findings</h2>
339
+ {resourceAgentStatus === 'running' && (
340
+ <span className="ml-auto text-purple-600 font-medium animate-pulse">● RUNNING</span>
341
+ )}
342
+ {((resources?.health_situations || []).length > 0) && (
343
+ <span className="ml-auto bg-green-100 text-green-700 px-3 py-1 rounded-full text-sm font-medium">
344
+ {(resources?.health_situations || []).length}
345
+ </span>
346
+ )}
347
+ </div>
348
+ <div className="space-y-2 max-h-[42rem] overflow-y-auto">
349
+ {resourceAgentStatus !== 'complete' && (
350
+ <p className="text-slate-400 text-center py-8 text-sm">
351
+ {conversationActive
352
+ ? 'Runs automatically when the conversation completes...'
353
+ : 'Runs automatically when the conversation completes. Evidence-backed emergent themes (open coding).'}
354
+ </p>
355
+ )}
356
+ {resourceAgentStatus === 'complete' && resources && (
357
+ <>
358
+ {(resources.health_situations || []).length === 0 ? (
359
+ <p className="text-slate-400 text-center py-8 text-sm">No findings detected.</p>
360
+ ) : (
361
+ <div className="space-y-3">
362
+ {(resources.health_situations || []).map((item, idx) => (
363
+ <div key={idx} className="bg-slate-50 border border-slate-200 rounded-lg p-3">
364
+ <div className="flex items-center gap-2">
365
+ <div className="font-semibold text-slate-800">
366
+ {(item.code && String(item.code).trim().length > 0) ? item.code : 'Finding'}
367
+ </div>
368
+ {typeof item.confidence === 'number' && (
369
+ <div className="ml-auto text-xs text-slate-500">
370
+ {confidenceToPercent(item.confidence)}%
371
+ </div>
372
+ )}
373
+ </div>
374
+ {item.summary && (
375
+ <div className="text-sm text-slate-700 mt-1">
376
+ <span className="font-semibold text-slate-700">Summary:</span> {item.summary}
377
+ </div>
378
+ )}
379
+ <details className="mt-2 bg-white/40 rounded-md px-2 py-1 border border-slate-200">
380
+ <summary className="cursor-pointer select-none text-xs font-semibold text-slate-700">
381
+ Evidence ({(item.evidence || []).slice(0, 3).length})
382
+ </summary>
383
+ <div className="mt-2 space-y-1">
384
+ {(item.evidence || []).slice(0, 3).map((ev, evIdx) => {
385
+ const { label, snippet } = getEvidenceSnippet(ev);
386
+ return (
387
+ <div key={evIdx} className="text-xs text-slate-600">
388
+ <span className="font-medium text-slate-700">{label}:</span> {snippet}
389
+ </div>
390
+ );
391
+ })}
392
+ </div>
393
+ </details>
394
+ </div>
395
+ ))}
396
+ </div>
397
+ )}
398
+ </>
399
+ )}
400
+ </div>
401
+ </div>
402
+
403
+ <div className="bg-white rounded-lg shadow-lg p-6">
404
+ <div className="flex items-center gap-2 mb-4">
405
+ <span className="text-2xl">📚</span>
406
+ <h2 className="text-xl font-bold text-slate-800">Top-Down Coding</h2>
407
+ {resourceAgentStatus === 'running' && (
408
+ <span className="ml-auto text-purple-600 font-medium animate-pulse">● RUNNING</span>
409
+ )}
410
+ </div>
411
+
412
+ {resourceAgentStatus !== 'complete' && (
413
+ <p className="text-slate-400 text-center py-8 text-sm">
414
+ {conversationActive
415
+ ? 'Runs automatically when the conversation completes...'
416
+ : 'Runs automatically when the conversation completes. Rubric + a priori codebook (top-down coding).'}
417
+ </p>
418
+ )}
419
+
420
+ {resourceAgentStatus === 'complete' && (
421
+ <div className="grid grid-cols-2 gap-4">
422
+ <div className="space-y-3">
423
+ <div className="text-lg font-extrabold text-slate-900 mb-2">Care experience rubric</div>
424
+ {(() => {
425
+ const care = resources?.care_experience || {};
426
+ const positive = care.positive || null;
427
+ const mixed = care.mixed || null;
428
+ const negative = care.negative || null;
429
+ const neutral = care.neutral || null;
430
+
431
+ return (
432
+ <>
433
+ {renderCareBox('Positive', 'positive', positive, 'No positive insights detected.')}
434
+ {renderCareBox('Mixed / Tradeoffs', 'mixed', mixed, 'No mixed/tradeoff insights detected.')}
435
+ {renderCareBox('Negative', 'negative', negative, 'No negative insights detected.')}
436
+ {renderCareBox('Neutral / Additional', 'neutral', neutral, 'No neutral/additional observations detected.')}
437
+ </>
438
+ );
439
+ })()}
440
+ </div>
441
+
442
+ <div className="space-y-3">
443
+ <div className="text-lg font-extrabold text-slate-900 mb-2">Top-down codebook categories</div>
444
+ {(() => {
445
+ const td = resources?.top_down_codes || {};
446
+ const order = [
447
+ { key: 'symptoms_concerns', label: 'Symptoms/concerns', empty: 'No symptoms/concerns excerpts detected.' },
448
+ { key: 'daily_management', label: 'Daily management', empty: 'No daily management excerpts detected.' },
449
+ { key: 'barriers_constraints', label: 'Barriers/constraints', empty: 'No barriers/constraints excerpts detected.' },
450
+ { key: 'support_resources', label: 'Support/resources', empty: 'No support/resources excerpts detected.' }
451
+ ];
452
+
453
+ return (
454
+ <div className="space-y-3">
455
+ {order.map(({ key, label, empty }) => {
456
+ const arr = td[key] || [];
457
+ return (
458
+ <div key={key} className="bg-slate-50 border border-slate-200 rounded-lg p-3">
459
+ <div className="font-extrabold text-slate-900">Code: {label}</div>
460
+ {arr.length === 0 ? (
461
+ <div className="text-sm text-slate-600 mt-2">{empty}</div>
462
+ ) : (
463
+ <div className="mt-2 space-y-3">
464
+ {arr.map((item, idx) => (
465
+ <div key={idx} className="bg-white border border-slate-200 rounded-lg p-3">
466
+ <div className="flex items-center gap-2">
467
+ <div className="font-semibold text-slate-800">
468
+ {(item.code && String(item.code).trim().length > 0) ? item.code : 'Finding'}
469
+ </div>
470
+ {typeof item.confidence === 'number' && (
471
+ <div className="ml-auto text-xs text-slate-500">
472
+ {confidenceToPercent(item.confidence)}%
473
+ </div>
474
+ )}
475
+ </div>
476
+ {item.summary && (
477
+ <div className="text-sm text-slate-700 mt-1">
478
+ <span className="font-semibold text-slate-700">Summary:</span> {item.summary}
479
+ </div>
480
+ )}
481
+ <details className="mt-2 bg-white/40 rounded-md px-2 py-1 border border-slate-200">
482
+ <summary className="cursor-pointer select-none text-xs font-semibold text-slate-700">
483
+ Evidence ({(item.evidence || []).slice(0, 3).length})
484
+ </summary>
485
+ <div className="mt-2 space-y-1">
486
+ {(item.evidence || []).slice(0, 3).map((ev, idx2) => {
487
+ const { label: evLabel, snippet } = getEvidenceSnippet(ev);
488
+ return (
489
+ <div key={idx2} className="text-xs text-slate-600">
490
+ <span className="font-medium text-slate-700">{evLabel}:</span> {snippet}
491
+ </div>
492
+ );
493
+ })}
494
+ </div>
495
+ </details>
496
+ </div>
497
+ ))}
498
+ </div>
499
+ )}
500
+ </div>
501
+ );
502
+ })}
503
+ </div>
504
+ );
505
+ })()}
506
+ </div>
507
+ </div>
508
+ )}
509
+ </div>
510
+ </div>
511
+ )}
512
+ </div>
513
+ </div>
514
+ );
515
+ }
516
+
517
+ const root = ReactDOM.createRoot(document.getElementById('root'));
518
+ root.render(<App />);
519
+ </script>
520
+ </body>
521
+ </html>
522
+ """
523
+
524
+ return html.replace("/*__CONFIG_VIEW__*/", get_config_view_js())
frontend/react_gradio_hybrid.py CHANGED
@@ -1,15 +1,12 @@
1
  import sys
2
- import json
3
- import time
4
  import os
5
  import asyncio
6
  from pathlib import Path
7
- from typing import Dict, List
8
  import logging
9
 
10
- from fastapi import FastAPI, WebSocket, WebSocketDisconnect
11
- from fastapi.staticfiles import StaticFiles
12
- from fastapi.responses import HTMLResponse, FileResponse
13
  from fastapi.middleware.cors import CORSMiddleware
14
  import uvicorn
15
 
@@ -19,10 +16,20 @@ sys.path.insert(0, str(project_root))
19
  sys.path.insert(0, str(project_root / "frontend"))
20
 
21
  from config.settings import get_settings
22
- from websocket_manager import WebSocketManager, ManagerState
 
 
23
  from backend.api.main import app as backend_app
24
  from backend.api.conversation_ws import manager as backend_ws_manager
25
  from backend.api.conversation_service import initialize_conversation_service
 
 
 
 
 
 
 
 
26
 
27
  # Load settings
28
  settings = get_settings()
@@ -38,11 +45,12 @@ app = FastAPI(title="AI Survey Simulator - React Frontend")
38
  # Mount backend API under /api so the Space can run as a single process
39
  app.mount("/api", backend_app)
40
 
 
41
  @app.on_event("startup")
42
  async def initialize_backend_services():
43
- """Initialize backend services when running the mounted backend inside this app."""
44
  initialize_conversation_service(backend_ws_manager, settings)
45
 
 
46
  # Enable CORS for local development
47
  app.add_middleware(
48
  CORSMiddleware,
@@ -57,732 +65,136 @@ active_managers: Dict[str, WebSocketManager] = {}
57
  frontend_connections: Dict[str, WebSocket] = {}
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  @app.get("/", response_class=HTMLResponse)
61
- async def serve_frontend():
62
- """Serve the React frontend."""
63
- html_content = """
64
- <!DOCTYPE html>
65
- <html lang="en">
66
- <head>
67
- <meta charset="UTF-8">
68
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
69
- <title>Agentic AI Survey Simulator</title>
70
- <script src="https://cdn.tailwindcss.com"></script>
71
- <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
72
- <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
73
- <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
74
- </head>
75
- <body>
76
- <div id="root"></div>
77
-
78
- <script type="text/babel">
79
- const { useState, useEffect, useRef } = React;
80
-
81
- function App() {
82
- const [conversationActive, setConversationActive] = useState(false);
83
- const [messages, setMessages] = useState([]);
84
- const [insights, setInsights] = useState([]);
85
- const [routing, setRouting] = useState(null);
86
- const [resources, setResources] = useState(null);
87
- const [resourceAgentStatus, setResourceAgentStatus] = useState('idle'); // idle|running|complete|error
88
- const [resourceAgentError, setResourceAgentError] = useState(null);
89
- const [connectionStatus, setConnectionStatus] = useState('disconnected');
90
- const [stats, setStats] = useState({ sent: 0, received: 0 });
91
- const [personas, setPersonas] = useState({ surveyors: [], patients: [] });
92
- const [selectedSurveyorId, setSelectedSurveyorId] = useState('friendly_researcher_001');
93
- const [selectedPatientId, setSelectedPatientId] = useState('cooperative_senior_001');
94
- const [surveyorPromptAddition, setSurveyorPromptAddition] = useState('');
95
- const [patientPromptAddition, setPatientPromptAddition] = useState('');
96
-
97
- const wsRef = useRef(null);
98
- const conversationIdRef = useRef(null);
99
-
100
- useEffect(() => {
101
- // Load personas for configuration panel
102
- fetch('/api/personas')
103
- .then(r => r.json())
104
- .then(data => {
105
- if (data && (data.surveyors || data.patients)) {
106
- setPersonas({
107
- surveyors: data.surveyors || [],
108
- patients: data.patients || []
109
- });
110
- }
111
- })
112
- .catch(() => {});
113
-
114
- return () => {
115
- if (wsRef.current) {
116
- wsRef.current.close();
117
- }
118
- };
119
- }, []);
120
-
121
- const connectWebSocket = () => {
122
- conversationIdRef.current = `react_conv_${Date.now()}`;
123
- const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
124
- const wsUrl = `${wsScheme}://${window.location.host}/ws/frontend/${conversationIdRef.current}`;
125
-
126
- const websocket = new WebSocket(wsUrl);
127
-
128
- websocket.onopen = () => {
129
- console.log('WebSocket connected');
130
- setConnectionStatus('connected');
131
- wsRef.current = websocket;
132
- };
133
-
134
- websocket.onmessage = (event) => {
135
- const message = JSON.parse(event.data);
136
- handleMessage(message);
137
- };
138
-
139
- websocket.onerror = (error) => {
140
- console.error('WebSocket error:', error);
141
- setConnectionStatus('error');
142
- };
143
-
144
- websocket.onclose = () => {
145
- console.log('WebSocket closed');
146
- setConnectionStatus('disconnected');
147
- wsRef.current = null;
148
- };
149
- };
150
-
151
- const handleMessage = (message) => {
152
- console.log('Received:', message.type);
153
-
154
- if (message.type === 'conversation_message') {
155
- const newMsg = {
156
- role: message.role,
157
- text: message.content,
158
- time: new Date().toLocaleTimeString(),
159
- persona: message.persona || message.role
160
- };
161
- setMessages(prev => [...prev, newMsg]);
162
-
163
- // Auto-extract insights
164
- extractInsightsFromMessage(message.content, message.role);
165
- }
166
- else if (message.type === 'conversation_status') {
167
- console.log('Status:', message.status);
168
- if (message.status === 'completed') {
169
- setConversationActive(false);
170
- }
171
- }
172
- else if (message.type === 'conversation_error') {
173
- const err = message.error || 'Unknown conversation error';
174
- console.error('Conversation error:', err);
175
- }
176
- else if (message.type === 'stats') {
177
- setStats(message.data);
178
- }
179
- else if (message.type === 'error') {
180
- console.error('Error:', message.error);
181
- }
182
- else if (message.type === 'resource_agent_status') {
183
- const status = message.status || 'unknown';
184
- setResourceAgentStatus(status);
185
- if (status === 'error') {
186
- const err = message.error || 'Resource agent failed';
187
- setResourceAgentError(err);
188
- }
189
- }
190
- else if (message.type === 'resource_agent_result') {
191
- setResources(message.data || null);
192
- setResourceAgentStatus('complete');
193
- setResourceAgentError(null);
194
- }
195
- };
196
-
197
- const getEvidenceSnippet = (evidence) => {
198
- const evidenceId = evidence?.evidence_id;
199
- if (!evidenceId || !resources?.evidence_catalog) {
200
- return { label: 'Unknown', snippet: '' };
201
- }
202
- const entry = resources.evidence_catalog[evidenceId];
203
- if (!entry) {
204
- return { label: evidenceId, snippet: '' };
205
- }
206
-
207
- const idx = entry.message_index;
208
- const msg = messages[idx];
209
- const label = msg ? `${msg.role === 'surveyor' ? 'Surveyor' : 'Patient'} (${msg.persona})` : `Message #${idx}`;
210
- return { label, snippet: entry.text || '' };
211
- };
212
-
213
- const confidenceToPercent = (confidence) => {
214
- if (typeof confidence !== 'number' || !Number.isFinite(confidence)) return null;
215
- const normalized = confidence <= 1 ? confidence : (confidence <= 100 ? confidence / 100 : 1);
216
- return Math.max(0, Math.min(100, Math.round(normalized * 100)));
217
- };
218
-
219
- const extractInsightsFromMessage = (content, role) => {
220
- const lowerContent = content.toLowerCase();
221
-
222
- // Health conditions
223
- const conditions = [
224
- { keyword: 'diabetes', icon: '🏥', label: 'Type 2 Diabetes' },
225
- { keyword: 'pressure', icon: '🏥', label: 'Blood Pressure' },
226
- { keyword: 'arthritis', icon: '🏥', label: 'Arthritis' },
227
- { keyword: 'pain', icon: '🏥', label: 'Chronic Pain' }
228
- ];
229
-
230
- // Activities
231
- const activities = [
232
- { keyword: 'walk', icon: '🚶', label: 'Walking' },
233
- { keyword: 'exercise', icon: '💪', label: 'Exercise' },
234
- { keyword: 'yoga', icon: '🧘', label: 'Yoga' },
235
- { keyword: 'swim', icon: '🏊', label: 'Swimming' }
236
- ];
237
-
238
- // Barriers
239
- const barriers = [
240
- { keyword: 'difficult', icon: '⚠️', label: 'Difficulty' },
241
- { keyword: 'hard', icon: '⚠️', label: 'Challenge' },
242
- { keyword: 'mobility', icon: '⚠️', label: 'Mobility Issues' }
243
- ];
244
-
245
- // Emotions
246
- const emotions = [
247
- { keyword: 'discouraged', icon: '😔', label: 'Feeling Discouraged' },
248
- { keyword: 'frustrated', icon: '😞', label: 'Frustrated' },
249
- { keyword: 'worried', icon: '😰', label: 'Worried' },
250
- { keyword: 'happy', icon: '😊', label: 'Happy' }
251
- ];
252
-
253
- const allKeywords = [
254
- ...conditions.map(c => ({...c, category: 'Condition'})),
255
- ...activities.map(a => ({...a, category: 'Activity'})),
256
- ...barriers.map(b => ({...b, category: 'Barrier'})),
257
- ...emotions.map(e => ({...e, category: 'Emotional'}))
258
- ];
259
-
260
- allKeywords.forEach(item => {
261
- if (lowerContent.includes(item.keyword)) {
262
- setInsights(prev => {
263
- // Avoid duplicates
264
- if (prev.find(i => i.value === item.label)) return prev;
265
-
266
- return [...prev, {
267
- category: item.category,
268
- value: item.label,
269
- confidence: 0.85 + Math.random() * 0.13,
270
- icon: item.icon
271
- }];
272
- });
273
- }
274
- });
275
- };
276
-
277
- const startConversation = () => {
278
- setMessages([]);
279
- setInsights([]);
280
- setRouting(null);
281
- setResources(null);
282
- setResourceAgentStatus('idle');
283
- setResourceAgentError(null);
284
- setConversationActive(true);
285
-
286
- connectWebSocket();
287
-
288
- // Wait for connection then send start message
289
- setTimeout(() => {
290
- if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
291
- wsRef.current.send(JSON.stringify({
292
- type: 'start_conversation',
293
- surveyor_persona_id: selectedSurveyorId,
294
- patient_persona_id: selectedPatientId,
295
- surveyor_prompt_addition: surveyorPromptAddition || undefined,
296
- patient_prompt_addition: patientPromptAddition || undefined
297
- }));
298
- }
299
- }, 500);
300
- };
301
-
302
- const stopConversation = () => {
303
- if (wsRef.current) {
304
- wsRef.current.send(JSON.stringify({
305
- type: 'stop_conversation'
306
- }));
307
- }
308
- setConversationActive(false);
309
- };
310
-
311
- return (
312
- <div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-6">
313
- <div className="max-w-7xl mx-auto">
314
- {/* Header */}
315
- <div className="bg-white rounded-lg shadow-lg p-6 mb-6">
316
- <div className="flex items-center justify-between">
317
- <div>
318
- <h1 className="text-3xl font-bold text-slate-800 flex items-center gap-3">
319
- <span className="text-4xl">🧠</span>
320
- Agentic AI Survey Simulator
321
- </h1>
322
- <p className="text-slate-600 mt-2">Multi-agent health conversation analysis system</p>
323
- </div>
324
- <div className="flex gap-3">
325
- {!conversationActive ? (
326
- <button
327
- onClick={startConversation}
328
- className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold flex items-center gap-2 transition-all shadow-lg hover:shadow-xl"
329
- >
330
- <span>⚡</span>
331
- Start Conversation
332
- </button>
333
- ) : (
334
- <button
335
- onClick={stopConversation}
336
- className="bg-red-600 hover:bg-red-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg"
337
- >
338
- ⏹️ Stop Conversation
339
- </button>
340
- )}
341
- </div>
342
- </div>
343
-
344
- {/* Configuration Panel */}
345
- {!conversationActive && (
346
- <div className="mt-6 border-t border-slate-200 pt-6">
347
- <div className="flex items-center gap-2 mb-4">
348
- <span className="text-xl">⚙️</span>
349
- <h2 className="text-lg font-bold text-slate-800">Configuration</h2>
350
- <span className="text-xs text-slate-500">(applies to the next run)</span>
351
- </div>
352
-
353
- <div className="grid grid-cols-2 gap-6">
354
- <div>
355
- <label className="block text-sm font-semibold text-slate-700 mb-2">Surveyor persona</label>
356
- <select
357
- className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
358
- value={selectedSurveyorId}
359
- onChange={(e) => setSelectedSurveyorId(e.target.value)}
360
- >
361
- {(personas.surveyors.length ? personas.surveyors : [{id: 'friendly_researcher_001', name: 'Alex Thompson'}]).map(p => (
362
- <option key={p.id} value={p.id}>{p.name} ({p.id})</option>
363
- ))}
364
- </select>
365
- <label className="block text-sm font-semibold text-slate-700 mt-4 mb-2">Surveyor prompt addition (optional)</label>
366
- <textarea
367
- className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white h-24"
368
- placeholder="E.g., Ask shorter questions, focus on follow-ups, avoid advice..."
369
- value={surveyorPromptAddition}
370
- onChange={(e) => setSurveyorPromptAddition(e.target.value)}
371
- />
372
- </div>
373
-
374
- <div>
375
- <label className="block text-sm font-semibold text-slate-700 mb-2">Patient persona</label>
376
- <select
377
- className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
378
- value={selectedPatientId}
379
- onChange={(e) => setSelectedPatientId(e.target.value)}
380
- >
381
- {(personas.patients.length ? personas.patients : [{id: 'cooperative_senior_001', name: 'Margaret Thompson'}]).map(p => (
382
- <option key={p.id} value={p.id}>{p.name} ({p.id})</option>
383
- ))}
384
- </select>
385
- <label className="block text-sm font-semibold text-slate-700 mt-4 mb-2">Patient prompt addition (optional)</label>
386
- <textarea
387
- className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white h-24"
388
- placeholder="E.g., Be brief, focus on daily routines, mention barriers..."
389
- value={patientPromptAddition}
390
- onChange={(e) => setPatientPromptAddition(e.target.value)}
391
- />
392
- </div>
393
- </div>
394
- </div>
395
- )}
396
- </div>
397
-
398
- {/* System Status Bar */}
399
- <div className="bg-white rounded-lg shadow-lg p-4 mb-6">
400
- <div className="grid grid-cols-5 gap-4 text-sm">
401
- <div className="flex items-center gap-2">
402
- <div className={`w-3 h-3 rounded-full ${connectionStatus === 'connected' ? 'bg-green-500 animate-pulse' : 'bg-gray-300'}`}></div>
403
- <span className="font-medium">Connection: {connectionStatus}</span>
404
- </div>
405
- <div className="text-slate-600">Messages: {messages.length}</div>
406
- <div className="text-slate-600">Findings: {(resources?.health_situations || []).length}</div>
407
- <div className="text-slate-600">Sent: {stats.sent} | Received: {stats.received}</div>
408
- <div className="text-slate-600">Conv ID: {conversationIdRef.current ? `#${conversationIdRef.current.slice(-6)}` : 'N/A'}</div>
409
- </div>
410
- </div>
411
-
412
- {/* Main Content Grid */}
413
- <div className="grid grid-cols-3 gap-6">
414
- {/* Left Column - Conversation */}
415
- <div className="col-span-2 space-y-6">
416
- <div className="bg-white rounded-lg shadow-lg p-6">
417
- <div className="flex items-center gap-2 mb-4">
418
- <span className="text-2xl">💬</span>
419
- <h2 className="text-xl font-bold text-slate-800">Live Conversation</h2>
420
- {conversationActive && <span className="ml-auto text-green-600 font-medium animate-pulse">● LIVE</span>}
421
- </div>
422
- <div className="space-y-3 h-96 overflow-y-auto bg-slate-50 p-4 rounded-lg">
423
- {messages.length === 0 && (
424
- <div className="text-center text-slate-400 py-20">
425
- {conversationActive
426
- ? '🔄 Waiting for the first messages...'
427
- : '👋 Click "Start Conversation" to begin. This panel streams the conversation utterances in real time.'}
428
- </div>
429
- )}
430
- {messages.map((msg, idx) => (
431
- <div key={idx} className={`p-4 rounded-lg shadow-sm ${msg.role === 'surveyor' ? 'bg-blue-50 border-l-4 border-blue-500' : 'bg-green-50 border-l-4 border-green-500'}`}>
432
- <div className="flex items-center gap-2 mb-1">
433
- <span className="font-semibold text-sm">
434
- {msg.role === 'surveyor' ? '🔵' : '🟢'} {msg.persona}
435
- </span>
436
- <span className="text-xs text-slate-500">{msg.time}</span>
437
- </div>
438
- <p className="text-slate-700">{msg.text}</p>
439
- </div>
440
- ))}
441
- </div>
442
- </div>
443
- </div>
444
-
445
- {/* Right Column - Agents */}
446
- <div className="space-y-6">
447
- {/* Insights */}
448
- <div className="bg-white rounded-lg shadow-lg p-6">
449
- <div className="flex items-center gap-2 mb-4">
450
- <span className="text-2xl">📊</span>
451
- <h2 className="text-xl font-bold text-slate-800">Bottom-Up Findings (Emergent Themes)</h2>
452
- {resourceAgentStatus === 'running' && (
453
- <span className="ml-auto text-purple-600 font-medium animate-pulse">● RUNNING</span>
454
- )}
455
- {((resources?.health_situations || []).length > 0) && (
456
- <span className="ml-auto bg-green-100 text-green-700 px-3 py-1 rounded-full text-sm font-medium">
457
- {(resources?.health_situations || []).length}
458
- </span>
459
- )}
460
- </div>
461
- <div className="space-y-2 max-h-96 overflow-y-auto">
462
- {resourceAgentStatus !== 'complete' && (
463
- <p className="text-slate-400 text-center py-8 text-sm">
464
- {conversationActive
465
- ? 'Runs automatically when the conversation completes. Waiting to generate bottom-up themes...'
466
- : 'Runs automatically when the conversation completes. This panel will show bottom-up, evidence-backed emergent themes derived from the transcript (open coding).'}
467
- </p>
468
- )}
469
- {resourceAgentStatus === 'complete' && resources && (
470
- <>
471
- {(resources.health_situations || []).length === 0 ? (
472
- <p className="text-slate-400 text-center py-8 text-sm">No findings detected.</p>
473
- ) : (
474
- <div className="space-y-3">
475
- {(resources.health_situations || []).map((item, idx) => (
476
- <div key={idx} className="bg-slate-50 border border-slate-200 rounded-lg p-3">
477
- <div className="flex items-center gap-2">
478
- <div className="font-semibold text-slate-800">
479
- {(item.code && String(item.code).trim().length > 0) ? item.code : 'Finding'}
480
- </div>
481
- {typeof item.confidence === 'number' && (
482
- <div className="ml-auto text-xs text-slate-500">
483
- {confidenceToPercent(item.confidence)}%
484
- </div>
485
- )}
486
- </div>
487
- {item.summary && (
488
- <div className="text-sm text-slate-700 mt-1">
489
- <span className="font-semibold text-slate-700">Summary:</span> {item.summary}
490
- </div>
491
- )}
492
- <div className="mt-2">
493
- <div className="text-xs font-semibold text-slate-600 mb-1">Evidence:</div>
494
- <div className="space-y-1">
495
- {(item.evidence || []).slice(0, 3).map((ev, evIdx) => {
496
- const { label, snippet } = getEvidenceSnippet(ev);
497
- return (
498
- <div key={evIdx} className="text-xs text-slate-600">
499
- <span className="font-medium text-slate-700">{label}:</span> {snippet}
500
- </div>
501
- );
502
- })}
503
- </div>
504
- </div>
505
- </div>
506
- ))}
507
- </div>
508
- )}
509
- </>
510
- )}
511
- </div>
512
- </div>
513
- </div>
514
- </div>
515
-
516
- {/* Resource Agent (full-width) */}
517
- <div className="mt-6 bg-white rounded-lg shadow-lg p-6">
518
- <div className="flex items-center gap-2 mb-4">
519
- <span className="text-2xl">📚</span>
520
- <h2 className="text-xl font-bold text-slate-800">Top-Down Coding — Rubric + Codebook</h2>
521
- {resourceAgentStatus === 'running' && (
522
- <span className="ml-auto text-purple-600 font-medium animate-pulse">● RUNNING</span>
523
- )}
524
- </div>
525
- {resourceAgentStatus === 'idle' && (
526
- <div className="text-slate-400 text-center py-8 text-sm">
527
- Runs automatically when the conversation completes.
528
- <div className="mt-3 text-slate-500 text-xs">
529
- Applies a top-down care experience rubric and additional a priori codebook categories to the transcript, returning evidence-backed findings.
530
- </div>
531
- </div>
532
- )}
533
- {resourceAgentStatus === 'error' && (
534
- <div className="text-red-600 text-center py-6 text-sm">
535
- {resourceAgentError || 'Resource agent failed.'}
536
- </div>
537
- )}
538
- {resourceAgentStatus === 'complete' && resources && (
539
- <div className="grid grid-cols-2 gap-6">
540
- <div className="border border-slate-200 rounded-lg p-4 bg-slate-50">
541
- {(() => {
542
- const tdc = resources.top_down_codes || {};
543
- const sections = [
544
- { key: 'symptoms_concerns', title: 'Symptoms / concerns' },
545
- { key: 'daily_management', title: 'Daily management' },
546
- { key: 'barriers_constraints', title: 'Barriers / constraints' },
547
- { key: 'support_resources', title: 'Support / resources' },
548
- ];
549
-
550
- const renderItem = (item, idx) => (
551
- <div key={idx} className="bg-white border border-slate-200 rounded-lg p-3">
552
- <div className="flex items-center gap-2">
553
- <div className="font-extrabold text-slate-900 text-lg leading-snug">
554
- {(item.code && String(item.code).trim().length > 0) ? item.code : 'Code'}
555
- </div>
556
- {typeof item.confidence === 'number' && (
557
- <div className="ml-auto text-xs text-slate-500">
558
- {confidenceToPercent(item.confidence)}%
559
- </div>
560
- )}
561
- </div>
562
- {item.summary && (
563
- <div className="text-sm text-slate-700 mt-1">
564
- <span className="font-semibold text-slate-700">Summary:</span> {item.summary}
565
- </div>
566
- )}
567
- <div className="mt-2">
568
- <div className="text-xs font-semibold text-slate-600 mb-1">Evidence:</div>
569
- <div className="space-y-1">
570
- {(item.evidence || []).slice(0, 2).map((ev, evIdx) => {
571
- const { label, snippet } = getEvidenceSnippet(ev);
572
- return (
573
- <div key={evIdx} className="text-xs text-slate-600">
574
- <span className="font-medium text-slate-700">{label}:</span> {snippet}
575
- </div>
576
- );
577
- })}
578
- </div>
579
- </div>
580
- </div>
581
- );
582
-
583
- return (
584
- <div className="space-y-4">
585
- {sections.map(section => {
586
- const items = (tdc[section.key] || []).slice(0, 3);
587
- return (
588
- <div key={section.key}>
589
- <div className="text-lg font-extrabold text-slate-900 mb-2">
590
- Code: {section.title}
591
- </div>
592
- {items.length === 0 ? (
593
- <div className="text-slate-400 text-sm">No codes found.</div>
594
- ) : (
595
- <div className="space-y-2">
596
- {items.map(renderItem)}
597
- </div>
598
- )}
599
- </div>
600
- );
601
- })}
602
- </div>
603
- );
604
- })()}
605
- </div>
606
-
607
- <div className="border border-slate-200 rounded-lg p-4 bg-slate-50">
608
- <div className="text-lg font-extrabold text-slate-900 mb-2">Care experience rubric</div>
609
- <div className="space-y-4">
610
- {(() => {
611
- const ce = resources.care_experience || {};
612
- const positive = ce.positive || (ce.sentiment === 'positive' ? ce : null) || null;
613
- const negative = ce.negative || (ce.sentiment === 'negative' ? ce : null) || null;
614
- const mixed = ce.mixed || (ce.sentiment === 'mixed' ? ce : null) || null;
615
- const neutral = ce.neutral || (['neutral', 'mixed', 'unknown'].includes(ce.sentiment) ? ce : null) || null;
616
-
617
- const renderCareBox = (title, kind, data, emptyText) => (
618
- (() => {
619
- const stylesByKind = {
620
- positive: { border: 'border-green-200', title: 'text-green-700' },
621
- negative: { border: 'border-red-200', title: 'text-red-700' },
622
- mixed: { border: 'border-amber-200', title: 'text-amber-700' },
623
- neutral: { border: 'border-slate-200', title: 'text-slate-800' },
624
- };
625
- const styles = stylesByKind[kind] || stylesByKind.neutral;
626
- const hasContent = !!(data && (
627
- (data.summary && String(data.summary).trim().length > 0) ||
628
- (Array.isArray(data.reasons) && data.reasons.length > 0) ||
629
- (Array.isArray(data.evidence) && data.evidence.length > 0)
630
- ));
631
-
632
- return (
633
- <div className={`bg-white border rounded-lg p-3 ${styles.border}`}>
634
- <div className={`text-sm font-semibold mb-2 ${styles.title}`}>{title}</div>
635
- {!hasContent ? (
636
- <div className="text-slate-500 text-sm">{emptyText}</div>
637
- ) : (
638
- <>
639
- {typeof data.confidence === 'number' && (
640
- <div className="text-xs text-slate-500 mb-1">
641
- Confidence: {confidenceToPercent(data.confidence)}%
642
- </div>
643
- )}
644
- {data.summary && (
645
- <div className="text-sm text-slate-700">
646
- <span className="font-semibold text-slate-700">Summary:</span> {data.summary}
647
- </div>
648
- )}
649
- {(data.reasons || []).length > 0 && (
650
- <div className="mt-2 space-y-1">
651
- {(data.reasons || []).slice(0, 5).map((reason, idx) => (
652
- <div key={idx} className="text-xs text-slate-600">- {reason}</div>
653
- ))}
654
- </div>
655
- )}
656
- <div className="mt-2">
657
- <div className="text-xs font-semibold text-slate-600 mb-1">Evidence:</div>
658
- <div className="space-y-1">
659
- {(data.evidence || []).slice(0, 3).map((ev, idx) => {
660
- const { label, snippet } = getEvidenceSnippet(ev);
661
- return (
662
- <div key={idx} className="text-xs text-slate-600">
663
- <span className="font-medium text-slate-700">{label}:</span> {snippet}
664
- </div>
665
- );
666
- })}
667
- </div>
668
- </div>
669
- </>
670
- )}
671
- </div>
672
- );
673
- })()
674
- );
675
-
676
- return (
677
- <>
678
- {renderCareBox('Positive', 'positive', positive, 'No insights found for positive care experience.')}
679
- {renderCareBox('Mixed / Tradeoffs', 'mixed', mixed, 'No insights found for mixed/tradeoff care experience.')}
680
- {renderCareBox('Negative', 'negative', negative, 'No insights found for negative care experience.')}
681
- {renderCareBox('Neutral / Additional', 'neutral', neutral, 'No insights found for neutral/additional care observations.')}
682
- </>
683
- );
684
- })()}
685
- </div>
686
- </div>
687
- </div>
688
- )}
689
- </div>
690
-
691
- {/* Footer */}
692
- {conversationActive && (
693
- <div className="mt-6 bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg shadow-lg p-4">
694
- <div className="flex items-center justify-between text-white">
695
- <div className="flex items-center gap-2">
696
- <span className="text-xl">📡</span>
697
- <span className="font-semibold">All Agents Active</span>
698
- </div>
699
- <div className="flex gap-6 text-sm">
700
- <div>Extraction: ✓</div>
701
- <div>Routing: Standby</div>
702
- <div>Resources: {resourceAgentStatus}</div>
703
- <div>QA: ✓</div>
704
- </div>
705
- </div>
706
- </div>
707
- )}
708
- </div>
709
- </div>
710
- );
711
- }
712
-
713
- const root = ReactDOM.createRoot(document.getElementById('root'));
714
- root.render(<App />);
715
- </script>
716
- </body>
717
- </html>
718
- """
719
- return HTMLResponse(content=html_content)
720
 
721
 
722
  @app.websocket("/ws/frontend/{conversation_id}")
723
  async def frontend_websocket(websocket: WebSocket, conversation_id: str):
724
  """WebSocket endpoint for frontend connections."""
 
 
 
 
 
 
 
725
  await websocket.accept()
726
  logger.info(f"Frontend WebSocket connected: {conversation_id}")
727
-
728
  frontend_connections[conversation_id] = websocket
729
-
730
- # Create backend WebSocket manager
731
  backend_url = f"{settings.frontend.websocket_url}/{conversation_id}"
732
- manager = WebSocketManager(backend_url, conversation_id)
 
 
 
733
  active_managers[conversation_id] = manager
734
-
735
  try:
736
  while True:
737
- # Receive message from frontend
738
  data = await websocket.receive_json()
739
  message_type = data.get("type")
740
-
741
  logger.info(f"Frontend message: {message_type}")
742
-
743
  if message_type == "start_conversation":
744
- # Start the backend WebSocket manager
745
  success = await asyncio.to_thread(manager.start)
746
-
747
  if success:
748
- # Send start message to backend
749
  manager.send_message({
750
  "type": "start_conversation",
751
  "content": "start",
752
  "surveyor_persona_id": data.get("surveyor_persona_id", "friendly_researcher_001"),
753
  "patient_persona_id": data.get("patient_persona_id", "cooperative_senior_001"),
 
 
754
  "host": settings.llm.host,
755
- "model": settings.llm.model
756
  })
757
-
758
- # Start forwarding messages from backend to frontend
759
  asyncio.create_task(forward_backend_messages(conversation_id))
760
  else:
761
  await websocket.send_json({
762
  "type": "error",
763
- "error": f"Failed to connect to backend: {manager.last_error}"
764
  })
765
-
766
  elif message_type == "stop_conversation":
767
- # Stop conversation
768
  manager.send_message({
769
  "type": "conversation_control",
770
  "content": "stop",
771
- "action": "stop"
772
  })
773
  await asyncio.to_thread(manager.stop)
774
-
775
  await websocket.send_json({
776
  "type": "conversation_status",
777
- "status": "stopped"
778
  })
779
-
780
  except WebSocketDisconnect:
781
  logger.info(f"Frontend disconnected: {conversation_id}")
782
  except Exception as e:
783
  logger.error(f"Error in frontend WebSocket: {e}")
784
  finally:
785
- # Cleanup
786
  if conversation_id in frontend_connections:
787
  del frontend_connections[conversation_id]
788
  if conversation_id in active_managers:
@@ -794,41 +206,37 @@ async def forward_backend_messages(conversation_id: str):
794
  """Forward messages from backend WebSocket manager to frontend."""
795
  manager = active_managers.get(conversation_id)
796
  frontend_ws = frontend_connections.get(conversation_id)
797
-
798
  if not manager or not frontend_ws:
799
  return
800
-
801
  logger.info(f"Starting message forwarding for {conversation_id}")
802
-
803
  try:
804
  while conversation_id in active_managers:
805
- # Get messages from backend
806
  messages = manager.get_messages()
807
-
808
- # Forward to frontend
809
  for message in messages:
810
  try:
811
  await frontend_ws.send_json(message)
812
  except Exception as e:
813
  logger.error(f"Error sending to frontend: {e}")
814
  return
815
-
816
- # Send stats
817
  status = manager.get_status()
818
  try:
819
  await frontend_ws.send_json({
820
  "type": "stats",
821
  "data": {
822
  "sent": status["messages_sent"],
823
- "received": status["messages_received"]
824
- }
825
  })
826
- except:
827
  pass
828
-
829
- # Wait before checking again
830
  await asyncio.sleep(0.5)
831
-
832
  except Exception as e:
833
  logger.error(f"Error in message forwarding: {e}")
834
 
@@ -838,12 +246,11 @@ if __name__ == "__main__":
838
  print("🚀 Starting AI Survey Simulator - React + WebSocket Manager")
839
  print(f"📡 Backend URL: {settings.frontend.backend_base_url}")
840
  print(f"🌐 Frontend URL: http://127.0.0.1:{port}")
841
- print(f"🔧 Using WebSocketManager for backend connection")
842
- print("=" * 60)
843
-
844
  uvicorn.run(
845
  app,
846
  host="0.0.0.0",
847
  port=port,
848
- log_level=settings.log_level.lower()
849
  )
 
1
  import sys
 
 
2
  import os
3
  import asyncio
4
  from pathlib import Path
5
+ from typing import Dict, Optional
6
  import logging
7
 
8
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
9
+ from fastapi.responses import HTMLResponse, JSONResponse
 
10
  from fastapi.middleware.cors import CORSMiddleware
11
  import uvicorn
12
 
 
16
  sys.path.insert(0, str(project_root / "frontend"))
17
 
18
  from config.settings import get_settings
19
+ from pages.main_page import get_main_page_html
20
+ from pages.login_page import get_login_page_html
21
+ from websocket_manager import WebSocketManager
22
  from backend.api.main import app as backend_app
23
  from backend.api.conversation_ws import manager as backend_ws_manager
24
  from backend.api.conversation_service import initialize_conversation_service
25
+ from backend.core.auth import (
26
+ COOKIE_NAME,
27
+ INTERNAL_HEADER,
28
+ constant_time_equals,
29
+ create_session_token,
30
+ get_app_password,
31
+ verify_session_token,
32
+ )
33
 
34
  # Load settings
35
  settings = get_settings()
 
45
  # Mount backend API under /api so the Space can run as a single process
46
  app.mount("/api", backend_app)
47
 
48
+
49
  @app.on_event("startup")
50
  async def initialize_backend_services():
 
51
  initialize_conversation_service(backend_ws_manager, settings)
52
 
53
+
54
  # Enable CORS for local development
55
  app.add_middleware(
56
  CORSMiddleware,
 
65
  frontend_connections: Dict[str, WebSocket] = {}
66
 
67
 
68
+ def _is_authenticated_cookie(token: Optional[str], password: str) -> bool:
69
+ if not token:
70
+ return False
71
+ return verify_session_token(token, password)
72
+
73
+
74
+ def _is_request_authenticated(request: Request, password: str) -> bool:
75
+ if request.headers.get(INTERNAL_HEADER) == password:
76
+ return True
77
+ token = request.cookies.get(COOKIE_NAME)
78
+ return _is_authenticated_cookie(token, password)
79
+
80
+
81
  @app.get("/", response_class=HTMLResponse)
82
+ async def serve_frontend(request: Request):
83
+ """Serve login page (if locked) or the single-page React UI."""
84
+ password = get_app_password()
85
+ if not password:
86
+ return HTMLResponse(content=get_main_page_html())
87
+
88
+ if not _is_request_authenticated(request, password):
89
+ return HTMLResponse(content=get_login_page_html(app_name="ConverTA"))
90
+
91
+ return HTMLResponse(content=get_main_page_html())
92
+
93
+
94
+ @app.post("/login")
95
+ async def login(request: Request, payload: dict):
96
+ password = get_app_password()
97
+ if not password:
98
+ return JSONResponse({"error": "APP_PASSWORD not configured"}, status_code=500)
99
+
100
+ supplied = (payload or {}).get("password")
101
+ if not isinstance(supplied, str):
102
+ return JSONResponse({"error": "invalid"}, status_code=400)
103
+ if not constant_time_equals(supplied, password):
104
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
105
+
106
+ token = create_session_token(password)
107
+ resp = JSONResponse({"ok": True})
108
+ forwarded = request.headers.get("x-forwarded-proto")
109
+ secure = (forwarded == "https") or (request.url.scheme == "https")
110
+ resp.set_cookie(
111
+ COOKIE_NAME,
112
+ token,
113
+ httponly=True,
114
+ samesite="lax",
115
+ secure=secure,
116
+ max_age=7 * 24 * 60 * 60,
117
+ path="/",
118
+ )
119
+ return resp
120
+
121
+
122
+ @app.post("/logout")
123
+ async def logout():
124
+ resp = JSONResponse({"ok": True})
125
+ resp.delete_cookie(COOKIE_NAME, path="/")
126
+ return resp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
 
129
  @app.websocket("/ws/frontend/{conversation_id}")
130
  async def frontend_websocket(websocket: WebSocket, conversation_id: str):
131
  """WebSocket endpoint for frontend connections."""
132
+ password = get_app_password()
133
+ if password:
134
+ token = websocket.cookies.get(COOKIE_NAME)
135
+ if not _is_authenticated_cookie(token, password):
136
+ await websocket.close(code=1008)
137
+ return
138
+
139
  await websocket.accept()
140
  logger.info(f"Frontend WebSocket connected: {conversation_id}")
141
+
142
  frontend_connections[conversation_id] = websocket
143
+
 
144
  backend_url = f"{settings.frontend.websocket_url}/{conversation_id}"
145
+ internal_headers = None
146
+ if password:
147
+ internal_headers = {INTERNAL_HEADER: password}
148
+ manager = WebSocketManager(backend_url, conversation_id, extra_headers=internal_headers)
149
  active_managers[conversation_id] = manager
150
+
151
  try:
152
  while True:
 
153
  data = await websocket.receive_json()
154
  message_type = data.get("type")
155
+
156
  logger.info(f"Frontend message: {message_type}")
157
+
158
  if message_type == "start_conversation":
 
159
  success = await asyncio.to_thread(manager.start)
160
+
161
  if success:
 
162
  manager.send_message({
163
  "type": "start_conversation",
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_prompt_addition": data.get("surveyor_prompt_addition"),
168
+ "patient_prompt_addition": data.get("patient_prompt_addition"),
169
  "host": settings.llm.host,
170
+ "model": settings.llm.model,
171
  })
172
+
 
173
  asyncio.create_task(forward_backend_messages(conversation_id))
174
  else:
175
  await websocket.send_json({
176
  "type": "error",
177
+ "error": f"Failed to connect to backend: {manager.last_error}",
178
  })
179
+
180
  elif message_type == "stop_conversation":
 
181
  manager.send_message({
182
  "type": "conversation_control",
183
  "content": "stop",
184
+ "action": "stop",
185
  })
186
  await asyncio.to_thread(manager.stop)
187
+
188
  await websocket.send_json({
189
  "type": "conversation_status",
190
+ "status": "stopped",
191
  })
192
+
193
  except WebSocketDisconnect:
194
  logger.info(f"Frontend disconnected: {conversation_id}")
195
  except Exception as e:
196
  logger.error(f"Error in frontend WebSocket: {e}")
197
  finally:
 
198
  if conversation_id in frontend_connections:
199
  del frontend_connections[conversation_id]
200
  if conversation_id in active_managers:
 
206
  """Forward messages from backend WebSocket manager to frontend."""
207
  manager = active_managers.get(conversation_id)
208
  frontend_ws = frontend_connections.get(conversation_id)
209
+
210
  if not manager or not frontend_ws:
211
  return
212
+
213
  logger.info(f"Starting message forwarding for {conversation_id}")
214
+
215
  try:
216
  while conversation_id in active_managers:
 
217
  messages = manager.get_messages()
218
+
 
219
  for message in messages:
220
  try:
221
  await frontend_ws.send_json(message)
222
  except Exception as e:
223
  logger.error(f"Error sending to frontend: {e}")
224
  return
225
+
 
226
  status = manager.get_status()
227
  try:
228
  await frontend_ws.send_json({
229
  "type": "stats",
230
  "data": {
231
  "sent": status["messages_sent"],
232
+ "received": status["messages_received"],
233
+ },
234
  })
235
+ except Exception:
236
  pass
237
+
 
238
  await asyncio.sleep(0.5)
239
+
240
  except Exception as e:
241
  logger.error(f"Error in message forwarding: {e}")
242
 
 
246
  print("🚀 Starting AI Survey Simulator - React + WebSocket Manager")
247
  print(f"📡 Backend URL: {settings.frontend.backend_base_url}")
248
  print(f"🌐 Frontend URL: http://127.0.0.1:{port}")
249
+ print("============================================================")
250
+
 
251
  uvicorn.run(
252
  app,
253
  host="0.0.0.0",
254
  port=port,
255
+ log_level=settings.log_level.lower(),
256
  )
frontend/websocket_manager.py CHANGED
@@ -47,15 +47,17 @@ class ManagerState(Enum):
47
  class WebSocketManager:
48
  """Thread-safe WebSocket manager for Gradio frontend."""
49
 
50
- def __init__(self, url: str, conversation_id: str):
51
  """Initialize WebSocket manager.
52
 
53
  Args:
54
  url: WebSocket server URL
55
  conversation_id: Unique conversation identifier
 
56
  """
57
  self.url = url
58
  self.conversation_id = conversation_id
 
59
 
60
  # State management
61
  self.state = ManagerState.STOPPED
@@ -240,6 +242,7 @@ class WebSocketManager:
240
 
241
  async with websockets.connect(
242
  self.url,
 
243
  ping_interval=20,
244
  ping_timeout=10
245
  ) as websocket:
@@ -351,4 +354,4 @@ class WebSocketManager:
351
  try:
352
  self.stop()
353
  except:
354
- pass
 
47
  class WebSocketManager:
48
  """Thread-safe WebSocket manager for Gradio frontend."""
49
 
50
+ def __init__(self, url: str, conversation_id: str, extra_headers: Optional[Dict[str, str]] = None):
51
  """Initialize WebSocket manager.
52
 
53
  Args:
54
  url: WebSocket server URL
55
  conversation_id: Unique conversation identifier
56
+ extra_headers: Optional headers to send during the WebSocket handshake
57
  """
58
  self.url = url
59
  self.conversation_id = conversation_id
60
+ self.extra_headers = extra_headers
61
 
62
  # State management
63
  self.state = ManagerState.STOPPED
 
242
 
243
  async with websockets.connect(
244
  self.url,
245
+ extra_headers=self.extra_headers,
246
  ping_interval=20,
247
  ping_timeout=10
248
  ) as websocket:
 
354
  try:
355
  self.stop()
356
  except:
357
+ pass