feat(lessons): a lesson names the rule it is an instance of — lesson_rule_links (milestone 440 step 1, #4630)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m13s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m13s
CI & Build / Build & push image (push) Skipped
The link between a lesson (one concrete situation) and the rule that governs it, with the operator's soft-then-hard design built into its state: suggested while evidence accumulates, confirmed or rejected once judged. Only confirmed will carry a rule in retrieval (#4633); rejected is kept so the pair is never proposed again. - models/lesson_rule_link.py + migration 0111: one row per (lesson, rule), CASCADE on both ends, indexed both ways, CHECK on state (rule 36), evidence JSONB and judged_at. - services/lesson_rules.py: require_rules (validated before any write, so a bad id leaves nothing half-linked), set_lesson_rules (set-semantics; a dropped rule becomes rejected, not forgotten), judge_link, and the two reads. ACL: write on the lesson (share-aware), ownership of the rule; a reader sees only rules they own. Decorations are fail-open (#4286). - MCP: create_lesson / update_lesson take rule_ids; get/create/update return `rules`; new judge_lesson_link tool. REST: the same on /api/lessons plus PUT /api/lessons/<id>/rules/<rule_id>. Rules: rule_detail carries `lessons`. - Backup v18: export (full and user-scoped, both ends in scope), builder, importer; both column guards register the table. - Tests: integration (states, set-semantics, judge, ACL all-or-nothing, cascade both ways, CHECK, one row per pair); unit (door wiring, judge registered, migration/model state agreement, backup skip and unjudged stays unjudged). conftest stubs the decorations for unit tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -77,6 +77,8 @@ from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
|
||||
from scribe.models.rulebook import ( # noqa: E402, F401
|
||||
Rulebook, RulebookTopic, Rule, RuleRelation, rule_systems,
|
||||
)
|
||||
# After notes and rules: it foreign-keys both (milestone 440).
|
||||
from scribe.models.lesson_rule_link import LessonRuleLink # noqa: E402, F401
|
||||
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
|
||||
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
|
||||
from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, Text, UniqueConstraint
|
||||
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, rule 36).
|
||||
LINK_STATES = (SUGGESTED, CONFIRMED, REJECTED)
|
||||
|
||||
|
||||
class LessonRuleLink(Base, CreatedAtMixin):
|
||||
"""A lesson says it is an instance of a rule (milestone 440, #4196).
|
||||
|
||||
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, #4637).
|
||||
# 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),
|
||||
}
|
||||
Reference in New Issue
Block a user