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
90 lines
4.2 KiB
Python
90 lines
4.2 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import DateTime, Index, Integer, Text
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from scribe.models import Base
|
|
|
|
SURFACED = "surfaced"
|
|
PULLED = "pulled"
|
|
|
|
|
|
class NoteUsageEvent(Base):
|
|
"""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)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
)
|
|
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)
|
|
|
|
__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"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"user_id": self.user_id,
|
|
"note_id": self.note_id,
|
|
"event": self.event,
|
|
"source": self.source,
|
|
}
|