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>
86 lines
4.1 KiB
Python
86 lines
4.1 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)
|
|
|
|
__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": iso(self.created_at),
|
|
"user_id": self.user_id,
|
|
"note_id": self.note_id,
|
|
"event": self.event,
|
|
"source": self.source,
|
|
}
|