"""Core domain entities, persistence- and framework-agnostic. Languages are first-class data (`source_lang` / `target_lang`, ISO 639-1) rather than constants: English is the launch target, but the model must not need a migration to add Spanish or German later. """ from datetime import UTC, datetime from enum import StrEnum from typing import Any from uuid import uuid4 from pydantic import BaseModel, Field def _new_id() -> str: return uuid4().hex def _now() -> datetime: return datetime.now(UTC) class CEFRLevel(StrEnum): A1 = "A1" A2 = "A2" B1 = "B1" B2 = "B2" C1 = "C1" C2 = "C2" @property def rank(self) -> int: """0-based ordinal position (A1=0 ... C2=5), for ordinal metrics and adjacency.""" return list(CEFRLevel).index(self) def distance(self, other: "CEFRLevel") -> int: """Absolute level distance; `<= 1` is the classic 'adjacent accuracy' criterion.""" return abs(self.rank - other.rank) class ExerciseType(StrEnum): READING_QA = "reading_qa" # M1 — comprehension questions on a CEFR-graded text DICTATION = "dictation" # M2 — TTS audio, learner types what they hear WRITING = "writing" # M3 — free writing, LLM correction with typed errors PRONUNCIATION = "pronunciation" # M5 — read-aloud with pronunciation scoring class Learner(BaseModel): id: str = Field(default_factory=_new_id) display_name: str source_lang: str = "fr" target_lang: str = "en" created_at: datetime = Field(default_factory=_now) class Exercise(BaseModel): """A generated, cacheable exercise. `payload` is type-specific content (text, questions, audio reference...). Generated exercises are content-addressed upstream (hash of source text + prompt version) so LLM cost stays ~0 and evals are reproducible. """ id: str = Field(default_factory=_new_id) type: ExerciseType target_lang: str cefr_level: CEFRLevel payload: dict[str, Any] = Field(default_factory=dict) created_at: datetime = Field(default_factory=_now) class Attempt(BaseModel): """One learner answer to one exercise, with its scoring/feedback.""" id: str = Field(default_factory=_new_id) learner_id: str exercise_id: str response: dict[str, Any] = Field(default_factory=dict) score: float | None = Field(default=None, ge=0.0, le=1.0) feedback: dict[str, Any] = Field(default_factory=dict) created_at: datetime = Field(default_factory=_now) class ReviewItem(BaseModel): """An atomic, schedulable knowledge item (vocab word, recurring error, ...). `scheduler_state` is an opaque dict owned by the SRS engine (FSRS-ready in M4) so the domain model is not coupled to one algorithm's parameters. """ id: str = Field(default_factory=_new_id) learner_id: str kind: str # "vocab" | "error" | ... (free-form until M4 hardens it) content: dict[str, Any] = Field(default_factory=dict) due_at: datetime | None = None scheduler_state: dict[str, Any] = Field(default_factory=dict) created_at: datetime = Field(default_factory=_now)