CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 55s
CI & Build / Python tests (push) Failing after 1m16s
CI & Build / Build & push image (push) Skipped
On all three rule arms (prompt_rule, pre_tool_rule, write_path_rule), after the direct match, a lesson matching the moment at the notes menu's own bar brings the rule(s) it is CONFIRMED to be an instance of, rendered in rule voice with "Reached through lesson #N “…”, a recorded instance of it." - Confirmed links only (lesson_rules.confirmed_lessons / confirmed_rules_in_scope). A suggested link carrying its rule would manufacture the co-arrival #4637 counts and prove itself. - Scope kept: a lesson surfaces everywhere, but the rule it brings must be global or this project's own — milestone 414's boundary, not reopened through a side door. - Suppression is by RULE: anything the direct band named or the session ledger holds is skipped, whichever lesson reached it. - Its own slot (VIA_LESSON_LIMIT = 1), not a rule slot — a stated default, since the plan's "decide by measurement" has nothing to measure until links are confirmed (#4632). Its own source, rule_via_lesson: registered, RANKED for pull-through, logged whenever it searches. - Nothing searched at all while no lesson carries a confirmed link, which is every install until one is judged. - build_prompt_rule_hint / build_tool_rule_hint are now thin wrappers over the direct arms (_prompt_rule_hint / _tool_rule_hint); the arm leaves its query in `_via_query` only when it ran, so a disabled arm or a blank prompt brings no rule in either, and the key never leaves the server. - Via-lesson rules are not fed to the #4637 co-surfacing recorder: only direct matches are evidence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
16 KiB
Python
350 lines
16 KiB
Python
"""Real-Postgres tests for lesson → rule links (milestone 440 step 1, #4630).
|
|
|
|
What a mock cannot show: that the CHECK holds the three states, that deleting
|
|
either end takes the link with it, that a rule the caller does not own cannot
|
|
be linked (and nothing is half-written when one id is bad), and that each side
|
|
reads only what its reader may see.
|
|
"""
|
|
import uuid
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.lesson_rule_link import LessonRuleLink
|
|
from scribe.models.note import Note
|
|
from scribe.models.project import Project
|
|
from scribe.models.rulebook import Rule
|
|
from scribe.services import lesson_rules as links_svc
|
|
from scribe.services import lessons as lessons_svc
|
|
from scribe.services import rulebooks as rulebooks_svc
|
|
from tests.helpers import ensure_user
|
|
|
|
pytestmark = [
|
|
pytest.mark.integration,
|
|
pytest.mark.usefixtures("_dispose_engine", "_no_embedding"),
|
|
]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_reindex():
|
|
"""Rule writes detach an embedding refresh that outlives the test's loop."""
|
|
with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()):
|
|
yield
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def world():
|
|
"""An owner with a lesson and two rules; a stranger with a rule of their own."""
|
|
tag = uuid.uuid4().hex[:8]
|
|
async with async_session() as s:
|
|
owner = await ensure_user(s, f"lrl_owner_{tag}")
|
|
stranger = await ensure_user(s, f"lrl_stranger_{tag}")
|
|
mine = Project(user_id=owner.id, title="Mine")
|
|
theirs = Project(user_id=stranger.id, title="Theirs")
|
|
s.add_all([mine, theirs])
|
|
await s.flush()
|
|
ids = {"owner": owner.id, "stranger": stranger.id,
|
|
"mine": mine.id, "theirs": theirs.id}
|
|
await s.commit()
|
|
|
|
owner = ids["owner"]
|
|
r1 = await rulebooks_svc.create_project_rule(
|
|
ids["mine"], owner, "Read the job log first", "Before waiting longer.",
|
|
when_to_apply="a CI run has overrun its usual duration",
|
|
)
|
|
r2 = await rulebooks_svc.create_project_rule(
|
|
ids["mine"], owner, "Probe the system itself", "Not a proxy for it.",
|
|
when_to_apply="about to state what version is deployed",
|
|
)
|
|
foreign = await rulebooks_svc.create_project_rule(
|
|
ids["theirs"], ids["stranger"], "Their rule", "Not yours.",
|
|
when_to_apply="something only they do",
|
|
)
|
|
lesson = await lessons_svc.create_lesson(
|
|
owner, what="An overrun run usually failed early",
|
|
when_to_apply="a CI run is still in_progress far past its usual time",
|
|
insight="Read the log; the failure is often minutes old.",
|
|
project_id=ids["mine"],
|
|
)
|
|
ids.update(r1=r1.id, r2=r2.id, foreign=foreign.id, lesson=lesson.id)
|
|
return ids
|
|
|
|
|
|
async def _states(lesson_id: int) -> dict[int, str]:
|
|
async with async_session() as s:
|
|
rows = (await s.execute(
|
|
select(LessonRuleLink).where(LessonRuleLink.lesson_id == lesson_id)
|
|
)).scalars().all()
|
|
return {r.rule_id: r.state for r in rows}
|
|
|
|
|
|
async def test_naming_rules_confirms_them_and_both_sides_read_the_link(world):
|
|
owner = world["owner"]
|
|
await links_svc.set_lesson_rules(owner, world["lesson"], [world["r1"], world["r2"]])
|
|
|
|
assert await _states(world["lesson"]) == {world["r1"]: "confirmed", world["r2"]: "confirmed"}
|
|
rules = (await links_svc.rules_for_lessons(owner, [world["lesson"]]))[world["lesson"]]
|
|
assert {r["id"] for r in rules} == {world["r1"], world["r2"]}
|
|
lessons = await links_svc.lessons_for_rule(owner, world["r1"])
|
|
assert [(l["id"], l["state"]) for l in lessons] == [(world["lesson"], "confirmed")]
|
|
|
|
|
|
async def test_a_rule_left_out_of_the_set_is_rejected_not_forgotten(world):
|
|
owner = world["owner"]
|
|
await links_svc.set_lesson_rules(owner, world["lesson"], [world["r1"], world["r2"]])
|
|
await links_svc.set_lesson_rules(owner, world["lesson"], [world["r1"]])
|
|
|
|
assert await _states(world["lesson"]) == {world["r1"]: "confirmed", world["r2"]: "rejected"}
|
|
rules = (await links_svc.rules_for_lessons(owner, [world["lesson"]]))[world["lesson"]]
|
|
# Confirmed reads first; the rejection keeps its reason.
|
|
assert [r["state"] for r in rules] == ["confirmed", "rejected"]
|
|
assert rules[1]["note"] == links_svc._REMOVED_NOTE
|
|
|
|
|
|
async def test_judge_moves_a_pair_both_ways_and_refuses_a_nonsense_verdict(world):
|
|
owner = world["owner"]
|
|
out = await links_svc.judge_link(owner, world["lesson"], world["r1"], "reject", "different failure")
|
|
assert (out["state"], out["note"]) == ("rejected", "different failure")
|
|
assert out["judged_at"] is not None
|
|
out = await links_svc.judge_link(owner, world["lesson"], world["r1"], "confirm")
|
|
assert out["state"] == "confirmed"
|
|
with pytest.raises(ValueError):
|
|
await links_svc.judge_link(owner, world["lesson"], world["r1"], "maybe")
|
|
|
|
|
|
async def test_a_rule_the_caller_cannot_read_links_nothing_at_all(world):
|
|
"""All-or-nothing: one unreadable id refuses the whole set, so the readable
|
|
one beside it is not linked either."""
|
|
with pytest.raises(ValueError):
|
|
await links_svc.set_lesson_rules(
|
|
world["owner"], world["lesson"], [world["r1"], world["foreign"]],
|
|
)
|
|
assert await _states(world["lesson"]) == {}
|
|
|
|
|
|
async def test_a_stranger_cannot_link_someone_elses_lesson(world):
|
|
with pytest.raises((ValueError, PermissionError)):
|
|
await links_svc.set_lesson_rules(world["stranger"], world["lesson"], [world["foreign"]])
|
|
assert await _states(world["lesson"]) == {}
|
|
|
|
|
|
async def test_deleting_either_end_takes_the_link_with_it(world):
|
|
owner = world["owner"]
|
|
await links_svc.set_lesson_rules(owner, world["lesson"], [world["r1"], world["r2"]])
|
|
async with async_session() as s:
|
|
await s.execute(delete(Rule).where(Rule.id == world["r1"]))
|
|
await s.commit()
|
|
assert await _states(world["lesson"]) == {world["r2"]: "confirmed"}
|
|
async with async_session() as s:
|
|
await s.execute(delete(Note).where(Note.id == world["lesson"]))
|
|
await s.commit()
|
|
assert await _states(world["lesson"]) == {}
|
|
|
|
|
|
async def test_the_check_holds_the_three_states(world):
|
|
async with async_session() as s:
|
|
s.add(LessonRuleLink(lesson_id=world["lesson"], rule_id=world["r1"], state="maybe"))
|
|
with pytest.raises(IntegrityError):
|
|
await s.commit()
|
|
|
|
|
|
async def test_one_row_per_pair(world):
|
|
async with async_session() as s:
|
|
s.add_all([
|
|
LessonRuleLink(lesson_id=world["lesson"], rule_id=world["r1"], state="suggested"),
|
|
LessonRuleLink(lesson_id=world["lesson"], rule_id=world["r1"], state="confirmed"),
|
|
])
|
|
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 == []
|
|
|
|
|
|
# ── #4637: soft links from co-surfacing ─────────────────────────────────────
|
|
|
|
|
|
async def _link(lesson_id: int, rule_id: int):
|
|
async with async_session() as s:
|
|
return (await s.execute(
|
|
select(LessonRuleLink).where(
|
|
LessonRuleLink.lesson_id == lesson_id, LessonRuleLink.rule_id == rule_id,
|
|
)
|
|
)).scalar_one_or_none()
|
|
|
|
|
|
async def _arrive(world, prompt: str, project: int | None = None, rules=None) -> str:
|
|
return await links_svc.co_surfaced(
|
|
world["owner"], [world["lesson"]], rules or [world["r1"]],
|
|
arm="p", situation=prompt, project_id=project,
|
|
)
|
|
|
|
|
|
async def test_distinct_situations_reach_the_bar_and_the_proposal_fires_once(world):
|
|
assert await _arrive(world, "the CI run is still going after twenty minutes") == ""
|
|
assert await _arrive(world, "deploy job hanging on the image build step") == ""
|
|
line = await _arrive(world, "integration lane overran its usual duration", world["mine"])
|
|
assert f"judge_lesson_link({world['lesson']}, {world['r1']}" in line
|
|
|
|
link = await _link(world["lesson"], world["r1"])
|
|
assert link.state == "suggested"
|
|
assert links_svc.evidence_summary(link.evidence)["situations"] == 3
|
|
# Within the cooldown a fourth situation is counted but not asked again.
|
|
assert await _arrive(world, "pipeline stuck waiting on a runner for ages") == ""
|
|
link = await _link(world["lesson"], world["r1"])
|
|
assert links_svc.evidence_summary(link.evidence)["situations"] == 4
|
|
|
|
|
|
async def test_the_same_prompt_repeated_never_reaches_the_bar(world):
|
|
for _ in range(5):
|
|
assert await _arrive(world, "is the CI run still going") == ""
|
|
ev = links_svc.evidence_summary((await _link(world["lesson"], world["r1"])).evidence)
|
|
assert ev == {"situations": 1, "projects": 0, "co_surfaced": 5}
|
|
|
|
|
|
async def test_a_judged_pair_gathers_nothing_and_is_never_proposed(world):
|
|
owner = world["owner"]
|
|
await links_svc.judge_link(owner, world["lesson"], world["r1"], "reject", "different failure")
|
|
await links_svc.judge_link(owner, world["lesson"], world["r2"], "confirm")
|
|
for prompt in ("first situation here", "second situation there", "third situation elsewhere"):
|
|
assert await _arrive(world, prompt, rules=[world["r1"], world["r2"]]) == ""
|
|
assert (await _link(world["lesson"], world["r1"])).evidence is None
|
|
assert (await _link(world["lesson"], world["r2"])).evidence is None
|
|
|
|
|
|
async def test_a_rule_the_reader_does_not_own_is_not_paired(world):
|
|
for prompt in ("first situation here", "second situation there", "third situation elsewhere"):
|
|
assert await _arrive(world, prompt, rules=[world["foreign"]]) == ""
|
|
assert await _link(world["lesson"], world["foreign"]) is None
|
|
|
|
|
|
async def test_a_suggestion_shows_its_evidence_and_leaves_the_lesson_unjudged(world):
|
|
await _arrive(world, "the CI run is still going after twenty minutes", world["mine"])
|
|
rules = (await links_svc.rules_for_lessons(world["owner"], [world["lesson"]]))[world["lesson"]]
|
|
assert rules[0]["state"] == "suggested"
|
|
assert rules[0]["evidence"] == {"situations": 1, "projects": 1, "co_surfaced": 1}
|
|
assert (await _judgment(world["lesson"]))["rule_judgment"] == "unjudged"
|
|
|
|
|
|
async def test_confirming_a_suggestion_keeps_what_it_rested_on(world):
|
|
for prompt in ("first situation here", "second situation there", "third situation elsewhere"):
|
|
await _arrive(world, prompt)
|
|
out = await links_svc.judge_link(world["owner"], world["lesson"], world["r1"], "confirm", "same failure class")
|
|
assert out["state"] == "confirmed"
|
|
assert len(out["evidence"]["situations"]) == 3
|
|
|
|
|
|
# ── #4633: which links can carry a rule ─────────────────────────────────────
|
|
|
|
|
|
async def test_only_a_confirmed_link_can_carry_its_rule(world):
|
|
"""A suggested link never expands: it would manufacture the co-arrival
|
|
that #4637 counts, and prove itself. A rejected one obviously not."""
|
|
owner, lesson = world["owner"], world["lesson"]
|
|
await links_svc.co_surfaced(owner, [lesson], [world["r1"]], arm="p", situation="one ask here")
|
|
await links_svc.judge_link(owner, lesson, world["r2"], "reject", "not this")
|
|
assert await links_svc.confirmed_lessons(owner) == set()
|
|
assert await links_svc.confirmed_rules_in_scope(owner, [lesson], world["mine"]) == {}
|
|
|
|
await links_svc.judge_link(owner, lesson, world["r1"], "confirm", "same failure class")
|
|
assert lesson in await links_svc.confirmed_lessons(owner)
|
|
rules = await links_svc.confirmed_rules_in_scope(owner, [lesson], world["mine"])
|
|
assert [r.id for r in rules[lesson]] == [world["r1"]]
|
|
|
|
|
|
async def test_a_project_rule_stays_in_its_project_even_through_a_lesson(world):
|
|
"""Milestone 414's scope, kept on the side door: the lesson surfaces
|
|
everywhere, its project rule only in its project."""
|
|
owner, lesson = world["owner"], world["lesson"]
|
|
await links_svc.set_lesson_rules(owner, lesson, [world["r1"]])
|
|
assert await links_svc.confirmed_rules_in_scope(owner, [lesson], world["mine"])
|
|
assert await links_svc.confirmed_rules_in_scope(owner, [lesson], world["theirs"]) == {}
|
|
assert await links_svc.confirmed_rules_in_scope(owner, [lesson], None) == {}
|
|
|
|
|
|
async def test_a_stranger_reaches_no_rule_through_someone_elses_link(world):
|
|
await links_svc.set_lesson_rules(world["owner"], world["lesson"], [world["r1"]])
|
|
assert await links_svc.confirmed_lessons(world["stranger"]) == set()
|