Spaces:
Sleeping
Sleeping
| """ | |
| server.py | |
| ========= | |
| FastAPI backend for the TFG decision-personalization study. | |
| Run it (from the repository root) with: | |
| python -m uvicorn apps.user_study_app.backend.server:app --reload --port 8000 | |
| or use the helper scripts in ``apps/user_study_app/`` | |
| (``run_study_app.bat`` on Windows, ``run_study_app.sh`` elsewhere). Then open | |
| http://127.0.0.1:8000/ in a browser. | |
| Endpoints | |
| --------- | |
| GET / -> the study frontend (index.html) | |
| GET /api/health -> liveness + whether the model loaded | |
| GET /api/config -> public study config (model version, scenario count, ...) | |
| POST /api/decisions -> per-scenario A/B decisions (randomized slots, hidden mapping) | |
| POST /api/save -> persist a full session to CSV/JSON | |
| The experimental condition behind each A/B slot is returned to the browser so it | |
| can be stored, but it is NEVER displayed to participants (see frontend/app.js). | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import os | |
| import random | |
| from pathlib import Path | |
| from typing import Any | |
| from fastapi import FastAPI | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel, Field | |
| from . import scenario_catalog | |
| from .decision_text import render_decision_text, short_label | |
| from .model_inference_adapter import ( | |
| CONDITION_NON_PERSONALIZED, | |
| CONDITION_PERSONALIZED, | |
| build_adapter_from_config, | |
| ) | |
| from .storage import StudyStorage | |
| from .taxonomy import sanitize_snapshot | |
| # --------------------------------------------------------------------------- # | |
| # Paths & configuration | |
| # --------------------------------------------------------------------------- # | |
| APP_DIR = Path(__file__).resolve().parent.parent # .../app | |
| FRONTEND_DIR = APP_DIR / "frontend" | |
| # Which config (model) this server instance serves. An env var lets you run a | |
| # SECOND questionnaire (different model) without editing files: e.g. point it at | |
| # config_new_model.json and launch on another port. Relative paths resolve from | |
| # the app dir. Defaults to the original config.json so existing setups are | |
| # unchanged. Inside a Space build the selected config is baked in as config.json. | |
| _env_config = os.environ.get("STUDY_CONFIG_PATH", "").strip() | |
| if _env_config: | |
| _cfg_path = Path(_env_config) | |
| if _cfg_path.is_absolute(): | |
| CONFIG_PATH = _cfg_path | |
| elif (APP_DIR / _cfg_path).exists(): | |
| # bare filename / app-dir-relative (e.g. "config_new_model.json") | |
| CONFIG_PATH = APP_DIR / _cfg_path | |
| else: | |
| # otherwise treat as relative to the current working directory (e.g. a | |
| # repo-root-relative "apps/user_study_app/config_new_model.json") | |
| CONFIG_PATH = _cfg_path | |
| else: | |
| CONFIG_PATH = APP_DIR / "config.json" | |
| def _load_config() -> dict[str, Any]: | |
| if CONFIG_PATH.exists(): | |
| try: | |
| return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) | |
| except json.JSONDecodeError as exc: # pragma: no cover - config typo guard | |
| raise RuntimeError(f"Invalid config.json: {exc}") from exc | |
| return {} | |
| CONFIG = _load_config() | |
| from . import results_store # noqa: E402 | |
| from .model_inference_adapter import REPO_ROOT # noqa: E402 (REPO_ROOT computed there) | |
| # Storage directory: an env var (used in cloud deployments) overrides config.json, | |
| # which overrides the default local path. Absolute env paths are honored as-is. | |
| _env_storage_dir = os.environ.get("STUDY_COLLECTED_DATA_DIR", "").strip() | |
| _storage_dir = ( | |
| CONFIG.get("storage", {}).get("collected_data_dir", "apps/user_study_app/collected_data") | |
| ) | |
| if _env_storage_dir: | |
| STORAGE = StudyStorage(Path(_env_storage_dir)) | |
| else: | |
| STORAGE = StudyStorage(REPO_ROOT / _storage_dir) | |
| # Optional remote sink (e.g. a private Hugging Face Dataset) for shared hosting. | |
| # A no-op unless the relevant environment variables are set. | |
| REMOTE_SINK = results_store.build_sink_from_env() | |
| ADAPTER = build_adapter_from_config(CONFIG) | |
| STUDY_CFG = CONFIG.get("study", {}) or {} | |
| SCENARIOS_PER_SESSION = STUDY_CFG.get("scenarios_per_session", None) | |
| SHUFFLE_SCENARIOS = bool(STUDY_CFG.get("shuffle_scenarios", True)) | |
| REQUIRE_DIFFERENT_AB = bool(STUDY_CFG.get("require_different_ab", True)) | |
| MIN_SCENARIOS = int(STUDY_CFG.get("min_scenarios", 0) or 0) | |
| # Diversity: how many situations may share the SAME robot action (action_text). | |
| # 1 = every shown situation has a distinct action (no "take out the trash" twice). | |
| MAX_PER_ACTION = int(STUDY_CFG.get("max_situations_per_action", 1) or 1) | |
| ENABLE_WRONG = bool(STUDY_CFG.get("enable_wrong_preferences_condition", False)) | |
| _MODEL_LOAD_ERROR: str | None = None | |
| # --------------------------------------------------------------------------- # | |
| # App | |
| # --------------------------------------------------------------------------- # | |
| app = FastAPI(title="TFG Decision Personalization Study", version="1.0.0") | |
| # Permissive CORS so the frontend also works if opened from a different origin | |
| # (e.g. a different local port). Safe for a local research tool. | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| def _startup() -> None: | |
| global _MODEL_LOAD_ERROR | |
| STORAGE.ensure_dirs() | |
| # Always (re)write the static scenario catalog so analysts have it on disk. | |
| STORAGE.write_scenario_catalog(scenario_catalog.catalog_rows()) | |
| # Eagerly load the model so the first /api/decisions request is fast. If it | |
| # fails (e.g. missing checkpoint), keep the server up and report via /health. | |
| try: | |
| ADAPTER.warm_up() | |
| except Exception as exc: # pragma: no cover - depends on local checkpoint | |
| _MODEL_LOAD_ERROR = f"{type(exc).__name__}: {exc}" | |
| # --------------------------------------------------------------------------- # | |
| # Request models | |
| # --------------------------------------------------------------------------- # | |
| class PreferenceRow(BaseModel): | |
| signal_name: str | |
| polarity: str | |
| weight: float | None = None # 1-10 importance; consumed by the model | |
| class DecisionsRequest(BaseModel): | |
| session_id: str | None = None | |
| preference_snapshot: list[PreferenceRow] = Field(default_factory=list) | |
| scenario_ids: list[str] | None = None | |
| # --------------------------------------------------------------------------- # | |
| # Helpers | |
| # --------------------------------------------------------------------------- # | |
| def _seeded_rng(*parts: str) -> random.Random: | |
| digest = hashlib.sha256("::".join(parts).encode("utf-8")).hexdigest() | |
| return random.Random(int(digest[:16], 16)) | |
| def _slot_payload(slot: str, result_dict: dict[str, Any], decision_text: str) -> dict[str, Any]: | |
| return { | |
| "slot": slot, | |
| # condition is stored by the client but never rendered to the participant. | |
| "condition": result_dict["condition"], | |
| "model_action": result_dict["model_action"], | |
| "decision_text": decision_text, | |
| "short_label": short_label(result_dict["model_action"]), | |
| "model_confidence": result_dict["model_confidence"], | |
| "model_version": result_dict["model_version"], | |
| } | |
| def _build_scenario_out( | |
| scenario: dict[str, Any], | |
| snapshot: list[dict[str, str]], | |
| session_id: str, | |
| ) -> tuple[dict[str, Any], bool]: | |
| """ | |
| Run both conditions for one scenario, lay them out in randomized A/B slots, | |
| and report whether the two displayed decisions DIFFER. | |
| """ | |
| personalized = ADAPTER.decide_personalized( | |
| scenario=scenario, preference_snapshot=snapshot, user_external_id=session_id | |
| ).to_dict() | |
| non_personalized = ADAPTER.decide_non_personalized( | |
| scenario=scenario, user_external_id=session_id | |
| ).to_dict() | |
| p_text = render_decision_text(scenario, personalized["model_action"]) | |
| n_text = render_decision_text(scenario, non_personalized["model_action"]) | |
| differs = p_text != n_text | |
| # Randomize which condition is shown in slot A vs B (seeded -> idempotent | |
| # across re-requests for the same session/scenario, so reloads are safe). | |
| rng = _seeded_rng(session_id, scenario["scenario_id"], "ab") | |
| if rng.random() < 0.5: | |
| slot_a_res, slot_a_text = personalized, p_text | |
| slot_b_res, slot_b_text = non_personalized, n_text | |
| else: | |
| slot_a_res, slot_a_text = non_personalized, n_text | |
| slot_b_res, slot_b_text = personalized, p_text | |
| scenario_out: dict[str, Any] = { | |
| **scenario_catalog.public_view(scenario), | |
| "slot_a": _slot_payload("A", slot_a_res, slot_a_text), | |
| "slot_b": _slot_payload("B", slot_b_res, slot_b_text), | |
| "model_version": ADAPTER.model_version, | |
| } | |
| if ENABLE_WRONG: | |
| try: | |
| wrong = ADAPTER.decide_wrong_preferences( | |
| scenario=scenario, preference_snapshot=snapshot, user_external_id=session_id | |
| ).to_dict() | |
| wrong["decision_text"] = render_decision_text(scenario, wrong["model_action"]) | |
| scenario_out["wrong_preferences"] = wrong # not shown in the UI | |
| except Exception: | |
| pass | |
| return scenario_out, differs | |
| # --------------------------------------------------------------------------- # | |
| # API | |
| # --------------------------------------------------------------------------- # | |
| def health() -> dict[str, Any]: | |
| return { | |
| "status": "ok", | |
| "model_loaded": ADAPTER.is_loaded, | |
| "model_load_error": _MODEL_LOAD_ERROR, | |
| "model_version": ADAPTER.model_version, | |
| "checkpoint_path": str(ADAPTER.checkpoint_path), | |
| "remote_sink": REMOTE_SINK.status(), | |
| "collected_data_dir": str(STORAGE.root), | |
| } | |
| def public_config() -> dict[str, Any]: | |
| return { | |
| "app_name": STUDY_CFG.get("app_name") or "Assistive Robot Study", | |
| "theme": STUDY_CFG.get("theme") or "default", | |
| "model_version": ADAPTER.model_version, | |
| "num_scenarios": len(scenario_catalog.all_scenarios()), | |
| "scenarios_per_session": SCENARIOS_PER_SESSION, | |
| "shuffle_scenarios": SHUFFLE_SCENARIOS, | |
| "enable_wrong_preferences_condition": ENABLE_WRONG, | |
| "decision_metrics": [ | |
| "perceived_appropriateness", | |
| "preference_respect", | |
| "usefulness", | |
| "trust", | |
| "satisfaction", | |
| "intrusiveness", | |
| "perceived_safety", | |
| ], | |
| } | |
| def decisions(req: DecisionsRequest) -> JSONResponse: | |
| """ | |
| Compute the personalized vs non-personalized decision for each scenario and | |
| return them in randomized A/B slots. The mapping (which slot is which | |
| condition) is included so the client can store it, but the frontend must not | |
| display it. | |
| """ | |
| if _MODEL_LOAD_ERROR is not None and not ADAPTER.is_loaded: | |
| return JSONResponse( | |
| status_code=503, | |
| content={ | |
| "error": "model_not_loaded", | |
| "detail": _MODEL_LOAD_ERROR, | |
| "hint": "Fix model.checkpoint_path in config.json and restart the backend.", | |
| }, | |
| ) | |
| session_id = req.session_id or "anonymous_session" | |
| snapshot = sanitize_snapshot([row.model_dump() for row in req.preference_snapshot]) | |
| # Build a candidate pool TAILORED to the participant: situations anchored to | |
| # the signals they declared a preference on come first (most likely to make | |
| # personalized vs non-personalized differ), then the rest as fallback. | |
| explicit = bool(req.scenario_ids) | |
| if explicit: | |
| pool = [s for sid in req.scenario_ids if (s := scenario_catalog.get_scenario(sid))] | |
| else: | |
| pool = scenario_catalog.select_for_snapshot( | |
| snapshot, | |
| seed=int(_seeded_rng(session_id, "order").random() * 1_000_000), | |
| shuffle=SHUFFLE_SCENARIOS, | |
| ) | |
| target = SCENARIOS_PER_SESSION if SCENARIOS_PER_SESSION else len(pool) | |
| # We "reroll" through the pool, running the model on each candidate, keeping | |
| # only those whose two decisions differ, until we have `target`. Evaluation is | |
| # capped so the request stays fast even in the rare low-differ case. | |
| max_eval = max(target * 20, 160) | |
| def _action_key(scn: dict[str, Any]) -> str: | |
| return str((scn.get("action_input", {}) or {}).get("action_text", "")).strip().lower() | |
| out_scenarios: list[dict[str, Any]] = [] | |
| fallback: list[tuple[str, dict[str, Any]]] = [] # (action_key, scenario_out) | |
| action_counts: dict[str, int] = {} | |
| evaluated = 0 | |
| for scenario in pool: | |
| if len(out_scenarios) >= target or evaluated >= max_eval: | |
| break | |
| act = _action_key(scenario) | |
| # Diversity guard: never show the same robot action more than MAX_PER_ACTION | |
| # times (so the set isn't "take out the trash" at 10 different hours). | |
| if not explicit and action_counts.get(act, 0) >= MAX_PER_ACTION: | |
| continue | |
| evaluated += 1 | |
| try: | |
| scenario_out, differs = _build_scenario_out(scenario, snapshot, session_id) | |
| except Exception as exc: | |
| return JSONResponse( | |
| status_code=500, | |
| content={ | |
| "error": "inference_failed", | |
| "scenario_id": scenario["scenario_id"], | |
| "detail": f"{type(exc).__name__}: {exc}", | |
| }, | |
| ) | |
| if REQUIRE_DIFFERENT_AB and not explicit and not differs: | |
| fallback.append((act, scenario_out)) | |
| continue | |
| out_scenarios.append(scenario_out) | |
| action_counts[act] = action_counts.get(act, 0) + 1 | |
| # Floor: if too few differed, top up to min_scenarios with coinciding ones, | |
| # still respecting the per-action diversity cap. | |
| need = max(min(MIN_SCENARIOS, target), 1 if not out_scenarios else 0) | |
| for act, sc_out in fallback: | |
| if len(out_scenarios) >= need: | |
| break | |
| if action_counts.get(act, 0) >= MAX_PER_ACTION: | |
| continue | |
| out_scenarios.append(sc_out) | |
| action_counts[act] = action_counts.get(act, 0) + 1 | |
| return JSONResponse( | |
| content={ | |
| "model_version": ADAPTER.model_version, | |
| "session_id": session_id, | |
| "snapshot_size": len(snapshot), | |
| "scenarios": out_scenarios, | |
| "num_differing": sum( | |
| 1 for s in out_scenarios | |
| if s["slot_a"]["decision_text"] != s["slot_b"]["decision_text"] | |
| ), | |
| "num_evaluated": evaluated, | |
| } | |
| ) | |
| def save(payload: dict[str, Any]) -> JSONResponse: | |
| """ | |
| Persist a session. Two independent sinks are attempted: | |
| - local CSV/JSON (always; on a cloud host this disk is ephemeral), | |
| - the remote sink (e.g. a private HF Dataset) if configured. | |
| The save is considered successful if EITHER sink succeeded, so a participant | |
| on a cloud host is fine even if the ephemeral local disk write fails. | |
| """ | |
| participant = payload.get("participant") or {} | |
| session_id = str(participant.get("session_id") or payload.get("session_id") or "") | |
| counts = { | |
| "preferences": len(payload.get("preferences") or []), | |
| "decision_trials": len(payload.get("decision_trials") or []), | |
| "ab_comparisons": len(payload.get("ab_comparisons") or []), | |
| } | |
| local_result: dict[str, Any] = {"ok": False} | |
| try: | |
| local_summary = STORAGE.save_session(payload) | |
| local_result = {"ok": True, "export_file": local_summary.get("export_file")} | |
| session_id = local_summary.get("session_id", session_id) | |
| except Exception as exc: # pragma: no cover - e.g. read-only fs | |
| local_result = {"ok": False, "error": f"{type(exc).__name__}: {exc}"} | |
| remote_result: dict[str, Any] = {"ok": False, "skipped": True} | |
| if REMOTE_SINK.configured: | |
| try: | |
| remote_result = REMOTE_SINK.save_session(payload) | |
| except Exception as exc: | |
| remote_result = {"ok": False, "error": f"{type(exc).__name__}: {exc}"} | |
| ok = bool(local_result.get("ok") or remote_result.get("ok")) | |
| status_code = 200 if ok else 500 | |
| return JSONResponse( | |
| status_code=status_code, | |
| content={ | |
| "ok": ok, | |
| "session_id": session_id, | |
| "counts": counts, | |
| "local": local_result, | |
| "remote": remote_result, | |
| }, | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Frontend (served last so /api/* takes precedence) | |
| # --------------------------------------------------------------------------- # | |
| def index() -> FileResponse: | |
| return FileResponse(FRONTEND_DIR / "index.html") | |
| if FRONTEND_DIR.is_dir(): | |
| app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend") | |