Skip to content

Commit f394fb0

Browse files
committed
Merge feat/data-version-auto-refresh into release/v0.3.0
Auto-detect + refresh stale per-report and per-(spec,encounter,metric) data when the code shape changes between releases. Version constants live next to the importers; dev bumps them when adding new fields, the analyzer entrypoint enforces freshness before each analysis.
2 parents cc89f41 + 053216e commit f394fb0

3 files changed

Lines changed: 178 additions & 5 deletions

File tree

backend/app/services/ai/analyzer.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,18 @@ async def _fetch_top_log_references(
224224
.limit(limit)
225225
)
226226
rows = (await session.execute(stmt)).scalars().all()
227+
# Drop rows whose detail blob was written by an older code shape —
228+
# they'd feed the AI a payload missing fields (e.g. boss_casts on
229+
# pre-v0.3.0 detail). When all rows are stale this returns ``[]``,
230+
# which makes the caller fall through to ``_ensure_references`` →
231+
# incremental refresh → fresh detail with the current version stamp.
232+
from app.services.top_logs_service import TOP_LOG_DETAIL_VERSION as _DV
233+
234+
rows = [
235+
r
236+
for r in rows
237+
if int((r.detail_payload or {}).get("_v") or 0) >= _DV
238+
]
227239
out: list[dict[str, Any]] = []
228240
for r in rows:
229241
detail = r.detail_payload or {}
@@ -634,6 +646,67 @@ async def request_analysis(
634646
if not report or report.id != fight.report_id:
635647
raise NotFoundError("Report mismatch.")
636648

649+
# ----- Auto-refresh stale data before running the analysis ---------------
650+
# When the code shipped a new field since this report (or its cached
651+
# top-log references) was imported, the analyser would otherwise read
652+
# missing fields and the AI prompt would silently lose features.
653+
# ``REPORT_DATA_VERSION`` / ``TOP_LOG_DETAIL_VERSION`` are bumped by the
654+
# importer code when the shape changes; we compare against the stored
655+
# ``_v`` stamp and re-fetch when behind. Failures are logged but never
656+
# blocking — we proceed with whatever data we have, the AI degrades
657+
# gracefully on missing fields.
658+
from app.services.report_service import (
659+
REPORT_DATA_VERSION as _RDV,
660+
report_data_is_current,
661+
run_report_import,
662+
)
663+
664+
if not await report_data_is_current(session, report.id):
665+
logger.info(
666+
"report %s data older than v%d — auto-refreshing before analysis",
667+
report.id,
668+
_RDV,
669+
)
670+
try:
671+
async with WclClient() as wcl:
672+
await run_report_import(session, report_id=report.id, wcl_client=wcl)
673+
await session.commit()
674+
# Re-load fight + player after the import wiped + re-inserted them.
675+
fight = (
676+
await session.execute(
677+
select(ReportFight).where(
678+
ReportFight.report_id == report.id,
679+
ReportFight.fight_id == fight.fight_id,
680+
)
681+
)
682+
).scalar_one_or_none()
683+
player = (
684+
await session.execute(
685+
select(ReportPlayer)
686+
.where(
687+
ReportPlayer.fight_id == fight.id if fight else False,
688+
ReportPlayer.actor_id == player.actor_id,
689+
)
690+
.options(
691+
selectinload(ReportPlayer.casts),
692+
selectinload(ReportPlayer.gear),
693+
)
694+
)
695+
).scalar_one_or_none()
696+
if not fight or not player:
697+
raise UpstreamError(
698+
"Report data was refreshed but the original fight/player "
699+
"rows could not be re-located — the underlying WCL log "
700+
"may have changed."
701+
)
702+
except UpstreamError:
703+
raise
704+
except Exception: # noqa: BLE001
705+
logger.exception(
706+
"Auto-refresh of report %s failed — continuing with stale data",
707+
report.id,
708+
)
709+
637710
role_focus = "healer" if player.role == "healer" else ("tank" if player.role == "tank" else "dps")
638711

639712
# Lazy-fetch extras (buffs / debuffs / damage_taken) from WCL the first time

backend/app/services/report_service.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
from typing import Any
66

7-
from sqlalchemy import select
7+
from sqlalchemy import delete, select
88
from sqlalchemy.ext.asyncio import AsyncSession
99
from sqlalchemy.orm import selectinload
1010

@@ -38,6 +38,23 @@
3838

3939
logger = logging.getLogger(__name__)
4040

41+
# Bumped whenever the shape of ``ReportFight.extras`` / ``ReportPlayer.extras``
42+
# / ``ReportPlayerGear`` / ``ReportPlayerCast`` changes in a way the AI prompt
43+
# or analyser needs. Stored verbatim into ``extras['_v']`` on every fresh
44+
# import; ``run_report_import`` re-imports a report when any fight's stored
45+
# ``_v`` is below this value. Missing ``_v`` is treated as version 0
46+
# (= ancient), which forces a refresh.
47+
#
48+
# History:
49+
# v1: initial shape (basic per-fight/per-player rollups).
50+
# v2 (v0.2.0): added talent_ids/stats/active_time_ms in player.extras,
51+
# gear rows in report_player_gear, talents_loadout JSONB.
52+
# v3 (v0.3.0): added wcl_fight_start_ms + fight-relative-second
53+
# phase_transitions + boss_casts on fight.extras;
54+
# mana_recovery is lazy-fetched at analysis time so it
55+
# doesn't gate this version.
56+
REPORT_DATA_VERSION = 3
57+
4158

4259
async def fetch_boss_cast_timeline(
4360
client: WclClient,
@@ -114,6 +131,28 @@ async def create_import_skeleton(
114131
return report
115132

116133

134+
async def report_data_is_current(session: AsyncSession, report_id: Any) -> bool:
135+
"""Return ``True`` iff every fight on the report carries the current
136+
``REPORT_DATA_VERSION`` stamp. Empty (no fights) → ``False`` because
137+
a freshly-skeletoned but never-imported report counts as not current.
138+
139+
Missing ``_v`` (older code's writes) is treated as version 0, which is
140+
always below the current constant — so legacy data automatically
141+
triggers a refresh on the next analysis without any per-row migration.
142+
"""
143+
fights = (
144+
await session.execute(
145+
select(ReportFight.extras).where(ReportFight.report_id == report_id)
146+
)
147+
).all()
148+
if not fights:
149+
return False
150+
return all(
151+
int((extras or {}).get("_v") or 0) >= REPORT_DATA_VERSION
152+
for (extras,) in fights
153+
)
154+
155+
117156
async def run_report_import(
118157
session: AsyncSession,
119158
*,
@@ -124,15 +163,34 @@ async def run_report_import(
124163
125164
Drives ``import_status``: ``importing`` → ``ready`` (or ``failed`` on
126165
exception). Idempotent — calling on a ``ready`` row that already has
127-
fights does nothing besides refreshing the status.
166+
fights does nothing besides refreshing the status, UNLESS the stored
167+
data was written by an older code version (different
168+
``REPORT_DATA_VERSION``). In that case the existing fights are wiped
169+
and a full re-import runs so the analyser can rely on the current
170+
fight/player extras shape.
128171
"""
129172
report = (
130173
await session.execute(select(Report).where(Report.id == report_id))
131174
).scalar_one_or_none()
132175
if report is None:
133176
raise NotFoundError("Report not found.")
134177
if report.import_status == "ready" and report.start_time is not None:
135-
return # already populated; nothing to do
178+
if await report_data_is_current(session, report_id):
179+
return
180+
logger.info(
181+
"report %s data is older than current version %d — re-importing",
182+
report_id,
183+
REPORT_DATA_VERSION,
184+
)
185+
# Cascade through fights → players → casts/gear/analyses. We keep
186+
# the Report row itself (analyses referencing it survive the
187+
# version bump cleanly — their historical structured output is
188+
# unchanged, only the *next* analysis sees fresh data).
189+
await session.execute(
190+
delete(ReportFight).where(ReportFight.report_id == report_id)
191+
)
192+
report.import_status = "importing"
193+
await session.flush()
136194

137195
code = report.wcl_code
138196
owner_user_id = report.owner_user_id
@@ -178,8 +236,12 @@ async def run_report_import(
178236
fight_start_ms=fight_start_ms,
179237
)
180238
# Mutate in place — JSONB will pick this up on the next flush.
239+
# ``_v`` is the version stamp the analyser checks to decide
240+
# whether this row's shape matches what the current code
241+
# expects to read.
181242
merged_extras = dict(fight_row.extras or {})
182243
merged_extras["boss_casts"] = boss_casts
244+
merged_extras["_v"] = REPORT_DATA_VERSION
183245
fight_row.extras = merged_extras
184246
await session.flush()
185247

@@ -360,6 +422,14 @@ async def _populate_players(
360422
"rank": None,
361423
"out_of": None,
362424
},
425+
# Version stamp matched against ``REPORT_DATA_VERSION`` —
426+
# if a future code update bumps the constant, the
427+
# analyser sees this player as stale and triggers a
428+
# full re-import for the parent report. Lazy-fetched
429+
# fields (buffs/debuffs/casts/mana_recovery) are NOT
430+
# gated on this number; only the shape produced by the
431+
# import flow is.
432+
"_v": REPORT_DATA_VERSION,
363433
},
364434
)
365435
session.add(db_player)

backend/app/services/top_logs_service.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,22 @@
4646

4747
logger = logging.getLogger(__name__)
4848

49+
# Bumped whenever ``TopLog.detail_payload`` shape gains a new field the
50+
# analyser depends on. The incremental refresh path snapshots existing
51+
# detail rows for cache-reuse — stale rows (``_v`` below this constant,
52+
# or missing entirely) are skipped from the snapshot so they get re-
53+
# fetched freshly. The analyser also filters stale rows out of its
54+
# reference query, so a fully-stale (spec, encounter, metric) bucket
55+
# triggers a clean re-seed instead of feeding the AI old detail.
56+
#
57+
# History:
58+
# v1: initial detail shape (casts / gear / buffs / debuffs / damage_taken).
59+
# v2 (v0.3.0): added stats, talent_ids, boss_casts. Phase-transition
60+
# shape on the parent report is also a v3 thing but not
61+
# duplicated into detail_payload, so v2 stays the right
62+
# stamp.
63+
TOP_LOG_DETAIL_VERSION = 2
64+
4965

5066
def _metric_for_role(role: Role) -> str:
5167
if role == Role.healer:
@@ -103,8 +119,17 @@ async def refresh_top_logs_for_spec_encounter(
103119
)
104120
).scalars().all()
105121
for r in existing_rows:
106-
if r.detail_payload:
107-
cached_detail[(r.wcl_report_code, int(r.wcl_fight_id))] = r.detail_payload
122+
detail = r.detail_payload
123+
if not detail:
124+
continue
125+
# Only reuse detail blobs written by the current code shape.
126+
# Stale ones (``_v`` below the current ``TOP_LOG_DETAIL_VERSION``
127+
# — or missing entirely, which counts as version 0) get dropped
128+
# from the cache so the fetch loop below re-pulls them, and
129+
# the row gets re-inserted with fresh data + current version.
130+
if int(detail.get("_v") or 0) < TOP_LOG_DETAIL_VERSION:
131+
continue
132+
cached_detail[(r.wcl_report_code, int(r.wcl_fight_id))] = detail
108133

109134
try:
110135
spec_name = _wcl_spec_name(spec.name_en)
@@ -354,6 +379,11 @@ async def _fetch_detail_for_top_log(
354379
# between the user's pull and the reference kill directly.
355380
"boss_casts": boss_casts,
356381
"metric": metric,
382+
# Version stamp — the incremental cache snapshot in
383+
# ``refresh_top_logs_for_spec_encounter`` only reuses a detail
384+
# blob when this matches ``TOP_LOG_DETAIL_VERSION``. Bumping the
385+
# constant forces a clean re-fetch.
386+
"_v": TOP_LOG_DETAIL_VERSION,
357387
}
358388

359389

0 commit comments

Comments
 (0)