Fastwhisper / app /models.py
Mbonea's picture
Add discretion, positive reinforcement, and low-friction daily
8d145ad
Raw
History Blame Contribute Delete
12.7 kB
"""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))