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
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user