374 lines
13 KiB
Python
374 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
AI Card Reviewer - Uses Claude's vision to validate and correct card data.
|
|
|
|
Usage:
|
|
python tools/ai_card_reviewer.py # Review all unreviewed cards
|
|
python tools/ai_card_reviewer.py --set 1 # Review only Opus 1 cards
|
|
python tools/ai_card_reviewer.py --card 1-001H # Review a specific card
|
|
python tools/ai_card_reviewer.py --limit 10 # Review only 10 cards
|
|
python tools/ai_card_reviewer.py --dry-run # Don't save changes, just show what would change
|
|
|
|
Requires:
|
|
pip install anthropic
|
|
|
|
Set your API key:
|
|
export ANTHROPIC_API_KEY=your-key-here
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import anthropic
|
|
except ImportError:
|
|
print("Error: anthropic package not installed. Run: pip install anthropic")
|
|
sys.exit(1)
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent
|
|
CARDS_FILE = PROJECT_ROOT / "data" / "cards.json"
|
|
REVIEWED_FILE = PROJECT_ROOT / "data" / "reviewed.json"
|
|
SOURCE_CARDS_DIR = PROJECT_ROOT / "source-cards"
|
|
|
|
# Rate limiting
|
|
REQUESTS_PER_MINUTE = 30
|
|
REQUEST_DELAY = 60 / REQUESTS_PER_MINUTE
|
|
|
|
|
|
def load_cards():
|
|
with open(CARDS_FILE, "r") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def save_cards(data):
|
|
with open(CARDS_FILE, "w") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
|
|
|
|
def load_reviewed():
|
|
if REVIEWED_FILE.exists():
|
|
with open(REVIEWED_FILE, "r") as f:
|
|
return set(json.load(f).get("reviewed", []))
|
|
return set()
|
|
|
|
|
|
def save_reviewed(reviewed_set):
|
|
with open(REVIEWED_FILE, "w") as f:
|
|
json.dump({"reviewed": list(reviewed_set)}, f, indent=2)
|
|
|
|
|
|
def encode_image(image_path: Path) -> tuple[str, str]:
|
|
"""Encode image to base64 and return (data, media_type)."""
|
|
with open(image_path, "rb") as f:
|
|
data = base64.standard_b64encode(f.read()).decode("utf-8")
|
|
|
|
ext = image_path.suffix.lower()
|
|
if ext in (".jpg", ".jpeg"):
|
|
media_type = "image/jpeg"
|
|
elif ext == ".png":
|
|
media_type = "image/png"
|
|
elif ext == ".gif":
|
|
media_type = "image/gif"
|
|
elif ext == ".webp":
|
|
media_type = "image/webp"
|
|
else:
|
|
media_type = "image/jpeg" # fallback
|
|
|
|
return data, media_type
|
|
|
|
|
|
SYSTEM_PROMPT = """You are a data validator for Final Fantasy Trading Card Game (FFTCG) cards.
|
|
You will be shown a card image and its current JSON data. Your job is to:
|
|
|
|
1. Verify all fields match what's visible on the card
|
|
2. Correct any errors you find
|
|
3. Fill in any missing data
|
|
4. Ensure abilities are accurately transcribed
|
|
|
|
FFTCG Card Structure:
|
|
- id: Card number (e.g., "1-001H" = Opus 1, card 001, Hero rarity)
|
|
- name: Character/card name
|
|
- type: Forward, Backup, Summon, or Monster
|
|
- element: Fire, Ice, Wind, Earth, Lightning, Water, Light, or Dark (can be array for multi-element)
|
|
- cost: Crystal Point cost (number in top-left)
|
|
- power: Power value for Forwards (number at bottom, in thousands like 7000). Backups/Summons have null power.
|
|
- job: Job class (e.g., "Warrior", "Knight")
|
|
- category: Game title (e.g., "VII", "X", "TACTICS")
|
|
- is_generic: true if card has no category (generic cards)
|
|
- has_ex_burst: true if card has EX BURST ability (lightning bolt icon)
|
|
- has_haste: true if the card has the Haste keyword ability (can attack/use abilities the turn it enters)
|
|
|
|
Ability Types:
|
|
- field: Passive abilities always active (includes keyword abilities like Haste, Brave, First Strike)
|
|
- auto: Triggered abilities (start with "When..." or have a trigger condition)
|
|
- action: Activated abilities (have a cost, often require dulling with S symbol)
|
|
- special: Special abilities (usually named abilities with S symbol cost)
|
|
|
|
Important Keywords to Identify:
|
|
- Haste: "This card can attack and use abilities the turn it enters the field" - set has_haste=true
|
|
- Brave: Card doesn't dull when attacking
|
|
- First Strike: Deals damage before opponent in combat
|
|
|
|
Respond ONLY with valid JSON in this exact format:
|
|
{
|
|
"changes_made": true/false,
|
|
"confidence": "high"/"medium"/"low",
|
|
"notes": "Brief explanation of changes or issues",
|
|
"corrected_data": {
|
|
// Complete card JSON with all fields
|
|
}
|
|
}
|
|
|
|
If the data looks correct, set changes_made to false and return the original data in corrected_data.
|
|
Always include ALL fields in corrected_data, even if unchanged."""
|
|
|
|
|
|
def review_card(client: anthropic.Anthropic, card: dict, image_path: Path) -> dict:
|
|
"""Review a single card using Claude's vision."""
|
|
|
|
image_data, media_type = encode_image(image_path)
|
|
|
|
user_message = f"""Please review this FFTCG card image and verify/correct the following JSON data:
|
|
|
|
```json
|
|
{json.dumps(card, indent=2)}
|
|
```
|
|
|
|
Look carefully at:
|
|
1. Card name spelling
|
|
2. Element (color of the crystal/card border)
|
|
3. Cost (number in the crystal)
|
|
4. Power (number at bottom for Forwards, should be null for Backups/Summons)
|
|
5. Job and Category text
|
|
6. All abilities - check type, name, trigger, effect text
|
|
7. EX BURST indicator (lightning bolt symbol)
|
|
8. Haste keyword - if the card mentions attacking or using abilities the turn it enters, has_haste should be true
|
|
|
|
Return the corrected JSON."""
|
|
|
|
try:
|
|
response = client.messages.create(
|
|
model="claude-opus-4-5-20251101",
|
|
max_tokens=4096,
|
|
system=SYSTEM_PROMPT,
|
|
messages=[
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": media_type,
|
|
"data": image_data,
|
|
},
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": user_message,
|
|
},
|
|
],
|
|
}
|
|
],
|
|
)
|
|
|
|
# Extract JSON from response
|
|
response_text = response.content[0].text
|
|
|
|
# Try to parse JSON (handle markdown code blocks)
|
|
if "```json" in response_text:
|
|
json_str = response_text.split("```json")[1].split("```")[0].strip()
|
|
elif "```" in response_text:
|
|
json_str = response_text.split("```")[1].split("```")[0].strip()
|
|
else:
|
|
json_str = response_text.strip()
|
|
|
|
return json.loads(json_str)
|
|
|
|
except json.JSONDecodeError as e:
|
|
print(f" JSON parse error: {e}")
|
|
print(f" Response: {response_text[:500]}...")
|
|
return None
|
|
except anthropic.APIError as e:
|
|
print(f" API error: {e}")
|
|
return None
|
|
|
|
|
|
def print_diff(original: dict, corrected: dict, card_id: str):
|
|
"""Print differences between original and corrected card data."""
|
|
changes = []
|
|
|
|
# Compare top-level fields
|
|
for key in set(list(original.keys()) + list(corrected.keys())):
|
|
if key in ("abilities", "image"):
|
|
continue
|
|
orig_val = original.get(key)
|
|
corr_val = corrected.get(key)
|
|
if orig_val != corr_val:
|
|
changes.append(f" {key}: {orig_val!r} -> {corr_val!r}")
|
|
|
|
# Compare abilities (simplified)
|
|
orig_abilities = original.get("abilities", [])
|
|
corr_abilities = corrected.get("abilities", [])
|
|
|
|
if len(orig_abilities) != len(corr_abilities):
|
|
changes.append(f" abilities: {len(orig_abilities)} -> {len(corr_abilities)} abilities")
|
|
else:
|
|
for i, (orig_ab, corr_ab) in enumerate(zip(orig_abilities, corr_abilities)):
|
|
if orig_ab != corr_ab:
|
|
changes.append(f" abilities[{i}]: modified")
|
|
|
|
if changes:
|
|
print(f"\n[{card_id}] Changes:")
|
|
for change in changes:
|
|
print(change)
|
|
else:
|
|
print(f"[{card_id}] No changes needed")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="AI Card Reviewer using Claude Vision")
|
|
parser.add_argument("--set", type=str, help="Only review cards from this set/opus (e.g., '1' for Opus 1)")
|
|
parser.add_argument("--card", type=str, help="Review a specific card by ID")
|
|
parser.add_argument("--limit", type=int, help="Maximum number of cards to review")
|
|
parser.add_argument("--dry-run", action="store_true", help="Don't save changes, just show what would change")
|
|
parser.add_argument("--include-reviewed", action="store_true", help="Re-review already reviewed cards")
|
|
parser.add_argument("--auto-mark-reviewed", action="store_true", help="Automatically mark cards as reviewed after AI review")
|
|
args = parser.parse_args()
|
|
|
|
# Check API key
|
|
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
if not api_key:
|
|
print("Error: ANTHROPIC_API_KEY environment variable not set")
|
|
print("Set it with: export ANTHROPIC_API_KEY=your-key-here")
|
|
sys.exit(1)
|
|
|
|
client = anthropic.Anthropic(api_key=api_key)
|
|
|
|
# Load data
|
|
cards_data = load_cards()
|
|
reviewed_set = load_reviewed()
|
|
all_cards = cards_data["cards"]
|
|
|
|
print(f"Loaded {len(all_cards)} cards, {len(reviewed_set)} already reviewed")
|
|
|
|
# Filter cards to review
|
|
cards_to_review = []
|
|
for card in all_cards:
|
|
# Skip if already reviewed (unless --include-reviewed)
|
|
if not args.include_reviewed and card["id"] in reviewed_set:
|
|
continue
|
|
|
|
# Filter by set if specified
|
|
if args.set and not card["id"].startswith(args.set + "-"):
|
|
continue
|
|
|
|
# Filter by specific card if specified
|
|
if args.card and card["id"] != args.card:
|
|
continue
|
|
|
|
# Check image exists
|
|
image_path = SOURCE_CARDS_DIR / card.get("image", "")
|
|
if not image_path.exists():
|
|
print(f"Warning: Image not found for {card['id']}: {image_path}")
|
|
continue
|
|
|
|
cards_to_review.append((card, image_path))
|
|
|
|
# Apply limit
|
|
if args.limit:
|
|
cards_to_review = cards_to_review[:args.limit]
|
|
|
|
if not cards_to_review:
|
|
print("No cards to review matching criteria")
|
|
return
|
|
|
|
print(f"\nReviewing {len(cards_to_review)} cards...")
|
|
if args.dry_run:
|
|
print("(DRY RUN - no changes will be saved)")
|
|
print()
|
|
|
|
# Track statistics
|
|
stats = {
|
|
"reviewed": 0,
|
|
"changed": 0,
|
|
"errors": 0,
|
|
"high_confidence": 0,
|
|
"medium_confidence": 0,
|
|
"low_confidence": 0,
|
|
}
|
|
|
|
# Review each card
|
|
for i, (card, image_path) in enumerate(cards_to_review):
|
|
print(f"[{i+1}/{len(cards_to_review)}] Reviewing {card['id']}: {card.get('name', 'Unknown')}...", end="", flush=True)
|
|
|
|
result = review_card(client, card, image_path)
|
|
|
|
if result is None:
|
|
print(" ERROR")
|
|
stats["errors"] += 1
|
|
time.sleep(REQUEST_DELAY)
|
|
continue
|
|
|
|
stats["reviewed"] += 1
|
|
confidence = result.get("confidence", "unknown")
|
|
stats[f"{confidence}_confidence"] = stats.get(f"{confidence}_confidence", 0) + 1
|
|
|
|
if result.get("changes_made"):
|
|
stats["changed"] += 1
|
|
print(f" CHANGED ({confidence} confidence)")
|
|
print(f" Notes: {result.get('notes', 'No notes')}")
|
|
|
|
corrected = result.get("corrected_data", {})
|
|
print_diff(card, corrected, card["id"])
|
|
|
|
if not args.dry_run:
|
|
# Update card in data
|
|
for j, c in enumerate(cards_data["cards"]):
|
|
if c["id"] == card["id"]:
|
|
# Preserve image path
|
|
corrected["image"] = card.get("image", "")
|
|
cards_data["cards"][j] = corrected
|
|
break
|
|
else:
|
|
print(f" OK ({confidence} confidence)")
|
|
|
|
# Mark as reviewed if auto-mark enabled
|
|
if args.auto_mark_reviewed and not args.dry_run:
|
|
reviewed_set.add(card["id"])
|
|
|
|
# Rate limiting
|
|
if i < len(cards_to_review) - 1:
|
|
time.sleep(REQUEST_DELAY)
|
|
|
|
# Save changes
|
|
if not args.dry_run and stats["changed"] > 0:
|
|
print(f"\nSaving changes to {CARDS_FILE}...")
|
|
save_cards(cards_data)
|
|
|
|
if not args.dry_run and args.auto_mark_reviewed:
|
|
print(f"Saving reviewed status to {REVIEWED_FILE}...")
|
|
save_reviewed(reviewed_set)
|
|
|
|
# Print summary
|
|
print(f"\n{'='*50}")
|
|
print("Summary:")
|
|
print(f" Cards reviewed: {stats['reviewed']}")
|
|
print(f" Cards changed: {stats['changed']}")
|
|
print(f" Errors: {stats['errors']}")
|
|
print(f" High confidence: {stats['high_confidence']}")
|
|
print(f" Medium confidence: {stats['medium_confidence']}")
|
|
print(f" Low confidence: {stats['low_confidence']}")
|
|
|
|
if args.dry_run and stats["changed"] > 0:
|
|
print(f"\n(Dry run - {stats['changed']} changes NOT saved)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|