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:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user