"""Lessons point at rules (milestone 440, #4196). A lesson is a non-binding record of one situation; a rule is the binding choice for a class of them. This service owns the link between the two — which rule a lesson is an instance of — and both directions of reading it. THE STATES (models/lesson_rule_link.py says why each exists): `suggested` while evidence accumulates, `confirmed` or `rejected` once a judgment is made. Every write here is a JUDGMENT, so every write lands as confirmed or rejected; `suggested` rows come from the co-surfacing recorder (#4637), never from a caller naming a rule. THE THIRD ANSWER (#4631). "Which rule is this an instance of?" has three answers, and only two of them are links: a rule named (confirmed), or NO RULE FITS — a `lesson_no_rule` row carrying the reason. A lesson with neither is UNJUDGED, and `list_unjudged` lists those. The three are kept apart because a lesson that stands alone is the raw material for a rule nobody has written yet (#4634), and that is unreadable if "nobody looked" means the same thing. ACL (rule 78). Linking changes what a lesson says about itself, so it needs WRITE on the lesson (`access.can_write_note`, share-aware). It names a rule, so it needs the rule to be one the caller may read — rules are owner-scoped, and `rulebooks._fetch_owned_rule` / `_owned_rules_clause` are that check's two forms. A read shows only the rules the READER owns: a lesson shared with someone must not hand them the titles of its owner's private rules. """ from __future__ import annotations import logging from datetime import datetime, timezone from sqlalchemy import and_, delete, exists, func, not_, select from scribe.models import async_session from scribe.models.lesson_rule_link import ( CONFIRMED, REJECTED, SUGGESTED, LessonNoRule, LessonRuleLink, ) from scribe.models.note import Note from scribe.models.rulebook import Rule logger = logging.getLogger(__name__) # What a caller says to judge one pair. Words rather than the stored states, # because "confirm" and "reject" are acts and the states are their results. VERDICTS = {"confirm": CONFIRMED, "reject": REJECTED} # Recorded on a link that set-semantics removed, so a reader of the rejected # row can tell an explicit "not this rule" from a link dropped by a rewrite. _REMOVED_NOTE = "removed from the lesson's rules by an update" # Recorded on a confirmed link that a "no rule fits" answer overturned, ahead # of that answer's reason. _NO_RULE_NOTE = "no rule fits: " # What `rule_judgment` reads on a lesson payload — the three answers. LINKED, NO_RULE, UNJUDGED = "linked", "no_rule", "unjudged" # How many rules a new lesson is offered to judge against. A handful, not the # fifty `what_might_apply` returns: this is read in the create response, at # the moment of writing, and a long tail there buries the one that fits. CANDIDATE_LIMIT = 5 def _ids(values) -> list[int]: """Positive ints, de-duplicated, in the order given.""" out: list[int] = [] for v in values or []: try: i = int(v) except (TypeError, ValueError): raise ValueError(f"rule id {v!r} is not an integer") if i > 0 and i not in out: out.append(i) return out async def _require_lesson_writable(user_id: int, lesson_id: int) -> None: from scribe.services import access from scribe.services import lessons as lessons_svc note = await lessons_svc.get_lesson(user_id, lesson_id) if note is None: raise ValueError(f"lesson {lesson_id} not found") if not await access.can_write_note(user_id, lesson_id): raise PermissionError(f"lesson {lesson_id} is not yours to change") async def require_rules(user_id: int, rule_ids) -> list[int]: """The ids, validated as rules the caller owns — all of them or none. Checked BEFORE anything is written, by every caller, so a lesson create that names a rule it cannot see fails without leaving a half-linked lesson behind. """ from scribe.services import rulebooks as rulebooks_svc wanted = _ids(rule_ids) missing = [rid for rid in wanted if await rulebooks_svc.get_rule(rid, user_id) is None] if missing: raise ValueError( f"rule(s) {missing} not found — a lesson can point only at a rule " "you can read. Nothing was linked." ) return wanted def require_one_answer(linked, no_rule: str) -> None: """Refuse "these rules fit" and "no rule fits" together, before any write — they contradict, and either order of applying them loses one.""" if linked and (no_rule or "").strip(): raise ValueError( "rule_ids and no_rule are the two answers to \"which rule is this " "an instance of?\" — give the one that holds. Nothing was written." ) async def _upsert(session, lesson_id: int, rule_id: int, state: str, note: str) -> None: now = datetime.now(timezone.utc) row = (await session.execute( select(LessonRuleLink).where( LessonRuleLink.lesson_id == lesson_id, LessonRuleLink.rule_id == rule_id, ) )).scalar_one_or_none() if row is None: session.add(LessonRuleLink( lesson_id=lesson_id, rule_id=rule_id, state=state, note=note or None, judged_at=now, )) return # Evidence is kept across a judgment: a confirmation reads best beside # what it rested on. row.state = state row.note = note or row.note row.judged_at = now async def _clear_no_rule(session, lesson_id: int) -> None: """A confirmed rule overturns "no rule fits" — both cannot be the answer.""" await session.execute(delete(LessonNoRule).where(LessonNoRule.lesson_id == lesson_id)) async def set_lesson_rules( user_id: int, lesson_id: int, rule_ids, *, note: str = "", ) -> None: """Make the lesson's CONFIRMED rules exactly `rule_ids` (set-semantics). A rule named here is confirmed, whatever state it was in: the writer of a lesson saying "this is an instance of rule N" is the judgment the suggested state waits for, so it needs no evidence bar. A rule that WAS confirmed and is no longer named becomes `rejected`, not deleted. Dropping it is a judgment that the lesson is not an instance of that rule, and a deleted row would let the co-surfacing recorder propose the same pair again. """ await _require_lesson_writable(user_id, lesson_id) wanted = await require_rules(user_id, rule_ids) async with async_session() as session: current = (await session.execute( select(LessonRuleLink).where( LessonRuleLink.lesson_id == lesson_id, LessonRuleLink.state == CONFIRMED, ) )).scalars().all() for row in current: if row.rule_id not in wanted: row.state = REJECTED row.note = _REMOVED_NOTE row.judged_at = datetime.now(timezone.utc) for rid in wanted: await _upsert(session, lesson_id, rid, CONFIRMED, note) if wanted: await _clear_no_rule(session, lesson_id) await session.commit() async def set_no_rule(user_id: int, lesson_id: int, why: str) -> dict: """Record that no rule governs this lesson's situation, and why. A confirmed link the lesson still carries becomes `rejected`, with the reason: saying none fits is saying the linked one does not either. A second answer replaces the first, so the reason on file is the latest. """ why = (why or "").strip() if not why: raise ValueError( "no_rule takes the reason no rule fits, in a line — it is what " "lets the answer be re-judged when a rule is later written for " "this situation." ) await _require_lesson_writable(user_id, lesson_id) now = datetime.now(timezone.utc) async with async_session() as session: confirmed = (await session.execute( select(LessonRuleLink).where( LessonRuleLink.lesson_id == lesson_id, LessonRuleLink.state == CONFIRMED, ) )).scalars().all() for row in confirmed: row.state = REJECTED row.note = _NO_RULE_NOTE + why row.judged_at = now answer = await session.get(LessonNoRule, lesson_id) if answer is None: session.add(LessonNoRule(lesson_id=lesson_id, why=why, judged_at=now)) else: answer.why = why answer.judged_at = now await session.commit() return {"why": why, "judged_at": now.isoformat()} async def judge_link( user_id: int, lesson_id: int, rule_id: int, verdict: str, note: str = "", ) -> dict: """Confirm or reject one (lesson, rule) pair, suggested or not.""" state = VERDICTS.get((verdict or "").strip().lower()) if state is None: raise ValueError(f"verdict must be one of {sorted(VERDICTS)}, got {verdict!r}") await _require_lesson_writable(user_id, lesson_id) [rid] = await require_rules(user_id, [rule_id]) async with async_session() as session: await _upsert(session, lesson_id, rid, state, note) if state == CONFIRMED: await _clear_no_rule(session, lesson_id) await session.commit() row = (await session.execute( select(LessonRuleLink).where( LessonRuleLink.lesson_id == lesson_id, LessonRuleLink.rule_id == rid, ) )).scalar_one() return row.to_dict() async def rules_for_lessons(user_id: int, lesson_ids) -> dict[int, list[dict]]: """{lesson_id: [{id, title, kind, state, note}]}, for rules the READER owns. One query for the whole set — a lesson list would otherwise be N+1. Confirmed first, then suggested, then rejected: the order a reader cares about them in. """ from scribe.services.rulebooks import _owned_rules_clause ids = [int(i) for i in lesson_ids or []] out: dict[int, list[dict]] = {i: [] for i in ids} if not ids: return out order = {s: n for n, s in enumerate((CONFIRMED, SUGGESTED, REJECTED))} async with async_session() as session: rows = (await session.execute( select(LessonRuleLink, Rule) .join(Rule, Rule.id == LessonRuleLink.rule_id) .where(LessonRuleLink.lesson_id.in_(ids)) .where(_owned_rules_clause(user_id)) )).all() for link, rule in sorted(rows, key=lambda r: (order.get(r[0].state, 9), r[1].id)): out[link.lesson_id].append({ "id": rule.id, "title": rule.title, "kind": rule.kind, "state": link.state, "note": link.note or "", }) return out async def lessons_for_rule(user_id: int, rule_id: int) -> list[dict]: """The lessons that point at one rule, readable by the caller (share-aware). The reverse direction, and the one a rule's page needs: the concrete situations that have been judged instances of it. """ from scribe.services.access import readable_notes_clause async with async_session() as session: rows = (await session.execute( select(LessonRuleLink, Note) .join(Note, Note.id == LessonRuleLink.lesson_id) .where(LessonRuleLink.rule_id == int(rule_id)) .where(Note.deleted_at.is_(None)) .where(readable_notes_clause(user_id)) )).all() order = {s: n for n, s in enumerate((CONFIRMED, SUGGESTED, REJECTED))} return [ {"id": note.id, "title": note.title, "state": link.state, "note": link.note or ""} for link, note in sorted(rows, key=lambda r: (order.get(r[0].state, 9), r[1].id)) ] async def judgments_for_lessons(lesson_ids) -> dict[int, dict]: """{lesson_id: {"rule_judgment": linked|no_rule|unjudged, "no_rule"?}}. Read from the link and answer tables directly, NOT from the reader's view of the rules: a lesson shared with someone who cannot see its owner's rule is still a judged lesson, and calling it unjudged would invite them to judge it again. """ ids = [int(i) for i in lesson_ids or []] if not ids: return {} async with async_session() as session: linked = set((await session.execute( select(LessonRuleLink.lesson_id).where( LessonRuleLink.lesson_id.in_(ids), LessonRuleLink.state == CONFIRMED, ) )).scalars().all()) answers = { a.lesson_id: a for a in (await session.execute( select(LessonNoRule).where(LessonNoRule.lesson_id.in_(ids)) )).scalars().all() } out: dict[int, dict] = {} for i in ids: if i in linked: out[i] = {"rule_judgment": LINKED} elif i in answers: out[i] = {"rule_judgment": NO_RULE, "no_rule": answers[i].to_dict()} else: out[i] = {"rule_judgment": UNJUDGED} return out async def attach_lesson_rules(user_id: int, rows: list[dict], *, key: str = "id") -> None: """Add `rules` and `rule_judgment` (plus `no_rule` when that is the answer) to each lesson payload row, in place — a fixed number of queries per page. Fail-open (snippet #4286): this decorates a lesson the caller already has, so a failed lookup leaves the keys off and logs, rather than refusing the lesson. An absent key reads as "not attached", never as "no rule". """ ids = [int(r[key]) for r in rows if isinstance(r.get(key), int)] try: found = await rules_for_lessons(user_id, ids) judged = await judgments_for_lessons(ids) except Exception: logger.warning("lesson→rule links could not be read", exc_info=True) return for r in rows: if isinstance(r.get(key), int): r["rules"] = found.get(r[key], []) r.update(judged.get(r[key], {})) def unjudged_clause(): """SQL: a lesson (a `notes` row) with no confirmed rule and no "no rule fits" answer. A rejected link alone leaves it unjudged — "not that rule" does not say whether another one fits.""" from scribe.models.note import Note return and_( not_(exists().where(and_( LessonRuleLink.lesson_id == Note.id, LessonRuleLink.state == CONFIRMED, ))), not_(exists().where(LessonNoRule.lesson_id == Note.id)), ) async def list_unjudged( user_id: int, *, tag: str = "", project_id: int | None = None, limit: int = 50, offset: int = 0, ) -> tuple[list[dict], int]: """The lessons nobody has answered "which rule?" for, newest edit first. The browse scope (`browsable_notes_clause`), the list it sits beside uses: a lesson shared directly with the caller is search-only and stays out of an ambient list. Rows come back in the knowledge list's item shape. """ from scribe.models.note import Note from scribe.services.access import browsable_notes_clause from scribe.services.knowledge import _note_to_item from scribe.services.lessons import LESSON_NOTE_TYPE base = ( select(Note) .where(browsable_notes_clause(user_id)) .where(Note.note_type == LESSON_NOTE_TYPE) .where(Note.deleted_at.is_(None)) .where(unjudged_clause()) ) if tag: base = base.where(Note.tags.contains([tag])) if project_id is not None: base = base.where(Note.project_id == project_id) async with async_session() as session: total = (await session.execute( select(func.count()).select_from(base.subquery()) )).scalar_one() rows = (await session.execute( base.order_by(Note.updated_at.desc()).limit(limit).offset(offset) )).scalars().all() return [_note_to_item(n) for n in rows], int(total) async def rule_candidates( user_id: int, what: str, when_to_apply: str, project_id: int | None = None, ) -> list[dict] | None: """The rules a lesson most resembles, for the writer to judge against. Searched with the lesson's claim and its trigger — the same two halves a rule's own document leads with (`{title} — {trigger}`), so like is compared with like; the insight is the story and would dilute the vector. Scoped as any project read is: global rules plus the lesson's project's own. The bar is the explicit rule search's, since this is an explicit question rather than an injection. None when the search could not run (no embedder, a failed query), so a caller can say "candidates unavailable" rather than "nothing resembles". """ from scribe.services.embeddings import ( DEFAULT_SIMILARITY_THRESHOLD, semantic_search_rules, ) query = " — ".join(p for p in ((what or "").strip(), (when_to_apply or "").strip()) if p) report: dict = {} found = await semantic_search_rules( user_id, query, limit=CANDIDATE_LIMIT, threshold=DEFAULT_SIMILARITY_THRESHOLD, report=report, project_id=project_id or None, ) if not report.get("searched"): return None return [ { "id": rule.id, "title": rule.title, "kind": rule.kind, "when_to_apply": rule.when_to_apply or "", "score": round(score, 3), } for score, rule in found ] async def attach_rule_lessons(user_id: int, data: dict, rule_id: int) -> None: """Add `lessons` to a rule payload, in place, when there are any. Present-only, as a rule's `systems` and `relations` are (#2483), and fail-open for the reason `attach_lesson_rules` gives. """ try: lessons = await lessons_for_rule(user_id, rule_id) except Exception: logger.warning("rule→lesson links could not be read", exc_info=True) return if lessons: data["lessons"] = lessons