Spaces:
Sleeping
Sleeping
| """Define API request, response, and persisted configuration models. | |
| Envelope helpers keep every endpoint response shape consistent. | |
| """ | |
| from datetime import date, datetime | |
| from enum import Enum | |
| from typing import Any, Literal | |
| from fastapi.encoders import jsonable_encoder | |
| from fastapi.responses import JSONResponse | |
| from pydantic import BaseModel, ConfigDict, Field, field_validator | |
| ErrorCode = Literal[ | |
| "unauthorized", | |
| "forbidden", | |
| "validation_error", | |
| "not_found", | |
| "setup_required", | |
| "rate_limited", | |
| "internal", | |
| ] | |
| class ApiError(BaseModel): | |
| """Structured API failure details.""" | |
| code: ErrorCode | |
| message: str | |
| class ApiEnvelope(BaseModel): | |
| """Common success or failure response wrapper.""" | |
| ok: bool | |
| data: Any | None | |
| error: ApiError | None | |
| class StoredConfig(BaseModel): | |
| """Durable password and session invalidation state.""" | |
| model_config = ConfigDict(extra="forbid") | |
| password_hash: str | |
| session_version: int = Field(ge=1) | |
| created_at: datetime | |
| updated_at: datetime | |
| class LoginRequest(BaseModel): | |
| """Password login payload.""" | |
| password: str = Field(min_length=1) | |
| class ChangePasswordRequest(BaseModel): | |
| """Authenticated password rotation payload.""" | |
| old_password: str = Field(min_length=1) | |
| new_password: str = Field(min_length=1) | |
| class AuthenticatedData(BaseModel): | |
| """Authentication state returned after a session mutation.""" | |
| authenticated: bool | |
| class MeData(AuthenticatedData): | |
| """Public session status and discrete application name.""" | |
| app_name: str | |
| class HealthData(BaseModel): | |
| """Liveness information.""" | |
| status: Literal["up"] | |
| time: datetime | |
| class ReadyData(BaseModel): | |
| """Bootstrap and storage readiness information.""" | |
| ready: bool | |
| data_writable: bool | |
| config_present: bool | |
| class Result(str, Enum): | |
| """Outcome of a logged remedy; `pending` is excluded from stats.""" | |
| worked = "worked" | |
| partial = "partial" | |
| failed = "failed" | |
| pending = "pending" | |
| def normalize_emotions(values: list[str]) -> list[str]: | |
| """Lowercase, strip, drop blanks, and de-duplicate emotion labels.""" | |
| out: list[str] = [] | |
| seen: set[str] = set() | |
| for value in values: | |
| token = value.strip().lower() | |
| if token and token not in seen: | |
| seen.add(token) | |
| out.append(token) | |
| return out | |
| def normalize_tags(values: list[str]) -> list[str]: | |
| """Lowercase, strip, collapse spaces to underscores, and de-duplicate.""" | |
| out: list[str] = [] | |
| seen: set[str] = set() | |
| for value in values: | |
| token = "_".join(value.strip().lower().split()) | |
| if token and token not in seen: | |
| seen.add(token) | |
| out.append(token) | |
| return out | |
| class CoachMeta(BaseModel): | |
| """Coach reply metadata persisted on an entry.""" | |
| text: str | None = None | |
| source: str | None = None | |
| model: str | None = None | |
| ts: datetime | None = None | |
| trace_id: str | None = None | |
| WalkType = Literal["interrupt", "fantasy", "mixed"] | |
| class EntryCreate(BaseModel): | |
| """Validated payload for creating a log entry.""" | |
| model_config = ConfigDict(extra="forbid") | |
| activity: str = Field(min_length=1, max_length=500) | |
| happened: str = Field(min_length=1, max_length=4000) | |
| emotions: list[str] = Field(default_factory=list, max_length=12) | |
| intensity: int = Field(ge=1, le=10) | |
| remedy: str = Field(default="", max_length=2000) | |
| result: Result | |
| tags: list[str] = Field(default_factory=list, max_length=20) | |
| notes: str = Field(default="", max_length=2000) | |
| ts: datetime | None = None | |
| fse_spike: bool | None = None | |
| avoidance_types: list[str] = Field(default_factory=list, max_length=20) | |
| proof_brick: str | None = Field(default=None, max_length=80) | |
| walk_type: WalkType | None = None | |
| def _strip_required(cls, value: str) -> str: | |
| stripped = value.strip() | |
| if not stripped: | |
| raise ValueError("must not be blank") | |
| return stripped | |
| def _strip_optional(cls, value: str) -> str: | |
| return value.strip() | |
| def _strip_proof(cls, value: str | None) -> str | None: | |
| if value is None: | |
| return None | |
| stripped = value.strip() | |
| return stripped or None | |
| def _clean_emotions(cls, value: list[str]) -> list[str]: | |
| return normalize_emotions(value) | |
| def _clean_tags(cls, value: list[str]) -> list[str]: | |
| return normalize_tags(value) | |
| class EntryUpdate(BaseModel): | |
| """Partial update payload; only provided fields are applied.""" | |
| model_config = ConfigDict(extra="forbid") | |
| activity: str | None = Field(default=None, min_length=1, max_length=500) | |
| happened: str | None = Field(default=None, min_length=1, max_length=4000) | |
| emotions: list[str] | None = Field(default=None, max_length=12) | |
| intensity: int | None = Field(default=None, ge=1, le=10) | |
| remedy: str | None = Field(default=None, max_length=2000) | |
| result: Result | None = None | |
| tags: list[str] | None = Field(default=None, max_length=20) | |
| notes: str | None = Field(default=None, max_length=2000) | |
| ts: datetime | None = None | |
| fse_spike: bool | None = None | |
| avoidance_types: list[str] | None = Field(default=None, max_length=20) | |
| proof_brick: str | None = Field(default=None, max_length=80) | |
| walk_type: WalkType | None = None | |
| def _strip_required(cls, value: str | None) -> str | None: | |
| if value is None: | |
| return None | |
| stripped = value.strip() | |
| if not stripped: | |
| raise ValueError("must not be blank") | |
| return stripped | |
| def _strip_optional(cls, value: str | None) -> str | None: | |
| return value.strip() if value is not None else None | |
| def _strip_proof(cls, value: str | None) -> str | None: | |
| if value is None: | |
| return None | |
| stripped = value.strip() | |
| return stripped or None | |
| def _clean_emotions(cls, value: list[str] | None) -> list[str] | None: | |
| return normalize_emotions(value) if value is not None else None | |
| def _clean_tags(cls, value: list[str] | None) -> list[str] | None: | |
| return normalize_tags(value) if value is not None else None | |
| class Entry(BaseModel): | |
| """A persisted log entry as stored on one JSONL line.""" | |
| model_config = ConfigDict(extra="ignore") | |
| id: str | |
| ts: datetime | |
| created_at: datetime | |
| updated_at: datetime | |
| activity: str | |
| happened: str | |
| emotions: list[str] = Field(default_factory=list) | |
| intensity: int = Field(ge=1, le=10) | |
| remedy: str = "" | |
| result: Result | |
| tags: list[str] = Field(default_factory=list) | |
| notes: str = "" | |
| fse_spike: bool | None = None | |
| avoidance_types: list[str] = Field(default_factory=list) | |
| proof_brick: str | None = None | |
| walk_type: WalkType | None = None | |
| coach: CoachMeta = Field(default_factory=CoachMeta) | |
| class EntryListData(BaseModel): | |
| """Paginated entry listing with total match count.""" | |
| items: list[Entry] | |
| total: int | |
| limit: int | |
| offset: int | |
| class DeletedData(BaseModel): | |
| """Confirmation payload for a hard delete.""" | |
| deleted: bool | |
| id: str | |
| class PrimaryBrick(str, Enum): | |
| """Daily primary brick label.""" | |
| A = "A" | |
| B = "B" | |
| C = "C" | |
| D = "D" | |
| E = "E" | |
| S = "S" | |
| none = "none" | |
| class Daydream(str, Enum): | |
| """Daily daydream status.""" | |
| none = "none" | |
| done = "done" | |
| fc = "fc" | |
| class Rerun(str, Enum): | |
| """Daily rerun status.""" | |
| clean = "clean" | |
| R = "R" | |
| class Court(str, Enum): | |
| """Daily court status.""" | |
| closed = "closed" | |
| court = "court" | |
| # P1 labels + legacy kinds kept so old Daily rows still validate. | |
| ProofBrickKind = Literal[ | |
| "interrupt_walk", | |
| "body_or_room", | |
| "trip_admin", | |
| "earn", | |
| "boundary", | |
| "food", | |
| "survive", | |
| "other", | |
| # legacy | |
| "leave_room", | |
| "admin_line", | |
| "send_message", | |
| "body_care", | |
| ] | |
| class DailyUpsert(BaseModel): | |
| """Client payload for upserting a daily scoreboard row.""" | |
| model_config = ConfigDict(extra="forbid") | |
| primary_brick: PrimaryBrick = PrimaryBrick.none | |
| brick_done: bool = False | |
| corn_sessions: int = Field(default=0, ge=0, le=50) | |
| delay_ok: bool = True | |
| daydream: Daydream = Daydream.none | |
| rerun: Rerun = Rerun.clean | |
| court: Court = Court.closed | |
| stayed_indoors_all_day: bool = False | |
| left_room: bool = False | |
| left_home: bool = False | |
| movement_minutes: int = Field(default=0, ge=0, le=24 * 60) | |
| interrupt_walk_minutes: int = Field(default=0, ge=0, le=24 * 60) | |
| fantasy_walk_minutes: int = Field(default=0, ge=0, le=24 * 60) | |
| headphones_on_walk: bool = False | |
| music_cinematic_on_walk: bool = False | |
| proof_brick_done: bool = False | |
| proof_brick_kind: ProofBrickKind | None = None | |
| fantasy_minutes_scheduled: int = Field(default=0, ge=0, le=24 * 60) | |
| fantasy_minutes_unplanned: int = Field(default=0, ge=0, le=24 * 60) | |
| note: str = Field(default="", max_length=2000) | |
| win: str = Field(default="", max_length=2000) | |
| def _strip_note(cls, value: str) -> str: | |
| return value.strip() | |
| class DailyRow(BaseModel): | |
| """A persisted daily scoreboard row; points are server-authoritative.""" | |
| model_config = ConfigDict(extra="ignore") | |
| date: date | |
| primary_brick: PrimaryBrick = PrimaryBrick.none | |
| brick_done: bool = False | |
| corn_sessions: int = Field(default=0, ge=0, le=50) | |
| delay_ok: bool = True | |
| daydream: Daydream = Daydream.none | |
| rerun: Rerun = Rerun.clean | |
| court: Court = Court.closed | |
| stayed_indoors_all_day: bool = False | |
| left_room: bool = False | |
| left_home: bool = False | |
| movement_minutes: int = Field(default=0, ge=0, le=24 * 60) | |
| interrupt_walk_minutes: int = Field(default=0, ge=0, le=24 * 60) | |
| fantasy_walk_minutes: int = Field(default=0, ge=0, le=24 * 60) | |
| headphones_on_walk: bool = False | |
| music_cinematic_on_walk: bool = False | |
| proof_brick_done: bool = False | |
| proof_brick_kind: ProofBrickKind | None = None | |
| fantasy_minutes_scheduled: int = Field(default=0, ge=0, le=24 * 60) | |
| fantasy_minutes_unplanned: int = Field(default=0, ge=0, le=24 * 60) | |
| points: int = Field(ge=0, le=6) | |
| note: str = "" | |
| win: str = "" | |
| updated_at: datetime | |
| class DailyRangeData(BaseModel): | |
| """Daily rows for a date range plus week band summary.""" | |
| items: list[DailyRow] | |
| week_points: int | |
| band: Literal["incomplete", "strong", "mixed", "escape_heavy"] | |
| days_present: int | |
| class CoachRequest(BaseModel): | |
| """Coach request: free text and/or entry id.""" | |
| model_config = ConfigDict(extra="forbid") | |
| text: str | None = None | |
| entry_id: str | None = None | |
| include_history: int | None = Field(default=None, ge=0, le=50) | |
| persist: bool = False | |
| force_backup: bool = False | |
| class CoachResponseData(BaseModel): | |
| """Coach reply always includes text and source.""" | |
| text: str | |
| source: Literal["model", "backup", "model_unparsed_fallback"] | |
| model: str | None = None | |
| trace_id: str | |
| flags: list[str] = Field(default_factory=list) | |
| server_picks: list[dict[str, Any]] = Field(default_factory=list) | |
| parsed: dict[str, str] | None = None | |
| class SettingsStatusData(BaseModel): | |
| """Authenticated settings status without secrets.""" | |
| app_name: str | |
| app_version: str | |
| coach_configured: bool | |
| model: str | |
| data_ok: bool | |
| env: str | |
| def ok(data: BaseModel | dict[str, Any] | list[Any]) -> dict[str, Any]: | |
| """Build a JSON-serializable success envelope.""" | |
| return jsonable_encoder(ApiEnvelope(ok=True, data=data, error=None)) | |
| def err(code: ErrorCode, message: str, status_code: int) -> JSONResponse: | |
| """Build a consistent failure response.""" | |
| payload = ApiEnvelope( | |
| ok=False, | |
| data=None, | |
| error=ApiError(code=code, message=message), | |
| ) | |
| return JSONResponse(status_code=status_code, content=jsonable_encoder(payload)) | |