Lessons reach the session that needs them, every kind is a full citizen, and a slow disk no longer takes the instance down #167
@@ -106,6 +106,7 @@ _READ_ONLY_TOOLS = frozenset({
|
|||||||
# free-text records and withholds the structured ones (#2496).
|
# free-text records and withholds the structured ones (#2496).
|
||||||
"get_snippet", "list_snippets",
|
"get_snippet", "list_snippets",
|
||||||
"get_process", "list_processes",
|
"get_process", "list_processes",
|
||||||
|
"get_lesson",
|
||||||
# Design systems: read, resolve (inheritance + mode), render, and compare
|
# Design systems: read, resolve (inheritance + mode), render, and compare
|
||||||
# against recorded snippets. All four compute from stored records and write
|
# against recorded snippets. All four compute from stored records and write
|
||||||
# nothing — the drift report is a report, and applying it is a separate
|
# nothing — the drift report is a report, and applying it is a separate
|
||||||
@@ -158,6 +159,7 @@ _READ_ONLY_TOOLS = frozenset({
|
|||||||
_WRITE_TOOLS = frozenset({
|
_WRITE_TOOLS = frozenset({
|
||||||
# notes, tasks, planning
|
# notes, tasks, planning
|
||||||
"create_note", "update_note", "delete_note",
|
"create_note", "update_note", "delete_note",
|
||||||
|
"create_lesson", "update_lesson",
|
||||||
"create_task", "update_task", "delete_task", "add_task_log",
|
"create_task", "update_task", "delete_task", "add_task_log",
|
||||||
"create_records", "start_planning",
|
"create_records", "start_planning",
|
||||||
"create_milestone", "update_milestone", "delete_milestone",
|
"create_milestone", "update_milestone", "delete_milestone",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
|||||||
from `mcp.server.build_mcp_server`.
|
from `mcp.server.build_mcp_server`.
|
||||||
"""
|
"""
|
||||||
from scribe.mcp.tools import (
|
from scribe.mcp.tools import (
|
||||||
design_systems, milestones, notes, processes, projects, recent, repos, retrieval_tuning,
|
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
|
||||||
|
retrieval_tuning,
|
||||||
wide_net,
|
wide_net,
|
||||||
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
|
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
|
||||||
)
|
)
|
||||||
@@ -27,6 +28,7 @@ def register_all(mcp) -> None:
|
|||||||
repos.register(mcp)
|
repos.register(mcp)
|
||||||
processes.register(mcp)
|
processes.register(mcp)
|
||||||
snippets.register(mcp)
|
snippets.register(mcp)
|
||||||
|
lessons.register(mcp)
|
||||||
shapes.register(mcp)
|
shapes.register(mcp)
|
||||||
rulebooks.register(mcp)
|
rulebooks.register(mcp)
|
||||||
trash.register(mcp)
|
trash.register(mcp)
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Lesson MCP tools: a transferable insight, retrievable by situation.
|
||||||
|
|
||||||
|
Its own module rather than `create_note(note_type="lesson")`, on the precedent
|
||||||
|
of snippets and processes — and for the reason that precedent exists. A kind
|
||||||
|
whose value depends on a field being filled needs a door that ASKS for that
|
||||||
|
field by name. `create_note` would take a lesson through a generic body
|
||||||
|
parameter, and the trigger — the whole of why a lesson is findable at all —
|
||||||
|
would be something the writer had to know to include.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from scribe.mcp._context import current_user_id
|
||||||
|
from scribe.services import access as access_svc
|
||||||
|
from scribe.services import dedup as dedup_svc
|
||||||
|
from scribe.services import lessons as lessons_svc
|
||||||
|
from scribe.services import systems as systems_svc
|
||||||
|
from scribe.mcp.tools import systems as systems_tools
|
||||||
|
from scribe.services.note_usage import record_pulled
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dict(note) -> dict:
|
||||||
|
"""A lesson as the tools return it — the composed fields read back out,
|
||||||
|
not the raw row, so a caller sees the same vocabulary it wrote with."""
|
||||||
|
return {
|
||||||
|
"id": note.id,
|
||||||
|
"title": note.title,
|
||||||
|
"body": note.body,
|
||||||
|
"when_to_apply": lessons_svc.lesson_trigger(note),
|
||||||
|
"learned_from": lessons_svc.lesson_sources(note),
|
||||||
|
"tags": list(note.tags or []),
|
||||||
|
"project_id": note.project_id,
|
||||||
|
"note_type": note.note_type,
|
||||||
|
"created_at": note.created_at.isoformat() if note.created_at else None,
|
||||||
|
"updated_at": note.updated_at.isoformat() if note.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_lesson(
|
||||||
|
what: str,
|
||||||
|
when_to_apply: str,
|
||||||
|
insight: str = "",
|
||||||
|
learned_from: list[int] | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
project_id: int = 0,
|
||||||
|
system_ids: list[int] | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Record something you LEARNED, so a later session meets it at the moment
|
||||||
|
it applies — on this project or any other.
|
||||||
|
|
||||||
|
A LESSON OR A RULE? The difference is FORCE, not importance. A rule is
|
||||||
|
something that must be followed; a lesson is something worth knowing. If
|
||||||
|
ignoring it would be a mistake, it is a rule (create_rule) and needs the
|
||||||
|
operator's yes, because a rule binds every future session. If ignoring it
|
||||||
|
just means someone re-derives it the slow way, it is a lesson — write it
|
||||||
|
now, and nobody is bound by it.
|
||||||
|
|
||||||
|
That distinction is the whole reason this kind exists. Sessions holding a
|
||||||
|
transferable insight were reaching for create_rule because it was the only
|
||||||
|
surface that is both global and situation-keyed, and proposing rules for
|
||||||
|
things that should never have bound anyone.
|
||||||
|
|
||||||
|
WHAT A LESSON IS NOT: a shape to copy is a SNIPPET (create_snippet); a
|
||||||
|
procedure followed start to finish is a PROCESS (create_process); a record
|
||||||
|
of what happened, findable by topic, is a NOTE (create_note). A lesson is
|
||||||
|
the claim you would want handed to you in the same situation next time.
|
||||||
|
|
||||||
|
`when_to_apply` IS THE RECORD. Everything else is the payload.
|
||||||
|
|
||||||
|
A lesson reaches a session by resembling the SITUATION someone is in, never
|
||||||
|
by topic — that is what separates it from a note, and it is done by putting
|
||||||
|
the trigger in the title and again at the head of the body, so the document
|
||||||
|
is dominated by when it applies. A lesson written without one still saves,
|
||||||
|
still reads correctly in every listing, and will not surface when it is
|
||||||
|
needed. There is nothing to notice afterwards: it looks exactly like a
|
||||||
|
lesson that works.
|
||||||
|
|
||||||
|
So write the SYMPTOM, in the words the situation will present itself in —
|
||||||
|
what someone would be seeing, saying or about to do. "A test fails on code
|
||||||
|
you believe is correct" is a trigger. "Testing" is a topic, and a topic
|
||||||
|
matches everything and surfaces for nothing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
what: The insight in one line — the claim itself, as you would say it.
|
||||||
|
This becomes the title, joined with the trigger.
|
||||||
|
when_to_apply: The situation this applies in, as a symptom. Required.
|
||||||
|
insight: The body — what to do, and the incident that taught it.
|
||||||
|
Write the story here for the reader; it costs the ranking nothing,
|
||||||
|
because a long body is split into chunks that each still carry the
|
||||||
|
trigger.
|
||||||
|
learned_from: Ids of the issues, tasks or notes this was drawn from —
|
||||||
|
ALL of them. A lesson that generalises three incidents into one
|
||||||
|
claim is the good case, not the edge case, so this is a list.
|
||||||
|
tags: Optional tags.
|
||||||
|
project_id: Where it was learned. Kept as a fact, and it does not limit
|
||||||
|
reach: a lesson is retrievable from every project (that is the
|
||||||
|
point of the kind). 0 = none.
|
||||||
|
system_ids: Systems (subsystems/areas) to file it under.
|
||||||
|
force: Create even if a near-duplicate exists.
|
||||||
|
|
||||||
|
Returns the created lesson. On a near-duplicate, returns the existing id
|
||||||
|
instead of creating — two lessons about one failure class want to be one
|
||||||
|
lesson, so update that one rather than adding a second.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
if not when_to_apply or not when_to_apply.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"when_to_apply is required: it is how a lesson is found. Say the "
|
||||||
|
"SYMPTOM — what someone would be seeing, saying or about to do "
|
||||||
|
"when this applies — not the topic it is about. Without it this "
|
||||||
|
"record saves, reads correctly, and never surfaces."
|
||||||
|
)
|
||||||
|
|
||||||
|
sources = lessons_svc.normalize_sources(learned_from)
|
||||||
|
title, body = lessons_svc.lesson_document(
|
||||||
|
what, when_to_apply, insight, sources,
|
||||||
|
)
|
||||||
|
if not force:
|
||||||
|
dup = await dedup_svc.find_duplicate_note(
|
||||||
|
uid, title, body, project_id=project_id or None,
|
||||||
|
is_task=False, note_type=lessons_svc.LESSON_NOTE_TYPE,
|
||||||
|
)
|
||||||
|
if dup is not None:
|
||||||
|
return dedup_svc.duplicate_response(dup, "lesson")
|
||||||
|
|
||||||
|
note = await lessons_svc.create_lesson(
|
||||||
|
uid, what=what, when_to_apply=when_to_apply, insight=insight,
|
||||||
|
learned_from=sources, tags=tags, project_id=project_id or None,
|
||||||
|
)
|
||||||
|
if system_ids:
|
||||||
|
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
||||||
|
data = _to_dict(note)
|
||||||
|
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def get_lesson(lesson_id: int) -> dict:
|
||||||
|
"""Fetch one lesson by id, with its trigger and sources read back out."""
|
||||||
|
uid = current_user_id()
|
||||||
|
note = await lessons_svc.get_lesson(uid, lesson_id)
|
||||||
|
if note is None:
|
||||||
|
raise ValueError(f"lesson {lesson_id} not found")
|
||||||
|
out = _to_dict(note)
|
||||||
|
out.update(await access_svc.describe_provenance(uid, note))
|
||||||
|
# Every explicit open records the pull. A lesson is surfaced by the same
|
||||||
|
# retrieval as any other note, so a getter that records nothing would leave
|
||||||
|
# the kind permanently at zero pulls — reading as dead weight beside kinds
|
||||||
|
# that merely had a counter (#2476, the repeat of #2245).
|
||||||
|
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_lesson")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def update_lesson(
|
||||||
|
lesson_id: int,
|
||||||
|
what: str = "",
|
||||||
|
when_to_apply: str = "",
|
||||||
|
insight: str = "",
|
||||||
|
learned_from: list[int] | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Update a lesson. Empty fields are left unchanged.
|
||||||
|
|
||||||
|
REWORDING A LESSON IS ORDINARY WORK. Understanding improves, and a trigger
|
||||||
|
that turned out to fire on the wrong situation is the single most valuable
|
||||||
|
thing to fix here — a lesson nobody is reaching is usually not wrong, it is
|
||||||
|
keyed to a situation nobody is in.
|
||||||
|
|
||||||
|
Title, body and the indexed mirror are re-composed together from the merged
|
||||||
|
fields, so a partial update cannot leave the trigger saying one thing in
|
||||||
|
the title and another in the body.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lesson_id: Lesson to update.
|
||||||
|
what: New one-line claim. Empty leaves unchanged.
|
||||||
|
when_to_apply: New trigger, as a symptom. Empty leaves unchanged.
|
||||||
|
insight: New body. Empty leaves unchanged.
|
||||||
|
learned_from: Replace the source ids. None leaves unchanged; pass the
|
||||||
|
FULL list, including the ones already there.
|
||||||
|
tags: Replace tags. None leaves unchanged.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
note = await lessons_svc.update_lesson(
|
||||||
|
uid, lesson_id,
|
||||||
|
what=what or None,
|
||||||
|
when_to_apply=when_to_apply or None,
|
||||||
|
insight=insight or None,
|
||||||
|
learned_from=learned_from,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
if note is None:
|
||||||
|
raise ValueError(f"lesson {lesson_id} not found")
|
||||||
|
return _to_dict(note)
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp) -> None:
|
||||||
|
for fn in (create_lesson, get_lesson, update_lesson):
|
||||||
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
@@ -38,6 +38,7 @@ from scribe.services import embeddings as embeddings_svc
|
|||||||
# Imported rather than redeclared: no service imports this module (the create
|
# Imported rather than redeclared: no service imports this module (the create
|
||||||
# gate is called from the routes/tools layer), so there is no cycle to dodge,
|
# gate is called from the routes/tools layer), so there is no cycle to dodge,
|
||||||
# and a second copy of the constant is a thing to drift.
|
# and a second copy of the constant is a thing to drift.
|
||||||
|
from scribe.services.lessons import LESSON_NOTE_TYPE
|
||||||
from scribe.services.snippets import SNIPPET_NOTE_TYPE
|
from scribe.services.snippets import SNIPPET_NOTE_TYPE
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -71,6 +72,28 @@ _SEMANTIC_THRESHOLD = 0.90
|
|||||||
# structural signals cannot see.
|
# structural signals cannot see.
|
||||||
_SNIPPET_SEMANTIC_THRESHOLD = 0.96
|
_SNIPPET_SEMANTIC_THRESHOLD = 0.96
|
||||||
|
|
||||||
|
# A LESSON is measured the same way, for the first of those reasons and not the
|
||||||
|
# second. Its document is `{what} — {trigger}` over a body that opens by
|
||||||
|
# restating the trigger (milestone 385 step 3) — the same prose-about-the-thing
|
||||||
|
# shape, so two GENUINELY DIFFERENT lessons about one area ("CI cannot see this
|
||||||
|
# class of failure") land in the same sibling band that refused .btn-danger
|
||||||
|
# against .btn-danger-outline at 0.92.
|
||||||
|
#
|
||||||
|
# Its own constant rather than reusing the snippet's, because the two are
|
||||||
|
# separate facts that happen to coincide: this number is INHERITED from #2518's
|
||||||
|
# measurement of a structurally analogous corpus, not measured on lessons —
|
||||||
|
# there are none yet to measure. When there are, this moves without dragging
|
||||||
|
# snippets with it.
|
||||||
|
#
|
||||||
|
# The trade-off differs and is worth naming. A snippet has structural signals
|
||||||
|
# (code, repo·path·symbol) to catch the literal copy a high bar lets through; a
|
||||||
|
# lesson has none, and is not in `_REPORT_KINDS` either, so the duplicate report
|
||||||
|
# is not a backstop for it yet. What remains is the exact-title check, which
|
||||||
|
# still fires. That is the right way round for a gate that BLOCKS: a false
|
||||||
|
# positive refuses a real lesson outright, while a false negative leaves two
|
||||||
|
# records that can still be merged by hand.
|
||||||
|
_LESSON_SEMANTIC_THRESHOLD = 0.96
|
||||||
|
|
||||||
# The gate queries per CHUNK of the candidate (#280) — this caps how many
|
# The gate queries per CHUNK of the candidate (#280) — this caps how many
|
||||||
# searches one save may cost. Eight chunks ≈ five thousand words of candidate;
|
# searches one save may cost. Eight chunks ≈ five thousand words of candidate;
|
||||||
# a duplicate hiding past that is the duplicate report's job to find, not a
|
# a duplicate hiding past that is the duplicate report's job to find, not a
|
||||||
@@ -205,6 +228,17 @@ async def _find_snippet_by_structure(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_threshold(note_type: str) -> float:
|
||||||
|
"""The semantic bar for this kind — a lookup, so the kinds that need a
|
||||||
|
different one are named in a single place rather than in a conditional
|
||||||
|
that grows a branch per kind."""
|
||||||
|
if note_type == SNIPPET_NOTE_TYPE:
|
||||||
|
return _SNIPPET_SEMANTIC_THRESHOLD
|
||||||
|
if note_type == LESSON_NOTE_TYPE:
|
||||||
|
return _LESSON_SEMANTIC_THRESHOLD
|
||||||
|
return _SEMANTIC_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
async def find_duplicate_note(
|
async def find_duplicate_note(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
title: str,
|
title: str,
|
||||||
@@ -286,8 +320,7 @@ async def find_duplicate_note(
|
|||||||
user_id, query, project_id=project_id, is_task=is_task,
|
user_id, query, project_id=project_id, is_task=is_task,
|
||||||
orphan_only=(project_id is None),
|
orphan_only=(project_id is None),
|
||||||
limit=3,
|
limit=3,
|
||||||
threshold=(_SNIPPET_SEMANTIC_THRESHOLD
|
threshold=_semantic_threshold(note_type),
|
||||||
if note_type == SNIPPET_NOTE_TYPE else _SEMANTIC_THRESHOLD),
|
|
||||||
# Owner-only, deliberately: this gate BLOCKS a create and tells
|
# Owner-only, deliberately: this gate BLOCKS a create and tells
|
||||||
# the caller to update the match instead. Matching someone
|
# the caller to update the match instead. Matching someone
|
||||||
# else's record would refuse their write and point them at
|
# else's record would refuse their write and point them at
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ LESSON_NOTE_TYPE = "lesson"
|
|||||||
# a second word for it.
|
# a second word for it.
|
||||||
TRIGGER_KEY = "when_to_apply"
|
TRIGGER_KEY = "when_to_apply"
|
||||||
|
|
||||||
|
# What taught this lesson: the ids of the issues, tasks or notes it was drawn
|
||||||
|
# from. A LIST, and that is the whole decision — see `normalize_sources`.
|
||||||
|
SOURCES_KEY = "taught_by"
|
||||||
|
|
||||||
# The body's trigger line, and the pattern that reads it back. The body is the
|
# The body's trigger line, and the pattern that reads it back. The body is the
|
||||||
# readable form and the thing that gets embedded; `data` is the queryable
|
# readable form and the thing that gets embedded; `data` is the queryable
|
||||||
# mirror. Reads prefer the mirror and fall back to this, which is the discipline
|
# mirror. Reads prefer the mirror and fall back to this, which is the discipline
|
||||||
@@ -102,6 +106,12 @@ TRIGGER_KEY = "when_to_apply"
|
|||||||
# existed is still readable.
|
# existed is still readable.
|
||||||
_BODY_TRIGGER_RE = re.compile(r"^\*\*When to apply:\*\*\s*(.+?)\s*$", re.M)
|
_BODY_TRIGGER_RE = re.compile(r"^\*\*When to apply:\*\*\s*(.+?)\s*$", re.M)
|
||||||
|
|
||||||
|
# The readable mirror of `data[SOURCES_KEY]`, and the pattern that reads it
|
||||||
|
# back — the shape snippets use for `**Merged from:** #ids`, for the same
|
||||||
|
# reason: the body is what a human sees and what survives a row with no `data`.
|
||||||
|
_BODY_SOURCES_RE = re.compile(r"^\*\*Learned from:\*\*\s*(.+?)\s*$", re.M)
|
||||||
|
_ID_RE = re.compile(r"#(\d+)")
|
||||||
|
|
||||||
|
|
||||||
def lesson_trigger(note) -> str:
|
def lesson_trigger(note) -> str:
|
||||||
"""When this lesson applies, or "" — the mirror first, then the body.
|
"""When this lesson applies, or "" — the mirror first, then the body.
|
||||||
@@ -120,6 +130,80 @@ def lesson_trigger(note) -> str:
|
|||||||
return match.group(1).strip() if match else ""
|
return match.group(1).strip() if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_sources(entries: list | None) -> list[int]:
|
||||||
|
"""The ids that taught this lesson — ints, de-duplicated, in the order given.
|
||||||
|
|
||||||
|
THE CARDINALITY DECISION, and why it is a list.
|
||||||
|
|
||||||
|
`arose_from_id` already exists and holds ONE id, which is the obvious first
|
||||||
|
answer and the wrong one. The lesson that started this milestone generalised
|
||||||
|
THREE incidents — a badge collision, an un-backfilled column, a duplicated
|
||||||
|
const — into one claim about failure classes no CI lane can see. Generalising
|
||||||
|
across incidents is the shape a good lesson HAS, not an edge case. A single
|
||||||
|
id would keep the first and silently drop the rest, and a record that drops
|
||||||
|
two of its three sources is worse than one that names none, because it reads
|
||||||
|
as complete.
|
||||||
|
|
||||||
|
It lives in `notes.data` rather than a join table for exactly the reason
|
||||||
|
decision #4157 put the trigger there: a join table would settle, for every
|
||||||
|
note kind at once, whether provenance is multi-valued — a question nothing
|
||||||
|
has measured. `data` is JSONB with a GIN index (0070), so the list is
|
||||||
|
queryable today and a table can be migrated to later if the need is shown.
|
||||||
|
|
||||||
|
Order is history, not sorting: the incidents stay in the sequence the writer
|
||||||
|
named them, which is the order they were learned in.
|
||||||
|
"""
|
||||||
|
out: list[int] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for raw in entries or []:
|
||||||
|
ident = raw.get("id") if isinstance(raw, dict) else raw
|
||||||
|
try:
|
||||||
|
i = int(ident)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if i > 0 and i not in seen:
|
||||||
|
seen.add(i)
|
||||||
|
out.append(i)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def lesson_sources(note) -> list[int]:
|
||||||
|
"""What taught this lesson — the mirror first, then the body, then
|
||||||
|
`arose_from_id`.
|
||||||
|
|
||||||
|
Three fallbacks rather than two, because the third is what keeps this
|
||||||
|
honest on a record written before the kind existed: a note carrying only
|
||||||
|
`arose_from_id` has exactly one source and this returns it, so a caller
|
||||||
|
never has to ask which field to read.
|
||||||
|
"""
|
||||||
|
data = getattr(note, "data", None) or {}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
from_mirror = normalize_sources(data.get(SOURCES_KEY))
|
||||||
|
if from_mirror:
|
||||||
|
return from_mirror
|
||||||
|
match = _BODY_SOURCES_RE.search(getattr(note, "body", None) or "")
|
||||||
|
if match:
|
||||||
|
found = normalize_sources(_ID_RE.findall(match.group(1)))
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
single = getattr(note, "arose_from_id", None)
|
||||||
|
return normalize_sources([single]) if single else []
|
||||||
|
|
||||||
|
|
||||||
|
def sole_source(sources: list[int] | None) -> int | None:
|
||||||
|
"""`arose_from_id` for this lesson: the id when there is exactly ONE.
|
||||||
|
|
||||||
|
Left NULL for a lesson drawn from several, deliberately. Every existing
|
||||||
|
surface that renders provenance reads `arose_from_id` and renders it as
|
||||||
|
THE origin; handing it one of three would make those surfaces state
|
||||||
|
something false. Showing nothing there is accurate — there is no single
|
||||||
|
origin — and `data[SOURCES_KEY]` carries all of them for the surfaces that
|
||||||
|
know to ask.
|
||||||
|
"""
|
||||||
|
ids = normalize_sources(sources)
|
||||||
|
return ids[0] if len(ids) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
def compose_title(what: str, when_to_apply: str = "") -> str:
|
def compose_title(what: str, when_to_apply: str = "") -> str:
|
||||||
"""`{what} — {when it applies}`, the half of the document that ranks.
|
"""`{what} — {when it applies}`, the half of the document that ranks.
|
||||||
|
|
||||||
@@ -138,7 +222,9 @@ def compose_title(what: str, when_to_apply: str = "") -> str:
|
|||||||
return trigger_title(what, when_to_apply)
|
return trigger_title(what, when_to_apply)
|
||||||
|
|
||||||
|
|
||||||
def compose_body(insight: str, when_to_apply: str = "") -> str:
|
def compose_body(
|
||||||
|
insight: str, when_to_apply: str = "", learned_from: list[int] | None = None,
|
||||||
|
) -> str:
|
||||||
"""The lesson body — the trigger line first, the insight after.
|
"""The lesson body — the trigger line first, the insight after.
|
||||||
|
|
||||||
The mirror of `compose_title` on the other half of the document, and the
|
The mirror of `compose_title` on the other half of the document, and the
|
||||||
@@ -172,11 +258,19 @@ def compose_body(insight: str, when_to_apply: str = "") -> str:
|
|||||||
insight = (insight or "").strip()
|
insight = (insight or "").strip()
|
||||||
if insight:
|
if insight:
|
||||||
lines.append(insight)
|
lines.append(insight)
|
||||||
|
sources = normalize_sources(learned_from)
|
||||||
|
if sources:
|
||||||
|
# LAST, not beside the trigger. The first line has to be what this
|
||||||
|
# lesson is FOR; a provenance line above the insight would push the
|
||||||
|
# thing the reader came for below a list of ids, and would put
|
||||||
|
# numbers where the trigger's second appearance does its work.
|
||||||
|
lines.append("**Learned from:** " + ", ".join(f"#{i}" for i in sources))
|
||||||
return "\n\n".join(lines)
|
return "\n\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def lesson_document(
|
def lesson_document(
|
||||||
what: str, when_to_apply: str = "", insight: str = "",
|
what: str, when_to_apply: str = "", insight: str = "",
|
||||||
|
learned_from: list[int] | None = None,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""The (title, body) a lesson is STORED — and therefore embedded — as.
|
"""The (title, body) a lesson is STORED — and therefore embedded — as.
|
||||||
|
|
||||||
@@ -193,4 +287,156 @@ def lesson_document(
|
|||||||
instead — the stored record IS the sharp document — which is why nothing
|
instead — the stored record IS the sharp document — which is why nothing
|
||||||
re-embeds and `CHUNKER_VERSION` does not move.
|
re-embeds and `CHUNKER_VERSION` does not move.
|
||||||
"""
|
"""
|
||||||
return compose_title(what, when_to_apply), compose_body(insight, when_to_apply)
|
return (
|
||||||
|
compose_title(what, when_to_apply),
|
||||||
|
compose_body(insight, when_to_apply, learned_from),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_data(
|
||||||
|
what: str, when_to_apply: str = "", learned_from: list[int] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""The indexed mirror of the same fields the body renders (0070).
|
||||||
|
|
||||||
|
Written together with the body by the one caller that composes both, so the
|
||||||
|
two can never describe different things — the discipline `compose_data`
|
||||||
|
follows for snippets, and the reason `lesson_trigger` can prefer `data`
|
||||||
|
without checking whether it agrees with the prose.
|
||||||
|
|
||||||
|
Empty values are omitted so the column stays sparse: a lesson with no
|
||||||
|
sources has no `taught_by` key rather than an empty list, which keeps a
|
||||||
|
`?` containment query honest.
|
||||||
|
"""
|
||||||
|
data: dict = {"what": what.strip()} if what and what.strip() else {}
|
||||||
|
trigger = (when_to_apply or "").strip()
|
||||||
|
if trigger:
|
||||||
|
data[TRIGGER_KEY] = trigger
|
||||||
|
sources = normalize_sources(learned_from)
|
||||||
|
if sources:
|
||||||
|
data[SOURCES_KEY] = sources
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def create_lesson(
|
||||||
|
user_id: int,
|
||||||
|
*,
|
||||||
|
what: str,
|
||||||
|
when_to_apply: str = "",
|
||||||
|
insight: str = "",
|
||||||
|
learned_from: list[int] | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
project_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""Create a lesson note. Returns the created Note.
|
||||||
|
|
||||||
|
The title, the body and the `data` mirror are composed HERE from named
|
||||||
|
parameters rather than asked of the caller. That is the whole evidence base
|
||||||
|
for this design and not a convenience: the snippet corpus carries a trigger
|
||||||
|
on 164 of 164 records with no guard anywhere, because a service builds the
|
||||||
|
title from a parameter — what is at 100% is a named structured field, not an
|
||||||
|
agent typing a convention correctly.
|
||||||
|
|
||||||
|
`project_id` is accepted and kept, even though a lesson is reachable from
|
||||||
|
every project (step 3). Where it was learned is a fact worth keeping; it
|
||||||
|
simply stops being the limit of where it can be found.
|
||||||
|
"""
|
||||||
|
from scribe.services import notes as notes_svc
|
||||||
|
|
||||||
|
sources = normalize_sources(learned_from)
|
||||||
|
title, body = lesson_document(what, when_to_apply, insight, sources)
|
||||||
|
return await notes_svc.create_note(
|
||||||
|
user_id,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
note_type=LESSON_NOTE_TYPE,
|
||||||
|
tags=tags,
|
||||||
|
project_id=project_id,
|
||||||
|
# NULL unless there is exactly one source — see `sole_source`.
|
||||||
|
arose_from_id=sole_source(sources),
|
||||||
|
data=compose_data(what, when_to_apply, sources),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_lesson(user_id: int, lesson_id: int):
|
||||||
|
"""Fetch a lesson by id, or None if it isn't one / isn't readable.
|
||||||
|
|
||||||
|
Share-aware (rule 78): a fetch by id is an explicit act, so it resolves the
|
||||||
|
caller's full read scope rather than ownership alone — without this, a
|
||||||
|
lesson a search legitimately surfaced could not then be opened (#2093).
|
||||||
|
"""
|
||||||
|
from scribe.services import notes as notes_svc
|
||||||
|
|
||||||
|
result = await notes_svc.get_note_for_user(user_id, lesson_id)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
note, _permission = result
|
||||||
|
if note.note_type != LESSON_NOTE_TYPE or note.deleted_at is not None:
|
||||||
|
return None
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
async def update_lesson(
|
||||||
|
user_id: int,
|
||||||
|
lesson_id: int,
|
||||||
|
*,
|
||||||
|
what: str | None = None,
|
||||||
|
when_to_apply: str | None = None,
|
||||||
|
insight: str | None = None,
|
||||||
|
learned_from: list[int] | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
):
|
||||||
|
"""Update a lesson, re-composing title, body and mirror from the merged
|
||||||
|
fields. Returns the updated Note, or None if it isn't a readable lesson.
|
||||||
|
|
||||||
|
READ-MODIFY-WRITE over the whole record rather than patching one half.
|
||||||
|
The three fields are not independent: the trigger appears in the title AND
|
||||||
|
at the head of the body, so editing it in place would need two edits that
|
||||||
|
a caller could do one of. Re-composing from the merged values means a
|
||||||
|
partial update cannot leave the halves disagreeing — which, because the
|
||||||
|
document is what ranks, would be a lesson that still reads correctly and
|
||||||
|
quietly stops being retrievable.
|
||||||
|
"""
|
||||||
|
from scribe.services import notes as notes_svc
|
||||||
|
|
||||||
|
note = await get_lesson(user_id, lesson_id)
|
||||||
|
if note is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current = note.data if isinstance(note.data, dict) else {}
|
||||||
|
merged_what = current.get("what") or "" if what is None else what
|
||||||
|
merged_trigger = lesson_trigger(note) if when_to_apply is None else when_to_apply
|
||||||
|
merged_sources = (
|
||||||
|
lesson_sources(note) if learned_from is None
|
||||||
|
else normalize_sources(learned_from)
|
||||||
|
)
|
||||||
|
if insight is None:
|
||||||
|
insight = _strip_composed_lines(note.body)
|
||||||
|
|
||||||
|
title, body = lesson_document(
|
||||||
|
merged_what, merged_trigger, insight, merged_sources,
|
||||||
|
)
|
||||||
|
fields: dict = {
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"arose_from_id": sole_source(merged_sources),
|
||||||
|
"data": compose_data(merged_what, merged_trigger, merged_sources),
|
||||||
|
}
|
||||||
|
if tags is not None:
|
||||||
|
fields["tags"] = tags
|
||||||
|
return await notes_svc.update_note(user_id, lesson_id, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_composed_lines(body: str | None) -> str:
|
||||||
|
"""The insight alone — the body with the lines `compose_body` wrote removed.
|
||||||
|
|
||||||
|
An update that keeps the insight has to hand it back to `compose_body`,
|
||||||
|
which will re-add the trigger and provenance lines. Without this the two
|
||||||
|
composed lines accumulate a copy per edit, and since the trigger line is
|
||||||
|
half of what makes the document rank, the duplicates would look like the
|
||||||
|
shape working rather than a bug.
|
||||||
|
"""
|
||||||
|
kept = [
|
||||||
|
line for line in (body or "").splitlines()
|
||||||
|
if not _BODY_TRIGGER_RE.match(line) and not _BODY_SOURCES_RE.match(line)
|
||||||
|
]
|
||||||
|
return "\n".join(kept).strip()
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Creating a lesson, and tying it to what taught it (milestone 385 step 4).
|
||||||
|
|
||||||
|
THE TRIGGER IS THE RECORD, so the door refuses a lesson without one.
|
||||||
|
|
||||||
|
Step 1 could have chosen "flag it visibly" instead. Refusing is the stronger
|
||||||
|
answer for the same reason `create_rule` makes enforcement the deciding
|
||||||
|
question: a lesson with no trigger is not a weaker lesson, it is a note that
|
||||||
|
will never surface, and nothing downstream can tell the difference. It saves,
|
||||||
|
it reads correctly in every listing, and it is silently absent from the one
|
||||||
|
moment it was written for. A flag would be a warning nobody is present to read
|
||||||
|
— the write path is where the writer still is.
|
||||||
|
|
||||||
|
THE SOURCE IDS ARE A LIST, and `test_a_lesson_keeps_every_incident_that_taught
|
||||||
|
_it` is why. The founding example generalised three incidents into one claim;
|
||||||
|
`arose_from_id` holds one, and a record that keeps the first and drops two
|
||||||
|
reads as complete.
|
||||||
|
"""
|
||||||
|
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.lessons import create_lesson, update_lesson
|
||||||
|
from scribe.services import lessons as lessons_svc
|
||||||
|
|
||||||
|
TRIGGER = "a test fails on code you believe is correct"
|
||||||
|
SUBJECT = "Suspect the guard before the code"
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_note(**kw):
|
||||||
|
"""A stand-in lesson row — every attribute the tool's _to_dict reads."""
|
||||||
|
base = dict(
|
||||||
|
id=1, title="t", body="b", tags=[], project_id=None,
|
||||||
|
note_type="lesson", data={}, arose_from_id=None,
|
||||||
|
created_at=None, updated_at=None,
|
||||||
|
)
|
||||||
|
base.update(kw)
|
||||||
|
return SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_lesson_without_a_trigger_is_refused():
|
||||||
|
"""THE guard. Falsifiable: it names the parameter, so a door that stopped
|
||||||
|
asking for it fails here rather than quietly writing an unfindable record."""
|
||||||
|
_user_id_ctx.set(7)
|
||||||
|
with pytest.raises(ValueError) as err:
|
||||||
|
await create_lesson(what=SUBJECT, when_to_apply="")
|
||||||
|
|
||||||
|
message = str(err.value)
|
||||||
|
assert "when_to_apply" in message
|
||||||
|
# It says what to write, not just that something is missing — the writer is
|
||||||
|
# here now, and "required" alone produces a topic where a symptom was wanted.
|
||||||
|
assert "symptom" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_whitespace_is_not_a_trigger():
|
||||||
|
"""The refusal reads the stripped value. A door checking only falsiness
|
||||||
|
accepts a space and produces exactly the record it meant to prevent."""
|
||||||
|
_user_id_ctx.set(7)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await create_lesson(what=SUBJECT, when_to_apply=" ")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_lesson_keeps_every_incident_that_taught_it():
|
||||||
|
"""Three sources in, three sources stored — and `arose_from_id` left NULL,
|
||||||
|
because one of three on a surface that renders it as THE origin would make
|
||||||
|
that surface state something false."""
|
||||||
|
_user_id_ctx.set(7)
|
||||||
|
created = AsyncMock(return_value=_stub_note())
|
||||||
|
with patch.object(lessons_svc, "create_lesson", created), \
|
||||||
|
patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note",
|
||||||
|
AsyncMock(return_value=None)), \
|
||||||
|
patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()):
|
||||||
|
await create_lesson(
|
||||||
|
what=SUBJECT, when_to_apply=TRIGGER, learned_from=[11, 22, 33],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created.await_args.kwargs["learned_from"] == [11, 22, 33]
|
||||||
|
assert lessons_svc.sole_source([11, 22, 33]) is None
|
||||||
|
assert lessons_svc.compose_data(SUBJECT, TRIGGER, [11, 22, 33])["taught_by"] == [
|
||||||
|
11, 22, 33,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_single_source_still_reaches_arose_from_id():
|
||||||
|
"""The existing provenance field keeps working for the ordinary case — a
|
||||||
|
lesson drawn from one issue is not made less connected by the list."""
|
||||||
|
assert lessons_svc.sole_source([11]) == 11
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_duplicate_gate_runs_before_anything_is_created():
|
||||||
|
"""A near-match returns the existing id and writes nothing: two lessons
|
||||||
|
about one failure class want to be one lesson."""
|
||||||
|
_user_id_ctx.set(7)
|
||||||
|
hit = SimpleNamespace(id=99, title="already recorded", similarity=0.97,
|
||||||
|
reason="semantic")
|
||||||
|
created = AsyncMock()
|
||||||
|
with patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note",
|
||||||
|
AsyncMock(return_value=hit)), \
|
||||||
|
patch.object(lessons_svc, "create_lesson", created):
|
||||||
|
out = await create_lesson(what=SUBJECT, when_to_apply=TRIGGER)
|
||||||
|
|
||||||
|
assert created.await_count == 0
|
||||||
|
assert out["duplicate"] is True
|
||||||
|
assert out["existing_id"] == 99
|
||||||
|
# The hint names the lesson's own updater, so the caller is pointed at a
|
||||||
|
# tool that exists rather than at update_note.
|
||||||
|
assert "update_lesson" in out["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_gate_compares_the_composed_document_not_the_raw_fields():
|
||||||
|
"""What reaches the gate is the title and body a lesson will actually be
|
||||||
|
stored as. Comparing `what` alone would miss that the trigger is half the
|
||||||
|
document, and would judge two lessons alike that rank nothing alike."""
|
||||||
|
_user_id_ctx.set(7)
|
||||||
|
gate = AsyncMock(return_value=None)
|
||||||
|
with patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note", gate), \
|
||||||
|
patch.object(lessons_svc, "create_lesson",
|
||||||
|
AsyncMock(return_value=_stub_note())), \
|
||||||
|
patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()):
|
||||||
|
await create_lesson(what=SUBJECT, when_to_apply=TRIGGER, insight="Look.")
|
||||||
|
|
||||||
|
title, body = gate.await_args.args[1], gate.await_args.args[2]
|
||||||
|
assert title == f"{SUBJECT} — {TRIGGER}"
|
||||||
|
assert body.startswith(f"**When to apply:** {TRIGGER}")
|
||||||
|
assert gate.await_args.kwargs["note_type"] == "lesson"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lesson_is_judged_at_the_trigger_dominated_bar():
|
||||||
|
"""#2518 measured deliberately-parallel siblings at 0.92 on a document that
|
||||||
|
is mostly prose ABOUT the thing. A lesson's document is that shape, so the
|
||||||
|
bar sits above that band — otherwise two different lessons about one area
|
||||||
|
block each other, which is the failure step 4 asked to check for."""
|
||||||
|
from scribe.services.dedup import (
|
||||||
|
_LESSON_SEMANTIC_THRESHOLD,
|
||||||
|
_SEMANTIC_THRESHOLD,
|
||||||
|
_semantic_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _semantic_threshold("lesson") == _LESSON_SEMANTIC_THRESHOLD
|
||||||
|
assert _LESSON_SEMANTIC_THRESHOLD > 0.92, (
|
||||||
|
"below the observed sibling band, so two genuinely different lessons "
|
||||||
|
"about one area would refuse each other"
|
||||||
|
)
|
||||||
|
assert _LESSON_SEMANTIC_THRESHOLD > _SEMANTIC_THRESHOLD
|
||||||
|
# An ordinary note is untouched — the carve-out is per kind, not a
|
||||||
|
# loosening of the gate.
|
||||||
|
assert _semantic_threshold("note") == _SEMANTIC_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_update_recomposes_both_halves_of_the_document():
|
||||||
|
"""A new trigger has to reach the title AND the head of the body. Patching
|
||||||
|
one would leave a lesson that reads correctly and ranks on the old
|
||||||
|
situation — the failure mode with no symptom."""
|
||||||
|
_user_id_ctx.set(7)
|
||||||
|
stored = _stub_note(
|
||||||
|
title=f"{SUBJECT} — {TRIGGER}",
|
||||||
|
body=f"**When to apply:** {TRIGGER}\n\nLook at the guard.",
|
||||||
|
data={"what": SUBJECT, "when_to_apply": TRIGGER},
|
||||||
|
)
|
||||||
|
updated = AsyncMock(return_value=_stub_note())
|
||||||
|
with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=stored)), \
|
||||||
|
patch("scribe.services.notes.update_note", updated):
|
||||||
|
await lessons_svc.update_lesson(7, 1, when_to_apply="a guard goes red")
|
||||||
|
|
||||||
|
fields = updated.await_args.kwargs
|
||||||
|
assert fields["title"] == f"{SUBJECT} — a guard goes red"
|
||||||
|
assert fields["body"].startswith("**When to apply:** a guard goes red")
|
||||||
|
assert fields["data"]["when_to_apply"] == "a guard goes red"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_update_does_not_stack_the_composed_lines():
|
||||||
|
"""The insight is handed back to `compose_body`, which re-adds the trigger
|
||||||
|
and provenance lines. Without stripping them first they accumulate a copy
|
||||||
|
per edit — and because the trigger line is half of what makes the document
|
||||||
|
rank, the duplicates would look like the shape working."""
|
||||||
|
_user_id_ctx.set(7)
|
||||||
|
stored = _stub_note(
|
||||||
|
body=f"**When to apply:** {TRIGGER}\n\nLook at the guard."
|
||||||
|
"\n\n**Learned from:** #5",
|
||||||
|
data={"what": SUBJECT, "when_to_apply": TRIGGER, "taught_by": [5]},
|
||||||
|
)
|
||||||
|
updated = AsyncMock(return_value=_stub_note())
|
||||||
|
with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=stored)), \
|
||||||
|
patch("scribe.services.notes.update_note", updated):
|
||||||
|
await lessons_svc.update_lesson(7, 1, what="Suspect the guard")
|
||||||
|
|
||||||
|
body = updated.await_args.kwargs["body"]
|
||||||
|
assert body.count("**When to apply:**") == 1
|
||||||
|
assert body.count("**Learned from:**") == 1
|
||||||
|
assert "Look at the guard." in body
|
||||||
Reference in New Issue
Block a user