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
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user