"""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 @field_validator("activity", "happened", mode="after") @classmethod def _strip_required(cls, value: str) -> str: stripped = value.strip() if not stripped: raise ValueError("must not be blank") return stripped @field_validator("remedy", "notes", mode="after") @classmethod def _strip_optional(cls, value: str) -> str: return value.strip() @field_validator("proof_brick", mode="after") @classmethod def _strip_proof(cls, value: str | None) -> str | None: if value is None: return None stripped = value.strip() return stripped or None @field_validator("emotions", mode="after") @classmethod def _clean_emotions(cls, value: list[str]) -> list[str]: return normalize_emotions(value) @field_validator("tags", "avoidance_types", mode="after") @classmethod 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 @field_validator("activity", "happened", mode="after") @classmethod 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 @field_validator("remedy", "notes", mode="after") @classmethod def _strip_optional(cls, value: str | None) -> str | None: return value.strip() if value is not None else None @field_validator("proof_brick", mode="after") @classmethod def _strip_proof(cls, value: str | None) -> str | None: if value is None: return None stripped = value.strip() return stripped or None @field_validator("emotions", mode="after") @classmethod def _clean_emotions(cls, value: list[str] | None) -> list[str] | None: return normalize_emotions(value) if value is not None else None @field_validator("tags", "avoidance_types", mode="after") @classmethod 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) @field_validator("note", "win", mode="after") @classmethod 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))