v1.17.1 — Auto-refresh fight_model on report ingest
v1.17.0 unified cloned encounter IDs but the fight_model only rebuilt when an operator explicitly POSTed `/api/encounters/{id}/fight-model/persist` (then `classify` + `annotate-ca…
Why
v1.17.0 unified cloned encounter IDs but the fight_model only rebuilt when an operator explicitly POSTed /api/encounters/{id}/fight-model/persist (then classify + annotate-cactbot). The user wants this auto-triggered on each new report ingest so every fresh pull contributes to the canonical model immediately — particularly important during new-ultimate prog where every kill is rare and load-bearing.
Added — analysis/fight_model_refresh.py
refresh_fight_model_for_encounter(session, encounter_id, *, throttle_seconds=60, force=False)— runs the 3-step pipelinewrite_consensus_to_fight_model→classify_canonical_abilities→annotate_fight_model_for_encounterfor one canonical encounter (v1.17.0 helpers canonicalize input). Returns a structured summary{encounter_id, skipped, persist?, classify?, annotate?, error?}.- Throttle: queries
MAX(FightModel.updated_at)for the canonical encounter; skips if last refresh <throttle_secondsago. State lives in the table itself, so the throttle is durable across server restarts. skippedoutcomes:"throttle"(recent refresh),"no_data"(consensus produced 0 rows — need ≥3 kills with events),"error_persist"/"error_classify"/"error_annotate"(each step's failure is captured + rolled back without crashing the caller), orNoneon full success.refresh_for_report(session, code, ...)— convenience wrapper that finds every canonical encounter the report has fights for and refreshes each. Used by the poll path; returns one entry per encounter touched.encounter_ids_for_report— exported as a helper so callers can pre-filter.
Changed — jobs/poll_watched._poll_one_row auto-refreshes on success
After ingest_events_for_report commits, the poll path now calls refresh_for_report for any encounter this report contributes to. Only fires when meta.new_fights > 0 OR ev.events_inserted > 0 (no-op polls skip the refresh entirely). The new optional kwarg auto_refresh_fight_model=True lets tests / ad-hoc scripts bypass.
Failure semantics: refresh errors land in the response as fight_model_refresh_error but don't flip the poll's status away from "ok" — the ingest already committed. This applies to both the live poller (poll_once) and the Poll-now endpoint (POST /api/watched-reports/{code}/poll), since both go through _poll_one_row.
Changed — jobs/backfill_field.backfill_once refreshes per-encounter
After each encounter's reports + events are pulled, the canonical fight_model refreshes with force=True (nightly cadence already debounces; we want every backfill pass to land in the model). Failures land in per_enc["errors"] without aborting the encounter loop. Opt-out via the new auto_refresh_fight_model=False kwarg.
Cost note
classify_canonical_abilities re-scans every kill's damage events on each refresh. On an Ultimate with hundreds of kills this is multi-second. Acceptable at the 60s debounce default (≤1× per encounter per polling cycle); if a future deployment polls every ~30s during prog sessions, incremental classification could be added later. Not in scope for v1.17.1.
Tests
- 8 new in tests/test_fight_model_refresh_v1_17_1.py: end-to-end (persist + classify + annotate), throttle skips recent runs,
force=Truebypasses throttle,no_dataoutcome on encounters with too few kills,refresh_for_reportreturns one per canonical encounter, canonicalizes legacy aliases, empty result for unknown report, poll path skips refresh when no new data was ingested,auto_refresh_fight_model=Falsedisables the wire-in. - 536 tests passing (528 → 536, +8). No regressions.
Operator notes
- No schema migration, no new env vars. Existing operators get auto-refresh for free on next Python restart.
- The 60s throttle is per-canonical-encounter; a multi-encounter prog day (running both M9S + DSR) refreshes both independently.
- Field backfill bypasses the throttle on purpose — the nightly cadence makes the debounce moot, and we want maximum model freshness from each pass.