feat(usage): count what happened away from a record's own project — the readout #3735 needs
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 1m6s
CI & Build / Python tests (push) Successful in 1m50s
CI & Build / Build & push image (push) Successful in 38s

Milestone 385 step 8 (#3735 "a lesson is recalled on a project it was not
written on") is defined by opened-on-another-project. The reader's project has
been recorded on every usage event since 0c8e109, but nothing compared it with
the record's own, so the criterion was still unreadable.

- note_usage.usage_for_notes: a second aggregate in the same session joins
  notes and counts surfaced_away_count / pulled_away_count. Counted only where
  both projects are known and differ; ranked surfacings only (#2477). The
  first aggregate is untouched, so events on deleted notes still count.
- empty_usage carries both keys zero-filled; every door that attaches usage
  (list_lessons, get_lesson, snippets, knowledge) gets them through
  attach_usage.
- UsageBadge tooltip says "On other projects: surfaced N×, opened M×" when it
  happened, and nothing when it did not.
- Tests: the mocked split test feeds both aggregates; a unit test for the
  away counters; a real-Postgres test that home, unreported and ambient
  events are all left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 10:47:03 -04:00
co-authored by Claude Opus 5.5
parent 582a5a4f48
commit 2e4c2d9493
4 changed files with 140 additions and 4 deletions
+42
View File
@@ -35,6 +35,7 @@ from collections.abc import Sequence
from sqlalchemy import case, func, select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
from scribe.models.base import iso
from scribe.services.background import report_telemetry_failure
@@ -193,6 +194,13 @@ def empty_usage() -> dict:
record. `ambient_count` is the rest (see AMBIENT_SOURCES). The split is the
readout half of #2477: the "high surfaced, zero pulls → dead weight"
reading is only valid over surfacings that were choices.
`surfaced_away_count` / `pulled_away_count` are the subsets that happened
on a project other than the one the record was written on — the evidence
that a record TRANSFERRED, which is the claim the lesson kind rests on
(milestone 385, #3735). Counted only where both projects are known: an
event with no reader project, or a record with no project of its own,
cannot speak to "away" and is left out rather than guessed.
"""
return {
"surfaced_count": 0,
@@ -200,6 +208,8 @@ def empty_usage() -> dict:
"pull_count": 0,
"last_surfaced_at": None,
"last_pulled_at": None,
"surfaced_away_count": 0,
"pulled_away_count": 0,
}
@@ -247,6 +257,30 @@ async def usage_for_notes(note_ids: list[int]) -> dict[int, dict]:
)
)
).all()
# Away from home: the reader's project against the record's own.
# A second aggregate rather than a column on the first, because
# it needs the join to `notes` and the first must keep counting
# events whose note has since been deleted (the table is FK-free
# so that evidence outlives the row). Ranked surfacings only, for
# the same reason surfaced_count is (#2477).
away_rows = (
await session.execute(
select(
NoteUsageEvent.note_id,
NoteUsageEvent.event,
func.count().label("n"),
)
.join(Note, Note.id == NoteUsageEvent.note_id)
.where(
NoteUsageEvent.note_id.in_(ids),
NoteUsageEvent.project_id.is_not(None),
Note.project_id.is_not(None),
NoteUsageEvent.project_id != Note.project_id,
NoteUsageEvent.source.not_in(AMBIENT_SOURCES),
)
.group_by(NoteUsageEvent.note_id, NoteUsageEvent.event)
)
).all()
except Exception:
# A telemetry readout must not be able to break the list it decorates —
# but it must say it failed, or a broken readout is indistinguishable
@@ -271,6 +305,14 @@ async def usage_for_notes(note_ids: list[int]) -> dict[int, dict]:
latest = iso(last_at)
if latest and (slot["last_pulled_at"] or "") < latest:
slot["last_pulled_at"] = latest
for note_id, event, n in away_rows:
slot = out.get(int(note_id))
if slot is None:
continue
if event == SURFACED:
slot["surfaced_away_count"] = int(n)
elif event == PULLED:
slot["pulled_away_count"] = int(n)
return out