feat: add Budget Mode with price cache infrastructure and stale price warnings (#61)

This commit is contained in:
mwisnowski 2026-03-23 16:38:18 -07:00 committed by GitHub
parent 1aa8e4d7e8
commit 8643b72108
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 6976 additions and 2753 deletions

View file

@ -243,6 +243,20 @@ async def build_alternatives(
return HTMLResponse(cached)
def _render_and_cache(_items: list[dict]):
# Enrich each item with USD price from PriceService (best-effort)
try:
from ..services.price_service import get_price_service
_svc = get_price_service()
_svc._ensure_loaded()
_prices = _svc._cache # keyed by lowercase card name → {usd, usd_foil, ...}
for _it in _items:
if not _it.get("price"):
_nm = (_it.get("name_lower") or str(_it.get("name", ""))).lower()
_entry = _prices.get(_nm)
if _entry:
_it["price"] = _entry.get("usd")
except Exception:
pass
html_str = templates.get_template("build/_alternatives.html").render({
"request": request,
"name": name_disp,

View file

@ -20,6 +20,7 @@ from ..app import (
ENABLE_BATCH_BUILD,
DEFAULT_THEME_MATCH_MODE,
THEME_POOL_SECTIONS,
ENABLE_BUDGET_MODE,
)
from ..services.build_utils import (
step5_ctx_from_result,
@ -113,6 +114,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"enable_budget_mode": ENABLE_BUDGET_MODE,
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": {
@ -425,6 +427,10 @@ async def build_new_submit(
enforcement_mode: str = Form("warn"),
allow_illegal: bool = Form(False),
fuzzy_matching: bool = Form(True),
# Budget (optional)
budget_total: float | None = Form(None),
card_ceiling: float | None = Form(None),
pool_tolerance: str | None = Form(None), # percent string; blank/None treated as 0% (hard cap at ceiling)
# Build count for multi-build
build_count: int = Form(1),
# Quick Build flag
@ -474,6 +480,9 @@ async def build_new_submit(
"partner_enabled": partner_form_state["partner_enabled"],
"secondary_commander": partner_form_state["secondary_commander"],
"background": partner_form_state["background"],
"budget_total": budget_total,
"card_ceiling": card_ceiling,
"pool_tolerance": pool_tolerance if pool_tolerance is not None else "15",
}
commander_detail = lookup_commander_detail(commander)
@ -501,6 +510,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"enable_budget_mode": ENABLE_BUDGET_MODE,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(suggested),
"tag_slot_html": None,
@ -527,6 +537,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"enable_budget_mode": ENABLE_BUDGET_MODE,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(commander),
"tag_slot_html": None,
@ -633,6 +644,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"enable_budget_mode": ENABLE_BUDGET_MODE,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(primary_commander_name),
"tag_slot_html": tag_slot_html,
@ -773,6 +785,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD,
"enable_budget_mode": ENABLE_BUDGET_MODE,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(sess.get("commander", "")),
"tag_slot_html": None,
@ -906,7 +919,31 @@ async def build_new_submit(
# If exclude parsing fails, log but don't block the build
import logging
logging.warning(f"Failed to parse exclude cards: {e}")
# Store budget config in session (only when a total is provided)
try:
if ENABLE_BUDGET_MODE and budget_total and float(budget_total) > 0:
budget_cfg: dict = {"total": float(budget_total), "mode": "soft"}
if card_ceiling and float(card_ceiling) > 0:
budget_cfg["card_ceiling"] = float(card_ceiling)
# pool_tolerance: blank/None → 0.0 (hard cap at ceiling); digit string → float (e.g. "15" → 0.15)
# Absence of key in budget_cfg means M4 falls back to the env-level default.
_tol = (pool_tolerance or "").strip()
try:
budget_cfg["pool_tolerance"] = float(_tol) / 100.0 if _tol else 0.0
except ValueError:
budget_cfg["pool_tolerance"] = 0.15 # bad input → safe default
sess["budget_config"] = budget_cfg
else:
sess.pop("budget_config", None)
except Exception:
sess.pop("budget_config", None)
# Assign a stable build ID for this build run (used by JS to detect true new builds,
# distinct from per-stage summary_token which increments every stage)
import time as _time
sess["build_id"] = str(int(_time.time() * 1000))
# Clear any old staged build context
for k in ["build_ctx", "locks", "replace_mode"]:
if k in sess:
@ -1285,9 +1322,9 @@ def quick_build_progress(request: Request):
# Return Step 5 which will replace the whole wizard div
response = templates.TemplateResponse("build/_step5.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
# Tell HTMX to target #wizard and swap outerHTML to replace the container
# Tell HTMX to target #wizard and swap innerHTML (keeps #wizard in DOM for subsequent interactions)
response.headers["HX-Retarget"] = "#wizard"
response.headers["HX-Reswap"] = "outerHTML"
response.headers["HX-Reswap"] = "innerHTML"
return response
# Fallback if no result yet
return HTMLResponse('Build complete. Please refresh.')
@ -1302,3 +1339,124 @@ def quick_build_progress(request: Request):
response = templates.TemplateResponse("build/_quick_build_progress_content.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response
# ---------------------------------------------------------------------------
# M5: Budget swap route — swap one card in the finalized deck and re-evaluate
# ---------------------------------------------------------------------------
@router.post("/budget-swap", response_class=HTMLResponse)
async def budget_swap(
request: Request,
old_card: str = Form(...),
new_card: str = Form(...),
):
"""Replace one card in the session budget snapshot and re-render the review panel."""
sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
if not sid:
return HTMLResponse("")
sess = get_session(sid)
snapshot: list[str] = list(sess.get("budget_deck_snapshot") or [])
if not snapshot:
return HTMLResponse("")
old_lower = old_card.strip().lower()
new_name = new_card.strip()
replaced = False
for i, name in enumerate(snapshot):
if name.strip().lower() == old_lower:
snapshot[i] = new_name
replaced = True
break
if not replaced:
return HTMLResponse("")
sess["budget_deck_snapshot"] = snapshot
# Re-evaluate budget
budget_cfg = sess.get("budget_config") or {}
try:
budget_total = float(budget_cfg.get("total") or 0)
except Exception:
budget_total = 0.0
if budget_total <= 0:
return HTMLResponse("")
budget_mode = str(budget_cfg.get("mode", "soft")).strip().lower()
try:
card_ceiling = float(budget_cfg.get("card_ceiling")) if budget_cfg.get("card_ceiling") else None
except Exception:
card_ceiling = None
include_cards = [str(c).strip() for c in (sess.get("include_cards") or []) if str(c).strip()]
color_identity: list[str] | None = None
try:
ci_raw = sess.get("color_identity")
if ci_raw and isinstance(ci_raw, list):
color_identity = [str(c).upper() for c in ci_raw]
except Exception:
pass
try:
from ..services.budget_evaluator import BudgetEvaluatorService
svc = BudgetEvaluatorService()
report = svc.evaluate_deck(
snapshot,
budget_total,
mode=budget_mode,
card_ceiling=card_ceiling,
include_cards=include_cards,
color_identity=color_identity,
)
except Exception:
return HTMLResponse("")
total_price = float(report.get("total_price", 0.0))
tolerance = bc.BUDGET_TOTAL_TOLERANCE
over_budget_review = total_price > budget_total * (1.0 + tolerance)
overage_pct = round((total_price - budget_total) / budget_total * 100, 1) if (budget_total and over_budget_review) else 0.0
include_set = {c.lower().strip() for c in include_cards}
# Use price_breakdown sorted by price desc — most expensive cards contribute most to total overage
breakdown = report.get("price_breakdown") or []
priced = sorted(
[e for e in breakdown
if not e.get("is_include") and (e.get("price") is not None) and float(e.get("price") or 0.0) > 0],
key=lambda x: -float(x.get("price") or 0.0),
)
over_cards_out: list[dict] = []
for entry in priced[:6]:
name = entry.get("card", "")
price = float(entry.get("price") or 0.0)
is_include = name.lower().strip() in include_set
try:
alts_raw = svc.find_cheaper_alternatives(
name,
max_price=max(0.0, price - 0.01),
region="usd",
color_identity=color_identity,
)
except Exception:
alts_raw = []
over_cards_out.append({
"name": name,
"price": price,
"swap_disabled": is_include,
"alternatives": [
{"name": a["name"], "price": a.get("price"), "shared_tags": a.get("shared_tags", [])}
for a in alts_raw[:3]
],
})
ctx = {
"request": request,
"budget_review_visible": over_budget_review,
"over_budget_review": over_budget_review,
"budget_review_total": round(total_price, 2),
"budget_review_cap": round(budget_total, 2),
"budget_overage_pct": overage_pct,
"over_budget_cards": over_cards_out,
}
response = templates.TemplateResponse("build/_budget_review.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response

View file

@ -948,7 +948,10 @@ async def build_step5_start_get(request: Request) -> HTMLResponse:
@router.post("/step5/start", response_class=HTMLResponse)
async def build_step5_start(request: Request) -> HTMLResponse:
return RedirectResponse("/build", status_code=302)
"""(Re)start the build from step 4 review page."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
commander = sess.get("commander")
if not commander:
resp = templates.TemplateResponse(
"build/_step1.html",
@ -957,7 +960,8 @@ async def build_step5_start(request: Request) -> HTMLResponse:
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
try:
# Initialize step-by-step build context and run first stage
import time as _time
sess["build_id"] = str(int(_time.time() * 1000))
sess["build_ctx"] = start_ctx_from_session(sess)
show_skipped = False
try:
@ -966,18 +970,15 @@ async def build_step5_start(request: Request) -> HTMLResponse:
except Exception:
pass
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
# Save summary to session for deck_summary partial to access
if res.get("summary"):
sess["summary"] = res["summary"]
status = "Stage complete" if not res.get("done") else "Build complete"
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
try:
if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
mc = sess.get("multi_copy")
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
except Exception:
pass
# Note: no redirect; the inline compliance panel will render inside Step 5
sess["last_step"] = 5
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
resp = templates.TemplateResponse("build/_step5.html", ctx)
@ -985,14 +986,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}})
return resp
except Exception as e:
# Surface a friendly error on the step 5 screen with normalized context
err_ctx = step5_error_ctx(
request,
sess,
f"Failed to start build: {e}",
include_name=False,
)
# Ensure commander stays visible if set
err_ctx = step5_error_ctx(request, sess, f"Failed to start build: {e}", include_name=False)
err_ctx["commander"] = commander
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")

View file

@ -1,15 +1,17 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, Response
from pathlib import Path
import csv
import io
import os
from typing import Any, Dict, List, Optional, Tuple
from ..app import templates
from ..services.orchestrator import tags_for_commander
from ..services.summary_utils import format_theme_label, format_theme_list, summary_ctx
from ..app import ENABLE_BUDGET_MODE
router = APIRouter(prefix="/decks")
@ -402,6 +404,47 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
"commander_role_label": format_theme_label("Commander"),
}
)
# Budget evaluation (only when budget_config is stored in the sidecar meta)
if ENABLE_BUDGET_MODE:
budget_config = meta_info.get("budget_config") if isinstance(meta_info, dict) else None
if isinstance(budget_config, dict) and budget_config.get("total"):
try:
from ..services.budget_evaluator import BudgetEvaluatorService
card_counts = _read_deck_counts(p)
decklist = list(card_counts.keys())
color_identity = meta_info.get("color_identity") if isinstance(meta_info, dict) else None
include_cards = list(meta_info.get("include_cards") or []) if isinstance(meta_info, dict) else []
svc = BudgetEvaluatorService()
budget_report = svc.evaluate_deck(
decklist=decklist,
budget_total=float(budget_config["total"]),
mode=str(budget_config.get("mode", "soft")),
card_ceiling=float(budget_config["card_ceiling"]) if budget_config.get("card_ceiling") else None,
color_identity=color_identity,
include_cards=include_cards or None,
)
ctx["budget_report"] = budget_report
ctx["budget_config"] = budget_config
# M8: Price charts
try:
from ..services.budget_evaluator import compute_price_category_breakdown, compute_price_histogram
_breakdown = budget_report.get("price_breakdown") or []
_card_tags: Dict[str, List[str]] = {}
if isinstance(summary, dict):
_tb = (summary.get("type_breakdown") or {}).get("cards") or {}
for _clist in _tb.values():
for _c in (_clist or []):
if isinstance(_c, dict) and _c.get("name"):
_card_tags[_c["name"]] = list(_c.get("tags") or [])
_enriched = [{**item, "tags": _card_tags.get(item.get("card", ""), [])} for item in _breakdown]
ctx["price_category_chart"] = compute_price_category_breakdown(_enriched)
ctx["price_histogram_chart"] = compute_price_histogram(_breakdown)
except Exception:
pass
except Exception:
pass
return templates.TemplateResponse("decks/view.html", ctx)
@ -486,3 +529,144 @@ async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[s
"metaB": metaB,
},
)
@router.get("/pickups", response_class=HTMLResponse)
async def decks_pickups(request: Request, name: str) -> HTMLResponse:
"""Show the pickups list for a deck that was built with budget mode enabled."""
base = _deck_dir()
p = (base / name).resolve()
if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"):
return templates.TemplateResponse(
"decks/index.html",
{"request": request, "items": _list_decks(), "error": "Deck not found."},
)
meta_info: Dict[str, Any] = {}
commander_name = ""
sidecar = p.with_suffix(".summary.json")
if sidecar.exists():
try:
import json as _json
payload = _json.loads(sidecar.read_text(encoding="utf-8"))
if isinstance(payload, dict):
meta_info = payload.get("meta") or {}
commander_name = meta_info.get("commander") or ""
except Exception:
pass
budget_config = meta_info.get("budget_config") if isinstance(meta_info, dict) else None
budget_report = None
error_msg = None
if not ENABLE_BUDGET_MODE:
error_msg = "Budget mode is not enabled (set ENABLE_BUDGET_MODE=1)."
elif not isinstance(budget_config, dict) or not budget_config.get("total"):
error_msg = "Budget mode was not enabled when this deck was built."
else:
try:
from ..services.budget_evaluator import BudgetEvaluatorService
card_counts = _read_deck_counts(p)
decklist = list(card_counts.keys())
color_identity = meta_info.get("color_identity") if isinstance(meta_info, dict) else None
include_cards = list(meta_info.get("include_cards") or []) if isinstance(meta_info, dict) else []
svc = BudgetEvaluatorService()
budget_report = svc.evaluate_deck(
decklist=decklist,
budget_total=float(budget_config["total"]),
mode=str(budget_config.get("mode", "soft")),
card_ceiling=float(budget_config["card_ceiling"]) if budget_config.get("card_ceiling") else None,
color_identity=color_identity,
include_cards=include_cards or None,
)
except Exception as exc:
error_msg = f"Budget evaluation failed: {exc}"
stale_prices: set[str] = set()
stale_prices_global = False
try:
from ..services.price_service import get_price_service
from code.settings import PRICE_STALE_WARNING_HOURS
_psvc = get_price_service()
_psvc._ensure_loaded()
if PRICE_STALE_WARNING_HOURS > 0:
_stale = _psvc.get_stale_cards(PRICE_STALE_WARNING_HOURS)
if _stale and len(_stale) > len(_psvc._cache) * 0.5:
stale_prices_global = True
else:
stale_prices = _stale
except Exception:
pass
return templates.TemplateResponse(
"decks/pickups.html",
{
"request": request,
"name": p.name,
"commander": commander_name,
"budget_config": budget_config,
"budget_report": budget_report,
"error": error_msg,
"stale_prices": stale_prices,
"stale_prices_global": stale_prices_global,
},
)
@router.get("/download-csv")
async def decks_download_csv(name: str) -> Response:
"""Serve a CSV export with live prices fetched at download time."""
base = _deck_dir()
p = (base / name).resolve()
if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"):
return HTMLResponse("File not found", status_code=404)
try:
with p.open("r", encoding="utf-8", newline="") as f:
reader = csv.reader(f)
headers = next(reader, [])
data_rows = list(reader)
except Exception:
return HTMLResponse("Could not read CSV", status_code=500)
# Strip any stale baked Price column
if "Price" in headers:
price_idx = headers.index("Price")
headers = [h for i, h in enumerate(headers) if i != price_idx]
data_rows = [[v for i, v in enumerate(row) if i != price_idx] for row in data_rows]
name_idx = headers.index("Name") if "Name" in headers else 0
card_names = [
row[name_idx] for row in data_rows
if row and len(row) > name_idx and row[name_idx] and row[name_idx] != "Total"
]
prices_map: Dict[str, Any] = {}
try:
from ..services.price_service import get_price_service
prices_map = get_price_service().get_prices_batch(card_names) or {}
except Exception:
pass
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(headers + ["Price"])
for row in data_rows:
if not row:
continue
name_val = row[name_idx] if len(row) > name_idx else ""
if name_val == "Total":
continue
price_val = prices_map.get(name_val)
writer.writerow(row + [f"{price_val:.2f}" if price_val is not None else ""])
if prices_map:
total = sum(v for v in prices_map.values() if v is not None)
empty = [""] * len(headers)
empty[name_idx] = "Total"
writer.writerow(empty + [f"{total:.2f}"])
return Response(
content=output.getvalue().encode("utf-8"),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{p.name}"'},
)

111
code/web/routes/price.py Normal file
View file

@ -0,0 +1,111 @@
"""Price API routes for card price lookups.
Provides endpoints for single-card and batch price queries backed by
the PriceService (Scryfall bulk data + JSON cache).
"""
from __future__ import annotations
import threading
from typing import List, Optional
from urllib.parse import unquote
from fastapi import APIRouter, Body, Query
from fastapi.responses import JSONResponse
from code.web.services.price_service import get_price_service
from code.web.decorators.telemetry import track_route_access, log_route_errors
router = APIRouter(prefix="/api/price")
@router.get("/stats")
@track_route_access("price_cache_stats")
async def price_cache_stats():
"""Return cache telemetry for the PriceService."""
svc = get_price_service()
return JSONResponse(svc.cache_stats())
@router.post("/refresh")
@track_route_access("price_cache_refresh")
async def refresh_price_cache():
"""Trigger a background rebuild of the price cache and parquet price columns.
Returns immediately the rebuild runs in a daemon thread.
"""
def _run() -> None:
try:
from code.file_setup.setup import refresh_prices_parquet
refresh_prices_parquet()
except Exception as exc:
import logging
logging.getLogger(__name__).error("Manual price refresh failed: %s", exc)
t = threading.Thread(target=_run, daemon=True, name="price-manual-refresh")
t.start()
return JSONResponse({"ok": True, "message": "Price cache refresh started in background."})
@router.get("/{card_name:path}")
@track_route_access("price_lookup")
@log_route_errors("price_lookup")
async def get_card_price(
card_name: str,
region: str = Query("usd", pattern="^(usd|eur)$"),
foil: bool = Query(False),
):
"""Look up the price for a single card.
Args:
card_name: Card name (URL-encoded, case-insensitive).
region: Price region ``usd`` or ``eur``.
foil: If true, return the foil price.
Returns:
JSON with ``card_name``, ``price`` (float or null), ``region``,
``foil``, ``found`` (bool).
"""
name = unquote(card_name).strip()
svc = get_price_service()
price = svc.get_price(name, region=region, foil=foil)
return JSONResponse({
"card_name": name,
"price": price,
"region": region,
"foil": foil,
"found": price is not None,
})
@router.post("/batch")
@track_route_access("price_batch_lookup")
@log_route_errors("price_batch_lookup")
async def get_prices_batch(
card_names: List[str] = Body(..., max_length=100),
region: str = Query("usd", pattern="^(usd|eur)$"),
foil: bool = Query(False),
):
"""Look up prices for multiple cards in a single request.
Request body: JSON array of card name strings (max 100).
Args:
card_names: List of card names.
region: Price region ``usd`` or ``eur``.
foil: If true, return foil prices.
Returns:
JSON with ``prices`` (dict namefloat|null) and ``missing`` (list
of names with no price data).
"""
svc = get_price_service()
prices = svc.get_prices_batch(card_names, region=region, foil=foil)
missing = [n for n, p in prices.items() if p is None]
return JSONResponse({
"prices": prices,
"missing": missing,
"region": region,
"foil": foil,
"total": len(card_names),
"found": len(card_names) - len(missing),
})

View file

@ -196,10 +196,14 @@ async def download_github():
async def setup_index(request: Request) -> HTMLResponse:
import code.settings as settings
from code.file_setup.image_cache import ImageCache
from code.web.services.price_service import get_price_service
from code.web.app import PRICE_AUTO_REFRESH
image_cache = ImageCache()
return templates.TemplateResponse("setup/index.html", {
"request": request,
"similarity_enabled": settings.ENABLE_CARD_SIMILARITIES,
"image_cache_enabled": image_cache.is_enabled()
"image_cache_enabled": image_cache.is_enabled(),
"price_cache_built_at": get_price_service().get_cache_built_at(),
"price_auto_refresh": PRICE_AUTO_REFRESH,
})