refactor: extract evaluate-game business logic from router to service layer (#202) #207
No reviewers
Labels
No Label
ai-changes-requested
ai-failed
ai-merged
ai-pr-opened
ai-reviewed
ai-reviewing
ai-working
autonomous
bug
enhancement
evolution
performance
phase-0
phase-1a
phase-1b
phase-1c
phase-1d
security
size:M
size:S
tech-debt
todo
type:feature
type:stability
No Milestone
No project
No Assignees
2 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: cal/paper-dynasty-database#207
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "issue/202-refactor-extract-evaluate-game-business-logic-from"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #202
Summary
Extracts the
evaluate-gameboost orchestration logic from the router into a newapp/services/refractor_service.pyservice module, making the business logic testable without HTTP round-trips.New file:
app/services/refractor_service.pyensure_variant_cards(player_id, team_id, target_tier, card_type)— idempotent; creates missing variant cards for all tiers up totarget_tier. Skips existing variants, handles partial failures (stops at first failing tier, reports what committed), respects theREFRACTOR_BOOST_ENABLEDkill switch.evaluate_and_boost(player_id, team_id)— combinesevaluate_card(dry_run=True)withensure_variant_cards. Returns all fields from evaluate_card pluscurrent_tier(what was committed),variants_created(list of variant hashes), andboost_results. RaisesValueErrorwhen no state exists (same as evaluate_card).Router changes:
app/routers_v2/refractor.pyPOST /cards/{card_id}/evaluate— now callsevaluate_and_boostinstead ofevaluate_carddirectly. This also fixes the broken manual evaluate path where tier was recorded but no variant card was created.POST /evaluate-game/{game_id}— replaces ~55 lines of inline boost orchestration (boost_enabled env check, tier loop, last_successful_tier/failing_tier tracking, card_type/track null checks) with a singleevaluate_and_boostcall. Response shape is unchanged.Removes unused
import os(was only used by the removedboost_enabledvariable).Behaviour preserved
computed_tier > old_tier; now checksresult["current_tier"] > old_tier— equivalent in the success case; in partial failure the service already reports only the tiers that committed.variant_createdin tier_up response: previouslyboost_result.get("variant_created"); nowvariants_created[-1](the last variant hash created).Files changed
app/services/refractor_service.py(new, 175 lines)app/routers_v2/refractor.py(−62 lines net)AI Code Review
Verdict: REQUEST_CHANGES (posted as COMMENT — self-review restriction)
Files Reviewed
app/services/refractor_service.py(added, 200 lines)app/routers_v2/refractor.py(modified)Findings
Correctness
BLOCKING —
animated_urldropped from tier-up response (rebase regression)PR #208 (merged before this PR as commit
d83a4bd) addedanimated_urlto tier-up entries inevaluate_gamewhencomputed_tier >= 3(lines 482–488 of current main). This PR's branch was cut before #208 landed and was not rebased. The diff removes the entireboost_resultblock and replaces it with:animated_urlis silently dropped. The Discord bot relies on this field for T3/T4 tier-up embed notifications.The fix is to rebase on main and restore
animated_urlconstruction after the variants block. All the ingredients are still in scope in the router:state.track.card_typeforcard_type,variants_created[-1]forvariant_num, andnew_tierin place ofcomputed_tier:After rebase,
import osandfrom datetime import datemust both be kept. The PR's diff removesimport osbecause it was cut against a base that predates #208's animated_url code; post-rebase both imports remain in use.Security
Style & Conventions
BLOCKING — Lazy imports in service module (
# noqa: PLC0415)refractor_service.pydefers all its imports to inside function bodies:from app.db_engine import RefractorCardState— conditional on_state_model is Nonefrom app.services.refractor_boost import apply_tier_boost, compute_variant_hash— conditional on kill switchfrom app.db_engine import BattingCard as CardModel/PitchingCard as CardModel— insideif is_batter:branchfrom app.services.refractor_evaluator import evaluate_card— insideevaluate_and_boostCLAUDE.md: "Never add lazy imports to middle of file." The
# noqa: PLC0415annotations confirm linting catches this. PR #187 was blocked on the identical pattern. None of these have circular import risk —refractor_service→db_engine/refractor_boost/refractor_evaluatoris a clean one-way dependency. The DI testing goal does not require lazy imports;unittest.mock.patch('app.services.refractor_service.RefractorCardState')works equally well with a top-level import. Move all imports to module top level.Suggestions (non-blocking)
CardModel.player == player_idinensure_variant_cards— works in Peewee (FK resolves to underlying column), but the codebase convention isCardModel.player_id == player_id(e.g._build_card_state_responseline 90). Preferplayer_idfor consistency.ensure_variant_cardsidempotency, backfill logic) warrants follow-up test coverage.Summary
Two blockers:
animated_urldropped from T3/T4 tier-up response — stale branch base (pre-#208). Rebase on main and restore theanimated_urlblock.refractor_service.py. Move to module top level.The refactor design is sound:
ensure_variant_cardsidempotency,max(computed_tier, stored_tier)backfill path, kill-switch behaviour, and thePOST /cards/{card_id}/evaluatefix are all correct. Both blockers are mechanical fixes on an otherwise clean service extraction.Automated review by Claude PR Reviewer
Addressed both blockers from the review (commit
c61e7c1):1. Lazy imports → module top level (
refractor_service.py)Moved all four lazy imports to module level:
from app.db_engine import RefractorCardState, BattingCard, PitchingCardfrom app.services.refractor_boost import apply_tier_boost, compute_variant_hashfrom app.services.refractor_evaluator import evaluate_cardensure_variant_cardsnow assigns_state_model = RefractorCardStatewhen the DI param isNone(same runtime behaviour, no circular import risk).BattingCard/PitchingCardconditional alias replaced withCardModel = BattingCard if is_batter else PitchingCard. All# noqa: PLC0415annotations removed.2.
animated_urlrestored inevaluate_game(refractor.py)Added
import osandfrom datetime import dateat module top level, then restored theanimated_urlconstruction block (T3/T4 only, usingnew_tier,variant_num, andstate.track.card_type) that was dropped when the branch was cut before PR #208 merged.AI Code Review
Files Reviewed
app/services/refractor_service.py(added, 194 lines)app/routers_v2/refractor.py(modified, −62 lines net)Status Change from Previous Review
The blocking issue from the prior review (branch predating PR #208 merge, causing
animated_urlregression) is resolved. The PR branch now includesanimated_urlconstruction in the tier-up entry — confirmed by examining the branch directly. PR #208 was merged at commit7701777. Themergeable: falseflag on the PR metadata is a rebase artifact; the code itself is correct.Findings
Correctness
ensure_variant_cards—last_successful_tiersemantics are correct. Initialized tostate.current_tierbefore the loop. On full success, it advances totarget_tier. On first-tier failure, it stays atstate.current_tier— soevaluate_and_boostreturnscurrent_tier == old_stored_tier, andnew_tier > old_tieris false in the router. No spurious tier-up notification. This correctly matches the original "skip if no tier succeeded" behavior.variants_created[-1]is safe. The router only accessesvariants_created[-1]insideif variants_created:. No IndexError risk.evaluate_cardendpoint behavior change is intentional and correct. The old code calledevaluate_card(dry_run=False), which wrotecurrent_tierdirectly without creating a variant card. The new code callsevaluate_and_boost, which usesdry_run=Truethen delegates tier writes toapply_tier_boost. Tier is still written — throughapply_tier_boost, which writes tier + variant atomically. The PR body correctly identifies this as fixing the broken manual-evaluate path.CardModel.player == player_idquery is valid Peewee. Peewee FK fields accept raw integer comparison. This is functionally equivalent toCardModel.player_id == player_idand will generate the correct SQL.animated_urlis included correctly. Theanimated_urlfield usesvariants_created[-1]as the variant number,new_tier >= 3as the guard, and constructs the URL usingAPI_BASE_URL— matching the PR #208 pattern exactly. This is correct.Kill switch behavior is preserved.
ensure_variant_cardschecksREFRACTOR_BOOST_ENABLEDand returns early with emptyvariants_created. The router'sif variants_created:block is then skipped, so novariant_createdoranimated_urlis emitted. Correct.No issues found.
Security
No issues found. No new user-controlled inputs.
os.environ.getusage for URL construction is appropriate.Style & Conventions
PR body claims
import osis removed — it is not. The diff movesimport osfrom line 1 to line 3 (afterfrom datetime import date); it does not remove it.osis still required foros.environ.get("API_BASE_URL", "")in theanimated_urlconstruction. The PR body states "Removes unusedimport os(was only used by the removedboost_enabledvariable)" — this is inaccurate documentation. Minor issue, no code impact.Lazy imports in router are pre-existing. The PR replaces
from ..services.refractor_evaluator import evaluate_card as _evaluateandfrom ..services.refractor_boost import apply_tier_boost(both lazy, both inside function bodies) with a singlefrom ..services.refractor_service import evaluate_and_boostin the same positions. CLAUDE.md says "Never add lazy imports to middle of file" — this is a replacement of existing lazy imports, not new additions. Consistent with the established router pattern.ensure_variant_cardsiterates fromrange(1, target_tier + 1)instead ofrange(state.current_tier + 1, target_tier + 1). This means for a T0→T2 jump, the service checks tiers 1 and 2. For an already-T1 player jumping to T2, it checks tier 1 (finds existing variant, skips) then tier 2 (creates). This is correct idempotent behavior — the skip-if-exists guard makes the extra check safe and cheap. A minor observation: the loop could start fromstate.current_tier + 1for a slight optimization, but the current approach is clearer and idempotent-safe. Non-blocking.Suggestions
ensure_variant_cardsdoes not pass the injectable DI parameters (_batting_card_model, etc.) through toapply_tier_boost. This means tests ofensure_variant_cardsthat want to stubapply_tier_boostmust mock at the module level rather than via DI injection. Given thatapply_tier_boosthas 7 injectable params, passing them all through would be verbose. The current approach (test viaapply_tier_boostmock) is acceptable, but worth noting if deep unit tests of the service layer are planned.evaluate_and_boostaccepts_stats_modelfor DI butensure_variant_cards(called byevaluate_and_boost) does not accept or forward a_card_modeloverride. Tests that inject a fakeBattingCard/PitchingCardfor the idempotency check will need to use module-level patching. Minor testability gap, not blocking.Verdict: COMMENT
The service extraction is clean. All behavior from the router is faithfully reproduced in the service layer. The
animated_urlregression concern from the prior review is resolved — the PR branch incorporates PR #208's logic correctly. Themergeable: falseflag indicates a rebase is needed before merge (branch was cut before several PRs landed on main), but the code is correct. No blocking issues.Automated review by Claude PR Reviewer
AI Code Review
Files Reviewed
app/services/refractor_service.py(added, 194 lines)app/routers_v2/refractor.py(modified, −62 lines net)Findings
Correctness
No issues found. Full trace of all behaviour-critical paths:
REFRACTOR_BOOST_ENABLED=false):ensure_variant_cardsreturns early withcurrent_tier = state.current_tier.evaluate_and_boostreturns the same stored value →new_tier == old_tierin router → no tier-up notification sent. ✅card_typepath (state.trackis None):ensure_variant_cardslogs warning and returns early withcurrent_tier = state.current_tier→ same result as above. ✅last_successful_tierreflects the last committed tier. Higher tiers not attempted. ✅compute_variant_hashpre-checks existence beforeapply_tier_boost. Already-existing variants are skipped cleanly. ✅target_tier = max(computed_tier, stored_tier)— if formula says tier 1 but DB has tier 3, backfill creates any missing variants up to tier 3 without regressing. ✅variants_created[-1]: returns the hash of the last successfully created tier — equivalent to oldboost_result.get("variant_created"). ✅animated_url: generated whennew_tier >= 3 and variant_num and card_type— matches PR #208 contract. ✅import ospreserved: still used forAPI_BASE_URLenv lookup; PR body says "removes unusedimport os" but the diff correctly keeps it. Code is right, PR body is slightly inaccurate.Security
No issues. No new user-controlled data flows, no SQL injection vectors, no secrets introduced.
Style & Conventions
CardModel.player == player_idinrefractor_service.py(line ~108 in the new file): rest of the router usesCardModel.player_idfor FK equality checks (e.g._build_card_state_responseline 90,list_card_statesline 259). Functionally equivalent in Peewee (FK fields support integer equality on both forms) — non-blocking, but inconsistent with file convention.refractor_service.pyfollow CLAUDE.md (no lazy imports mid-file). ✅refractor.pyrouter functions match the existing pattern throughout that file. ✅Suggestions
refractor_service.py— consistent with codebase pattern of follow-up test PRs, butensure_variant_cardsandevaluate_and_boostare now the critical path for all tier-up writes; a test PR covering the partial-failure and idempotent cases would be high value.Merge Blocker (not a code issue)
mergeable: false— merge conflict withmain. The conflict is in therefractor.pyimport block: this branch predates PR #208 which addedfrom datetime import dateto the same file. A rebase onto currentmainresolves it with no code changes required.Verdict: COMMENT
Service extraction is clean. Logic is correct. No correctness, security, or convention issues in the code itself. Ready to merge after rebase.
Automated review by Claude PR Reviewer
Checkout
From your project repository, check out a new branch and test the changes.