feat(lessons): convergence is named at the write — no-rule lessons that keep landing in one situation suggest a rule (milestone 440 step 5, #4634)
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>
This commit is contained in:
2026-10-01 15:31:15 -04:00
co-authored by Claude Opus 5.5
parent f34249a2d8
commit 6d3dca0af5
6 changed files with 259 additions and 3 deletions
+19 -1
View File
@@ -188,6 +188,9 @@ async def create_lesson(
(milestone 440). Naming a rule here confirms the link.
no_rule: The reason no rule governs this situation, in a line — the
other answer to "which rule?". Give one or the other, not both.
When other lessons in the same situation also answered "no rule
fits", the response carries `convergence`: the group, and the
rule it may be missing.
force: Create even if a near-duplicate exists.
Returns the created lesson with its `rules` and `rule_judgment`, plus
@@ -237,9 +240,20 @@ async def create_lesson(
await lesson_rules_svc.attach_lesson_rules(uid, [data])
if not linked and not no_rule.strip():
await _offer_candidates(uid, data, what, when_to_apply, project_id)
elif no_rule.strip():
await _name_convergence(uid, data, note.id)
return data
async def _name_convergence(uid: int, data: dict, lesson_id: int) -> None:
"""A "no rule fits" answer is the moment to notice it is not the first
for this situation (#4634) — `convergence` names the group and the rule
it may be missing. Absent when there is no group."""
group = await lesson_rules_svc.convergence_for(uid, lesson_id)
if group:
data["convergence"] = group
async def _offer_candidates(uid: int, data: dict, what: str, trigger: str, project_id: int) -> None:
"""Put the rules an unjudged lesson resembles in front of its writer.
@@ -343,7 +357,9 @@ async def update_lesson(
no_rule: Record that no rule governs this lesson's situation, with the
reason in a line. Any rule still linked is rejected with that
reason. Empty leaves the answer unchanged; give this or a
non-empty `rule_ids`, not both.
non-empty `rule_ids`, not both. When other lessons in the same
situation also answered "no rule fits", the response carries
`convergence`: the group, and the rule it may be missing.
"""
uid = current_user_id()
linked = (
@@ -373,6 +389,8 @@ async def update_lesson(
await lesson_rules_svc.set_no_rule(uid, lesson_id, no_rule)
out = _to_dict(note)
await lesson_rules_svc.attach_lesson_rules(uid, [out])
if no_rule.strip():
await _name_convergence(uid, out, lesson_id)
return out
+10
View File
@@ -188,6 +188,10 @@ async def create_lesson_route():
elif no_rule:
await lesson_rules_svc.set_no_rule(uid, note.id, no_rule)
out = lessons_svc.lesson_to_dict(note)
if no_rule:
group = await lesson_rules_svc.convergence_for(uid, note.id)
if group:
out["convergence"] = group
out["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
]
@@ -292,6 +296,12 @@ async def update_lesson_route(lesson_id: int):
# may edit — it decides WHOSE reach the tagging uses (#47).
await systems_svc.set_record_systems(uid, lesson_id, data["system_ids"])
out = lessons_svc.lesson_to_dict(updated)
if no_rule:
# The same nudge the MCP door gives (#4634): this answer may complete
# a group of no-rule lessons in one situation.
group = await lesson_rules_svc.convergence_for(uid, lesson_id)
if group:
out["convergence"] = group
out["systems"] = [
s.to_dict()
for s in await systems_svc.list_record_systems(owner_uid, lesson_id)
+113
View File
@@ -720,3 +720,116 @@ async def confirmed_rules_in_scope(
for lesson_id, rule in rows:
out.setdefault(int(lesson_id), []).append(rule)
return out
# ── Convergence: lessons with no rule that keep landing in one place (#4634) ─
#
# A lesson answered "no rule fits" is a situation nothing binds. One is a
# lesson. Several that resemble each other are what a missing rule looks like
# from the outside — the same moment met again and again, each time written
# down as advice. This is noticed at the WRITE, when the newest of them is
# answered, and never by a sweep or a timer (#4183): the reader is in the
# situation then, and a nudge arriving anywhere else is one nobody acts on.
#
# DEFAULTS, stated as defaults (rules 32, 115). Three lessons — the new one
# and two it resembles — is the smallest group that is a pattern rather than
# a pair. The similarity bar sits above the notes menu's ("worth showing")
# and below the duplicate gate's ("the same record"): these lessons should be
# about one situation without being one lesson written twice, which the
# duplicate gate already catches.
CONVERGENCE_LESSONS = 3
CONVERGENCE_THRESHOLD = 0.65
# Candidates fetched before keeping the no-rule ones; most lessons near a
# situation may well have rules.
_CONVERGENCE_FETCH = 20
def convergence_group(members: list[dict]) -> dict | None:
"""Decide from a candidate group — the new lesson FIRST, then those it
resembles — whether it names a missing rule. Pure, so the bar is testable.
Each member is {id, title, sources, project_id}. The bar counts DISTINCT
LESSONS, never incidents: a single broad lesson drawn from many incidents
is still one judgment about one situation, and incidents cannot stand in
for lessons. And when every member says what taught it and all of them
point at the same single incident, that is one event written up several
times, not a situation recurring — the group stays quiet.
"""
seen: set[int] = set()
group = []
for m in members:
if m["id"] not in seen:
seen.add(m["id"])
group.append(m)
if len(group) < CONVERGENCE_LESSONS:
return None
incidents = sorted({s for m in group for s in (m.get("sources") or [])})
if all(m.get("sources") for m in group) and len(incidents) < 2:
return None
projects = sorted({m["project_id"] for m in group if m.get("project_id")})
named = ", ".join(f"#{m['id']} “{m['title']}”" for m in group)
return {
"lessons": [{"id": m["id"], "title": m["title"]} for m in group],
"incidents": incidents,
"projects": projects,
"hint": (
f"{len(group)} lessons answered \"no rule fits\" and keep landing "
f"in one situation: {named}. A situation met this often may want a "
"rule — the binding choice they each circle. Draft it with "
"create_rule (it goes to the operator, as every rule does), then "
"point each lesson at it with update_lesson(lesson_id, "
"rule_ids=[<new rule id>]). If no single choice is right every "
"time, the lessons are the right record and nothing more is needed."
),
}
async def _no_rule_ids(lesson_ids) -> set[int]:
ids = [int(i) for i in lesson_ids or []]
if not ids:
return set()
async with async_session() as session:
rows = (await session.execute(
select(LessonNoRule.lesson_id).where(LessonNoRule.lesson_id.in_(ids))
)).scalars().all()
return {int(i) for i in rows}
async def convergence_for(user_id: int, lesson_id: int) -> dict | None:
"""The convergence a newly answered no-rule lesson completes, or None.
Fail-open: this decorates a write that already succeeded, so a failed
search leaves the response as it was (snippet #4286's reasoning).
"""
try:
return await _convergence_for(user_id, lesson_id)
except Exception:
logger.warning("lesson convergence could not be checked", exc_info=True)
return None
async def _convergence_for(user_id: int, lesson_id: int) -> dict | None:
from scribe.services import lessons as lessons_svc
from scribe.services.embeddings import semantic_search_notes, trigger_title
lesson = await lessons_svc.get_lesson(user_id, lesson_id)
if lesson is None:
return None
data = lesson.data if isinstance(lesson.data, dict) else {}
query = trigger_title(data.get("what") or lesson.title, lessons_svc.lesson_trigger(lesson))
found = await semantic_search_notes(
user_id, query, exclude_ids={int(lesson.id)}, limit=_CONVERGENCE_FETCH,
threshold=CONVERGENCE_THRESHOLD, note_type=(lessons_svc.LESSON_NOTE_TYPE,),
include_global_kinds=True, scope="browse",
)
answered = await _no_rule_ids([int(n.id) for _s, n in found])
def member(note) -> dict:
return {
"id": int(note.id), "title": note.title,
"sources": lessons_svc.lesson_sources(note), "project_id": note.project_id,
}
return convergence_group(
[member(lesson)] + [member(n) for _s, n in found if int(n.id) in answered]
)