"""Convergence named at the write (milestone 440, #4634). When a lesson is answered "no rule fits", the response looks for other no-rule lessons in the same situation and, once there are enough, names the group and the rule it may be missing. The bar is pinned here as a pure function; the search around it with the database and embedder stubbed; the doors by what they return. """ from __future__ import annotations 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.services import lesson_rules as links_svc from scribe.services import lessons as lessons_svc # Bound before conftest's autouse stub replaces the module attribute. _REAL = links_svc.convergence_for def _m(lid, sources=(), project=None, title=None): return {"id": lid, "title": title or f"lesson {lid}", "sources": list(sources), "project_id": project} def test_below_the_group_size_nothing_is_named(): assert links_svc.convergence_group([_m(1), _m(2)]) is None def test_a_group_of_distinct_lessons_names_its_members_and_the_next_step(): group = links_svc.convergence_group([_m(1, [10], 2), _m(2, [11], 5), _m(3, [], 2)]) assert [m["id"] for m in group["lessons"]] == [1, 2, 3] assert group["incidents"] == [10, 11] assert group["projects"] == [2, 5] assert "create_rule" in group["hint"] and "update_lesson" in group["hint"] assert "#1 “lesson 1”" in group["hint"] def test_one_broad_lesson_cannot_reach_the_bar_alone(): """Many incidents behind ONE lesson is still one judgment: incidents never stand in for lessons, and a repeated id is one member.""" broad = _m(1, [10, 11, 12, 13, 14]) assert links_svc.convergence_group([broad]) is None assert links_svc.convergence_group([broad, broad, broad]) is None def test_one_incident_written_up_three_times_is_not_a_recurring_situation(): same = [_m(1, [10]), _m(2, [10]), _m(3, [10])] assert links_svc.convergence_group(same) is None def _note(lid, *, title="t", project=None, sources=()): data = {"what": title, "when_to_apply": "a CI run overran"} if sources: data["taught_by"] = list(sources) return SimpleNamespace( id=lid, title=title, note_type="lesson", body="", data=data, project_id=project, arose_from_id=None, tags=[], created_at=None, updated_at=None, ) @pytest.mark.asyncio async def test_only_lessons_answered_no_rule_join_the_group(): found = [(0.8, _note(2, sources=[11])), (0.7, _note(3, sources=[12])), (0.7, _note(4, sources=[13]))] with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=_note(1, sources=[10]))), \ patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=found)), \ patch.object(links_svc, "_no_rule_ids", AsyncMock(return_value={2})): assert await _REAL(7, 1) is None # only #2 answered: a pair with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=_note(1, sources=[10]))), \ patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=found)), \ patch.object(links_svc, "_no_rule_ids", AsyncMock(return_value={2, 4})): group = await _REAL(7, 1) assert [m["id"] for m in group["lessons"]] == [1, 2, 4] @pytest.mark.asyncio async def test_a_failed_search_names_nothing_and_raises_nothing(): with patch.object(lessons_svc, "get_lesson", AsyncMock(side_effect=RuntimeError("db down"))): assert await _REAL(7, 1) is None @pytest.mark.asyncio async def test_the_door_carries_convergence_only_with_a_no_rule_answer(): _user_id_ctx.set(7) group = {"lessons": [], "incidents": [], "projects": [], "hint": "h"} named = AsyncMock(return_value=group) with patch.object(lessons_svc, "update_lesson", AsyncMock(return_value=_note(41))), \ patch.object(links_svc, "set_no_rule", AsyncMock()), \ patch.object(links_svc, "convergence_for", named): out = await lesson_tools.update_lesson(lesson_id=41, no_rule="stands alone") assert out["convergence"] == group out = await lesson_tools.update_lesson(lesson_id=41, what="reworded") assert "convergence" not in out assert named.await_count == 1