Add config view and shared-password gate
Browse files- README.md +3 -2
- backend/api/main.py +31 -1
- backend/core/auth.py +70 -0
- docs/development.md +7 -0
- docs/hf.md +5 -0
- docs/overview.md +19 -1
- frontend/pages/__init__.py +2 -0
- frontend/pages/config_view.py +106 -0
- frontend/pages/login_page.py +83 -0
- frontend/pages/main_page.py +524 -0
- frontend/react_gradio_hybrid.py +114 -707
- frontend/websocket_manager.py +5 -2
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.
|
| 89 |
-
3. Click **
|
|
|
|
| 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,
|
| 8 |
import logging
|
| 9 |
|
| 10 |
-
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
| 11 |
-
from fastapi.
|
| 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
|
|
|
|
|
|
|
| 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
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 842 |
-
|
| 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
|