from datetime import datetime from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, Text, UniqueConstraint, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base from scribe.models.base import CreatedAtMixin, iso SUGGESTED = "suggested" CONFIRMED = "confirmed" REJECTED = "rejected" # CHECK ck_lesson_rule_links_state (migration 0111). LINK_STATES = (SUGGESTED, CONFIRMED, REJECTED) class LessonRuleLink(Base, CreatedAtMixin): """A lesson says it is an instance of a rule. A lesson records one concrete situation; a rule records the binding choice for a class of them. The link lets the rule be reached through the situations that keep proving it, and lets a situation with lessons and no rule be noticed. A lesson never BECOMES a rule — it points at one. ONE ROW PER PAIR, WITH A STATE, because the operator's design forms a link in two stages ("a sort of soft and then hard link once it's been proven"): - ``suggested`` — evidence is accumulating that the two belong together (they keep arriving in the same request, in distinct situations). It carries nothing in retrieval: a suggested link that brought its rule along would manufacture the co-surfacing it counts, and prove itself. - ``confirmed`` — a judgment said the lesson is an instance of the rule. The only state that changes what surfaces. - ``rejected`` — a judgment said it is not. Kept, evidence and all, so the pair is never proposed again and the reason stays readable. A table rather than a list on the lesson's `data`: the link needs a foreign key on both ends (a deleted rule must not leave a lesson pointing at nothing), a reverse index (a rule lists its lessons), and an id remap at restore — none of which a JSON list gives. """ __tablename__ = "lesson_rule_links" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) lesson_id: Mapped[int] = mapped_column( Integer, ForeignKey("notes.id", ondelete="CASCADE"), index=True, ) rule_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True, ) state: Mapped[str] = mapped_column(Text, default=SUGGESTED, server_default=SUGGESTED) # Why it was confirmed or rejected — the reasoning a later reader needs to # decide whether it still holds, as a rule relation's `note` is. note: Mapped[str | None] = mapped_column(Text, nullable=True) # What the suggestion rests on (filled by the co-surfacing recorder). # Kept after a judgment so a confirmation can be read beside its evidence. evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True) # When a judgment moved it out of `suggested`. Null while suggested. judged_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, ) __table_args__ = ( UniqueConstraint("lesson_id", "rule_id", name="uq_lesson_rule_links_pair"), ) def to_dict(self) -> dict: return { "lesson_id": self.lesson_id, "rule_id": self.rule_id, "state": self.state, "note": self.note or "", "evidence": self.evidence or {}, "judged_at": iso(self.judged_at), "created_at": iso(self.created_at), } class LessonNoRule(Base): """A judgment that no rule governs a lesson's situation. The third answer to "which rule is this an instance of?", beside naming one and leaving it open. It is what lets an UNJUDGED lesson be told apart from one that was looked at and found to stand alone — and a lesson with no rule is the raw material convergence reads, so the difference is the whole signal. A row of its own rather than a key in the lesson's `data`: that column is a mirror re-composed from the body on every edit (services/lessons.py), so anything kept there that the body does not render is erased by the next rewording — and the lesson would silently read as unjudged again. One row per lesson. Naming a rule later deletes it: "no rule fits" and "this rule fits" cannot both be the current answer. """ __tablename__ = "lesson_no_rule" lesson_id: Mapped[int] = mapped_column( Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True, ) # Why none fits — required, because "no rule" with no reason cannot be # re-judged when a rule is later written for the situation. why: Mapped[str] = mapped_column(Text) judged_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), ) def to_dict(self) -> dict: return {"why": self.why, "judged_at": iso(self.judged_at)}