CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m15s
CI & Build / Build & push image (push) Skipped
"Which rule is this lesson an instance of?" now has three recorded answers: a rule named (a confirmed link, #4630), no rule fits (new), or unjudged. - Model + migration 0112: lesson_no_rule (lesson_id PK, CASCADE from the note; why; judged_at). A table rather than a key in notes.data, because that mirror is re-composed from the body on every edit and would erase it. - Service (lesson_rules): set_no_rule rejects any confirmed link with the reason; a confirmation (set_lesson_rules or judge_link) deletes the answer; require_one_answer refuses both answers in one call before any write; judgments_for_lessons + attach_lesson_rules add rule_judgment (and no_rule) to every lesson payload; list_unjudged lists the open ones; rule_candidates searches rules with the lesson's claim + trigger at the explicit-search bar, None when the search could not run. - MCP: create_lesson/update_lesson take no_rule; an unanswered create returns rule_candidates, rule_judgment and a rule_hint; list_lessons(unjudged=true). - REST: the same on POST/PATCH /api/lessons and GET ?unjudged=1; create returns rule_candidates. - Backup v19: a lesson_no_rule section, export (full and per-user) and import. - Guidance: create_lesson docstring, writing-records.md in using-scribe (owner, pinned in test_guidance_ownership), create_rule docstring on linking the lessons a new rule governs. Plugin version minted. - Tests: door units, integration for the three states, the rejection reason, scoping, cascade; backup registries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
266 lines
11 KiB
Python
266 lines
11 KiB
Python
"""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
|
|
|
|
|
|
# ── #4631: judged when written ───────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unanswered_lesson_comes_back_with_rules_to_judge_against():
|
|
"""Neither answer given: the create offers the rules the lesson resembles,
|
|
searched with its claim and trigger and scoped to its project."""
|
|
_user_id_ctx.set(7)
|
|
offered = [{"id": 5, "title": "Read the log", "kind": "rule",
|
|
"when_to_apply": "a run overran", "score": 0.61}]
|
|
search = AsyncMock(return_value=offered)
|
|
p1, p2, p3 = _create_patches(AsyncMock(return_value=_stub_note()))
|
|
with p1, p2, p3, patch.object(links_svc, "rule_candidates", search):
|
|
out = await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER, project_id=2)
|
|
assert search.await_args.args == (7, "x", TRIGGER, 2)
|
|
assert out["rule_candidates"] == offered
|
|
assert out["rule_judgment"] == links_svc.UNJUDGED
|
|
assert "no_rule" in out["rule_hint"] and "rule_ids" in out["rule_hint"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_search_that_could_not_run_offers_no_list_rather_than_an_empty_one():
|
|
"""None means unavailable; an empty list would claim nothing resembles it."""
|
|
_user_id_ctx.set(7)
|
|
p1, p2, p3 = _create_patches(AsyncMock(return_value=_stub_note()))
|
|
with p1, p2, p3, patch.object(links_svc, "rule_candidates", AsyncMock(return_value=None)):
|
|
out = await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER)
|
|
assert "rule_candidates" not in out
|
|
assert out["rule_judgment"] == links_svc.UNJUDGED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_rule_on_create_records_the_answer_and_offers_nothing():
|
|
_user_id_ctx.set(7)
|
|
answered = AsyncMock()
|
|
search = AsyncMock(return_value=[])
|
|
p1, p2, p3 = _create_patches(AsyncMock(return_value=_stub_note(id=41)))
|
|
with p1, p2, p3, patch.object(links_svc, "set_no_rule", answered), \
|
|
patch.object(links_svc, "rule_candidates", search):
|
|
out = await lesson_tools.create_lesson(
|
|
what="x", when_to_apply=TRIGGER, no_rule="a one-off of this CI host",
|
|
)
|
|
assert answered.await_args.args[1:] == (41, "a one-off of this CI host")
|
|
search.assert_not_awaited()
|
|
assert "rule_candidates" not in out
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rules_and_no_rule_together_write_nothing():
|
|
_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(return_value=[5])):
|
|
with pytest.raises(ValueError, match="no_rule"):
|
|
await lesson_tools.create_lesson(
|
|
what="x", when_to_apply=TRIGGER, rule_ids=[5], no_rule="none fits",
|
|
)
|
|
created.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_records_no_rule_and_refuses_it_beside_named_rules():
|
|
_user_id_ctx.set(7)
|
|
answered = AsyncMock()
|
|
updated = AsyncMock(return_value=_stub_note())
|
|
with patch.object(lessons_svc, "update_lesson", updated), \
|
|
patch.object(links_svc, "set_no_rule", answered):
|
|
await lesson_tools.update_lesson(lesson_id=41, no_rule="stands alone")
|
|
assert answered.await_args.args[1:] == (41, "stands alone")
|
|
|
|
updated.reset_mock()
|
|
with patch.object(lessons_svc, "update_lesson", updated), \
|
|
patch.object(links_svc, "require_rules", AsyncMock(return_value=[5])):
|
|
with pytest.raises(ValueError):
|
|
await lesson_tools.update_lesson(lesson_id=41, rule_ids=[5], no_rule="none")
|
|
updated.assert_not_awaited()
|
|
|
|
|
|
def test_an_empty_rule_list_beside_no_rule_is_one_answer_not_two():
|
|
"""`rule_ids=[]` clears; it does not name a rule, so it cannot contradict."""
|
|
links_svc.require_one_answer([], "none fits")
|
|
links_svc.require_one_answer(None, "none fits")
|
|
links_svc.require_one_answer([5], "")
|
|
with pytest.raises(ValueError):
|
|
links_svc.require_one_answer([5], "none fits")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_unjudged_is_a_listing_and_routes_to_its_own_query():
|
|
_user_id_ctx.set(7)
|
|
listed = AsyncMock(return_value=([], 0))
|
|
with patch.object(links_svc, "list_unjudged", listed), \
|
|
patch("scribe.mcp.tools.lessons.access_svc.label_shared_items", AsyncMock(return_value=[])), \
|
|
patch("scribe.mcp.tools.lessons.attach_usage", AsyncMock()):
|
|
out = await lesson_tools.list_lessons(unjudged=True, tag="ci", project_id=2)
|
|
assert out == {"lessons": [], "total": 0}
|
|
assert listed.await_args.kwargs["tag"] == "ci"
|
|
assert listed.await_args.kwargs["project_id"] == 2
|
|
with pytest.raises(ValueError):
|
|
await lesson_tools.list_lessons(unjudged=True, q="log")
|
|
|
|
|
|
def test_backup_skips_a_no_rule_answer_whose_lesson_did_not_restore():
|
|
maps = backup._Maps()
|
|
assert backup._build_lesson_no_rule({"lesson_id": 10, "why": "alone"}, maps) is None
|
|
maps.notes[10] = 110
|
|
built = backup._build_lesson_no_rule({"lesson_id": 10, "why": "alone"}, maps)
|
|
assert (built.lesson_id, built.why) == (110, "alone")
|
|
# An answer with no reason is not an answer; it restores as unjudged.
|
|
assert backup._build_lesson_no_rule({"lesson_id": 10, "why": " "}, maps) is None
|
|
|
|
|
|
def test_the_no_rule_migration_follows_the_link_migration():
|
|
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0112_lesson_no_rule.py"
|
|
spec = importlib.util.spec_from_file_location("m0112", path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
assert (module.revision, module.down_revision) == ("0112", "0111")
|