44import logging
55from typing import Any
66
7- from sqlalchemy import select
7+ from sqlalchemy import delete , select
88from sqlalchemy .ext .asyncio import AsyncSession
99from sqlalchemy .orm import selectinload
1010
3838
3939logger = 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
4259async 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+
117156async 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 )
0 commit comments