Spaces:
Running
Running
| """app/state/session.py — interview state machine + session manager. | |
| The interview follows a strict, forward-only state machine. Once the person | |
| has moved forward, earlier answers are append-only — backward transitions | |
| raise ``ValueError`` (CLAUDE.md Critical Rule 3; ARCHITECTURE.md §3). | |
| Session state is in-memory only for the hackathon: no database, no external | |
| logging of personal data (ARCHITECTURE.md §Security and Privacy). | |
| """ | |
| from __future__ import annotations | |
| import uuid | |
| from dataclasses import dataclass, field | |
| from datetime import datetime, timezone | |
| from enum import IntEnum | |
| from typing import Optional | |
| class State(IntEnum): | |
| """Ordered interview / flow states. Order defines forward direction.""" | |
| LANGUAGE_SELECT = 0 | |
| INTAKE = 1 | |
| SITUATION = 2 | |
| HISTORY = 3 | |
| GOALS = 4 | |
| REVIEW = 5 | |
| ASSESSMENT = 6 | |
| RECOMMENDATIONS = 7 | |
| DOCUMENTS = 8 | |
| COMPLETE = 9 | |
| def label(self) -> str: | |
| return self.name | |
| # Allowed forward transitions: each state may advance to the next, or stay put | |
| # (idempotent re-entry, e.g. re-rendering the same phase). | |
| _NEXT: dict[State, State] = { | |
| State.LANGUAGE_SELECT: State.INTAKE, | |
| State.INTAKE: State.SITUATION, | |
| State.SITUATION: State.HISTORY, | |
| State.HISTORY: State.GOALS, | |
| State.GOALS: State.REVIEW, | |
| State.REVIEW: State.ASSESSMENT, | |
| State.ASSESSMENT: State.RECOMMENDATIONS, | |
| State.RECOMMENDATIONS: State.DOCUMENTS, | |
| State.DOCUMENTS: State.COMPLETE, | |
| State.COMPLETE: State.COMPLETE, | |
| } | |
| class Interview: | |
| origin_country: Optional[str] = None | |
| current_country: Optional[str] = None | |
| persecution_types: list[str] = field(default_factory=list) | |
| immediate_danger: Optional[bool] = None | |
| family_situation: Optional[str] = None | |
| documents_available: list[str] = field(default_factory=list) | |
| languages_spoken: list[str] = field(default_factory=list) | |
| destination_preferences: list[str] = field(default_factory=list) | |
| prior_claims: Optional[bool] = None | |
| displacement_duration: Optional[str] = None | |
| free_text_history: Optional[str] = None | |
| class Assessment: | |
| convention_grounds: list[str] = field(default_factory=list) | |
| risk_level: Optional[str] = None # "high" | "moderate" | "low" | |
| case_type: Optional[str] = None # refugee | broader_protection | statelessness | economic_or_other | unclear | |
| reasoning_trace: str = "" | |
| recommended_countries: list[dict] = field(default_factory=list) | |
| def _now_iso() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| class SessionState: | |
| """A single person's interview session. In-memory only.""" | |
| session_id: str = field(default_factory=lambda: str(uuid.uuid4())) | |
| language: Optional[str] = None | |
| state: State = State.LANGUAGE_SELECT | |
| interview: Interview = field(default_factory=Interview) | |
| assessment: Assessment = field(default_factory=Assessment) | |
| selected_country: Optional[str] = None | |
| messages: list[dict] = field(default_factory=list) | |
| created_at: str = field(default_factory=_now_iso) | |
| updated_at: str = field(default_factory=_now_iso) | |
| # -- state machine --------------------------------------------------- | |
| def can_transition(self, target: State) -> bool: | |
| """True iff ``target`` is the current state or its sole successor.""" | |
| target = State(target) | |
| if target == self.state: | |
| return True | |
| return _NEXT.get(self.state) == target and target != self.state | |
| def transition_to(self, target: State) -> State: | |
| """Advance the state machine. Forward-only. | |
| Raises ``ValueError`` on any backward move or a skipped state | |
| (e.g. REVIEW -> SITUATION, or INTAKE -> ASSESSMENT). | |
| """ | |
| target = State(target) | |
| if target == self.state: | |
| return self.state | |
| if not self.can_transition(target): | |
| raise ValueError( | |
| f"Illegal transition {self.state.label} -> {target.label}: " | |
| "the interview is append-only and forward-only." | |
| ) | |
| self.state = target | |
| self.updated_at = _now_iso() | |
| return self.state | |
| def advance(self) -> State: | |
| """Move to the next state in sequence.""" | |
| return self.transition_to(_NEXT[self.state]) | |
| # -- serialisation --------------------------------------------------- | |
| def touch(self) -> None: | |
| self.updated_at = _now_iso() | |
| class SessionManager: | |
| """Holds active sessions for the running process (in-memory).""" | |
| def __init__(self) -> None: | |
| self._sessions: dict[str, SessionState] = {} | |
| def create(self) -> SessionState: | |
| session = SessionState() | |
| self._sessions[session.session_id] = session | |
| return session | |
| def get(self, session_id: str) -> Optional[SessionState]: | |
| return self._sessions.get(session_id) | |
| def remove(self, session_id: str) -> None: | |
| self._sessions.pop(session_id, None) | |
| __all__ = [ | |
| "State", | |
| "Interview", | |
| "Assessment", | |
| "SessionState", | |
| "SessionManager", | |
| ] | |