Files
FabledGrimoire/src/scribe/models/lesson_rule_link.py
T
bvandeusenandClaude Opus 5.5 0c88e1b0b0
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 24s
docs(6206): settings, projects, api routes and three models drop citations; four capitalised services stragglers
- api.py's build-version docstring states why a channel name is not a build
  id, without the dated incident.
- rule_version states why rules need a history, without the rule that was
  rescoped and the date.
- Capitalised "Rule N" / "Milestone N" citations the earlier lowercase grep
  missed, in reply_shapes, supersession, services/family and models/family.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:42:41 -04:00

113 lines
4.7 KiB
Python

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)}