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
103 lines
5.2 KiB
Python
103 lines
5.2 KiB
Python
from sqlalchemy import Index, Integer, Text
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from scribe.models import Base
|
|
from scribe.models.base import CreatedAtMixin, iso
|
|
|
|
SURFACED = "surfaced"
|
|
PULLED = "pulled"
|
|
|
|
|
|
class NoteUsageEvent(Base, CreatedAtMixin):
|
|
"""One row per time a note was SURFACED to the agent, or PULLED in full.
|
|
|
|
Answers the question RetrievalLog cannot: not "what did the ranker return
|
|
and with what scores" but "did anyone ever actually open this?" A snippet
|
|
nobody opens is not neutral — it competes for the injection budget on every
|
|
future turn and dilutes the menu — so the surfaced:pulled ratio is what
|
|
makes dead weight visible and prunable.
|
|
|
|
WHY A SEPARATE TABLE FROM RetrievalLog. RetrievalLog is one row per *call*,
|
|
keyed on the score distribution it exists to capture; folding un-scored
|
|
events into it would corrupt exactly the distribution threshold tuning reads
|
|
(see the KNOWN GAP note this closes in plugin_context.build_write_path_hint).
|
|
This table is one row per *note per event*, which is the grain the usage
|
|
readout needs and the grain RetrievalLog's JSONB `result_ids` can't be
|
|
indexed at. The two are complements: RetrievalLog tunes the threshold, this
|
|
tunes the corpus.
|
|
|
|
WHY 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 hooks send
|
|
no session id — and introducing one would mean threading an opaque,
|
|
client-supplied token through every read path for a signal that does not
|
|
need it. Two independent counters answer the question without it: a note
|
|
surfaced 40 times and never pulled is dead weight regardless of which
|
|
sessions those events fell in.
|
|
|
|
Deliberately FK-free on user_id and note_id (mirrors RetrievalLog/AppLog):
|
|
telemetry outlives the row it describes, and deleting a note should not
|
|
erase the evidence that it was surfaced 40 times and never once opened.
|
|
"""
|
|
|
|
__tablename__ = "note_usage_events"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
note_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
# 'surfaced' | 'pulled'
|
|
event: Mapped[str] = mapped_column(Text, nullable=False)
|
|
# Which surface produced it. Kept granular so the place arm and the
|
|
# semantic arm can be compared — that comparison is the whole reason the
|
|
# place arm needed logging at all.
|
|
#
|
|
# A CONVENTION, not a fixed vocabulary: `mcp_<tool>` for an agent call,
|
|
# `rest_<kind>` for a human opening a detail view, and a bare name for a
|
|
# hook or background surface ('auto_inject', 'write_path_place',
|
|
# 'write_path_semantic'). This comment deliberately no longer lists the
|
|
# members — the previous list had gone stale, naming 'rest_note' that
|
|
# nothing wrote while omitting sources that existed, and a half-true
|
|
# enumeration reads as authoritative in exactly the way that misleads
|
|
# (#2476). `grep -rn record_pulled\\\|record_surfaced src/` is the
|
|
# authoritative list, and unlike a comment it cannot drift.
|
|
#
|
|
# The mcp_/rest_ split is load-bearing. "Is this dead weight?" is served by
|
|
# any pull; "was that injected line useful?" is served by AGENT pulls only,
|
|
# so never aggregate across the prefix without saying why (#1038, #2085).
|
|
source: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
# The project the READER was in when this happened — not the project the
|
|
# record belongs to, which is already on `notes.project_id`. The whole
|
|
# point is the comparison between the two: a record opened somewhere other
|
|
# than where it was written is the evidence that it TRANSFERRED, which is
|
|
# the claim the lesson kind rests on (milestone 385) and the one thing
|
|
# RetrievalLog could half-answer and this table could not answer at all.
|
|
#
|
|
# Nullable, and null is ordinary rather than historical. A surfacing arm
|
|
# always knows the project it searched; a getter knows only what its caller
|
|
# passed, and a caller that passed nothing is reporting "unknown", not
|
|
# "none". Treat a null as unreported: it still counts toward "is this dead
|
|
# weight", and it cannot speak to "was it opened away from home".
|
|
project_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
__table_args__ = (
|
|
# The readout is always "these note ids, split by event" — a covering
|
|
# composite beats separate single-column indexes for it.
|
|
Index("ix_note_usage_note_event", "note_id", "event"),
|
|
Index("ix_note_usage_created_at", "created_at"),
|
|
Index("ix_note_usage_user_id", "user_id"),
|
|
# note_id first, like ix_note_usage_note_event: every readout starts
|
|
# from a set of note ids and narrows, never from a project.
|
|
Index("ix_note_usage_note_project", "note_id", "project_id"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"created_at": iso(self.created_at),
|
|
"user_id": self.user_id,
|
|
"note_id": self.note_id,
|
|
"event": self.event,
|
|
"source": self.source,
|
|
}
|