fix(usage): one seam attaches the surfaced-vs-opened chip, and the Knowledge browse uses it (#4230)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 52s
CI & Build / Python tests (push) Failing after 1m2s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 52s
CI & Build / Python tests (push) Failing after 1m2s
CI & Build / Build & push image (push) Skipped
`usage_for_notes` is named for notes and works on every note row, yet the chip reached snippets and rules only. Notes had it nowhere. Lessons had it collected and shown nowhere a person could reach, because #4196 taught `/api/lessons` to attach it and `KnowledgeView` — the only lesson list in the UI — browses through `/api/knowledge`, so `listLessons` still has no consumer. The cause was not a missing line. SEVEN call sites carried their own copy of the same few lines: two REST lists, two REST details, two MCP lists, one MCP detail. Each read perfectly well alone, so "which doors attach usage?" had no answer anywhere in the code — the same asymmetry test_system_tagging_door_parity.py records for System tagging (#4249), where whichever door nobody exercised for a kind is the one that never grew the feature. `attach_usage(rows, key="id")` is now that answer, and all seven go through it. A detail payload is a one-row list, so the single-record doors share the seam rather than keeping a second shape beside it. Deliberately NO try/except: the fail-open already lives in `usage_for_notes`, which reports through `_report_failure("readout")` and returns the zero-filled map. Wrapping it again would swallow the REPORT as well as the error, and a silently-swallowed readout failure is exactly #2663 — every counter reading zero in production for weeks while the writes landed fine. `/api/knowledge` now attaches usage, which closes both holes at once: it is how notes, lessons and processes are all browsed. `KnowledgeView` renders the badge on the card footer, looking the advice up per row because the feed is mixed. The advice moves to utils/deadWeight.ts. Canon #3460 says each caller owns its own const, and that held while each caller showed ONE kind; a mixed feed would need five of its own and the next surface another five. The canon's actual invariant — advice is kind-specific and never baked into the badge — is kept: it is still a prop. The three existing callers now read the same table, so the sentence has one home rather than four. Recorded against #3460 so the next reader is not left re-litigating it. `_row_id` rejects bools explicitly: `int(True)` is 1, so a row carrying a flag under the key would be credited with note #1's counts, and a wrong chip is worse than no chip because it reads as a measurement. A row with no usable id is skipped rather than failing the page. Tests pin the PROPERTY, not one route: no door calls the aggregate directly (AST, so a comment naming it is not a false positive), and every door that shows usage reaches the seam. Plus the N+1 guard — one aggregate per page, asserted on await_count, because the per-row version reads more naturally and is invisible in review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -17,7 +17,7 @@ from scribe.services import lessons as lessons_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services.note_usage import empty_usage, record_pulled, usage_for_notes
|
||||
from scribe.services.note_usage import attach_usage, record_pulled
|
||||
|
||||
|
||||
# The payload shape lives in the service (`lesson_to_dict`), shared with the
|
||||
@@ -65,7 +65,7 @@ async def list_lessons(
|
||||
# per lesson (#4196). An agent listing lessons can see which of its own
|
||||
# triggers are firing and which are not, which is the reading that leads to
|
||||
# `update_lesson` rather than to a second lesson about the same failure.
|
||||
usage = await usage_for_notes([int(it["id"]) for it in labelled])
|
||||
await attach_usage(labelled)
|
||||
rows = [
|
||||
{
|
||||
"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
|
||||
@@ -73,7 +73,7 @@ async def list_lessons(
|
||||
# Projected by `_note_to_item` straight off the `data` mirror —
|
||||
# absent when the row carries none, rather than an empty string.
|
||||
"when_to_apply": it.get("when_to_apply", ""),
|
||||
"usage": usage.get(int(it["id"]), empty_usage()),
|
||||
"usage": it["usage"],
|
||||
**({"shared": True, "owner": it.get("owner")} if it.get("shared") else {}),
|
||||
}
|
||||
for it in labelled
|
||||
@@ -212,9 +212,7 @@ async def get_lesson(lesson_id: int, project_id: int = 0) -> dict:
|
||||
# Read BEFORE the pull is recorded, so the number an agent is shown is the
|
||||
# one that was true when it asked — otherwise every first read of a lesson
|
||||
# reports a pull that is its own.
|
||||
out["usage"] = (await usage_for_notes([int(note.id)])).get(
|
||||
int(note.id), empty_usage()
|
||||
)
|
||||
await attach_usage([out])
|
||||
record_pulled(
|
||||
user_id=uid, note_id=int(note.id),
|
||||
source="mcp_get_lesson", project_id=project_id,
|
||||
|
||||
@@ -15,7 +15,7 @@ from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import snippets as snippets_svc
|
||||
from scribe.services.note_usage import empty_usage, record_pulled, usage_for_notes
|
||||
from scribe.services.note_usage import attach_usage, record_pulled
|
||||
from scribe.services import systems as systems_svc
|
||||
|
||||
|
||||
@@ -90,9 +90,7 @@ async def list_snippets(
|
||||
repo=repo, path=path, symbol=symbol, verification=verification,
|
||||
)
|
||||
labeled = await access_svc.label_shared_items(uid, items)
|
||||
usage = await usage_for_notes([int(it["id"]) for it in labeled])
|
||||
for it in labeled:
|
||||
it["usage"] = usage.get(int(it["id"]), empty_usage())
|
||||
await attach_usage(labeled)
|
||||
return {"snippets": labeled, "total": total}
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.routes.utils import parse_pagination
|
||||
from scribe.services.access import label_shared_items
|
||||
from scribe.services.knowledge import FACET_TYPES
|
||||
from scribe.services.note_usage import attach_usage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -62,10 +63,23 @@ async def list_knowledge():
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
items = await label_shared_items(uid, items)
|
||||
# The surfaced-vs-opened counts, on the list a person actually browses
|
||||
# (#4230). `usage_for_notes` always worked on every note row, but only the
|
||||
# snippet and rule lists ever attached it — and this is the ONLY lesson and
|
||||
# note list in the UI, so those two kinds had the counter collected and
|
||||
# shown nowhere. Attaching here rather than teaching `/api/lessons` a
|
||||
# second time is what closes both holes at once: `/knowledge` is how notes,
|
||||
# lessons and processes are all browsed.
|
||||
#
|
||||
# Mixed kinds is not a problem for this: usage keys on the note row, which
|
||||
# every facet of this feed is.
|
||||
await attach_usage(items)
|
||||
|
||||
return jsonify({
|
||||
# Mark rows another user owns: this feed can be mixed-ownership, and an
|
||||
# unmarked card reads as one the viewer wrote.
|
||||
"items": await label_shared_items(uid, items),
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": limit,
|
||||
|
||||
@@ -36,7 +36,7 @@ from scribe.services.access import (
|
||||
describe_provenance,
|
||||
label_shared_items,
|
||||
)
|
||||
from scribe.services.note_usage import empty_usage, record_pulled, usage_for_notes
|
||||
from scribe.services.note_usage import attach_usage, record_pulled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -83,9 +83,7 @@ async def list_lessons_route():
|
||||
# surfaced AND repeatedly opened, and the far commoner reading of the same
|
||||
# row is that the trigger fires on the wrong situation, which `update_lesson`
|
||||
# exists to fix.
|
||||
usage = await usage_for_notes([int(it["id"]) for it in items])
|
||||
for it in items:
|
||||
it["usage"] = usage.get(int(it["id"]), empty_usage())
|
||||
await attach_usage(items)
|
||||
return jsonify({"lessons": items, "total": total})
|
||||
|
||||
|
||||
@@ -194,9 +192,7 @@ async def get_lesson_route(lesson_id: int):
|
||||
uid, out["learned_from"]
|
||||
)
|
||||
out.update(await describe_provenance(uid, note))
|
||||
out["usage"] = (await usage_for_notes([lesson_id])).get(
|
||||
lesson_id, empty_usage()
|
||||
)
|
||||
await attach_usage([out])
|
||||
# Opening the detail view IS a pull — the operator chose to look. Tagged
|
||||
# apart from the MCP sources so "an agent was handed it" and "a human read
|
||||
# it" stay distinguishable; they mean different things for pruning (#2085).
|
||||
|
||||
@@ -19,7 +19,7 @@ from scribe.routes.utils import not_found, parse_pagination
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import snippets as snippets_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services.note_usage import empty_usage, record_pulled, usage_for_notes
|
||||
from scribe.services.note_usage import attach_usage, record_pulled
|
||||
from scribe.services.access import (
|
||||
can_write_note,
|
||||
describe_provenance,
|
||||
@@ -75,9 +75,7 @@ async def list_snippets_route():
|
||||
# One aggregate for the whole page — a per-row lookup here would be N+1 by
|
||||
# construction. Every row gets the key, zero-filled, so the UI renders
|
||||
# "never pulled" rather than having to treat a missing field as a state.
|
||||
usage = await usage_for_notes([int(it["id"]) for it in items])
|
||||
for it in items:
|
||||
it["usage"] = usage.get(int(it["id"]), empty_usage())
|
||||
await attach_usage(items)
|
||||
return jsonify({"snippets": items, "total": total})
|
||||
|
||||
|
||||
@@ -168,9 +166,7 @@ async def get_snippet_route(snippet_id: int):
|
||||
for s in await systems_svc.list_record_systems(note.user_id, snippet_id)
|
||||
]
|
||||
data.update(await describe_provenance(uid, note))
|
||||
data["usage"] = (await usage_for_notes([snippet_id])).get(
|
||||
snippet_id, empty_usage()
|
||||
)
|
||||
await attach_usage([data])
|
||||
# Opening the detail view IS a pull — the operator chose to look. Tagged
|
||||
# apart from the MCP sources so "the agent reused it" and "a human read it"
|
||||
# stay distinguishable; they mean different things for pruning (#2085).
|
||||
|
||||
@@ -30,6 +30,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
|
||||
@@ -271,3 +272,64 @@ async def usage_for_notes(note_ids: list[int]) -> dict[int, dict]:
|
||||
if latest and (slot["last_pulled_at"] or "") < latest:
|
||||
slot["last_pulled_at"] = latest
|
||||
return out
|
||||
|
||||
|
||||
def _row_id(row: dict, key: str) -> int | None:
|
||||
"""The note id on a payload row, or None when there is not one to read.
|
||||
|
||||
Skipping is deliberate: an id this cannot parse is not a reason to fail a
|
||||
whole list, and GUESSING one would credit another record's counts to this
|
||||
row — a wrong chip is worse than no chip, because it reads as a
|
||||
measurement. `bool` is excluded explicitly because `int(True)` is 1, which
|
||||
would quietly attach note #1's usage to a row carrying a flag.
|
||||
"""
|
||||
raw = row.get(key)
|
||||
if raw is None or isinstance(raw, bool):
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def attach_usage(rows: Sequence[dict], *, key: str = "id") -> None:
|
||||
"""Add `usage` to every row of a payload a door is about to return (#4230).
|
||||
|
||||
The one seam both doors and every record kind share. Before this, four call
|
||||
sites carried their own copy of the same lines — two list routes and two
|
||||
detail routes — and `/api/knowledge`, which is the list a person ACTUALLY
|
||||
browses notes and lessons in, was about to become a fifth. That is how the
|
||||
chip came to reach two record kinds out of four while a service named
|
||||
`usage_for_notes` worked on all of them: each door read fine on its own,
|
||||
and nobody was comparing them.
|
||||
|
||||
ONE AGGREGATE FOR THE WHOLE PAGE. `usage_for_notes` is a single GROUP BY
|
||||
over the id set; calling it per row would be N+1 by construction, which is
|
||||
the one shape a list route must not have.
|
||||
|
||||
EVERY ROW GETS THE KEY, zero-filled, so a record predating the table reads
|
||||
as "never surfaced, never pulled" rather than making the UI treat a missing
|
||||
field as a state. `UsageBadge` then renders nothing at all below one
|
||||
surfacing, because "0/0" would look like a verdict where there is only an
|
||||
absence of evidence.
|
||||
|
||||
NO try/except HERE, deliberately — it is not an oversight. The fail-open
|
||||
already lives one layer down: `usage_for_notes` catches its own failure,
|
||||
reports it through `_report_failure("readout")` and returns the zero-filled
|
||||
map, so a broken readout degrades without breaking the list it decorates.
|
||||
Wrapping it again would swallow the REPORT along with the error, and a
|
||||
silently-swallowed readout failure is exactly #2663 — every counter reading
|
||||
zero in production for weeks while the writes landed fine.
|
||||
|
||||
Mutates in place and returns None, matching how the call sites already used
|
||||
it: these rows are the payload, not a copy of it.
|
||||
|
||||
A detail payload is just a one-row list — `await attach_usage([data])` —
|
||||
so the single-record doors share this seam rather than keeping a second
|
||||
shape that could drift from it.
|
||||
"""
|
||||
pairs = [(row, _row_id(row, key)) for row in rows]
|
||||
usage = await usage_for_notes([nid for _, nid in pairs if nid is not None])
|
||||
for row, nid in pairs:
|
||||
if nid is not None:
|
||||
row["usage"] = usage.get(nid, empty_usage())
|
||||
|
||||
Reference in New Issue
Block a user