feat(webapp): a bounded summary and a complete event log (#568) #587

Merged
claude-bot merged 1 commit from feat/568-summary-and-event-log into main 2026-09-10 06:18:55 +00:00
Contributor

Closes #568, to the design decided on that issue: both a short summary and a complete log, rather than choosing between them. Stacked behind #585; will be rebased before merge.

Why. The compose prompt asks for 3 to 6 paragraphs and for every event to be represented. Those agree only while a session yields a few dozen events. Gemma 4 26B verified 59 on a real session; compose dropped 12 and the retry recovered 2. Hosted models produced 84 to 93 and 10,000-character recaps.

Selection. select_beats_for_compose puts reveals, decisions, combat and travel ahead of social and other — two tiers, not six, because ranking within the first tier would prefer every reveal to every combat and misreport a session that was mostly a fight — then takes 30 and re-sorts chronologically so #566's promise holds. At or below the cap it returns the input list unchanged, so a normal session's summary is byte-identical to today; two tests pin that, including the inclusive boundary.

The threading matters more than the function. recover_dropped_events now gets the selected list, or the retry would re-request the events the cap deliberately omitted and undo it. The unsupported-sentence critic keeps the full list, because a name from a non-selected beat is a real person and the narrower set would cry wolf. Both are commented at the call sites.

The log is computed on read from the persisted beats, not stored: a frozen copy could only drift, and would drift exactly where it matters, when a transcript edit or an erasure changes what the beats mean. Doing it through render_summary_anchors closed a real duplication — to_response had its own inline anchor derivation, which is how a GM ends up sent to one timestamp by an anchor and another by the row beside it.

Visibility. sessions.summary is member-readable today (unlike transcript and beat_notes, which are nulled for non-GMs, and unlike the run detail, which is GM-only because it surfaces unverified beats). The log follows the summary exactly: member-readable, not gated on approval, never an unverified beat, 403 for a non-member. A session with no run returns an empty list rather than a 404, so "no log" is distinguishable from a failure.

Tests: backend 3,099 passed; frontend 851 passed with eslint and build clean; eval scores unchanged before and after, which is expected because every fixture holds 5 to 8 beats and the selection is therefore the identity function on all of them — the harness is wired through it anyway so a future larger fixture is scored on what the product actually delivers.

🤖 Generated with Claude Code

Closes #568, to the design decided on that issue: both a short summary and a complete log, rather than choosing between them. Stacked behind #585; will be rebased before merge. **Why.** The compose prompt asks for 3 to 6 paragraphs *and* for every event to be represented. Those agree only while a session yields a few dozen events. Gemma 4 26B verified 59 on a real session; compose dropped 12 and the retry recovered 2. Hosted models produced 84 to 93 and 10,000-character recaps. **Selection.** `select_beats_for_compose` puts reveals, decisions, combat and travel ahead of social and other — two tiers, not six, because ranking within the first tier would prefer every reveal to every combat and misreport a session that was mostly a fight — then takes 30 and re-sorts chronologically so #566's promise holds. At or below the cap it returns the input list unchanged, so a normal session's summary is byte-identical to today; two tests pin that, including the inclusive boundary. **The threading matters more than the function.** `recover_dropped_events` now gets the *selected* list, or the retry would re-request the events the cap deliberately omitted and undo it. The unsupported-sentence critic keeps the *full* list, because a name from a non-selected beat is a real person and the narrower set would cry wolf. Both are commented at the call sites. **The log is computed on read** from the persisted beats, not stored: a frozen copy could only drift, and would drift exactly where it matters, when a transcript edit or an erasure changes what the beats mean. Doing it through `render_summary_anchors` closed a real duplication — `to_response` had its own inline anchor derivation, which is how a GM ends up sent to one timestamp by an anchor and another by the row beside it. **Visibility.** `sessions.summary` is member-readable today (unlike `transcript` and `beat_notes`, which are nulled for non-GMs, and unlike the run detail, which is GM-only because it surfaces unverified beats). The log follows the summary exactly: member-readable, not gated on approval, never an unverified beat, 403 for a non-member. A session with no run returns an empty list rather than a 404, so "no log" is distinguishable from a failure. Tests: backend 3,099 passed; frontend 851 passed with eslint and build clean; eval scores unchanged before and after, which is expected because every fixture holds 5 to 8 beats and the selection is therefore the identity function on all of them — the harness is wired through it anyway so a future larger fixture is scored on what the product actually delivers. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(webapp): a bounded summary and a complete event log (#568)
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 56s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m59s
CI / Bot tests and audit (pull_request) Successful in 1m58s
CI / Backend lint (ruff) (pull_request) Successful in 27s
CI / Bot/backend version sync (pull_request) Successful in 21s
CI / Docker image build (pull_request) Successful in 3m15s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m17s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been cancelled
7dd25d043c
`COMPOSE_SYSTEM_PROMPT` carries two instructions that only agree while a
session yields a few dozen events: write 3-6 paragraphs, and (since #423)
represent *every* event in the list. On a replayed real session the local
Gemma 4 26B verified 59 events and compose left 12 out — the one recovery
retry got 2 back — while hosted models verified 84 to 93 and wrote
10,000-character "summaries" nobody would skim. Those are not two bugs; they
are one contradiction, resolved by the model, silently, in whichever
direction it happened to lean.

So both, not either.

**The summary is written from a selection.** `select_beats_for_compose`
ranks verified beats by kind — reveal, decision, combat and travel ahead of
social and other, two tiers rather than six so a session that was mostly a
fight is not misreported as one — then by time, cuts to `_COMPOSE_MAX_BEATS`
(30, which is what six paragraphs can actually hold), and re-sorts the
survivors chronologically, because the prompt tells the model the list is
already in time order and #566 is the record of what happens when it is not.
Below the cap it is the identity function: it returns the list it was given,
so a typical session's summary is byte-for-byte the one it was, and a test
pins that rather than trusting it. The prompt itself is unchanged — every
sentence in it is still true of the list the model is handed, which is now
the selected one, and it is deliberately never told a log exists, because a
model told its omissions are recoverable has been given permission to omit.

The selected list is also what `recover_dropped_events` is given. Asking
again for an event the prompt deliberately never mentioned would re-request
the whole session on the retry and undo the cap. The critic keeps the *full*
list, on the other hand: a name from a beat the selection left out is a real
person from this session, not an invention.

**Nothing is lost, because the rest are rendered in code.**
`render_summary_anchors` has answered "which beats, in what order, anchored
where" since #334 and nothing has ever called it. `render_event_log` is that
answer with the kind and the actors attached, and the anchors are now a
projection of it — `to_response` had grown its own inline copy of the same
derivation, which is how a GM ends up sent to one timestamp by an anchor and
a different one by the log row beside it.

`GET /api/sessions/{id}/event-log` computes the log on read from the stored
`session_beats` rows rather than storing a rendered copy at write time. The
beats are already persisted per run with their ranges, actors, kinds,
citations and verdicts; a second stored copy could only drift from them, and
would drift exactly where it matters, since a GM edit or an erasure changes
what the beats mean without touching a frozen blob. It costs one
already-eager-loaded relationship and a sort.

**Verified events only, and exactly the summary's audience.**
`build_session_response` returns `summary` to every campaign member ungated
— unlike `transcript` and `beat_notes`, which it nulls for non-GMs, and
unlike `content_approved_at`, which gates the Discord repost rather than the
API read — so the endpoint takes `get_session_for_member` and mirrors that.
It is deliberately not GM-only like `summarisation-runs`: that endpoint is
gated because a rejected beat's `problems` quote transcript content,
including a line a member may since have had erased, and this one carries no
rejected beats and no run internals. A rule that showed a player the prose
about an event but withheld the line saying when it happened would be an
accident, not a policy.

On the session page the log is a collapsed disclosure directly under the
summary — a real `<button>` with `aria-expanded`, absent entirely when there
are no verified events, and not fetched at all when there is no summary for
it to belong to.

The #349 harness calls the selection rather than mirroring it, since a
harness scoring the uncapped list would report a coverage the product does
not deliver. Every fixture is well under the cap, so the stub scores are
unchanged: coverage 0.950, attribution 0.950, chronology 1.000, beat
validation 0.950 before and after, and 1.000 across the board on the
synthetic-session fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rbrooks force-pushed feat/568-summary-and-event-log from 7dd25d043c
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 56s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m59s
CI / Bot tests and audit (pull_request) Successful in 1m58s
CI / Backend lint (ruff) (pull_request) Successful in 27s
CI / Bot/backend version sync (pull_request) Successful in 21s
CI / Docker image build (pull_request) Successful in 3m15s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m17s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been cancelled
to 935a19745c
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 26s
CI / Backend lint (ruff) (pull_request) Successful in 39s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m25s
CI / Bot tests and audit (pull_request) Successful in 2m31s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m16s
CI / Docker image build (pull_request) Successful in 3m14s
CI / Backend migration, tests, and audit (pull_request) Successful in 13m23s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 20m44s
2026-09-10 05:49:55 +00:00
Compare
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-09-10 05:49:59 +00:00
claude-bot deleted branch feat/568-summary-and-event-log 2026-09-10 06:18:57 +00:00
Sign in to join this conversation.
No description provided.