blizzarman/polyglot-tutor
0
1"""Core domain entities, persistence- and framework-agnostic.2 3Languages are first-class data (`source_lang` / `target_lang`, ISO 639-1) rather4than constants: English is the launch target, but the model must not need a5migration to add Spanish or German later.6"""7 8from datetime import UTC, datetime9from enum import StrEnum10from typing import Any11from uuid import uuid412 13from pydantic import BaseModel, Field14 15 16def _new_id() -> str:17 return uuid4().hex18 19 20def _now() -> datetime:21 return datetime.now(UTC)22 23 24class CEFRLevel(StrEnum):25 A1 = "A1"26 A2 = "A2"27 B1 = "B1"28 B2 = "B2"29 C1 = "C1"30 C2 = "C2"31 32 @property33 def rank(self) -> int:34 """0-based ordinal position (A1=0 ... C2=5), for ordinal metrics and adjacency."""35 return list(CEFRLevel).index(self)36 37 def distance(self, other: "CEFRLevel") -> int:38 """Absolute level distance; `<= 1` is the classic 'adjacent accuracy' criterion."""39 return abs(self.rank - other.rank)40 41 42class ExerciseType(StrEnum):43 READING_QA = "reading_qa" # M1 — comprehension questions on a CEFR-graded text44 DICTATION = "dictation" # M2 — TTS audio, learner types what they hear45 WRITING = "writing" # M3 — free writing, LLM correction with typed errors46 PRONUNCIATION = "pronunciation" # M5 — read-aloud with pronunciation scoring47 48 49class Learner(BaseModel):50 id: str = Field(default_factory=_new_id)51 display_name: str52 source_lang: str = "fr"53 target_lang: str = "en"54 created_at: datetime = Field(default_factory=_now)55 56 57class Exercise(BaseModel):58 """A generated, cacheable exercise.59 60 `payload` is type-specific content (text, questions, audio reference...).61 Generated exercises are content-addressed upstream (hash of source text +62 prompt version) so LLM cost stays ~0 and evals are reproducible.63 """64 65 id: str = Field(default_factory=_new_id)66 type: ExerciseType67 target_lang: str68 cefr_level: CEFRLevel69 payload: dict[str, Any] = Field(default_factory=dict)70 created_at: datetime = Field(default_factory=_now)71 72 73class Attempt(BaseModel):74 """One learner answer to one exercise, with its scoring/feedback."""75 76 id: str = Field(default_factory=_new_id)77 learner_id: str78 exercise_id: str79 response: dict[str, Any] = Field(default_factory=dict)80 score: float | None = Field(default=None, ge=0.0, le=1.0)81 feedback: dict[str, Any] = Field(default_factory=dict)82 created_at: datetime = Field(default_factory=_now)83 84 85class ReviewItem(BaseModel):86 """An atomic, schedulable knowledge item (vocab word, recurring error, ...).87 88 `scheduler_state` is an opaque dict owned by the SRS engine (FSRS-ready in89 M4) so the domain model is not coupled to one algorithm's parameters.90 """91 92 id: str = Field(default_factory=_new_id)93 learner_id: str94 kind: str # "vocab" | "error" | ... (free-form until M4 hardens it)95 content: dict[str, Any] = Field(default_factory=dict)96 due_at: datetime | None = None97 scheduler_state: dict[str, Any] = Field(default_factory=dict)98 created_at: datetime = Field(default_factory=_now)99 