rename(547): the server is grimoire — package, image, DB defaults, backups
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,651 @@
|
||||
"""Lesson service — a transferable insight, retrievable by situation.
|
||||
|
||||
A *lesson* is a Note with ``note_type='lesson'``: a better way to think about a
|
||||
problem, or a solution that transfers, recorded so a later session meets it at
|
||||
the moment it applies — and **without binding the reader**.
|
||||
|
||||
WHY THE KIND EXISTS
|
||||
|
||||
Without it, rules are the only surface that is global AND situation-keyed, so
|
||||
an agent holding a transferable insight has one door, and that door binds —
|
||||
and sessions propose rules for things that should not be rules.
|
||||
|
||||
The gap is a document-shape fact, not a threshold:
|
||||
|
||||
- a note is embedded as ``title\\nbody`` and is findable by **what it is
|
||||
about**;
|
||||
- a rule is embedded as ``{title} — {trigger}`` with ``When to apply:``
|
||||
repeated at the head of the body, so the trigger appears twice in a short
|
||||
document and dominates the vector — findable by **when it applies**.
|
||||
|
||||
No tuning reaches across that: the field the query would match on is simply not
|
||||
in a note's document. So a lesson carries a trigger and is embedded like a rule,
|
||||
while staying a note in every other respect.
|
||||
|
||||
WHERE THE TRIGGER LIVES
|
||||
|
||||
In ``notes.data`` under ``when_to_apply``, written through a named parameter and
|
||||
mirrored into the head of the body — the shape snippets use for
|
||||
``when_to_use``. Not a column on ``notes``. It is NOT in the stored title: the
|
||||
title is the lesson's subject, and the trigger joins it only in the embedded
|
||||
document (``embeddings.document_title``).
|
||||
|
||||
A named structured field rather than a column: snippets show such a field gets
|
||||
filled reliably without a guard, and a column would decide enforceability for
|
||||
every note kind at once.
|
||||
|
||||
The trigger in the document is what makes the vector sharp. It reaches it
|
||||
twice — in the embedded title, joined at embed time, and in the body's first
|
||||
line.
|
||||
|
||||
WHAT A LESSON INHERITS, AND THE CELLS LEFT EMPTY ON PURPOSE
|
||||
|
||||
A new kind inherits the note machinery wholesale. Which parts it should NOT get
|
||||
is stated here, so that an empty cell is a decision rather than an oversight.
|
||||
|
||||
Inherited, all deliberately:
|
||||
|
||||
- **versions** — a lesson is reworded as understanding improves, and what it
|
||||
used to say is worth as much as any note's history.
|
||||
- **supersession** — the event this most needs. A lesson replaced by a better
|
||||
lesson is precisely what ``note_supersessions`` models, and the demotion
|
||||
penalty already exists.
|
||||
- **trash**, **the share ACL**, **tags**, **project and System tagging**,
|
||||
**chunked embeddings**, **the near-duplicate gate**.
|
||||
|
||||
NOT inherited, and each for a stated reason:
|
||||
|
||||
- **status / task_kind / milestone_id** — a lesson is not work. ``is_task`` is
|
||||
``status is not None``, so a lesson that acquired a status would become a
|
||||
task and appear in open-work listings. This is the one cell where filling it
|
||||
in by accident silently changes what the record IS.
|
||||
- **recurrence** — task-only, and a lesson does not recur.
|
||||
- **verify_with / expires_when** — available, because they are generic note
|
||||
fields, but not part of a lesson's contract and not asked for on create.
|
||||
Those mark a record that asserts a FACT about someone else's software and
|
||||
can go false unwatched. A lesson is closer
|
||||
to a norm — "a better way to think about this" has no truth value that rots
|
||||
on its own. A lesson that does assert such a fact can still carry them.
|
||||
|
||||
WHY A KIND NEEDS NO MIGRATION
|
||||
|
||||
``note_type`` carries **no CHECK constraint** — only ``task_kind`` does
|
||||
(``notes_task_kind_check``). It is plain ``Text`` with a server default, so a
|
||||
value the database refuses on an un-migrated instance cannot arise for this
|
||||
column.
|
||||
|
||||
The real vocabulary is ``services.knowledge._FACETS``, which is where a kind
|
||||
becomes reachable on the browse surface and validated at the door. That is one
|
||||
table feeding both dialects of the type filter, so adding a kind there is a
|
||||
single edit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from grimoire.models import async_session
|
||||
from grimoire.models.note import Note
|
||||
|
||||
LESSON_NOTE_TYPE = "lesson"
|
||||
|
||||
# The key in `notes.data`. Named for the field it mirrors on `rules`, because it
|
||||
# answers the same question and a reader who knows one should not have to learn
|
||||
# a second word for it.
|
||||
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
|
||||
# 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
|
||||
# `snippet_fields` follows and the reason a row written before the mirror
|
||||
# existed is still readable.
|
||||
_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:
|
||||
"""When this lesson applies, or "" — the mirror first, then the body.
|
||||
|
||||
Prefers `data` for the same reason every snippet read does: it is indexed,
|
||||
and parsing a body to answer a question the database can answer is how a
|
||||
hot path ends up regexing markdown. The fallback is not dead code — it is
|
||||
what makes a lesson readable if the mirror is ever absent, and an absent
|
||||
mirror must degrade to the right answer rather than to silence.
|
||||
"""
|
||||
data = getattr(note, "data", None) or {}
|
||||
from_mirror = (data.get(TRIGGER_KEY) or "").strip() if isinstance(data, dict) else ""
|
||||
if from_mirror:
|
||||
return from_mirror
|
||||
match = _BODY_TRIGGER_RE.search(getattr(note, "body", None) or "")
|
||||
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. A good lesson often generalises several
|
||||
incidents — a badge collision, an un-backfilled column, a duplicated const
|
||||
— into one claim about a failure class. That 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 the reason the
|
||||
trigger lives there: a join table would settle, for every note kind at
|
||||
once, whether provenance is multi-valued. `data` is JSONB with a GIN
|
||||
index, 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_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 trigger's home in the text of the document, and half of what makes a
|
||||
lesson findable: `chunk_document` joins `{embedded title}\\n{body}`, and
|
||||
the embedded title is `what — when it applies` (`embeddings.document_title`),
|
||||
so the document states WHEN IT APPLIES in the title and again in the first
|
||||
line of the body. That twice-in-a-short-document shape is what measures
|
||||
sharp in the corpus.
|
||||
|
||||
`**When to apply:**` rather than plain text: the body is the READABLE
|
||||
form, `data` is the queryable mirror, and `_BODY_TRIGGER_RE` reads this
|
||||
line back when the mirror is missing. Its markdown must therefore match
|
||||
what that pattern expects, which is why neither is written by hand
|
||||
anywhere else.
|
||||
|
||||
The insight goes in the body rather than being held out of the document.
|
||||
`rule_document` excludes a rule's `why` because long narrative in ONE
|
||||
vector lands on the centroid of "development" — but a body over the
|
||||
budget is split into several chunks, EACH prefixed with the title, so a
|
||||
lesson's story does not average itself into its trigger: it occupies its
|
||||
own vectors, and every
|
||||
one of them still carries the trigger in its prefix. Holding it out would
|
||||
cost the reader the only part that explains the insight and would buy a
|
||||
sharpness the chunker already provides.
|
||||
"""
|
||||
lines = []
|
||||
trigger = (when_to_apply or "").strip()
|
||||
if trigger:
|
||||
lines.append(f"**When to apply:** {trigger}")
|
||||
insight = (insight or "").strip()
|
||||
if 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)
|
||||
|
||||
|
||||
# A lesson's NAME is its claim, and the claim is one line. `what` is the title
|
||||
# every listing, menu and search row prints, so a story written there instead
|
||||
# of in `insight` turns each of those lines into a document, with the claim it
|
||||
# exists to transfer somewhere in the middle. It also ranks badly: the embedded title
|
||||
# is `what — when_to_apply`, so a narrative crowds out the trigger.
|
||||
#
|
||||
# 240 is a long sentence. The bound is on what a NAME can be, not on what a
|
||||
# lesson can say — `insight` is the body, unbounded, chunked like any record,
|
||||
# and costs the ranking nothing.
|
||||
WHAT_MAX_CHARS = 240
|
||||
|
||||
|
||||
def require_claim(what: str | None) -> None:
|
||||
"""Refuse a `what` that is not one line — before anything is written.
|
||||
|
||||
A refusal rather than a cut, because a cut name reads as complete: the
|
||||
writer would never learn the story went to the wrong field, and the
|
||||
reader would get its first 240 characters as though they were the claim.
|
||||
"""
|
||||
text = (what or "").strip()
|
||||
multiline = "\n" in text
|
||||
if not multiline and len(text) <= WHAT_MAX_CHARS:
|
||||
return
|
||||
shape = "runs over several lines" if multiline else f"is {len(text)} characters"
|
||||
raise ValueError(
|
||||
f"`what` {shape}; it is the lesson's NAME — one claim, one line, at "
|
||||
f"most {WHAT_MAX_CHARS} characters — and every listing and menu prints "
|
||||
"it whole. Nothing was written. Put the claim in `what`, said as the "
|
||||
"sentence someone should come away with, and the incident that taught "
|
||||
"it in `insight`, which is the body and is not bounded."
|
||||
)
|
||||
|
||||
|
||||
def claim_line(what: str | None) -> str:
|
||||
"""A stored name as one menu line, whatever was stored.
|
||||
|
||||
The display half of `require_claim`, for names written before it existed:
|
||||
a guard at the door does not undo a value already stored, and on an
|
||||
install nobody has repaired, those rows would still print their whole
|
||||
story on every menu. Over the bound, the name's first sentence is shown
|
||||
and marked as cut — the record itself is unchanged, and opening it shows
|
||||
the rest.
|
||||
"""
|
||||
text = " ".join((what or "").split())
|
||||
if len(text) <= WHAT_MAX_CHARS:
|
||||
return text
|
||||
first = re.split(r"(?<=[.!?])\s", text, maxsplit=1)[0]
|
||||
if len(first) > WHAT_MAX_CHARS:
|
||||
first = first[:WHAT_MAX_CHARS].rsplit(" ", 1)[0]
|
||||
return first + " …"
|
||||
|
||||
|
||||
def lesson_document(
|
||||
what: str, when_to_apply: str = "", insight: str = "",
|
||||
learned_from: list[int] | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""The (title, body) a lesson is STORED as.
|
||||
|
||||
One call so the two halves cannot be composed apart. The body's first line
|
||||
carries the trigger; a lesson without it (and without the `data` mirror)
|
||||
would embed as an ordinary note wearing a label, and nothing would report
|
||||
it: the record would look right in every listing and simply never be
|
||||
retrieved at the moment it applies.
|
||||
|
||||
The title is the SUBJECT alone. A stored title carrying the trigger would
|
||||
make every listing, menu and search row show a title that runs to
|
||||
kilobytes. The trigger joins the title at embed time
|
||||
(`embeddings.document_title`, reading `data`).
|
||||
"""
|
||||
return (
|
||||
(what or "").strip(),
|
||||
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
|
||||
|
||||
|
||||
def recompose_data(note) -> dict:
|
||||
"""Rebuild a lesson's `data` mirror from its own title and body.
|
||||
|
||||
For the GENERIC note door. `update_lesson` composes the mirror itself from
|
||||
the merged field set and never needs this; a plain `update_note(body=...)`
|
||||
has no idea the mirror exists and would leave it behind.
|
||||
|
||||
THE COST OF LEAVING IT BEHIND IS HIGHER HERE THAN FOR A SNIPPET. A stale
|
||||
snippet mirror reports the wrong path. A stale lesson mirror reports the
|
||||
wrong TRIGGER — and `lesson_trigger` prefers the mirror, so the lesson goes
|
||||
on being retrieved for the situation it used to name while displaying the
|
||||
one it now names. The trigger is the entire retrieval story, so
|
||||
that is not a degraded record; it is a record that fires at the wrong
|
||||
moment and looks right when it does.
|
||||
|
||||
The body is the authority and the mirror is derived — already this file's
|
||||
rule. This is its enforcement on the path that bypasses `update_lesson`.
|
||||
|
||||
The subject comes back out of the title through `untrigger_title`, the
|
||||
inverse of the join that composed it, rather than by splitting on a
|
||||
separator spelled a second time here.
|
||||
"""
|
||||
from grimoire.services.embeddings import untrigger_title
|
||||
|
||||
body = getattr(note, "body", None) or ""
|
||||
trigger_match = _BODY_TRIGGER_RE.search(body)
|
||||
trigger = trigger_match.group(1).strip() if trigger_match else ""
|
||||
what = untrigger_title(getattr(note, "title", None), trigger)
|
||||
# Sources through the normal read, which already falls back body →
|
||||
# arose_from_id. A body edit that drops the provenance line should drop
|
||||
# the mirror's copy too: the body is the authority, and carrying a value
|
||||
# the reader just deleted is the failure this function exists to prevent.
|
||||
sources_match = _BODY_SOURCES_RE.search(body)
|
||||
sources = (
|
||||
normalize_sources(_ID_RE.findall(sources_match.group(1)))
|
||||
if sources_match else []
|
||||
)
|
||||
return compose_data(what, trigger, sources)
|
||||
|
||||
|
||||
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 design, not a
|
||||
convenience: a named structured field that a service composes is filled
|
||||
reliably, where a convention an agent must type correctly is not.
|
||||
|
||||
`project_id` is accepted and kept, even though a lesson is reachable from
|
||||
every project. Where it was learned is a fact worth keeping; it
|
||||
simply stops being the limit of where it can be found.
|
||||
"""
|
||||
from grimoire.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 (services/access.py): 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.
|
||||
"""
|
||||
from grimoire.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
|
||||
|
||||
|
||||
def lesson_to_dict(note) -> dict:
|
||||
"""A lesson as either door returns it — the composed fields read back out,
|
||||
not the raw row, so a caller sees the same vocabulary it wrote with.
|
||||
|
||||
In the SERVICE rather than in each door, on the `snippet_to_dict`
|
||||
precedent: the REST route feeds the web UI and the MCP tools feed an
|
||||
agent, and a shape spelled once per door is a shape that answers the two
|
||||
of them differently the first time a field is added.
|
||||
"""
|
||||
return {
|
||||
"id": note.id,
|
||||
"title": note.title,
|
||||
"body": note.body,
|
||||
# The composed vocabulary, not the storage: a caller that wrote
|
||||
# `when_to_apply` reads `when_to_apply` back.
|
||||
"what": (note.data or {}).get("what", "") if isinstance(note.data, dict) else "",
|
||||
"when_to_apply": lesson_trigger(note),
|
||||
"learned_from": lesson_sources(note),
|
||||
"insight": lesson_insight(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 source_records(user_id: int, ids: list[int]) -> list[dict]:
|
||||
"""The records that taught a lesson, resolved to something linkable.
|
||||
|
||||
`learned_from` is a list of bare ids, which is right for storage and
|
||||
useless on a page: "#4181" tells a reader nothing about whether it is
|
||||
worth opening. This resolves each to its title and kind so the UI can
|
||||
label the link, and so a source that has been deleted simply drops out
|
||||
rather than rendering a link to nothing.
|
||||
|
||||
One query for the whole list, not one per id — a lesson with six sources
|
||||
would otherwise be six round trips to draw one panel.
|
||||
|
||||
Share-aware (services/access.py). Order follows `ids`, because that order is the
|
||||
writer's: the first source is the one they reached for first.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from grimoire.services.access import readable_notes_clause
|
||||
|
||||
wanted = normalize_sources(ids)
|
||||
if not wanted:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Note)
|
||||
.where(Note.id.in_(wanted))
|
||||
.where(Note.deleted_at.is_(None))
|
||||
.where(readable_notes_clause(user_id))
|
||||
)
|
||||
found = {n.id: n for n in result.scalars().all()}
|
||||
return [
|
||||
{
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"note_type": n.note_type,
|
||||
"is_task": n.status is not None,
|
||||
"task_kind": n.task_kind,
|
||||
"status": n.status,
|
||||
}
|
||||
for i in wanted
|
||||
if (n := found.get(i)) is not None
|
||||
]
|
||||
|
||||
|
||||
async def lessons_taught_by(user_id: int, record_id: int, limit: int = 20):
|
||||
"""The lessons drawn FROM one record — the reverse of `learned_from`.
|
||||
|
||||
THE DIRECTION THAT GETS FORGOTTEN, and arguably the more useful one: a
|
||||
reader opening an old issue wants to know what was learned from it, and
|
||||
without this the relation is only navigable from the lesson's side. A
|
||||
record that taught something should say so on its own page.
|
||||
|
||||
Queried through `data[SOURCES_KEY]` rather than by scanning bodies: the
|
||||
mirror is JSONB with a GIN index, which is the whole reason the list
|
||||
lives there. `path_exists` is the same dialect the snippet location
|
||||
lookup uses, so both reverse lookups read the index the same way.
|
||||
|
||||
Share-aware via `readable_notes_clause`: this renders beside a
|
||||
record the caller can already see, and a lesson someone shared with them
|
||||
belongs in that list exactly as their own does.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from grimoire.services.access import readable_notes_clause
|
||||
|
||||
try:
|
||||
wanted = int(record_id)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
if wanted <= 0:
|
||||
return []
|
||||
|
||||
# The id is an int we just validated, never caller text, so it cannot
|
||||
# break out of the expression — the same guarantee `location_jsonpath`
|
||||
# gets from JSON-quoting its values.
|
||||
jsonpath = f"$.{SOURCES_KEY}[*] ? (@ == {wanted})"
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Note)
|
||||
.where(Note.note_type == LESSON_NOTE_TYPE)
|
||||
.where(Note.deleted_at.is_(None))
|
||||
.where(Note.data.path_exists(jsonpath))
|
||||
.where(readable_notes_clause(user_id))
|
||||
.order_by(Note.updated_at.desc())
|
||||
.limit(max(1, min(limit, 100)))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
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 grimoire.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 = lesson_insight(note)
|
||||
|
||||
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 lesson_insight(note) -> str:
|
||||
"""The authored insight alone — the body with what `compose_body` added removed.
|
||||
|
||||
An update that keeps the insight hands it back to `compose_body`, which
|
||||
re-adds the trigger and provenance. So this has to remove EXACTLY what was
|
||||
added; anything it leaves behind is re-prepended and the body grows by that
|
||||
much on every edit.
|
||||
|
||||
TAKES THE NOTE, NOT THE BODY. Filtering out lines matching
|
||||
`_BODY_TRIGGER_RE` is not enough: that pattern is `re.M` and matches ONE
|
||||
LINE, while `when_to_apply` is free text and routinely several paragraphs
|
||||
— `compose_body` writes all of it after the marker. Paragraphs 2..n would
|
||||
come back as part of "the insight", the full trigger would be prepended
|
||||
again, and the record would gain (n-1) paragraphs per update. Silent, and
|
||||
compounding.
|
||||
|
||||
What makes an exact strip possible is that the trigger is stored: the
|
||||
mirror in `data` holds its full text, so `composed` below is
|
||||
byte-for-byte what `compose_body` put at the head, and removing it is
|
||||
subtraction rather than pattern-matching.
|
||||
|
||||
The fallback is not dead code. A row whose mirror is missing degrades to
|
||||
line-wise removal, which is right for a one-paragraph trigger and is the
|
||||
best that can be done without knowing where the trigger ends — there is
|
||||
no marker for its last line.
|
||||
|
||||
The provenance line needs no such care: `compose_body` builds it by
|
||||
joining ids with ", ", so it cannot contain a newline.
|
||||
"""
|
||||
body = getattr(note, "body", None) or ""
|
||||
trigger = lesson_trigger(note).strip()
|
||||
head = body.lstrip("\n")
|
||||
composed = f"**When to apply:** {trigger}" if trigger else ""
|
||||
if composed and head.startswith(composed):
|
||||
lines = head[len(composed):].splitlines()
|
||||
else:
|
||||
lines = [ln for ln in body.splitlines() if not _BODY_TRIGGER_RE.match(ln)]
|
||||
kept = [ln for ln in lines if not _BODY_SOURCES_RE.match(ln)]
|
||||
return "\n".join(kept).strip()
|
||||
Reference in New Issue
Block a user