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:
@@ -0,0 +1,54 @@
|
||||
"""lesson_rule_links — a lesson points at the rule it is an instance of
|
||||
(milestone 440 step 1, #4196)
|
||||
|
||||
Revision ID: 0111
|
||||
Revises: 0110
|
||||
Create Date: 2026-10-01
|
||||
|
||||
One row per (lesson, rule) pair with a state: `suggested` while evidence
|
||||
accumulates, `confirmed` or `rejected` once a judgment is made. Only a
|
||||
confirmed link changes what surfaces; a rejected one is kept so the pair is not
|
||||
proposed again. CASCADE on both ends — a link to a record that no longer exists
|
||||
says nothing. No backfill: no lesson has ever been linked, and inventing a link
|
||||
would assert a judgment nobody made.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from alembic import op
|
||||
|
||||
revision = "0111"
|
||||
down_revision = "0110"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# One place, so the CHECK and the model's LINK_STATES cannot drift (rule 36:
|
||||
# a new value later means DROP + ADD CONSTRAINT in the same migration).
|
||||
_STATES = ("suggested", "confirmed", "rejected")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lesson_rule_links",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column("lesson_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("state", sa.Text(), nullable=False, server_default="suggested"),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("evidence", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("judged_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.UniqueConstraint("lesson_id", "rule_id", name="uq_lesson_rule_links_pair"),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_lesson_rule_links_state", "lesson_rule_links",
|
||||
"state IN (" + ", ".join(f"'{s}'" for s in _STATES) + ")",
|
||||
)
|
||||
op.create_index("ix_lesson_rule_links_lesson_id", "lesson_rule_links", ["lesson_id"])
|
||||
op.create_index("ix_lesson_rule_links_rule_id", "lesson_rule_links", ["rule_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_lesson_rule_links_rule_id", table_name="lesson_rule_links")
|
||||
op.drop_index("ix_lesson_rule_links_lesson_id", table_name="lesson_rule_links")
|
||||
op.drop_constraint("ck_lesson_rule_links_state", "lesson_rule_links", type_="check")
|
||||
op.drop_table("lesson_rule_links")
|
||||
Reference in New Issue
Block a user