feat(lessons): judged when written — a new lesson is offered its rules, and "no rule fits" is an answer (milestone 440 step 2, #4631)
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
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>
This commit is contained in:
@@ -160,3 +160,84 @@ async def test_one_row_per_pair(world):
|
||||
])
|
||||
with pytest.raises(IntegrityError):
|
||||
await s.commit()
|
||||
|
||||
|
||||
# ── #4631: "no rule fits" and the unjudged list ─────────────────────────────
|
||||
|
||||
|
||||
async def _judgment(lesson_id: int) -> dict:
|
||||
return (await links_svc.judgments_for_lessons([lesson_id]))[lesson_id]
|
||||
|
||||
|
||||
async def _unjudged_ids(user_id: int) -> set[int]:
|
||||
items, _total = await links_svc.list_unjudged(user_id, limit=100)
|
||||
return {i["id"] for i in items}
|
||||
|
||||
|
||||
async def test_a_new_lesson_is_unjudged_until_it_is_answered_either_way(world):
|
||||
owner, lesson = world["owner"], world["lesson"]
|
||||
assert (await _judgment(lesson))["rule_judgment"] == "unjudged"
|
||||
assert lesson in await _unjudged_ids(owner)
|
||||
|
||||
await links_svc.set_no_rule(owner, lesson, "specific to this CI host")
|
||||
judged = await _judgment(lesson)
|
||||
assert judged["rule_judgment"] == "no_rule"
|
||||
assert judged["no_rule"]["why"] == "specific to this CI host"
|
||||
assert lesson not in await _unjudged_ids(owner)
|
||||
|
||||
# Naming a rule overturns the answer — both cannot stand.
|
||||
await links_svc.set_lesson_rules(owner, lesson, [world["r1"]])
|
||||
judged = await _judgment(lesson)
|
||||
assert judged == {"rule_judgment": "linked"}
|
||||
assert lesson not in await _unjudged_ids(owner)
|
||||
|
||||
|
||||
async def test_no_rule_rejects_a_confirmed_link_with_its_reason(world):
|
||||
owner, lesson = world["owner"], world["lesson"]
|
||||
await links_svc.set_lesson_rules(owner, lesson, [world["r1"]])
|
||||
await links_svc.set_no_rule(owner, lesson, "the log was not the issue")
|
||||
|
||||
assert await _states(lesson) == {world["r1"]: "rejected"}
|
||||
rules = (await links_svc.rules_for_lessons(owner, [lesson]))[lesson]
|
||||
assert rules[0]["note"].endswith("the log was not the issue")
|
||||
assert (await _judgment(lesson))["rule_judgment"] == "no_rule"
|
||||
|
||||
|
||||
async def test_a_confirmation_by_judgment_also_clears_no_rule(world):
|
||||
owner, lesson = world["owner"], world["lesson"]
|
||||
await links_svc.set_no_rule(owner, lesson, "nothing yet")
|
||||
await links_svc.judge_link(owner, lesson, world["r2"], "confirm")
|
||||
assert (await _judgment(lesson))["rule_judgment"] == "linked"
|
||||
|
||||
|
||||
async def test_a_rejection_alone_leaves_the_lesson_unjudged(world):
|
||||
""""Not that rule" says nothing about whether another one fits."""
|
||||
owner, lesson = world["owner"], world["lesson"]
|
||||
await links_svc.judge_link(owner, lesson, world["r1"], "reject", "different failure")
|
||||
assert (await _judgment(lesson))["rule_judgment"] == "unjudged"
|
||||
assert lesson in await _unjudged_ids(owner)
|
||||
|
||||
|
||||
async def test_no_rule_needs_a_reason_and_a_writer(world):
|
||||
with pytest.raises(ValueError):
|
||||
await links_svc.set_no_rule(world["owner"], world["lesson"], " ")
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
await links_svc.set_no_rule(world["stranger"], world["lesson"], "mine now")
|
||||
assert (await _judgment(world["lesson"]))["rule_judgment"] == "unjudged"
|
||||
|
||||
|
||||
async def test_the_unjudged_list_is_scoped_to_the_reader(world):
|
||||
assert world["lesson"] not in await _unjudged_ids(world["stranger"])
|
||||
|
||||
|
||||
async def test_deleting_the_lesson_takes_its_answer_with_it(world):
|
||||
from scribe.models.lesson_rule_link import LessonNoRule
|
||||
|
||||
await links_svc.set_no_rule(world["owner"], world["lesson"], "alone")
|
||||
async with async_session() as s:
|
||||
await s.execute(delete(Note).where(Note.id == world["lesson"]))
|
||||
await s.commit()
|
||||
left = (await s.execute(
|
||||
select(LessonNoRule).where(LessonNoRule.lesson_id == world["lesson"])
|
||||
)).scalars().all()
|
||||
assert left == []
|
||||
|
||||
Reference in New Issue
Block a user