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,145 @@
|
||||
"""The lesson → rule link at its doors (milestone 440 step 1, #4630).
|
||||
|
||||
The link's own behaviour — states, cascade, ACL — is tested against Postgres in
|
||||
tests/test_integration_lesson_rule_links.py. These pin the wiring a mock CAN
|
||||
see: that a bad rule id stops a lesson create before anything is written, that
|
||||
the doors pass the set through with the semantics their docstrings promise,
|
||||
that the judge tool is registered, that backup skips a link it cannot map, and
|
||||
that the migration and the model agree about the states.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.mcp._context import _user_id_ctx
|
||||
from scribe.mcp.tools import lessons as lesson_tools
|
||||
from scribe.models.lesson_rule_link import LINK_STATES
|
||||
from scribe.services import backup
|
||||
from scribe.services import lesson_rules as links_svc
|
||||
from scribe.services import lessons as lessons_svc
|
||||
|
||||
TRIGGER = "a CI run is still in_progress far past its usual time"
|
||||
|
||||
|
||||
def _stub_note(**kw):
|
||||
base = dict(
|
||||
id=41, title="t", body="b", tags=[], project_id=None,
|
||||
note_type="lesson", data={}, arose_from_id=None,
|
||||
created_at=None, updated_at=None,
|
||||
)
|
||||
base.update(kw)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _create_patches(created):
|
||||
return (
|
||||
patch.object(lessons_svc, "create_lesson", created),
|
||||
patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note",
|
||||
AsyncMock(return_value=None)),
|
||||
patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unreadable_rule_stops_the_lesson_before_it_is_written():
|
||||
"""All-or-nothing at the door: the rule ids are validated BEFORE the
|
||||
lesson exists, so a bad id cannot leave a lesson saved and unlinked."""
|
||||
_user_id_ctx.set(7)
|
||||
created = AsyncMock(return_value=_stub_note())
|
||||
p1, p2, p3 = _create_patches(created)
|
||||
with p1, p2, p3, patch.object(
|
||||
links_svc, "require_rules", AsyncMock(side_effect=ValueError("rule(s) [9] not found")),
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER, rule_ids=[9])
|
||||
created.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_links_the_named_rules_to_the_new_lesson():
|
||||
_user_id_ctx.set(7)
|
||||
created = AsyncMock(return_value=_stub_note(id=41))
|
||||
linked = AsyncMock()
|
||||
p1, p2, p3 = _create_patches(created)
|
||||
with p1, p2, p3, \
|
||||
patch.object(links_svc, "require_rules", AsyncMock(return_value=[5, 6])), \
|
||||
patch.object(links_svc, "set_lesson_rules", linked):
|
||||
await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER, rule_ids=[5, 6])
|
||||
linked.assert_awaited_once()
|
||||
assert linked.await_args.args[1:] == (41, [5, 6])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_without_rules_writes_no_link():
|
||||
_user_id_ctx.set(7)
|
||||
linked = AsyncMock()
|
||||
p1, p2, p3 = _create_patches(AsyncMock(return_value=_stub_note()))
|
||||
with p1, p2, p3, patch.object(links_svc, "set_lesson_rules", linked):
|
||||
await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER)
|
||||
linked.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rule_ids, expect_call", [(None, False), ([], True), ([5], True)])
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_leaves_links_alone_on_none_and_replaces_on_a_list(rule_ids, expect_call):
|
||||
"""None is "unchanged"; a list — including [] — is the full new set, which
|
||||
the service turns into confirmations and rejections."""
|
||||
_user_id_ctx.set(7)
|
||||
linked = AsyncMock()
|
||||
with patch.object(lessons_svc, "update_lesson", AsyncMock(return_value=_stub_note())), \
|
||||
patch.object(links_svc, "require_rules", AsyncMock(side_effect=lambda uid, ids: list(ids))), \
|
||||
patch.object(links_svc, "set_lesson_rules", linked):
|
||||
await lesson_tools.update_lesson(lesson_id=41, rule_ids=rule_ids)
|
||||
assert linked.await_count == (1 if expect_call else 0)
|
||||
if expect_call:
|
||||
assert linked.await_args.args[1:] == (41, rule_ids)
|
||||
|
||||
|
||||
def test_the_judge_tool_is_registered():
|
||||
names = []
|
||||
fake = SimpleNamespace(tool=lambda name: (lambda fn: names.append(name) or fn))
|
||||
lesson_tools.register(fake)
|
||||
assert "judge_lesson_link" in names
|
||||
|
||||
|
||||
def test_verdicts_name_real_states():
|
||||
assert set(links_svc.VERDICTS.values()) <= set(LINK_STATES)
|
||||
|
||||
|
||||
def test_the_migration_check_and_the_model_agree_on_the_states():
|
||||
"""Rule 36's drift, guarded: the CHECK is written from the migration's
|
||||
tuple and the code from the model's, so they must be the same tuple."""
|
||||
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0111_lesson_rule_links.py"
|
||||
spec = importlib.util.spec_from_file_location("m0111", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
assert tuple(module._STATES) == tuple(LINK_STATES)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lesson_mapped, rule_mapped", [(False, True), (True, False)])
|
||||
def test_backup_skips_a_link_whose_end_did_not_restore(lesson_mapped, rule_mapped):
|
||||
maps = backup._Maps()
|
||||
if lesson_mapped:
|
||||
maps.notes[10] = 110
|
||||
if rule_mapped:
|
||||
maps.rules[20] = 120
|
||||
row = {"lesson_id": 10, "rule_id": 20, "state": "confirmed"}
|
||||
assert backup._build_lesson_rule_link(row, maps) is None
|
||||
|
||||
|
||||
def test_backup_keeps_an_unjudged_link_unjudged():
|
||||
"""A suggested link was never judged; restoring it with the restore's time
|
||||
in judged_at would say it was."""
|
||||
maps = backup._Maps()
|
||||
maps.notes[10] = 110
|
||||
maps.rules[20] = 120
|
||||
built = backup._build_lesson_rule_link(
|
||||
{"lesson_id": 10, "rule_id": 20, "state": "suggested", "judged_at": None}, maps,
|
||||
)
|
||||
assert (built.lesson_id, built.rule_id, built.state) == (110, 120, "suggested")
|
||||
assert built.judged_at is None
|
||||
Reference in New Issue
Block a user