mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat: theme catalog optimization with tag search and faster enrichment
This commit is contained in:
parent
952b151162
commit
9e6c68f559
26 changed files with 5906 additions and 5688 deletions
186
code/web/routes/cards.py
Normal file
186
code/web/routes/cards.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"""Card browsing and tag search API endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
# Import tag index from M3
|
||||
try:
|
||||
from code.tagging.tag_index import get_tag_index
|
||||
except ImportError:
|
||||
from tagging.tag_index import get_tag_index
|
||||
|
||||
# Import all cards loader
|
||||
try:
|
||||
from code.services.all_cards_loader import AllCardsLoader
|
||||
except ImportError:
|
||||
from services.all_cards_loader import AllCardsLoader
|
||||
|
||||
router = APIRouter(prefix="/api/cards", tags=["cards"])
|
||||
|
||||
# Cache for all_cards loader
|
||||
_all_cards_loader: Optional[AllCardsLoader] = None
|
||||
|
||||
|
||||
def _get_all_cards_loader() -> AllCardsLoader:
|
||||
"""Get cached AllCardsLoader instance."""
|
||||
global _all_cards_loader
|
||||
if _all_cards_loader is None:
|
||||
_all_cards_loader = AllCardsLoader()
|
||||
return _all_cards_loader
|
||||
|
||||
|
||||
@router.get("/by-tags")
|
||||
async def search_by_tags(
|
||||
tags: str = Query(..., description="Comma-separated list of theme tags"),
|
||||
logic: str = Query("AND", description="Search logic: AND (intersection) or OR (union)"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum number of results"),
|
||||
) -> JSONResponse:
|
||||
"""Search for cards by theme tags.
|
||||
|
||||
Examples:
|
||||
/api/cards/by-tags?tags=tokens&logic=AND
|
||||
/api/cards/by-tags?tags=tokens,sacrifice&logic=AND
|
||||
/api/cards/by-tags?tags=lifegain,lifelink&logic=OR
|
||||
|
||||
Args:
|
||||
tags: Comma-separated theme tags to search for
|
||||
logic: "AND" for cards with all tags, "OR" for cards with any tag
|
||||
limit: Maximum results to return
|
||||
|
||||
Returns:
|
||||
JSON with matching cards and metadata
|
||||
"""
|
||||
try:
|
||||
# Parse tags
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
if not tag_list:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "No valid tags provided"}
|
||||
)
|
||||
|
||||
# Get tag index and find matching cards
|
||||
tag_index = get_tag_index()
|
||||
|
||||
if logic.upper() == "AND":
|
||||
card_names = tag_index.get_cards_with_all_tags(tag_list)
|
||||
elif logic.upper() == "OR":
|
||||
card_names = tag_index.get_cards_with_any_tags(tag_list)
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": f"Invalid logic: {logic}. Use AND or OR."}
|
||||
)
|
||||
|
||||
# Load full card data
|
||||
all_cards = _get_all_cards_loader().load()
|
||||
matching_cards = all_cards[all_cards["name"].isin(card_names)]
|
||||
|
||||
# Limit results
|
||||
matching_cards = matching_cards.head(limit)
|
||||
|
||||
# Convert to dict
|
||||
results = matching_cards.to_dict("records")
|
||||
|
||||
return JSONResponse(content={
|
||||
"tags": tag_list,
|
||||
"logic": logic.upper(),
|
||||
"total_matches": len(card_names),
|
||||
"returned": len(results),
|
||||
"limit": limit,
|
||||
"cards": results
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": f"Search failed: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tags/search")
|
||||
async def search_tags(
|
||||
q: str = Query(..., min_length=2, description="Tag prefix to search for"),
|
||||
limit: int = Query(10, ge=1, le=50, description="Maximum number of suggestions"),
|
||||
) -> JSONResponse:
|
||||
"""Autocomplete search for theme tags.
|
||||
|
||||
Examples:
|
||||
/api/cards/tags/search?q=life
|
||||
/api/cards/tags/search?q=token&limit=5
|
||||
|
||||
Args:
|
||||
q: Tag prefix (minimum 2 characters)
|
||||
limit: Maximum suggestions to return
|
||||
|
||||
Returns:
|
||||
JSON with matching tags sorted by popularity
|
||||
"""
|
||||
try:
|
||||
tag_index = get_tag_index()
|
||||
|
||||
# Get all tags with counts - get_popular_tags returns all tags when given a high limit
|
||||
all_tags_with_counts = tag_index.get_popular_tags(limit=10000)
|
||||
|
||||
# Filter by prefix (case-insensitive)
|
||||
prefix_lower = q.lower()
|
||||
matches = [
|
||||
(tag, count)
|
||||
for tag, count in all_tags_with_counts
|
||||
if tag.lower().startswith(prefix_lower)
|
||||
]
|
||||
|
||||
# Already sorted by popularity from get_popular_tags
|
||||
# Limit results
|
||||
matches = matches[:limit]
|
||||
|
||||
return JSONResponse(content={
|
||||
"query": q,
|
||||
"matches": [
|
||||
{"tag": tag, "card_count": count}
|
||||
for tag, count in matches
|
||||
]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": f"Tag search failed: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tags/popular")
|
||||
async def get_popular_tags(
|
||||
limit: int = Query(50, ge=1, le=200, description="Number of popular tags to return"),
|
||||
) -> JSONResponse:
|
||||
"""Get the most popular theme tags by card count.
|
||||
|
||||
Examples:
|
||||
/api/cards/tags/popular
|
||||
/api/cards/tags/popular?limit=20
|
||||
|
||||
Args:
|
||||
limit: Maximum tags to return
|
||||
|
||||
Returns:
|
||||
JSON with popular tags sorted by card count
|
||||
"""
|
||||
try:
|
||||
tag_index = get_tag_index()
|
||||
popular = tag_index.get_popular_tags(limit=limit)
|
||||
|
||||
return JSONResponse(content={
|
||||
"count": len(popular),
|
||||
"tags": [
|
||||
{"tag": tag, "card_count": count}
|
||||
for tag, count in popular
|
||||
]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": f"Failed to get popular tags: {str(e)}"}
|
||||
)
|
||||
|
|
@ -526,6 +526,52 @@ def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, Commander
|
|||
return info
|
||||
|
||||
|
||||
@router.get("/theme-autocomplete", response_class=HTMLResponse)
|
||||
async def theme_autocomplete(
|
||||
request: Request,
|
||||
theme: str = Query(..., min_length=2, description="Theme prefix to search for"),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
) -> HTMLResponse:
|
||||
"""HTMX endpoint for theme tag autocomplete."""
|
||||
try:
|
||||
# Import tag_index
|
||||
try:
|
||||
from code.tagging.tag_index import get_tag_index
|
||||
except ImportError:
|
||||
from tagging.tag_index import get_tag_index
|
||||
|
||||
tag_index = get_tag_index()
|
||||
|
||||
# Get all tags with counts - get_popular_tags returns all tags when given a high limit
|
||||
all_tags_with_counts = tag_index.get_popular_tags(limit=10000)
|
||||
|
||||
# Filter by prefix (case-insensitive)
|
||||
prefix_lower = theme.lower()
|
||||
matches = [
|
||||
(tag, count)
|
||||
for tag, count in all_tags_with_counts
|
||||
if tag.lower().startswith(prefix_lower)
|
||||
]
|
||||
|
||||
# Already sorted by popularity from get_popular_tags
|
||||
matches = matches[:limit]
|
||||
|
||||
# Generate HTML suggestions with ARIA attributes
|
||||
html_parts = []
|
||||
for tag, count in matches:
|
||||
html_parts.append(
|
||||
f'<div class="autocomplete-item" data-value="{tag}" role="option">'
|
||||
f'{tag} <span class="tag-count">({count})</span></div>'
|
||||
)
|
||||
|
||||
html = "\n".join(html_parts) if html_parts else '<div class="autocomplete-empty">No matching themes</div>'
|
||||
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
except Exception as e:
|
||||
return HTMLResponse(content=f'<div class="autocomplete-error">Error: {str(e)}</div>')
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def commanders_index(
|
||||
request: Request,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue