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
- 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>
113 lines
4.7 KiB
Python
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)}
|
|
|