docs(6205): knowledge comments state the reason, not the instance that taught it

The ACL header states the share behaviour without its date and decision ids;
the facet table, door map, floor constant and facet-count comments state why
they are single sources instead of retelling how copies drifted; this
instance's counts (legacy plan-tasks, issue share) are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:22:42 -04:00
co-authored by Claude Opus 5.5
parent d1b442c572
commit 21a409eac1
+42 -52
View File
@@ -1,8 +1,7 @@
"""Knowledge service — one query across every record kind Scribe holds. """Knowledge service — one query across every record kind Scribe holds.
ACL (rules #47/#78, decision note 2094): these queries were owner-only until ACL (services/access.py): these queries honour shares, so a record shared with
2026-07-25, which meant a record shared with you could be opened by id but never you can be *found* as well as opened by id — at two different widths:
*found*. They now honour shares — at two different widths:
- **searching** (a `q` the caller typed) uses the full read scope, so a record - **searching** (a `q` the caller typed) uses the full read scope, so a record
shared directly with you is findable when you go looking for it. BOTH halves shared directly with you is findable when you go looking for it. BOTH halves
@@ -32,7 +31,7 @@ logger = logging.getLogger(__name__)
_SNIPPET_LEN = 200 _SNIPPET_LEN = 200
# --- the location filter (reverse lookup, #2083) ------------------------------ # --- the location filter (reverse lookup) ------------------------------------
# #
# ONE predicate in two dialects: "this record carries a location matching every # ONE predicate in two dialects: "this record carries a location matching every
# part asked for." The SQL form runs in the browse arm and the keyword arm, # part asked for." The SQL form runs in the browse arm and the keyword arm,
@@ -79,7 +78,7 @@ def location_matches(data: dict | None, parts: dict[str, str]) -> bool:
_path_matches((loc.get(key) or "").strip(), want) _path_matches((loc.get(key) or "").strip(), want)
if key == "path" if key == "path"
# Repo names are recorded free-form ("Scribe" / "FabledScribe" / # Repo names are recorded free-form ("Scribe" / "FabledScribe" /
# "fabledscribe") — case is never the distinguishing thing (#2874). # "fabledscribe") — case is never the distinguishing thing.
else (loc.get(key) or "").strip().lower() == want.lower() else (loc.get(key) or "").strip().lower() == want.lower()
if key == "repo" if key == "repo"
else (loc.get(key) or "").strip() == want else (loc.get(key) or "").strip() == want
@@ -106,7 +105,7 @@ def location_jsonpath(parts: dict[str, str]) -> str:
prefix = json.dumps(want.rstrip("/") + "/") prefix = json.dumps(want.rstrip("/") + "/")
filters.append(f"(@.path == {literal} || @.path starts with {prefix})") filters.append(f"(@.path == {literal} || @.path starts with {prefix})")
elif key == "repo": elif key == "repo":
# Case-insensitive, anchored, regex-escaped (#2874) — mirrors the # Case-insensitive, anchored, regex-escaped — mirrors the
# Python dialect's .lower() compare. # Python dialect's .lower() compare.
pattern = json.dumps("^" + re.escape(want) + "$") pattern = json.dumps("^" + re.escape(want) + "$")
filters.append(f'(@.repo like_regex {pattern} flag "i")') filters.append(f'(@.repo like_regex {pattern} flag "i")')
@@ -120,7 +119,7 @@ def _location_clause(parts: dict[str, str]):
return Note.data.path_exists(location_jsonpath(parts)) return Note.data.path_exists(location_jsonpath(parts))
# --- drift-check filter (#2086) ---------------------------------------------- # --- drift-check filter ------------------------------------------------------
# `verification` selects on the drift-check verdict stored in `data.verification` # `verification` selects on the drift-check verdict stored in `data.verification`
# (see services/snippets.py). Statuses are the service's own constants; the two # (see services/snippets.py). Statuses are the service's own constants; the two
# composite values are what the operator actually asks for. # composite values are what the operator actually asks for.
@@ -154,7 +153,7 @@ def verification_matches(data: dict | None, value: str) -> bool:
if not status: if not status:
return want == "unverified" return want == "unverified"
expired = verdict.get("code_sha") != (data or {}).get("code_sha") expired = verdict.get("code_sha") != (data or {}).get("code_sha")
# A push touched the recorded location since the verdict (#2691): the repo # A push touched the recorded location since the verdict: the repo
# moved under it, so it needs a look even though it hasn't failed. # moved under it, so it needs a look even though it hasn't failed.
invalidated = bool(verdict.get("invalidated_by")) invalidated = bool(verdict.get("invalidated_by"))
if want == "unverified": if want == "unverified":
@@ -171,7 +170,7 @@ def verification_matches(data: dict | None, value: str) -> bool:
_VERIFY_DRIFTED_JSONPATH = '$.verification ? (@.status != "ok")' _VERIFY_DRIFTED_JSONPATH = '$.verification ? (@.status != "ok")'
_VERIFY_EXPIRED_JSONPATH = "$ ? (@.verification.code_sha != @.code_sha)" _VERIFY_EXPIRED_JSONPATH = "$ ? (@.verification.code_sha != @.code_sha)"
_VERIFY_ANY_JSONPATH = "$.verification" _VERIFY_ANY_JSONPATH = "$.verification"
# A push touched the recorded location since the verdict (#2691). # A push touched the recorded location since the verdict.
_VERIFY_INVALIDATED_JSONPATH = "$.verification.invalidated_by" _VERIFY_INVALIDATED_JSONPATH = "$.verification.invalidated_by"
@@ -189,7 +188,7 @@ def _verification_clause(value: str):
return Note.data.path_exists(_VERIFY_DRIFTED_JSONPATH) return Note.data.path_exists(_VERIFY_DRIFTED_JSONPATH)
if want == "attention": if want == "attention":
# Everything worth looking at: a failing verdict, an expired one, OR # Everything worth looking at: a failing verdict, an expired one, OR
# one whose recorded location a push has since touched (#2691). # one whose recorded location a push has since touched.
return or_( return or_(
Note.data.path_exists(_VERIFY_DRIFTED_JSONPATH), Note.data.path_exists(_VERIFY_DRIFTED_JSONPATH),
and_(has_verdict, Note.data.path_exists(_VERIFY_EXPIRED_JSONPATH)), and_(has_verdict, Note.data.path_exists(_VERIFY_EXPIRED_JSONPATH)),
@@ -221,10 +220,9 @@ def _note_to_item(note: Note, chunks: dict[int, dict] | None = None) -> dict:
any other — it shows the record's opening. `snippet_is` says which, on any other — it shows the record's opening. `snippet_is` says which, on
every row, so the two never have to be told apart by guessing. every row, so the two never have to be told apart by guessing.
This was `(note.body or "")[:_SNIPPET_LEN]` on every row alike: the head of The head of the body on every row alike (`body[:_SNIPPET_LEN]`) would show
the document, on the human's main search surface, with nothing marking the a record matched on its sixth paragraph its first, which the search had
cut. A record matched on its sixth paragraph was shown its first, which the already scored lower, with nothing marking the cut.
search had already scored lower (#4243).
""" """
item: dict = { item: dict = {
"id": note.id, "id": note.id,
@@ -244,7 +242,7 @@ def _note_to_item(note: Note, chunks: dict[int, dict] | None = None) -> dict:
"created_at": iso(note.created_at), "created_at": iso(note.created_at),
"updated_at": iso(note.updated_at), "updated_at": iso(note.updated_at),
} }
# Drift verdict (#2086), when one has been recorded. Included here rather # Drift verdict, when one has been recorded. Included here rather
# than decorated on by the snippet layer because `current` is derivable from # than decorated on by the snippet layer because `current` is derivable from
# `data` alone — the verdict's code_sha against the row's — so this needs no # `data` alone — the verdict's code_sha against the row's — so this needs no
# body parsing and stays a plain projection of the column. Omitted entirely # body parsing and stays a plain projection of the column. Omitted entirely
@@ -253,7 +251,7 @@ def _note_to_item(note: Note, chunks: dict[int, dict] | None = None) -> dict:
# Snippet language, same reasoning as the verdict below: a plain projection of # Snippet language, same reasoning as the verdict below: a plain projection of
# the `data` mirror, no body parsing. Needed because a prior-art hit in a # the `data` mirror, no body parsing. Needed because a prior-art hit in a
# DIFFERENT language than the file being written is useful as the shape of a # DIFFERENT language than the file being written is useful as the shape of a
# solution but must not be mistaken for code to paste (#2244) — and the caller # solution but must not be mistaken for code to paste — and the caller
# can only say "different" if the language is on the item. # can only say "different" if the language is on the item.
language = (note.data or {}).get("language") if note.data else None language = (note.data or {}).get("language") if note.data else None
if language: if language:
@@ -267,8 +265,8 @@ def _note_to_item(note: Note, chunks: dict[int, dict] | None = None) -> dict:
trigger = (note.data or {}).get("when_to_apply") if note.data else None trigger = (note.data or {}).get("when_to_apply") if note.data else None
if trigger: if trigger:
item["when_to_apply"] = trigger item["when_to_apply"] = trigger
# A snippet's, for the same reason — and since milestone 427 the title no # A snippet's, for the same reason — the stored title does not carry it,
# longer carries it, so without this a list shows names with no situation. # so without this a list shows names with no situation.
usage = (note.data or {}).get("when_to_use") if note.data else None usage = (note.data or {}).get("when_to_use") if note.data else None
if usage: if usage:
item["when_to_use"] = usage item["when_to_use"] = usage
@@ -283,7 +281,7 @@ def _note_to_item(note: Note, chunks: dict[int, dict] | None = None) -> dict:
"path": verdict.get("path"), "path": verdict.get("path"),
} }
# Present only when a push has touched the recorded location since the # Present only when a push has touched the recorded location since the
# verdict (#2691) — the "recheck me" marker, cleared by re-verifying. # verdict — the "recheck me" marker, cleared by re-verifying.
if verdict.get("invalidated_by"): if verdict.get("invalidated_by"):
item["verification"]["invalidated_by"] = verdict["invalidated_by"] item["verification"]["invalidated_by"] = verdict["invalidated_by"]
@@ -305,17 +303,15 @@ def _note_to_item(note: Note, chunks: dict[int, dict] | None = None) -> dict:
# why this is a table rather than a chain of ifs. Each entry is # why this is a table rather than a chain of ifs. Each entry is
# (is_task, the value pinned on that axis); None pins nothing, i.e. every task. # (is_task, the value pinned on that axis); None pins nothing, i.e. every task.
# #
# It is a table because the alternative had already gone wrong. The predicate # It is a table because the predicate has three dialects — SQL, a Python test
# was written three times — a SQL if-chain, a Python if-chain over semantic # over semantic candidates, and the `is_task` pre-filter — and three hand-kept
# candidates, and a ternary computing the `is_task` pre-filter — and the three # copies agree only by luck. Adding `issue` to the SQL arm alone would set the
# only agreed by luck. Adding `issue` to the SQL arm alone (the obvious edit, # pre-filter to is_task=False, hand the Python arm a candidate set containing
# and the one #3128 was about to make) would have set the pre-filter to # no tasks at all, and return an empty semantic half for the Issues facet
# is_task=False, handed the Python arm a candidate set containing no tasks at # forever, with nothing red anywhere. A new facet is one row here.
# all, and returned an empty semantic half for the Issues facet forever, with
# nothing red anywhere. A new facet is now one row here.
# #
# `plan` is retired (0066) but kept: 90 legacy plan-tasks exist and a facet # `plan` is retired but kept: legacy plan-tasks may exist, and a facet they
# they answer to costs one line. It simply has no chip in the UI any more. # answer to costs one line. It has no chip in the UI.
_FACETS: dict[str, tuple[bool, str | None]] = { _FACETS: dict[str, tuple[bool, str | None]] = {
"task": (True, None), "task": (True, None),
"work": (True, "work"), "work": (True, "work"),
@@ -326,11 +322,10 @@ _FACETS: dict[str, tuple[bool, str | None]] = {
"process": (False, "process"), "process": (False, "process"),
"snippet": (False, "snippet"), "snippet": (False, "snippet"),
# A transferable insight, keyed by the situation it applies to rather than # A transferable insight, keyed by the situation it applies to rather than
# by its topic (milestone 385). It reaches the browse surface through this # by its topic. It reaches the browse surface through this one entry: the
# one entry: the door's validation, the counts and both dialects of the # door's validation, the counts and both dialects of the type filter are
# type filter are all generated from this table, which is the property # all generated from this table, so adding a kind is a single edit rather
# #3161 asked for so that adding a kind is a single edit rather than four # than four that must agree.
# that must agree.
"lesson": (False, "lesson"), "lesson": (False, "lesson"),
} }
@@ -358,7 +353,7 @@ def facet_is_task(note_type: str | None) -> bool | None:
Used to narrow the semantic candidate set before it is fetched. Reads the Used to narrow the semantic candidate set before it is fetched. Reads the
same table `_apply_type_filter` and `matches_facet` read, so the pre-filter same table `_apply_type_filter` and `matches_facet` read, so the pre-filter
can no longer disagree with the predicate it is meant to anticipate. cannot disagree with the predicate it is meant to anticipate.
""" """
if not note_type: if not note_type:
return None return None
@@ -388,7 +383,7 @@ def search_filters_for(facet: str) -> dict:
narrow and no fetched row to test — it is passing filters INTO narrow and no fetched row to test — it is passing filters INTO
`semantic_search_notes`. `_apply_type_filter` is the SQL dialect and `semantic_search_notes`. `_apply_type_filter` is the SQL dialect and
`matches_facet` the Python one; all three read `_FACETS`, which is what `matches_facet` the Python one; all three read `_FACETS`, which is what
keeps "adding a kind" a single edit (#3161). keeps "adding a kind" a single edit.
Returns kwargs rather than a tuple so a caller splats it and cannot pair Returns kwargs rather than a tuple so a caller splats it and cannot pair
`task_kind` with `is_task=False` by writing the positions out of order. `task_kind` with `is_task=False` by writing the positions out of order.
@@ -421,10 +416,9 @@ _CONTENT_TYPE_BROAD = {
def content_type_filters(content_type: str, extra: tuple = ()) -> dict: def content_type_filters(content_type: str, extra: tuple = ()) -> dict:
"""The search kwargs a door's `content_type` parameter implies. """The search kwargs a door's `content_type` parameter implies.
Lives here, beside `_FACETS`, because a door that keeps its own map is how Lives here, beside `_FACETS`, because a door that keeps its own map drifts:
this went wrong: the agent's search offered two kinds and browse offered one door offers two kinds while another offers nine. Derived, so adding a
nine, and a third copy in `/api/search` offered two more quietly still kind stays one edit.
(#4250). Derived, so adding a kind stays one edit (#3161).
Raises on anything unrecognised. `_facet` alone would fall back to reading Raises on anything unrecognised. `_facet` alone would fall back to reading
an unknown string as a note_type, which matches no row — so a typo comes an unknown string as a note_type, which matches no row — so a typo comes
@@ -611,18 +605,15 @@ async def _semantic_knowledge_search(
query=q, query=q,
report=_semantic_report, report=_semantic_report,
limit=min(200, limit * 4), limit=min(200, limit * 4),
# The shared interactive floor — this was a bare `0.3` while # The shared interactive floor, one constant, so no copy of the
# routes/search.py had the same number as a commented constant, the # number can move without the others.
# exact pair where one moves and the other doesn't (#2463).
threshold=INTERACTIVE_SEARCH_THRESHOLD, threshold=INTERACTIVE_SEARCH_THRESHOLD,
is_task=is_task_filter, is_task=is_task_filter,
project_id=project_id, project_id=project_id,
) )
# The human's MAIN search surface, and it logged nothing — so # The human's MAIN search surface, logged under its own source so
# retrieval_logs claimed the web's search was /api/search, a narrower
# path with (measured) zero frontend consumers. Distinct source, so
# threshold tuning can include or exclude human queries deliberately # threshold tuning can include or exclude human queries deliberately
# rather than by accident (#2463). # rather than by accident.
from scribe.services.retrieval_telemetry import record_retrieval from scribe.services.retrieval_telemetry import record_retrieval
record_retrieval( record_retrieval(
user_id=user_id, source="browse_search", query=q, user_id=user_id, source="browse_search", query=q,
@@ -698,11 +689,10 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
stmt = stmt.where(Note.tags.contains([tag])) stmt = stmt.where(Note.tags.contains([tag]))
return stmt return stmt
# One grouped query per typing axis. The task half used to be a count # One grouped query per typing axis. A count per kind needs a new
# for 'task' plus a second count for 'plan', which is why 'issue' — # query for every kind and leaves a kind nobody added with no number;
# 17% of every task here — had no number to show: each kind needed its # grouping by task_kind counts every kind, including ones added later,
# own query and nobody added one. Grouping by task_kind counts every # in two round-trips.
# kind, including ones added later, for the same two round-trips.
non_task = _scoped( non_task = _scoped(
select(Note.note_type, func.count(Note.id)) select(Note.note_type, func.count(Note.id))
.where(Note.status.is_(None)) .where(Note.status.is_(None))