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
+68
View File
@@ -0,0 +1,68 @@
/**
* What to suggest when a record looks like dead weight — one sentence per kind.
*
* `UsageBadge` deliberately takes the advice as a prop rather than deriving it,
* because the COUNTS read identically for every kind and the REMEDY does not: a
* snippet nobody opens should probably go, while a rule or a lesson in the same
* position more often has a trigger that fires on the wrong work. Telling an
* operator to delete one of those would be the wrong nudge about half the time.
*
* The table lives here because the copy was about to exist in five places.
* Three already had their own: `SnippetListView`, `RuleListPane`, and
* `LessonDetailView` with the sentence inline in its template. The unified
* Knowledge browse renders notes, tasks, processes, snippets and lessons in one
* mixed feed (#4230), so it needs all of them at once — and a per-view constant
* is how three surfaces end up giving three different answers to the same
* question.
*
* Every sentence names the COST of leaving it, not just the fact. "Never
* opened" is an observation; "takes a slot in every future menu" is why the
* reader should care, and it is the half that makes the chip actionable.
*/
/** The record kinds the Knowledge feed can show, plus the ones only their own
* views show. Keyed by `note_type`, with `rule` alongside — rules live in a
* separate table but answer the same question (see `RecordUsage`). */
export type DeadWeightKind =
| "note"
| "task"
| "process"
| "snippet"
| "lesson"
| "rule";
export const DEAD_WEIGHT_ADVICE: Record<DeadWeightKind, string> = {
snippet:
"Offered repeatedly without ever being opened — consider rewriting its " +
"“when to reach for it” so it says when, or deleting it. It takes a slot " +
"in every future auto-inject menu.",
rule:
"Kept arriving without being read. Either its trigger fires on the wrong " +
"work — reword “when to apply” so it says when — or it is not wanted here. " +
"Until one or the other, it takes a slot in every write it matches.",
lesson:
"Repeatedly offered and never opened usually means the trigger fires on " +
"the wrong situation — re-key `when_to_apply` rather than deleting the " +
"claim.",
note:
"Surfaced again and again and never opened. Usually the title is the " +
"problem: recall matches on it first, so a note titled for its author " +
"rather than for the situation keeps winning slots it cannot pay for.",
process:
"Offered without ever being run. Either the steps no longer match how " +
"the work is actually done, or it is being matched on the wrong trigger " +
"— check which before retiring it.",
task:
"Surfaced repeatedly and never opened. On a task this more often says " +
"the work has gone stale than that the record is wrong — decide whether " +
"it is still wanted before re-titling it.",
};
/** The advice for a kind, falling back to the note wording.
*
* The fallback is deliberate rather than an empty string: a kind added to the
* feed later should still get a usable sentence, and "the title is doing the
* matching" is the reading that holds for any record recall can choose. */
export function deadWeightAdvice(kind: string | null | undefined): string {
return DEAD_WEIGHT_ADVICE[kind as DeadWeightKind] ?? DEAD_WEIGHT_ADVICE.note;
}