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
+2 -11
View File
@@ -1,16 +1,7 @@
<script setup lang="ts">
import type { RuleHeader } from "@/api/rulebooks";
import UsageBadge from "@/components/UsageBadge.vue";
/** The dead-weight nudge for a RULE — two remedies, not one, which is the
* whole reason this advice is per-kind. A snippet nobody opens should
* probably go. A rule nobody opens may be perfectly good and simply firing on
* the wrong thing, so "delete it" would be the wrong nudge half the time and
* the operator has to be the one who picks. */
const RULE_DEAD_WEIGHT =
"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.";
import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight";
defineProps<{ topicId: number; rules: RuleHeader[] }>();
const emit = defineEmits<{
@@ -54,7 +45,7 @@ const emit = defineEmits<{
? 'Asserts a fact nobody has confirmed yet'
: `Check last passed ${r.last_verified}`"
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
<UsageBadge :usage="r.usage" :dead-weight-advice="RULE_DEAD_WEIGHT" />
<UsageBadge :usage="r.usage" :dead-weight-advice="DEAD_WEIGHT_ADVICE.rule" />
</div>
<div class="statement">{{ r.statement }}</div>
<div v-if="r.when_to_apply || r.updated_at" class="meta">
+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;
}
+17
View File
@@ -3,7 +3,10 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { useRouter } from "vue-router";
import { apiGet } from "@/api/client";
import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note";
import type { RecordUsage } from "@/types/usage";
import { deadWeightAdvice } from "@/utils/deadWeight";
import KindBadge from "@/components/KindBadge.vue";
import UsageBadge from "@/components/UsageBadge.vue";
import NoteSweepPane from "@/components/NoteSweepPane.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
@@ -44,6 +47,10 @@ interface KnowledgeItem {
project_id: number | null;
created_at: string;
updated_at: string;
/** Surfaced-vs-opened counts, zero-filled by the route for EVERY row, so
* "never surfaced" is a value here rather than a missing field (#4230).
* `UsageBadge` renders nothing at all below one surfacing. */
usage?: RecordUsage | null;
// Set only when another user owns this record — their suggestion, not one of
// yours. Absent means it's yours.
shared?: boolean;
@@ -659,6 +666,16 @@ onUnmounted(() => {
class="shared-tag"
:title="`Shared by ${item.owner ?? 'another user'} — their record, not yours`"
>by {{ item.owner ?? "another user" }}</span>
<!-- Surfaced-vs-opened (#4230). This is the only list in the UI
that browses notes and lessons, so it is the only place
those two kinds can show the counter at all. The advice is
looked up per row because this feed is mixed the remedy
for a snippet nobody opens is not the remedy for a lesson. -->
<UsageBadge
:usage="item.usage"
:noun="item.note_type"
:dead-weight-advice="deadWeightAdvice(item.note_type)"
/>
<span class="k-card-date">{{ formatDate(item.updated_at) }}</span>
</div>
</div>
+2 -1
View File
@@ -25,6 +25,7 @@ import { deleteLesson, getLesson, type Lesson } from "@/api/lessons";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import TagPill from "@/components/TagPill.vue";
import UsageBadge from "@/components/UsageBadge.vue";
import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
@@ -110,7 +111,7 @@ onMounted(load);
<UsageBadge
:usage="lesson.usage"
noun="lesson"
dead-weight-advice="Repeatedly offered and never opened usually means the trigger fires on the wrong situation re-key `when_to_apply` rather than deleting the claim."
:dead-weight-advice="DEAD_WEIGHT_ADVICE.lesson"
/>
</div>
+2 -9
View File
@@ -10,6 +10,7 @@ import {
} from "@/api/snippets";
import { useToastStore } from "@/stores/toast";
import UsageBadge from "@/components/UsageBadge.vue";
import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight";
const router = useRouter();
const toast = useToastStore();
@@ -237,14 +238,6 @@ function driftTitle(s: SnippetListItem): string {
const what = reasons[v.status] ?? "";
return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`;
}
/** The dead-weight nudge for a SNIPPET, passed to the shared badge. Kept here
* rather than inside the component because the remedy is kind-specific — a
* rule in the same position gets different advice (milestone 333 step 5). */
const SNIPPET_DEAD_WEIGHT =
"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.";
</script>
<template>
@@ -431,7 +424,7 @@ const SNIPPET_DEAD_WEIGHT =
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
{{ driftBadge(s) }}
</span>
<UsageBadge :usage="s.usage" :dead-weight-advice="SNIPPET_DEAD_WEIGHT" />
<UsageBadge :usage="s.usage" :dead-weight-advice="DEAD_WEIGHT_ADVICE.snippet" />
<span v-if="s.shared" class="shared-tag" :title="`Shared by ${s.owner ?? 'another user'} — a suggestion, not your own record`">
by {{ s.owner ?? "another user" }}
</span>