Commit Graph
4 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 a4aae974a2 feat(telemetry): a usage event records which project the reader was in (#4196, #3735)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m5s
CI & Build / Python tests (push) Failing after 1m15s
CI & Build / Build & push image (push) Skipped
`RetrievalLog` has carried `project_id` since it existed, so "this record
was SURFACED on project B" was always answerable. `note_usage_events`
had none, so "this record was OPENED on project B" was not — and the two
cannot be joined to recover it, because there is deliberately no session
identity server-side. NoteUsageEvent's own docstring rules that out.

That gap sat exactly on the question milestone 385 exists to answer. A
lesson's whole claim is that it reaches a session on a project it was not
written on, and step 8's acceptance is "retrieved on a different project
AND opened". Each half was answerable; the conjunction was not.

WHICH project, because the name is ambiguous and the wrong reading makes
the column useless: it is the project the READER was in, never the one
the record belongs to. The record's own project is already on the note;
copying it here would answer a question nobody asked while looking like
it answered this one.

The surfacing half is free — every arm already holds the scope it just
searched, so auto_inject, lesson_slot, the write-path arms and
enter_project now record it. process_skill_sync does not and should not:
it installs every Process the operator can reach, which is not a
project-scoped question, so a project there would be a fiction.

The pull half needs the caller, since a getter knows only what it was
handed. The five single-record getters take `project_id: int = 0` and
pass it through, following the convention `search` and `create_*`
already set. Null stays an ordinary answer meaning "not reported" — a
pull with no project is still a pull and still counts toward dead
weight; it simply cannot speak to transfer. The four REST detail views
report none for now: a human opening a record in a browser is a
different event from an agent recalling one, and #2245 left that
asymmetry deliberately undecided.

Guarded the way #2245 and #2476 taught: by source inspection, because a
parameter that was never threaded through changes no return value and
shows up only as a column that is mysteriously always null. Three
guards — the signature, the pass-through, and the arms — plus the
can-fail test rule 167 asks for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-19 23:26:55 -04:00
bvandeusenandClaude Fable 5 b0eda32575 refactor(models): one iso() for every to_dict timestamp; mixins replace hand-rolled created_at/updated_at (#2827, milestone 296 area 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 26s
Reading all 28 models against each other: 54 `x.isoformat() if x else None` /
`x.isoformat()` expressions in 23 to_dict methods, in two guarded/unguarded
wordings, become iso() from models/base.py — uniform, and a row read before
flush serialises as null instead of raising. Rulebook / RulebookTopic / Rule
carried byte-identical copies of TimestampMixin's two columns;
InvitationToken / PasswordResetToken / NoteUsageEvent carried CreatedAtMixin's —
all six now use the mixin. AppLog and RetrievalLog keep their explicit
created_at, commented: their composite index orders on `created_at.desc()`,
which needs the column object in the class body. Schema-neutral (same column
definitions) — no migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:17:42 -04:00
bvandeusen c18139622c fix(telemetry): the human half of the pull ledger recorded one kind in three
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 20s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 27s
Opening a snippet in the UI recorded rest_snippet. Opening a note or a task
recorded nothing — so the most direct evidence the product has that anyone
cares about a record existed for one kind out of three, and the other two sat
at zero pulls looking like dead weight beside a kind that merely had a counter.

Not an open question about intent: models/note_usage.py already documented
'rest_note' as a source value. Nothing wrote it. The design named it and the
implementation stopped at snippets.

Adds rest_note and rest_task. Tagged by SURFACE rather than by the record's
kind, matching rest_snippet — the kind is a join away, but which surface asked
is not recoverable after the fact. The mcp_/rest_ split stays load-bearing:
"is this dead weight" is served by any pull, "was that injected line useful" by
agent pulls alone, and a human clicking a link would inflate exactly the number
#1038 and #2085 gate on.

The vocabulary comment in the model was itself the stale-enumeration shape this
survey keeps finding — it named a source nothing wrote while omitting sources
that existed. Replaced with the naming CONVENTION plus a pointer to grep, which
cannot drift, rather than a longer list that would go stale the same way.

Guard extended to the REST surface, same derivation as the MCP half: a route
registered at exactly /<int:x> for GET is a detail view, and one reaching a
note-backed loader must record. Handler source is expanded one level through
module-private helpers, without which get_snippet_route — the route that
already got this right — would drop out of the check by loading via
_load_snippet. Verified the guard fires when a call is removed.

Renamed test_mcp_pull_telemetry.py -> test_pull_telemetry.py; it is no longer
only about MCP.

Closes #2476
2026-08-06 08:51:21 -04:00
bvandeusenandClaude Opus 5 2b85443dd1 feat(snippets): usage signal — was a surfaced record ever actually pulled?
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 56s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 44s
retrieval_logs answers "what did the ranker return, at what scores" — the
right substrate for tuning a threshold. It cannot answer the question the
snippet corpus actually needs: did anyone open this? A snippet nobody
opens is not neutral. It takes a slot in every future auto-inject menu and
crowds out something useful.

Adds note_usage_events (migration 0071): one row per note per event,
either 'surfaced' (we put its title in front of an agent) or 'pulled'
(someone opened it in full), tagged with which surface produced it.

Closes the gap #2082 recorded against this work. The write-path PLACE arm
carries no score, so it has no home in retrieval_logs — folding it in
would corrupt the score distribution that table exists to capture. The
result was that the arm firing on the STRONGEST claim ("there is already a
canonical helper in this exact file") was the one arm nobody could
measure. Both arms now emit usage events under distinct sources, so their
pull-through rates are finally comparable.

Deliberate departures from the task as written:

- Not in-session correlation. The original framing was "correlate
  result_ids against a later get_note in the same session." There is no
  session identity server-side — the MCP endpoint is stateless and the
  hooks send no session id — and adding one would mean threading an
  opaque client-supplied token through every read path. Two independent
  counters answer the question without it: surfaced 40×, pulled 0 is dead
  weight regardless of how those events distribute across sessions.

- Pulls record at the ENTRY POINTS (MCP tools, REST detail route), not in
  snippets_svc.get_snippet, which update and merge also reach. Counting
  those would inflate precisely the number meant to say "someone chose to
  look at this."

- get_note records for every note kind, not just snippets. The auto-inject
  menu surfaces tasks and processes too; scoping this to snippets would
  pin those at zero pulls forever and make them read as dead weight next
  to snippets that merely had a counter.

Surfaced in the Snippets list as an "N/M used" badge, warning-toned once a
record has been offered 3+ times and never opened, with the tooltip saying
what to do about it (usually: its "when to reach for it" doesn't say
when). No badge at all below one surfacing — "0/0" reads as a verdict when
it's an absence of evidence. Also returned from MCP list_snippets so the
agent can see dead weight without opening the UI.

Telemetry keeps the retrieval_telemetry contract throughout: writes are
fire-and-forget, reads degrade to zeroes, and no path can raise into the
surface it observes.

Refs #2085

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:09:17 -04:00