CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 27s
When a lesson is answered "no rule fits" (create_lesson / update_lesson on both doors), the response looks for other no-rule lessons it resembles and, once there are CONVERGENCE_LESSONS (3) of them, carries `convergence`: the members, their incidents and projects, and a hint to draft the missing rule with create_rule (operator approval as always) and point each lesson at it — or to leave them as lessons when no single choice is right every time. - convergence_group is the pure bar: distinct LESSONS count, incidents never stand in for them (one broad lesson cannot trigger it), and a group whose sources all point at one incident is one event written up several times. - convergence_for searches lessons by the new one's claim + trigger (trigger_title) at CONVERGENCE_THRESHOLD 0.65 — above the menu's "worth showing", below the duplicate gate's "same record" — then keeps the ones with a lesson_no_rule answer. Fail-open. No sweep, no timer (#4183). - Defaults stated as defaults (rules 32, 115). - Tests: the bar (pure), the search with stubs, the door, and the no-rule filter against Postgres; conftest stubs convergence_for for unit tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
101 lines
4.3 KiB
Python
101 lines
4.3 KiB
Python
"""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
|