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

`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:
2026-09-21 20:47:16 -04:00
co-authored by Claude Opus 5
parent 91cde6c3e4
commit 62f3a485ad
12 changed files with 385 additions and 46 deletions
+62
View File
@@ -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())