mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
feat: complete M3 Web UI Enhancement milestone with include/exclude cards, fuzzy matching, mobile responsive design, and performance optimization
- Include/exclude cards feature complete with 300+ card knowledge base and intelligent fuzzy matching - Enhanced visual validation with warning icons and performance benchmarks (100% pass rate) - Mobile responsive design with bottom-floating controls, two-column layout, and horizontal scroll prevention - Dark theme confirmation modal for fuzzy matches with card preview and alternatives - Dual architecture support for web UI staging system and CLI direct build paths - All M3 checklist items completed: fuzzy match modal, enhanced algorithm, summary panel, mobile responsive, Playwright tests
This commit is contained in:
parent
0516260304
commit
cfcc01db85
37 changed files with 3837 additions and 162 deletions
|
|
@ -440,7 +440,11 @@ async def build_new_submit(
|
|||
multi_count: int | None = Form(None),
|
||||
multi_thrumming: str | None = Form(None),
|
||||
# Must-haves/excludes (optional)
|
||||
include_cards: str = Form(""),
|
||||
exclude_cards: str = Form(""),
|
||||
enforcement_mode: str = Form("warn"),
|
||||
allow_illegal: bool = Form(False),
|
||||
fuzzy_matching: bool = Form(True),
|
||||
) -> HTMLResponse:
|
||||
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
|
@ -467,7 +471,11 @@ async def build_new_submit(
|
|||
"combo_count": combo_count,
|
||||
"combo_balance": (combo_balance or "mix"),
|
||||
"prefer_combos": bool(prefer_combos),
|
||||
"include_cards": include_cards or "",
|
||||
"exclude_cards": exclude_cards or "",
|
||||
"enforcement_mode": enforcement_mode or "warn",
|
||||
"allow_illegal": bool(allow_illegal),
|
||||
"fuzzy_matching": bool(fuzzy_matching),
|
||||
}
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
|
|
@ -575,37 +583,59 @@ async def build_new_submit(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Process exclude cards (M0.5: Phase 1 - Exclude Only)
|
||||
# Process include/exclude cards (M3: Phase 2 - Full Include/Exclude)
|
||||
try:
|
||||
from deck_builder.include_exclude_utils import parse_card_list_input, IncludeExcludeDiagnostics
|
||||
|
||||
# Clear any old exclude data
|
||||
for k in ["exclude_cards", "exclude_diagnostics"]:
|
||||
# Clear any old include/exclude data
|
||||
for k in ["include_cards", "exclude_cards", "include_exclude_diagnostics", "enforcement_mode", "allow_illegal", "fuzzy_matching"]:
|
||||
if k in sess:
|
||||
del sess[k]
|
||||
|
||||
# Process include cards
|
||||
if include_cards and include_cards.strip():
|
||||
print(f"DEBUG: Raw include_cards input: '{include_cards}'")
|
||||
include_list = parse_card_list_input(include_cards.strip())
|
||||
print(f"DEBUG: Parsed include_list: {include_list}")
|
||||
sess["include_cards"] = include_list
|
||||
else:
|
||||
print(f"DEBUG: include_cards is empty or None: '{include_cards}'")
|
||||
|
||||
# Process exclude cards
|
||||
if exclude_cards and exclude_cards.strip():
|
||||
# Parse the exclude list
|
||||
print(f"DEBUG: Raw exclude_cards input: '{exclude_cards}'")
|
||||
exclude_list = parse_card_list_input(exclude_cards.strip())
|
||||
|
||||
# Store in session for the build engine
|
||||
print(f"DEBUG: Parsed exclude_list: {exclude_list}")
|
||||
sess["exclude_cards"] = exclude_list
|
||||
|
||||
# Create diagnostics (for future status display)
|
||||
else:
|
||||
print(f"DEBUG: exclude_cards is empty or None: '{exclude_cards}'")
|
||||
|
||||
# Store advanced options
|
||||
sess["enforcement_mode"] = enforcement_mode
|
||||
sess["allow_illegal"] = allow_illegal
|
||||
sess["fuzzy_matching"] = fuzzy_matching
|
||||
|
||||
# Create basic diagnostics for status tracking
|
||||
if (include_cards and include_cards.strip()) or (exclude_cards and exclude_cards.strip()):
|
||||
diagnostics = IncludeExcludeDiagnostics(
|
||||
missing_includes=[],
|
||||
ignored_color_identity=[],
|
||||
illegal_dropped=[],
|
||||
illegal_allowed=[],
|
||||
excluded_removed=exclude_list,
|
||||
excluded_removed=sess.get("exclude_cards", []),
|
||||
duplicates_collapsed={},
|
||||
include_added=[],
|
||||
include_over_ideal={},
|
||||
fuzzy_corrections={},
|
||||
confirmation_needed=[],
|
||||
list_size_warnings={"excludes_count": len(exclude_list), "excludes_limit": 15}
|
||||
list_size_warnings={
|
||||
"includes_count": len(sess.get("include_cards", [])),
|
||||
"excludes_count": len(sess.get("exclude_cards", [])),
|
||||
"includes_limit": 10,
|
||||
"excludes_limit": 15
|
||||
}
|
||||
)
|
||||
sess["exclude_diagnostics"] = diagnostics.__dict__
|
||||
sess["include_exclude_diagnostics"] = diagnostics.__dict__
|
||||
except Exception as e:
|
||||
# If exclude parsing fails, log but don't block the build
|
||||
import logging
|
||||
|
|
@ -2570,9 +2600,18 @@ async def build_permalink(request: Request):
|
|||
"locks": list(sess.get("locks", [])),
|
||||
}
|
||||
|
||||
# Add exclude_cards if feature is enabled and present
|
||||
if ALLOW_MUST_HAVES and sess.get("exclude_cards"):
|
||||
payload["exclude_cards"] = sess.get("exclude_cards")
|
||||
# Add include/exclude cards and advanced options if feature is enabled
|
||||
if ALLOW_MUST_HAVES:
|
||||
if sess.get("include_cards"):
|
||||
payload["include_cards"] = sess.get("include_cards")
|
||||
if sess.get("exclude_cards"):
|
||||
payload["exclude_cards"] = sess.get("exclude_cards")
|
||||
if sess.get("enforcement_mode"):
|
||||
payload["enforcement_mode"] = sess.get("enforcement_mode")
|
||||
if sess.get("allow_illegal") is not None:
|
||||
payload["allow_illegal"] = sess.get("allow_illegal")
|
||||
if sess.get("fuzzy_matching") is not None:
|
||||
payload["fuzzy_matching"] = sess.get("fuzzy_matching")
|
||||
try:
|
||||
import base64
|
||||
import json as _json
|
||||
|
|
@ -2638,32 +2677,248 @@ async def validate_exclude_cards(
|
|||
exclude_cards: str = Form(default=""),
|
||||
commander: str = Form(default="")
|
||||
):
|
||||
"""Validate exclude cards list and return diagnostics."""
|
||||
"""Legacy exclude cards validation endpoint - redirect to new unified endpoint."""
|
||||
if not ALLOW_MUST_HAVES:
|
||||
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
|
||||
|
||||
# Call new unified endpoint
|
||||
result = await validate_include_exclude_cards(
|
||||
request=request,
|
||||
include_cards="",
|
||||
exclude_cards=exclude_cards,
|
||||
commander=commander,
|
||||
enforcement_mode="warn",
|
||||
allow_illegal=False,
|
||||
fuzzy_matching=True
|
||||
)
|
||||
|
||||
# Transform to legacy format for backward compatibility
|
||||
if hasattr(result, 'body'):
|
||||
import json
|
||||
data = json.loads(result.body)
|
||||
if 'excludes' in data:
|
||||
excludes = data['excludes']
|
||||
return JSONResponse({
|
||||
"count": excludes.get("count", 0),
|
||||
"limit": excludes.get("limit", 15),
|
||||
"over_limit": excludes.get("over_limit", False),
|
||||
"cards": excludes.get("cards", []),
|
||||
"duplicates": excludes.get("duplicates", {}),
|
||||
"warnings": excludes.get("warnings", [])
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/validate/include_exclude")
|
||||
async def validate_include_exclude_cards(
|
||||
request: Request,
|
||||
include_cards: str = Form(default=""),
|
||||
exclude_cards: str = Form(default=""),
|
||||
commander: str = Form(default=""),
|
||||
enforcement_mode: str = Form(default="warn"),
|
||||
allow_illegal: bool = Form(default=False),
|
||||
fuzzy_matching: bool = Form(default=True)
|
||||
):
|
||||
"""Validate include/exclude card lists with comprehensive diagnostics."""
|
||||
if not ALLOW_MUST_HAVES:
|
||||
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
|
||||
|
||||
try:
|
||||
from deck_builder.include_exclude_utils import parse_card_list_input
|
||||
from deck_builder.include_exclude_utils import (
|
||||
parse_card_list_input, collapse_duplicates,
|
||||
fuzzy_match_card_name, MAX_INCLUDES, MAX_EXCLUDES
|
||||
)
|
||||
from deck_builder.builder import DeckBuilder
|
||||
|
||||
# Parse the input
|
||||
card_list = parse_card_list_input(exclude_cards)
|
||||
# Parse inputs
|
||||
include_list = parse_card_list_input(include_cards) if include_cards.strip() else []
|
||||
exclude_list = parse_card_list_input(exclude_cards) if exclude_cards.strip() else []
|
||||
|
||||
# Basic validation
|
||||
total_count = len(card_list)
|
||||
max_excludes = 15
|
||||
# Collapse duplicates
|
||||
include_unique, include_dupes = collapse_duplicates(include_list)
|
||||
exclude_unique, exclude_dupes = collapse_duplicates(exclude_list)
|
||||
|
||||
# For now, just return count and limit info
|
||||
# Future: add fuzzy matching validation, commander color identity checks
|
||||
# Initialize result structure
|
||||
result = {
|
||||
"count": total_count,
|
||||
"limit": max_excludes,
|
||||
"over_limit": total_count > max_excludes,
|
||||
"cards": card_list[:10] if len(card_list) <= 10 else card_list[:7] + ["..."], # Show preview
|
||||
"warnings": []
|
||||
"includes": {
|
||||
"count": len(include_unique),
|
||||
"limit": MAX_INCLUDES,
|
||||
"over_limit": len(include_unique) > MAX_INCLUDES,
|
||||
"duplicates": include_dupes,
|
||||
"cards": include_unique[:10] if len(include_unique) <= 10 else include_unique[:7] + ["..."],
|
||||
"warnings": [],
|
||||
"legal": [],
|
||||
"illegal": [],
|
||||
"color_mismatched": [],
|
||||
"fuzzy_matches": {}
|
||||
},
|
||||
"excludes": {
|
||||
"count": len(exclude_unique),
|
||||
"limit": MAX_EXCLUDES,
|
||||
"over_limit": len(exclude_unique) > MAX_EXCLUDES,
|
||||
"duplicates": exclude_dupes,
|
||||
"cards": exclude_unique[:10] if len(exclude_unique) <= 10 else exclude_unique[:7] + ["..."],
|
||||
"warnings": [],
|
||||
"legal": [],
|
||||
"illegal": [],
|
||||
"fuzzy_matches": {}
|
||||
},
|
||||
"conflicts": [], # Cards that appear in both lists
|
||||
"confirmation_needed": [], # Cards needing fuzzy match confirmation
|
||||
"overall_warnings": []
|
||||
}
|
||||
|
||||
if total_count > max_excludes:
|
||||
result["warnings"].append(f"Too many excludes: {total_count}/{max_excludes}")
|
||||
# Check for conflicts (cards in both lists)
|
||||
conflicts = set(include_unique) & set(exclude_unique)
|
||||
if conflicts:
|
||||
result["conflicts"] = list(conflicts)
|
||||
result["overall_warnings"].append(f"Cards appear in both lists: {', '.join(list(conflicts)[:3])}{'...' if len(conflicts) > 3 else ''}")
|
||||
|
||||
# Size warnings based on actual counts
|
||||
if result["includes"]["over_limit"]:
|
||||
result["includes"]["warnings"].append(f"Too many includes: {len(include_unique)}/{MAX_INCLUDES}")
|
||||
elif len(include_unique) > MAX_INCLUDES * 0.8: # 80% capacity warning
|
||||
result["includes"]["warnings"].append(f"Approaching limit: {len(include_unique)}/{MAX_INCLUDES}")
|
||||
|
||||
if result["excludes"]["over_limit"]:
|
||||
result["excludes"]["warnings"].append(f"Too many excludes: {len(exclude_unique)}/{MAX_EXCLUDES}")
|
||||
elif len(exclude_unique) > MAX_EXCLUDES * 0.8: # 80% capacity warning
|
||||
result["excludes"]["warnings"].append(f"Approaching limit: {len(exclude_unique)}/{MAX_EXCLUDES}")
|
||||
|
||||
# Do fuzzy matching regardless of commander (for basic card validation)
|
||||
if fuzzy_matching and (include_unique or exclude_unique):
|
||||
print(f"DEBUG: Attempting fuzzy matching with {len(include_unique)} includes, {len(exclude_unique)} excludes")
|
||||
try:
|
||||
# Get card names directly from CSV without requiring commander setup
|
||||
import pandas as pd
|
||||
cards_df = pd.read_csv('csv_files/cards.csv')
|
||||
print(f"DEBUG: CSV columns: {list(cards_df.columns)}")
|
||||
|
||||
# Try to find the name column
|
||||
name_column = None
|
||||
for col in ['Name', 'name', 'card_name', 'CardName']:
|
||||
if col in cards_df.columns:
|
||||
name_column = col
|
||||
break
|
||||
|
||||
if name_column is None:
|
||||
raise ValueError(f"Could not find name column. Available columns: {list(cards_df.columns)}")
|
||||
|
||||
available_cards = set(cards_df[name_column].tolist())
|
||||
print(f"DEBUG: Loaded {len(available_cards)} available cards")
|
||||
|
||||
# Validate includes with fuzzy matching
|
||||
for card_name in include_unique:
|
||||
print(f"DEBUG: Testing include card: {card_name}")
|
||||
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||
print(f"DEBUG: Match result - name: {match_result.matched_name}, auto_accepted: {match_result.auto_accepted}, confidence: {match_result.confidence}")
|
||||
|
||||
if match_result.matched_name and match_result.auto_accepted:
|
||||
# Exact or high-confidence match
|
||||
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||
result["includes"]["legal"].append(match_result.matched_name)
|
||||
elif not match_result.auto_accepted and match_result.suggestions:
|
||||
# Needs confirmation - has suggestions but low confidence
|
||||
print(f"DEBUG: Adding confirmation for {card_name}")
|
||||
result["confirmation_needed"].append({
|
||||
"input": card_name,
|
||||
"suggestions": match_result.suggestions,
|
||||
"confidence": match_result.confidence,
|
||||
"type": "include"
|
||||
})
|
||||
else:
|
||||
# No match found at all, add to illegal
|
||||
result["includes"]["illegal"].append(card_name)
|
||||
|
||||
# Validate excludes with fuzzy matching
|
||||
for card_name in exclude_unique:
|
||||
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||
if match_result.matched_name:
|
||||
if match_result.auto_accepted:
|
||||
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||
result["excludes"]["legal"].append(match_result.matched_name)
|
||||
else:
|
||||
# Needs confirmation
|
||||
result["confirmation_needed"].append({
|
||||
"input": card_name,
|
||||
"suggestions": match_result.suggestions,
|
||||
"confidence": match_result.confidence,
|
||||
"type": "exclude"
|
||||
})
|
||||
else:
|
||||
# No match found, add to illegal
|
||||
result["excludes"]["illegal"].append(card_name)
|
||||
|
||||
except Exception as fuzzy_error:
|
||||
print(f"DEBUG: Fuzzy matching error: {str(fuzzy_error)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
result["overall_warnings"].append(f"Fuzzy matching unavailable: {str(fuzzy_error)}")
|
||||
|
||||
# If we have a commander, do advanced validation (color identity, etc.)
|
||||
if commander and commander.strip():
|
||||
try:
|
||||
# Create a temporary builder to get available card names
|
||||
builder = DeckBuilder()
|
||||
builder.setup_dataframes()
|
||||
|
||||
# Get available card names for fuzzy matching
|
||||
available_cards = set(builder._full_cards_df['Name'].tolist())
|
||||
|
||||
# Validate includes with fuzzy matching
|
||||
for card_name in include_unique:
|
||||
if fuzzy_matching:
|
||||
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||
if match_result.matched_name:
|
||||
if match_result.auto_accepted:
|
||||
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||
result["includes"]["legal"].append(match_result.matched_name)
|
||||
else:
|
||||
# Needs confirmation
|
||||
result["confirmation_needed"].append({
|
||||
"input": card_name,
|
||||
"suggestions": match_result.suggestions,
|
||||
"confidence": match_result.confidence,
|
||||
"type": "include"
|
||||
})
|
||||
else:
|
||||
result["includes"]["illegal"].append(card_name)
|
||||
else:
|
||||
# Exact match only
|
||||
if card_name in available_cards:
|
||||
result["includes"]["legal"].append(card_name)
|
||||
else:
|
||||
result["includes"]["illegal"].append(card_name)
|
||||
|
||||
# Validate excludes with fuzzy matching
|
||||
for card_name in exclude_unique:
|
||||
if fuzzy_matching:
|
||||
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||
if match_result.matched_name:
|
||||
if match_result.auto_accepted:
|
||||
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||
result["excludes"]["legal"].append(match_result.matched_name)
|
||||
else:
|
||||
# Needs confirmation
|
||||
result["confirmation_needed"].append({
|
||||
"input": card_name,
|
||||
"suggestions": match_result.suggestions,
|
||||
"confidence": match_result.confidence,
|
||||
"type": "exclude"
|
||||
})
|
||||
else:
|
||||
result["excludes"]["illegal"].append(card_name)
|
||||
else:
|
||||
# Exact match only
|
||||
if card_name in available_cards:
|
||||
result["excludes"]["legal"].append(card_name)
|
||||
else:
|
||||
result["excludes"]["illegal"].append(card_name)
|
||||
|
||||
except Exception as validation_error:
|
||||
# Advanced validation failed, but return basic validation
|
||||
result["overall_warnings"].append(f"Advanced validation unavailable: {str(validation_error)}")
|
||||
|
||||
return JSONResponse(result)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue