Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa43fe3ecd | ||
|
|
d0a1c10372 | ||
|
|
91d1e9449a | ||
|
|
1b2707014b | ||
|
|
91279d1d67 | ||
|
|
3ec0b73503 | ||
|
|
f9114b6118 | ||
|
|
1e3669af42 | ||
|
|
c41ca8f09b | ||
|
|
4189289945 | ||
|
|
f47b5182d2 | ||
|
|
5f606fee22 | ||
|
|
f5dae81193 | ||
|
|
36b3c8c55b | ||
|
|
a098524b98 | ||
|
|
acc9d7cd47 | ||
|
|
588314b282 | ||
|
|
7132b6f205 | ||
|
|
1704d7ca9c | ||
|
|
f639703fb4 | ||
|
|
cbedfd1ed2 | ||
|
|
be55a02860 |
No files matched your search
-21
@@ -1,21 +0,0 @@
|
||||
[run]
|
||||
source = src/commander_ai_lab
|
||||
omit =
|
||||
*/tests/*
|
||||
*/__pycache__/*
|
||||
*/web/static/*
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __repr__
|
||||
raise NotImplementedError
|
||||
if TYPE_CHECKING:
|
||||
if __name__ == .__main__.:
|
||||
\.\.\. # ellipsis
|
||||
|
||||
show_missing = true
|
||||
precision = 1
|
||||
|
||||
[html]
|
||||
directory = htmlcov
|
||||
@@ -1,36 +0,0 @@
|
||||
# Commander AI Lab - Environment Variables
|
||||
# Copy this file to .env and fill in your API keys.
|
||||
# .env is gitignored and will NOT be committed.
|
||||
|
||||
# Ximilar API Key (card scanner / visual AI recognition)
|
||||
# Get yours at: https://app.ximilar.com/
|
||||
XIMILAR_API_KEY=your-ximilar-api-key-here
|
||||
|
||||
# Perplexity API Key (AI deck research & generation)
|
||||
# Get yours at: https://www.perplexity.ai/settings/api
|
||||
PPLX_API_KEY=your-perplexity-api-key-here
|
||||
# Perplexity model for deck research & generation (sonar / sonar-pro / sonar-deep-research)
|
||||
PPLX_MODEL=sonar-pro
|
||||
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
|
||||
COACH_PROVIDER=local
|
||||
|
||||
# CORS allowed origins (comma-separated)
|
||||
# Default: http://localhost:5173,http://localhost:8080
|
||||
# Add your LAN IP if accessing from another machine on your network
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||
|
||||
# ── Dual-Model AI Brain ─────────────────────────────────────
|
||||
# Primary brain: GPT-OSS 20B (turn planner / decision maker)
|
||||
BRAIN_API_BASE=http://localhost:11434
|
||||
BRAIN_MODEL=gpt-oss:20b
|
||||
BRAIN_TEMPERATURE=0.3
|
||||
BRAIN_MAX_TOKENS=1024
|
||||
BRAIN_TIMEOUT=120.0
|
||||
|
||||
# Validator brain: DeepSeek-R1-Distill-Qwen-14B (legality + explanation)
|
||||
VALIDATOR_ENABLED=true
|
||||
VALIDATOR_API_BASE=http://localhost:11434
|
||||
VALIDATOR_MODEL=deepseek-r1:14b
|
||||
VALIDATOR_TEMPERATURE=0.0
|
||||
VALIDATOR_MAX_TOKENS=2048
|
||||
VALIDATOR_TIMEOUT=120.0
|
||||
@@ -1,48 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
python-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]" || pip install -r requirements.txt || true
|
||||
pip install ruff pytest pytest-asyncio httpx || true
|
||||
|
||||
- name: Lint with ruff
|
||||
run: ruff check . --select E,F,W --ignore E501 || true
|
||||
|
||||
- name: Run tests
|
||||
run: pytest -v --tb=short tests/ || true
|
||||
|
||||
java-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Build and run Maven tests
|
||||
run: mvn test -B --no-transfer-progress || true
|
||||
@@ -1,60 +0,0 @@
|
||||
name: Unity Build (Windows + WebGL)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'unity-client/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'unity-client/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
UNITY_PROJECT_PATH: unity-client
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.targetPlatform }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
targetPlatform:
|
||||
- StandaloneWindows64
|
||||
- WebGL
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Cache Unity Library
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.UNITY_PROJECT_PATH }}/Library
|
||||
key: Library-${{ matrix.targetPlatform }}-${{ hashFiles(format('{0}/Assets/**', env.UNITY_PROJECT_PATH)) }}
|
||||
restore-keys: |
|
||||
Library-${{ matrix.targetPlatform }}-
|
||||
Library-
|
||||
|
||||
- name: Build Unity project
|
||||
uses: game-ci/unity-builder@v4
|
||||
env:
|
||||
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
with:
|
||||
projectPath: ${{ env.UNITY_PROJECT_PATH }}
|
||||
targetPlatform: ${{ matrix.targetPlatform }}
|
||||
buildName: CommanderAILab
|
||||
versioning: Semantic
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Build-${{ matrix.targetPlatform }}
|
||||
path: build/${{ matrix.targetPlatform }}
|
||||
retention-days: 14
|
||||
@@ -1,145 +0,0 @@
|
||||
# Commander-AI-Lab GUI + LLM Opponents Roadmap
|
||||
|
||||
## Project Summary
|
||||
|
||||
Build a playable Magic: The Gathering Commander interface where human players face AI opponents powered by a local LLM. The existing Python rules engine handles game legality and state management, while the LLM acts as the decision-making and narration brain for each AI opponent. A Pygame (or web-based) GUI renders the battlefield, zones, and card interactions. Each AI opponent gets a unique personality prompt to simulate different playstyles (aggro, control, combo, politics).
|
||||
|
||||
**Local LLM:** GPT-OSS 20B (Q4_K_M) via Ollama or LM Studio
|
||||
|
||||
---
|
||||
|
||||
## GUI Strategy: Two-Phase Frontend Plan
|
||||
|
||||
### Phase A (Prototype) — Pygame
|
||||
Use **Pygame** for Phases 1–3 of development. Pure Python means the rules engine and AI opponent code plug in directly with zero bridging. Fast to iterate — a working battlefield renderer can be built in a day. Handles game loop, input events, and card rendering naturally. Ideal for testing AI decisions visually before investing in a polished UI.
|
||||
|
||||
### Phase B (Production) — Flask/FastAPI + Web Frontend
|
||||
Once the core AI gameplay is working, migrate the UI to a **Flask or FastAPI backend + HTML/JS frontend** (vanilla JS or React). Benefits:
|
||||
- Far more polished look for a Commander game
|
||||
- Card art from Scryfall API renders natively in browser
|
||||
- Easier to add multiplayer later via WebSockets
|
||||
- Python backend stays pure — frontend calls it via REST API
|
||||
- No Python/JS bridge complexity; clean separation of concerns
|
||||
|
||||
### GUI Framework Comparison
|
||||
|
||||
| Option | Language | Curve | Best For | Weakness |
|
||||
|---|---|---|---|---|
|
||||
| **Pygame** ✅ Phase A | Python | Low | Quick prototype, game loops | Dated UI, manual widgets |
|
||||
| **Flask + HTML/JS** ✅ Phase B | Python + JS | Medium | Polished UI, browser-based | JS context switch |
|
||||
| PyQt6 / PySide6 | Python | Medium | Desktop app widgets | Verbose, overkill for a game |
|
||||
| Godot | GDScript | Medium | Full game engine, animations | Separate codebase from engine |
|
||||
| Electron + Python | JS + Python | High | Desktop with web UI | Heavy, complex bridge |
|
||||
| Tkinter | Python | Low | Simple UI | Very dated look |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Game State (Python dict/dataclass)
|
||||
↓
|
||||
Legal Moves Generator (rules engine)
|
||||
↓
|
||||
LLM Prompt: "Given this state and these legal moves, what do you do?"
|
||||
↓ (GPT-OSS 20B Q4_K_M)
|
||||
LLM Response → parsed action
|
||||
↓
|
||||
Rules Engine executes + validates
|
||||
↓
|
||||
GUI updates board state (Pygame → Web)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task List
|
||||
|
||||
### Phase 1 — Game State Serialization
|
||||
- [ ] Define a compressed game state schema (Python dataclass or dict) covering: hand, battlefield, graveyard, exile, stack, life totals, commander zone, mana pool
|
||||
- [ ] Write a `state_to_prompt()` function that converts game state to a compact LLM-readable string
|
||||
- [ ] Write a `legal_moves_to_prompt()` function that lists valid actions in numbered format for LLM selection
|
||||
- [ ] Add a state snapshot logger for debugging AI decisions
|
||||
|
||||
### Phase 2 — LLM AI Opponent Integration
|
||||
- [ ] Load GPT-OSS 20B (Q4_K_M) in Ollama or LM Studio; verify inference speed on RTX 5070 Ti
|
||||
- [ ] Benchmark VRAM usage for GPT-OSS 20B Q4_K_M (expected ~12–14 GB VRAM at Q4_K_M quantization)
|
||||
- [ ] Build `AIOpponent` class with fields: name, personality_prompt, model_endpoint, deck
|
||||
- [ ] Implement `decide_action(game_state, legal_moves)` method — sends prompt to GPT-OSS 20B, parses LLM response, returns validated action index
|
||||
- [ ] Add fallback logic: if LLM returns invalid/unparseable action, default to highest-priority heuristic move
|
||||
- [ ] Write personality prompt templates (Aggro Timmy, Control Spike, Combo Johnny, Political Negotiator)
|
||||
- [ ] Add narration method: `narrate_play(action)` — GPT-OSS 20B generates in-character flavor text for each play
|
||||
- [ ] Tune system prompt length to stay within GPT-OSS 20B context window limits
|
||||
|
||||
### Phase 3 — Multi-Player Turn Management
|
||||
- [ ] Implement turn queue: Player → AI1 → AI2 → AI3 → repeat
|
||||
- [ ] Run AI decisions asynchronously (threading or asyncio) so GUI stays responsive during LLM inference
|
||||
- [ ] Add priority-passing logic for instants/triggers during other players' turns
|
||||
- [ ] Implement basic AI threat assessment: each AI scores opponents by board state danger level
|
||||
|
||||
### Phase 4 — Pygame Prototype GUI
|
||||
- [ ] Design zone layout: hand, battlefield (creature/non-creature split), graveyard, exile, command zone, stack
|
||||
- [ ] Build card rendering: card art placeholder, name, P/T or loyalty, tapped/untapped state
|
||||
- [ ] Add click-to-play card interaction for human player
|
||||
- [ ] Display AI narration/flavor text in a side panel
|
||||
- [ ] Add life total trackers and commander damage matrix (4-player)
|
||||
- [ ] Build phase/step indicator (Main 1, Combat, Main 2, etc.)
|
||||
- [ ] Add end-of-game screen with win condition display
|
||||
|
||||
### Phase 5 — Web Frontend Migration *(Flask/FastAPI + HTML/JS)*
|
||||
- [ ] Set up Flask or FastAPI backend exposing game state as REST API
|
||||
- [ ] Build HTML/JS board layout mirroring Pygame zone design
|
||||
- [ ] Integrate Scryfall API for card art rendering
|
||||
- [ ] Add WebSocket support for real-time game state updates
|
||||
- [ ] Add drag-and-drop card interaction
|
||||
- [ ] Port AI narration panel to web chat-style UI
|
||||
- [ ] Test full game loop end-to-end on web frontend
|
||||
|
||||
### Phase 6 — Politics & Table Talk *(Stretch Goal)*
|
||||
- [ ] Implement inter-AI negotiation: before attacking, AI queries GPT-OSS 20B "should I propose a deal?"
|
||||
- [ ] Build deal proposal UI: AI proposes, human can accept/decline, other AIs respond
|
||||
- [ ] Add threat-based targeting memory: AIs remember who attacked them last turn
|
||||
|
||||
### Phase 7 — Testing & Tuning
|
||||
- [ ] Run 10 full simulated games (4 AI vs AI) and log decision quality
|
||||
- [ ] Benchmark GPT-OSS 20B Q4_K_M inference time per decision on RTX 5070 Ti
|
||||
- [ ] Tune context window compression to stay within model token limits
|
||||
- [ ] Profile Pygame and web render loops for stability during AI thinking phases
|
||||
- [ ] Add unit tests for `state_to_prompt()` and `decide_action()` parsing
|
||||
|
||||
### Phase 8 — Multiplayer (Human vs Human + AI)
|
||||
- [ ] **Lobby system** — create/join game rooms with configurable seat count (2–4 players); assign each seat as Human or AI
|
||||
- [ ] **WebSocket game server** — upgrade Flask/FastAPI backend to handle persistent connections for real-time state sync across multiple browser clients
|
||||
- [ ] **Player session management** — unique session tokens per human player; reconnect support if connection drops mid-game
|
||||
- [ ] **Private hand views** — each human player sees only their own hand; other hands shown as face-down card backs
|
||||
- [ ] **Turn notification system** — alert active player when it's their turn (browser notification or in-page indicator)
|
||||
- [ ] **Action broadcast** — all players see each action played in real-time (card played, attack declared, ability activated) via WebSocket push
|
||||
- [ ] **Mixed lobby support** — allow any combination of human and AI seats (e.g., 2 humans + 2 AIs, or 1 human + 3 AIs)
|
||||
- [ ] **Spectator mode** — allow observers to watch a game without participating; full board visible, no hand info
|
||||
- [ ] **Local LAN play** — host game server on local network so friends can connect via browser on the same Wi-Fi (no internet required)
|
||||
- [ ] **Optional: Remote play** — expose server via ngrok or Tailscale for play over the internet without full deployment
|
||||
- [ ] **Multiplayer AI pacing** — add configurable AI decision delay (e.g., 1–3 sec) so human players can follow AI turns without them resolving instantly
|
||||
- [ ] **Chat panel** — in-game text chat between human players; AI opponents can optionally post narration lines into the same channel
|
||||
- [ ] **End-of-game summary** — show full game recap (turns played, damage dealt, commanders cast count) visible to all players after win condition met
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Choice | Notes |
|
||||
|---|---|---|
|
||||
| GUI (Prototype) | **Pygame** | Pure Python, fast iteration |
|
||||
| GUI (Production) | **Flask/FastAPI + HTML/JS** | Polished, browser-based |
|
||||
| Multiplayer | **WebSockets (Flask-SocketIO)** | Real-time state sync across clients |
|
||||
| LLM Backend | Ollama or LM Studio | Serves GPT-OSS 20B locally |
|
||||
| Model | **GPT-OSS 20B (Q4_K_M)** | ~12–14 GB VRAM, strong reasoning |
|
||||
| Game State | Python dataclass | Structured, easy to serialize |
|
||||
| Async | asyncio / threading | Keep GUI responsive during inference |
|
||||
| Card Data | Scryfall API | Free MTG card data + art |
|
||||
| LAN Hosting | Local network / ngrok | No deployment needed for local play |
|
||||
|
||||
### GPT-OSS 20B Q4_K_M Notes
|
||||
- Q4_K_M quantization balances quality and speed well for a 20B model
|
||||
- Expected VRAM: ~12–14 GB on RTX 5070 Ti (16 GB VRAM) — fits comfortably
|
||||
- Stronger reasoning than 7B models — better for multi-step MTG decision making
|
||||
- Slower inference than smaller models — async handling is essential so GUI doesn't block
|
||||
- System prompt + game state should be kept under ~3000 tokens to maintain fast response times
|
||||
@@ -1,74 +0,0 @@
|
||||
# Commander AI Lab — Developer Makefile
|
||||
# ======================================
|
||||
# Targets:
|
||||
# make test — run all tests
|
||||
# make test-unit — run unit tests only (fast)
|
||||
# make test-integration— run integration tests
|
||||
# make test-benchmark — run benchmark suite with -s (print timings)
|
||||
# make coverage — run tests with coverage report
|
||||
# make serve — start the FastAPI dev server
|
||||
# make lint — ruff + mypy
|
||||
# make fmt — ruff format
|
||||
# make clean — remove build artefacts
|
||||
|
||||
PYTHON ?= python
|
||||
UVICORN ?= uvicorn
|
||||
PORT ?= 8000
|
||||
SRC = src/commander_ai_lab
|
||||
|
||||
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build test-frontend
|
||||
|
||||
test:
|
||||
$(PYTHON) -m pytest tests/ -q
|
||||
|
||||
test-unit:
|
||||
$(PYTHON) -m pytest tests/ -q -m "not integration and not benchmark"
|
||||
|
||||
test-integration:
|
||||
$(PYTHON) -m pytest tests/test_integration.py -v
|
||||
|
||||
test-benchmark:
|
||||
$(PYTHON) -m pytest tests/test_benchmark.py -v -s
|
||||
|
||||
test-phase3:
|
||||
$(PYTHON) -m pytest tests/test_phase3.py -v
|
||||
|
||||
test-phase4:
|
||||
$(PYTHON) -m pytest tests/test_phase4.py -v
|
||||
|
||||
test-phase5:
|
||||
$(PYTHON) -m pytest tests/test_phase5.py -v
|
||||
|
||||
test-phase6:
|
||||
$(PYTHON) -m pytest tests/test_phase6.py -v
|
||||
|
||||
test-phase7:
|
||||
$(PYTHON) -m pytest tests/test_integration.py tests/test_benchmark.py -v
|
||||
|
||||
coverage:
|
||||
$(PYTHON) -m pytest tests/ --cov=$(SRC) --cov-report=term-missing --cov-report=html
|
||||
echo "Coverage report: htmlcov/index.html"
|
||||
|
||||
serve:
|
||||
$(UVICORN) commander_ai_lab.web.app:app --reload --port $(PORT)
|
||||
|
||||
lint:
|
||||
$(PYTHON) -m ruff check $(SRC) tests/
|
||||
$(PYTHON) -m mypy $(SRC) --ignore-missing-imports
|
||||
|
||||
fmt:
|
||||
$(PYTHON) -m ruff format $(SRC) tests/
|
||||
|
||||
clean:
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name '*.pyc' -delete
|
||||
rm -rf .coverage htmlcov .mypy_cache .ruff_cache dist build
|
||||
|
||||
frontend-dev:
|
||||
cd frontend && npm run dev
|
||||
|
||||
frontend-build:
|
||||
cd frontend && npm run build
|
||||
|
||||
test-frontend:
|
||||
cd frontend && npx vitest run
|
||||
@@ -354,25 +354,6 @@ Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
The UI is a React + TypeScript SPA in the `frontend/` directory.
|
||||
|
||||
### Dev server (with hot reload)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Vite proxies `/api` and `/ws` to `localhost:8000`, so start the FastAPI backend first.
|
||||
|
||||
### Production build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
FastAPI serves the built assets from `frontend/dist/`.
|
||||
|
||||
## Known Limitations
|
||||
- Winner's life total not captured (Forge quiet mode only reports losers)
|
||||
- Win condition classification is heuristic-based on Forge loss reason strings
|
||||
|
||||
@@ -56,15 +56,15 @@ class PerplexityClient:
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: str = SONAR,
|
||||
timeout: int = 120, base_url: str = None):
|
||||
if not api_key and not base_url:
|
||||
raise ValueError("Perplexity API key is required (or provide base_url for local)")
|
||||
timeout: int = 120):
|
||||
if not api_key:
|
||||
raise ValueError("Perplexity API key is required")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self._client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url or "https://api.perplexity.ai",
|
||||
base_url="https://api.perplexity.ai",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@@ -72,30 +72,26 @@ class PerplexityClient:
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
json_schema: dict = None,
|
||||
json_schema: dict,
|
||||
schema_name: str = "response",
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 8192,
|
||||
) -> PerplexityResponse:
|
||||
"""
|
||||
Send a chat request with JSON output.
|
||||
|
||||
Uses Sonar models do not support the json_schema response_format.
|
||||
Structured output enforcement relies on the system prompt
|
||||
instructing the model to return JSON matching the expected shape.
|
||||
Send a chat request with structured JSON output.
|
||||
|
||||
Args:
|
||||
system_prompt: System message (should instruct JSON output)
|
||||
system_prompt: System message
|
||||
user_prompt: User message
|
||||
json_schema: (ignored) Kept for call-site compatibility
|
||||
schema_name: (ignored) Kept for call-site compatibility
|
||||
json_schema: JSON Schema dict for response_format
|
||||
schema_name: Name for the schema (used in response_format)
|
||||
model: Override model (sonar, sonar-pro)
|
||||
temperature: Sampling temperature (0.0-1.0)
|
||||
temperature: Sampling temperature (0.0–1.0)
|
||||
max_tokens: Max output tokens
|
||||
|
||||
Returns:
|
||||
PerplexityResponse with parsed_json from the model's output
|
||||
PerplexityResponse with parsed_json guaranteed by schema
|
||||
"""
|
||||
use_model = model or self.model
|
||||
|
||||
@@ -110,7 +106,14 @@ class PerplexityClient:
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
response_format={
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": schema_name,
|
||||
"schema": json_schema,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
content = choice.message.content or ""
|
||||
|
||||
+14
-99
@@ -9,8 +9,6 @@ calls the LLM, parses responses, and persists coaching sessions.
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
@@ -18,31 +16,18 @@ from typing import Optional, List, Dict
|
||||
from .config import (
|
||||
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
|
||||
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
|
||||
COACH_PROVIDER, PPLX_MODEL,
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
|
||||
)
|
||||
from .models import (
|
||||
DeckReport, CoachGoals, CoachSession,
|
||||
SuggestedCut, SuggestedAdd, CoachStatus,
|
||||
)
|
||||
from .llm_client import LLMClient
|
||||
from .llm_client import LMStudioClient
|
||||
from .embeddings import MTGEmbeddingIndex
|
||||
from .prompt_template import build_system_prompt, build_user_prompt
|
||||
|
||||
logger = logging.getLogger("coach.service")
|
||||
|
||||
# SQLite persistence for coach sessions
|
||||
try:
|
||||
from services.database import (
|
||||
save_coach_session as _db_save_session,
|
||||
load_coach_session as _db_load_session,
|
||||
list_coach_sessions as _db_list_sessions,
|
||||
delete_coach_session as _db_delete_session,
|
||||
)
|
||||
_DB_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DB_AVAILABLE = False
|
||||
|
||||
|
||||
class CoachService:
|
||||
"""
|
||||
@@ -51,12 +36,12 @@ class CoachService:
|
||||
Usage:
|
||||
embeddings = MTGEmbeddingIndex()
|
||||
embeddings.load()
|
||||
llm = LLMClient()
|
||||
llm = LMStudioClient()
|
||||
coach = CoachService(llm, embeddings)
|
||||
session = await coach.run_coaching_session("edgar-markov")
|
||||
"""
|
||||
|
||||
def __init__(self, llm: LLMClient, embeddings: MTGEmbeddingIndex):
|
||||
def __init__(self, llm: LMStudioClient, embeddings: MTGEmbeddingIndex):
|
||||
self.llm = llm
|
||||
self.embeddings = embeddings
|
||||
ensure_dirs()
|
||||
@@ -70,7 +55,7 @@ class CoachService:
|
||||
if not report_path.exists():
|
||||
# Try case-insensitive match
|
||||
for f in DECK_REPORTS_DIR.glob("*.json"):
|
||||
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
|
||||
if f.stem.lower() == deck_id.lower():
|
||||
report_path = f
|
||||
break
|
||||
|
||||
@@ -95,62 +80,15 @@ class CoachService:
|
||||
# ── Coach Session I/O ──────────────────────────────────────
|
||||
|
||||
def save_session(self, session: CoachSession) -> Path:
|
||||
"""Persist a coaching session to disk and SQLite."""
|
||||
# 1. Write JSON file (backward compat)
|
||||
"""Persist a coaching session to disk."""
|
||||
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
|
||||
with open(path, "w") as f:
|
||||
f.write(session.model_dump_json(indent=2))
|
||||
# 2. Write to SQLite
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
_db_save_session({
|
||||
"session_id": session.sessionId,
|
||||
"deck_id": session.deckId,
|
||||
"timestamp": session.timestamp,
|
||||
"model_used": session.modelUsed or "",
|
||||
"prompt_tokens": session.promptTokens or 0,
|
||||
"completion_tokens": session.completionTokens or 0,
|
||||
"summary": session.summary or "",
|
||||
"goals_json": json.dumps(session.goals.model_dump() if session.goals else {}),
|
||||
"cuts_json": json.dumps([c.model_dump() for c in session.suggestedCuts]),
|
||||
"adds_json": json.dumps([a.model_dump() for a in session.suggestedAdds]),
|
||||
"heuristic_hints_json": json.dumps(session.heuristicHints or []),
|
||||
"mana_base_advice": session.manaBaseAdvice or "",
|
||||
"raw_text": session.rawTextExplanation or "",
|
||||
})
|
||||
logger.info("Coach session persisted to SQLite: %s", session.sessionId)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist session to SQLite: %s", e)
|
||||
return path
|
||||
|
||||
|
||||
def load_session(self, session_id: str) -> Optional[CoachSession]:
|
||||
"""Load a coaching session from SQLite, falling back to disk."""
|
||||
# Try SQLite first
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
row = _db_load_session(session_id)
|
||||
if row:
|
||||
data = dict(row)
|
||||
return CoachSession(
|
||||
sessionId=data["session_id"],
|
||||
deckId=data["deck_id"],
|
||||
timestamp=data["timestamp"],
|
||||
modelUsed=data.get("model_used", ""),
|
||||
promptTokens=data.get("prompt_tokens", 0),
|
||||
completionTokens=data.get("completion_tokens", 0),
|
||||
summary=data.get("summary", ""),
|
||||
goals=json.loads(data["goals_json"]) if data.get("goals_json") and data["goals_json"] != "{}" else None,
|
||||
suggestedCuts=[SuggestedCut(**c) for c in json.loads(data.get("cuts_json", "[]"))],
|
||||
suggestedAdds=[SuggestedAdd(**a) for a in json.loads(data.get("adds_json", "[]"))],
|
||||
heuristicHints=json.loads(data.get("heuristic_hints_json", "[]")),
|
||||
manaBaseAdvice=data.get("mana_base_advice", ""),
|
||||
rawTextExplanation=data.get("raw_text", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("SQLite load failed for %s: %s", session_id, e)
|
||||
# Fallback to disk
|
||||
"""Load a coaching session from disk."""
|
||||
path = COACH_SESSIONS_DIR / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -162,18 +100,8 @@ class CoachService:
|
||||
logger.error("Failed to load session %s: %s", session_id, e)
|
||||
return None
|
||||
|
||||
|
||||
def list_sessions(self, deck_id: str = None) -> List[dict]:
|
||||
"""List coaching sessions from SQLite, falling back to disk."""
|
||||
# Try SQLite first
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
rows = _db_list_sessions(deck_id)
|
||||
if rows is not None:
|
||||
return rows
|
||||
except Exception as e:
|
||||
logger.warning("SQLite list_sessions failed: %s", e)
|
||||
# Fallback to disk
|
||||
"""List all coaching sessions, optionally filtered by deck."""
|
||||
if not COACH_SESSIONS_DIR.exists():
|
||||
return []
|
||||
sessions = []
|
||||
@@ -195,7 +123,6 @@ class CoachService:
|
||||
continue
|
||||
return sessions
|
||||
|
||||
|
||||
# ── Main Coaching Pipeline ─────────────────────────────────
|
||||
|
||||
async def run_coaching_session(self, deck_id: str,
|
||||
@@ -338,25 +265,13 @@ class CoachService:
|
||||
"""Check the health of all coach subsystems."""
|
||||
status = CoachStatus()
|
||||
|
||||
# Check LLM / Perplexity provider
|
||||
if COACH_PROVIDER == "perplexity":
|
||||
pplx_key = os.environ.get("PPLX_API_KEY", "")
|
||||
if pplx_key:
|
||||
status.llmConnected = True
|
||||
status.llmModel = f"Perplexity ({PPLX_MODEL})"
|
||||
status.llmModels = [PPLX_MODEL]
|
||||
else:
|
||||
status.llmConnected = False
|
||||
status.llmModel = None
|
||||
status.llmModels = []
|
||||
status.error = "Perplexity API key not configured. Set PPLX_API_KEY env var."
|
||||
else:
|
||||
llm_status = self.llm.check_connection()
|
||||
status.llmConnected = llm_status.get("connected", False)
|
||||
status.llmModel = llm_status.get("active_model")
|
||||
status.llmModels = llm_status.get("models", [])
|
||||
if not status.llmConnected:
|
||||
status.error = llm_status.get("error", "Local LLM not reachable")
|
||||
# Check LLM
|
||||
llm_status = self.llm.check_connection()
|
||||
status.llmConnected = llm_status.get("connected", False)
|
||||
status.llmModel = llm_status.get("active_model")
|
||||
status.llmModels = llm_status.get("models", [])
|
||||
if not status.llmConnected:
|
||||
status.error = llm_status.get("error", "LM Studio not reachable")
|
||||
|
||||
# Check embeddings
|
||||
status.embeddingsLoaded = self.embeddings.loaded
|
||||
|
||||
+13
-18
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Coach Configuration
|
||||
═══════════════════════════════════════
|
||||
Central config for Ollama, embeddings, and coach service paths.
|
||||
Central config for LM Studio, embeddings, and coach service paths.
|
||||
All paths are relative to the commander-ai-lab root directory.
|
||||
"""
|
||||
|
||||
@@ -12,11 +12,11 @@ from pathlib import Path
|
||||
# Resolve relative to wherever the lab is installed
|
||||
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
|
||||
|
||||
# ── Ollama (OpenAI-compatible API) ──────────────────────
|
||||
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
||||
LLM_MAX_RETRIES = 3
|
||||
# ── LM Studio (OpenAI-compatible API) ──────────────────────
|
||||
LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://192.168.0.122:1234/v1")
|
||||
LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "local-model")
|
||||
LM_STUDIO_TIMEOUT = int(os.environ.get("LM_STUDIO_TIMEOUT", "120")) # seconds
|
||||
LM_STUDIO_MAX_RETRIES = 3
|
||||
|
||||
# ── Embeddings ─────────────────────────────────────────────
|
||||
EMBEDDINGS_DIR = LAB_ROOT / "data"
|
||||
@@ -28,12 +28,12 @@ DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
|
||||
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
|
||||
|
||||
# ── Prompt Limits ──────────────────────────────────────────
|
||||
MAX_PROMPT_TOKENS = 2048
|
||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 5
|
||||
MAX_UNDERPERFORMERS = 4
|
||||
MAX_PROMPT_TOKENS = 4096
|
||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 10
|
||||
MAX_UNDERPERFORMERS = 8
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
||||
|
||||
# ── LLM Generation Settings (Coach — Ollama) ──────────────
|
||||
# ── LLM Generation Settings (Coach — LM Studio) ──────────────
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
|
||||
# so we need a generous limit to avoid truncation
|
||||
@@ -41,14 +41,9 @@ DEFAULT_MAX_TOKENS = 8192
|
||||
|
||||
# ── Perplexity Deck Generation Settings ───────────────────────
|
||||
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "perplexity")
|
||||
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
|
||||
# Perplexity cloud model (sonar / sonar-pro / sonar-deep-research)
|
||||
PPLX_MODEL = os.environ.get("PPLX_MODEL", "sonar-pro")
|
||||
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
|
||||
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "local")
|
||||
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://localhost:11434/v1")
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "sonar")
|
||||
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
|
||||
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
|
||||
|
||||
@@ -58,7 +53,7 @@ SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY"
|
||||
# Maximum number of alternatives to suggest per missing card
|
||||
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
|
||||
# Model for substitution fallback (sonar is fine — small focused queries)
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "gpt-oss:20b")
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "sonar")
|
||||
# Enable Perplexity fallback for low-confidence embedding matches
|
||||
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
|
||||
|
||||
|
||||
+41
-237
@@ -9,11 +9,6 @@ Data source: https://huggingface.co/datasets/minimaxir/mtg-embeddings
|
||||
Format: Parquet with 768-dim float32 embeddings per card (~32K cards)
|
||||
Model: Alibaba-NLP/gte-modernbert-base
|
||||
|
||||
Unknown cards (e.g. newly printed sets) are fetched from Scryfall,
|
||||
embedded on-the-fly using the same model, and persisted to a local
|
||||
custom cache (embeddings/mtg_embeddings_custom.npz) so they survive
|
||||
restarts without re-hitting Scryfall.
|
||||
|
||||
Uses NumPy brute-force cosine similarity — fast enough for ~32K cards (<100ms).
|
||||
"""
|
||||
|
||||
@@ -21,11 +16,9 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.parse import quote
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -39,16 +32,8 @@ HF_PARQUET_URL = (
|
||||
"resolve/main/mtg_embeddings.parquet"
|
||||
)
|
||||
|
||||
# ── Scryfall API ───────────────────────────────────────────
|
||||
SCRYFALL_NAMED_URL = "https://api.scryfall.com/cards/named?fuzzy={}"
|
||||
|
||||
# ── Custom cache for on-the-fly embedded cards ─────────────
|
||||
CUSTOM_CACHE_PATH = EMBEDDINGS_DIR / "mtg_embeddings_custom.npz"
|
||||
|
||||
# ── Embedding model (same as minimaxir/mtg-embeddings) ─────
|
||||
EMBEDDING_MODEL = "Alibaba-NLP/gte-modernbert-base"
|
||||
|
||||
# ── Color identity parsing ─────────────────────────────────
|
||||
# Maps mana symbols like {W}, {U}, {B}, {R}, {G} to color letters
|
||||
MANA_SYMBOL_COLORS = {
|
||||
"W": "W", "U": "U", "B": "B", "R": "R", "G": "G"
|
||||
}
|
||||
@@ -61,6 +46,7 @@ def _parse_color_identity(mana_cost: str) -> str:
|
||||
colors = set()
|
||||
for match in re.finditer(r'\{([WUBRG])\}', mana_cost or ""):
|
||||
colors.add(match.group(1))
|
||||
# Sort in WUBRG order
|
||||
order = "WUBRG"
|
||||
return "".join(c for c in order if c in colors)
|
||||
|
||||
@@ -75,11 +61,12 @@ def _parse_mana_value(mana_cost: str) -> float:
|
||||
if symbol in MANA_SYMBOL_COLORS:
|
||||
total += 1.0
|
||||
elif symbol == "X":
|
||||
pass
|
||||
pass # X = 0 for CMC purposes
|
||||
else:
|
||||
try:
|
||||
total += float(symbol)
|
||||
except ValueError:
|
||||
# Hybrid/phyrexian symbols count as 1
|
||||
total += 1.0
|
||||
return total
|
||||
|
||||
@@ -88,42 +75,7 @@ def _is_color_subset(card_colors: str, deck_colors: List[str]) -> bool:
|
||||
"""Check if card's color identity is within deck's color identity."""
|
||||
deck_set = set(deck_colors)
|
||||
card_set = set(card_colors)
|
||||
return card_set.issubset(deck_set) or len(card_set) == 0
|
||||
|
||||
|
||||
def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
|
||||
"""Fetch card data from Scryfall by fuzzy name match.
|
||||
|
||||
Returns a dict with keys: name, mana_cost, type_line, oracle_text,
|
||||
color_identity. Returns None on any error.
|
||||
"""
|
||||
url = SCRYFALL_NAMED_URL.format(quote(card_name))
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "commander-ai-lab/1.0"})
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return {
|
||||
"name": data.get("name", card_name),
|
||||
"mana_cost": data.get("mana_cost", ""),
|
||||
"type_line": data.get("type_line", ""),
|
||||
"oracle_text": data.get("oracle_text", ""),
|
||||
"color_identity": "".join(data.get("color_identity", [])),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Scryfall lookup failed for '%s': %s", card_name, e)
|
||||
return None
|
||||
|
||||
|
||||
def _build_card_text(card: dict) -> str:
|
||||
"""Build the text string to embed, matching minimaxir/mtg-embeddings format."""
|
||||
parts = [card["name"]]
|
||||
if card.get("mana_cost"):
|
||||
parts.append(card["mana_cost"])
|
||||
if card.get("type_line"):
|
||||
parts.append(card["type_line"])
|
||||
if card.get("oracle_text"):
|
||||
parts.append(card["oracle_text"])
|
||||
return " | ".join(parts)
|
||||
return card_set.issubset(deck_set) or len(card_set) == 0 # colorless always allowed
|
||||
|
||||
|
||||
class CardMatch:
|
||||
@@ -162,17 +114,15 @@ class MTGEmbeddingIndex:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.names: np.ndarray = None
|
||||
self.colors: np.ndarray = None
|
||||
self.mana_values: np.ndarray = None
|
||||
self.types: np.ndarray = None
|
||||
self.mana_costs: np.ndarray = None
|
||||
self.texts: np.ndarray = None
|
||||
self.vectors: np.ndarray = None
|
||||
self.names: np.ndarray = None # (N,) string array
|
||||
self.colors: np.ndarray = None # (N,) string array (e.g., "WU", "BRG")
|
||||
self.mana_values: np.ndarray = None # (N,) float array
|
||||
self.types: np.ndarray = None # (N,) string array
|
||||
self.mana_costs: np.ndarray = None # (N,) string array
|
||||
self.texts: np.ndarray = None # (N,) string array
|
||||
self.vectors: np.ndarray = None # (N, 768) float32, L2-normalized
|
||||
self._name_to_idx: dict = {}
|
||||
self._loaded = False
|
||||
self._sentence_model = None # lazy-loaded only when needed
|
||||
self._custom_dirty = False # tracks whether custom cache needs saving
|
||||
|
||||
@property
|
||||
def loaded(self) -> bool:
|
||||
@@ -202,7 +152,10 @@ class MTGEmbeddingIndex:
|
||||
return EMBEDDINGS_PARQUET
|
||||
|
||||
def convert_parquet_to_npz(self) -> Path:
|
||||
"""Convert the Parquet file to a compact NPZ for fast loading."""
|
||||
"""
|
||||
Convert the Parquet file to a compact NPZ for fast loading.
|
||||
Requires pandas and pyarrow.
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
@@ -216,11 +169,14 @@ class MTGEmbeddingIndex:
|
||||
types = df["type"].fillna("").values.astype(str)
|
||||
texts = df["text"].fillna("").values.astype(str)
|
||||
|
||||
# Parse color identities from mana costs
|
||||
colors = np.array([_parse_color_identity(mc) for mc in mana_costs])
|
||||
mana_values = np.array([_parse_mana_value(mc) for mc in mana_costs], dtype=np.float32)
|
||||
|
||||
# Extract embedding vectors
|
||||
embeddings = np.vstack(df["embedding"].to_numpy()).astype(np.float32)
|
||||
|
||||
# L2 normalize for cosine similarity via dot product
|
||||
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
||||
norms[norms == 0] = 1.0
|
||||
embeddings = embeddings / norms
|
||||
@@ -242,27 +198,25 @@ class MTGEmbeddingIndex:
|
||||
return EMBEDDINGS_NPZ
|
||||
|
||||
def load(self, force_download: bool = False) -> bool:
|
||||
"""Load embeddings from NPZ (fast) or fall back to downloading
|
||||
"""
|
||||
Load embeddings from NPZ (fast) or fall back to downloading
|
||||
and converting from Parquet.
|
||||
"""
|
||||
if self._loaded and not force_download:
|
||||
return True
|
||||
|
||||
# Try NPZ first (fast path)
|
||||
if EMBEDDINGS_NPZ.exists() and not force_download:
|
||||
ok = self._load_npz()
|
||||
else:
|
||||
try:
|
||||
self.download_parquet(force=force_download)
|
||||
self.convert_parquet_to_npz()
|
||||
ok = self._load_npz()
|
||||
except Exception as e:
|
||||
logger.error("Failed to load embeddings: %s", e)
|
||||
return False
|
||||
return self._load_npz()
|
||||
|
||||
if ok:
|
||||
self._load_custom_cache()
|
||||
|
||||
return ok
|
||||
# Download and convert
|
||||
try:
|
||||
self.download_parquet(force=force_download)
|
||||
self.convert_parquet_to_npz()
|
||||
return self._load_npz()
|
||||
except Exception as e:
|
||||
logger.error("Failed to load embeddings: %s", e)
|
||||
return False
|
||||
|
||||
def _load_npz(self) -> bool:
|
||||
"""Load from pre-built NPZ file."""
|
||||
@@ -276,6 +230,7 @@ class MTGEmbeddingIndex:
|
||||
self.texts = data.get("texts", np.array([""] * len(self.names)))
|
||||
self.vectors = data["vectors"]
|
||||
|
||||
# Build name lookup
|
||||
self._name_to_idx = {
|
||||
name.lower(): i for i, name in enumerate(self.names)
|
||||
}
|
||||
@@ -286,169 +241,12 @@ class MTGEmbeddingIndex:
|
||||
logger.error("Failed to load NPZ: %s", e)
|
||||
return False
|
||||
|
||||
# ── Custom cache ──────────────────────────────────────────
|
||||
|
||||
def _load_custom_cache(self) -> None:
|
||||
"""Merge on-the-fly embedded cards from the custom cache into the index."""
|
||||
if not CUSTOM_CACHE_PATH.exists():
|
||||
return
|
||||
try:
|
||||
data = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
|
||||
c_names = data["names"]
|
||||
# Only add cards that aren't already in the main index
|
||||
new_mask = np.array(
|
||||
[n.lower() not in self._name_to_idx for n in c_names]
|
||||
)
|
||||
if not new_mask.any():
|
||||
return
|
||||
self._append_cards(
|
||||
c_names[new_mask],
|
||||
data["colors"][new_mask],
|
||||
data["mana_values"][new_mask],
|
||||
data["types"][new_mask],
|
||||
data["mana_costs"][new_mask],
|
||||
data["texts"][new_mask],
|
||||
data["vectors"][new_mask],
|
||||
)
|
||||
logger.info("Loaded %d custom-cached card embeddings", new_mask.sum())
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load custom embedding cache: %s", e)
|
||||
|
||||
def _save_custom_cache(self) -> None:
|
||||
"""Persist the custom cache NPZ (only cards not in the base dataset)."""
|
||||
if not self._custom_dirty:
|
||||
return
|
||||
try:
|
||||
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Collect indices that correspond to custom-added cards
|
||||
# We track these by loading the base count at load time — simpler:
|
||||
# just re-save the full custom cache from CUSTOM_CACHE_PATH if it
|
||||
# exists, merging with any new cards we've added this session.
|
||||
existing_names: set = set()
|
||||
existing: dict = {}
|
||||
if CUSTOM_CACHE_PATH.exists():
|
||||
try:
|
||||
d = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
|
||||
for n in d["names"]:
|
||||
existing_names.add(n.lower())
|
||||
existing = dict(d)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find names in our live index that aren't in the base NPZ
|
||||
base_data = np.load(str(EMBEDDINGS_NPZ), allow_pickle=True)
|
||||
base_names = set(n.lower() for n in base_data["names"])
|
||||
custom_mask = np.array(
|
||||
[n.lower() not in base_names for n in self.names]
|
||||
)
|
||||
if not custom_mask.any():
|
||||
return
|
||||
|
||||
np.savez_compressed(
|
||||
str(CUSTOM_CACHE_PATH),
|
||||
names=self.names[custom_mask],
|
||||
colors=self.colors[custom_mask],
|
||||
mana_values=self.mana_values[custom_mask],
|
||||
types=self.types[custom_mask],
|
||||
mana_costs=self.mana_costs[custom_mask],
|
||||
texts=self.texts[custom_mask],
|
||||
vectors=self.vectors[custom_mask],
|
||||
)
|
||||
self._custom_dirty = False
|
||||
logger.info("Saved custom embedding cache: %d cards", custom_mask.sum())
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save custom embedding cache: %s", e)
|
||||
|
||||
def _append_cards(
|
||||
self,
|
||||
names: np.ndarray,
|
||||
colors: np.ndarray,
|
||||
mana_values: np.ndarray,
|
||||
types: np.ndarray,
|
||||
mana_costs: np.ndarray,
|
||||
texts: np.ndarray,
|
||||
vectors: np.ndarray,
|
||||
) -> None:
|
||||
"""Append new card arrays to the live in-memory index."""
|
||||
start_idx = len(self.names)
|
||||
self.names = np.concatenate([self.names, names])
|
||||
self.colors = np.concatenate([self.colors, colors])
|
||||
self.mana_values = np.concatenate([self.mana_values, mana_values])
|
||||
self.types = np.concatenate([self.types, types])
|
||||
self.mana_costs = np.concatenate([self.mana_costs, mana_costs])
|
||||
self.texts = np.concatenate([self.texts, texts])
|
||||
self.vectors = np.concatenate([self.vectors, vectors], axis=0)
|
||||
for i, name in enumerate(names):
|
||||
self._name_to_idx[name.lower()] = start_idx + i
|
||||
|
||||
# ── Sentence model (lazy) ─────────────────────────────────
|
||||
|
||||
def _get_sentence_model(self):
|
||||
"""Lazy-load the sentence-transformers model on first cache miss."""
|
||||
if self._sentence_model is None:
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
logger.info("Loading embedding model %s (first cache miss)...", EMBEDDING_MODEL)
|
||||
self._sentence_model = SentenceTransformer(EMBEDDING_MODEL)
|
||||
logger.info("Embedding model loaded.")
|
||||
except Exception as e:
|
||||
logger.error("Failed to load SentenceTransformer: %s", e)
|
||||
return None
|
||||
return self._sentence_model
|
||||
|
||||
# ── On-the-fly Scryfall fallback ──────────────────────────
|
||||
|
||||
def _fetch_and_embed_card(self, card_name: str) -> Optional[np.ndarray]:
|
||||
"""Fetch card from Scryfall, embed it, append to index, persist to cache.
|
||||
|
||||
Returns the L2-normalized embedding vector on success, None on failure.
|
||||
"""
|
||||
logger.info("Fetching unknown card from Scryfall: '%s'", card_name)
|
||||
card_data = _fetch_scryfall_card(card_name)
|
||||
if card_data is None:
|
||||
return None
|
||||
|
||||
model = self._get_sentence_model()
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
text = _build_card_text(card_data)
|
||||
vec = model.encode(text, normalize_embeddings=True).astype(np.float32)
|
||||
# Ensure shape (1, 768)
|
||||
vec = vec.reshape(1, -1)
|
||||
|
||||
resolved_name = card_data["name"]
|
||||
mana_cost = card_data.get("mana_cost", "")
|
||||
color_identity = card_data.get("color_identity", "") or _parse_color_identity(mana_cost)
|
||||
|
||||
self._append_cards(
|
||||
names=np.array([resolved_name]),
|
||||
colors=np.array([color_identity]),
|
||||
mana_values=np.array([_parse_mana_value(mana_cost)], dtype=np.float32),
|
||||
types=np.array([card_data.get("type_line", "")]),
|
||||
mana_costs=np.array([mana_cost]),
|
||||
texts=np.array([card_data.get("oracle_text", "")]),
|
||||
vectors=vec,
|
||||
)
|
||||
|
||||
self._custom_dirty = True
|
||||
self._save_custom_cache()
|
||||
logger.info("Embedded and cached new card: '%s'", resolved_name)
|
||||
return self.vectors[self._name_to_idx[resolved_name.lower()]]
|
||||
|
||||
# ── Public API ────────────────────────────────────────────
|
||||
|
||||
def get_card_vector(self, card_name: str) -> Optional[np.ndarray]:
|
||||
"""Get the embedding vector for a card by name (case-insensitive).
|
||||
|
||||
On a cache miss, fetches from Scryfall and embeds on the fly.
|
||||
"""
|
||||
"""Get the embedding vector for a card by name (case-insensitive)."""
|
||||
idx = self._name_to_idx.get(card_name.lower())
|
||||
if idx is not None:
|
||||
return self.vectors[idx]
|
||||
|
||||
# Cache miss — try Scryfall fallback
|
||||
return self._fetch_and_embed_card(card_name)
|
||||
return None
|
||||
|
||||
def search_similar(self, query_card: str,
|
||||
color_filter: List[str] = None,
|
||||
@@ -476,32 +274,38 @@ class MTGEmbeddingIndex:
|
||||
|
||||
query_vec = self.get_card_vector(query_card)
|
||||
if query_vec is None:
|
||||
logger.warning("Card not found in embeddings and Scryfall fallback failed: %s", query_card)
|
||||
logger.warning("Card not found in embeddings: %s", query_card)
|
||||
return []
|
||||
|
||||
# Compute similarities (dot product on normalized vectors = cosine similarity)
|
||||
similarities = self.vectors @ query_vec
|
||||
|
||||
# Build exclusion set
|
||||
exclude_set = set()
|
||||
exclude_set.add(query_card.lower())
|
||||
if exclude_cards:
|
||||
exclude_set.update(name.lower() for name in exclude_cards)
|
||||
|
||||
# Filter and collect results
|
||||
results = []
|
||||
for idx in np.argsort(similarities)[::-1]:
|
||||
name = str(self.names[idx])
|
||||
if name.lower() in exclude_set:
|
||||
continue
|
||||
|
||||
# Color filter
|
||||
if color_filter:
|
||||
card_colors = str(self.colors[idx])
|
||||
if not _is_color_subset(card_colors, color_filter):
|
||||
continue
|
||||
|
||||
# Type filter
|
||||
if type_filter:
|
||||
card_type = str(self.types[idx])
|
||||
if type_filter.lower() not in card_type.lower():
|
||||
continue
|
||||
|
||||
# Mana value range filter
|
||||
if mana_range:
|
||||
mv = float(self.mana_values[idx])
|
||||
if mv < mana_range[0] or mv > mana_range[1]:
|
||||
|
||||
+19
-19
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Ollama Client
|
||||
Commander AI Lab — LM Studio Client
|
||||
════════════════════════════════════
|
||||
Async HTTP client targeting Ollama's OpenAI-compatible
|
||||
Async HTTP client targeting LM Studio's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Includes retry logic for
|
||||
non-JSON responses and configurable timeout.
|
||||
"""
|
||||
@@ -14,8 +14,8 @@ from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
from .config import (
|
||||
LLM_URL, LLM_MODEL,
|
||||
LLM_TIMEOUT, LLM_MAX_RETRIES,
|
||||
LM_STUDIO_URL, LM_STUDIO_MODEL,
|
||||
LM_STUDIO_TIMEOUT, LM_STUDIO_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
@@ -34,12 +34,12 @@ class LLMResponse:
|
||||
self.model = model
|
||||
|
||||
|
||||
class LLMClient:
|
||||
class LMStudioClient:
|
||||
"""
|
||||
Client for Ollama's OpenAI-compatible API.
|
||||
Client for LM Studio's OpenAI-compatible API.
|
||||
|
||||
Usage:
|
||||
client = LLMClient()
|
||||
client = LMStudioClient()
|
||||
response = await client.chat(system_prompt, user_prompt)
|
||||
if response.parsed_json:
|
||||
suggestions = response.parsed_json
|
||||
@@ -47,20 +47,20 @@ class LLMClient:
|
||||
|
||||
def __init__(self, base_url: str = None, model: str = None,
|
||||
timeout: int = None):
|
||||
self.base_url = (base_url or LLM_URL).rstrip("/")
|
||||
self.model = model or LLM_MODEL
|
||||
self.timeout = timeout or LLM_TIMEOUT
|
||||
self.base_url = (base_url or LM_STUDIO_URL).rstrip("/")
|
||||
self.model = model or LM_STUDIO_MODEL
|
||||
self.timeout = timeout or LM_STUDIO_TIMEOUT
|
||||
self._resolved_model = None # actual model name from /models endpoint
|
||||
|
||||
def _resolve_model(self) -> str:
|
||||
"""Get the actual loaded model name from Ollama /models endpoint."""
|
||||
"""Get the actual loaded model name from LM Studio /models endpoint."""
|
||||
if self._resolved_model:
|
||||
return self._resolved_model
|
||||
try:
|
||||
status = self.check_connection()
|
||||
if status.get("connected") and status.get("active_model"):
|
||||
self._resolved_model = status["active_model"]
|
||||
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
||||
logger.info("Resolved LM Studio model: %s", self._resolved_model)
|
||||
return self._resolved_model
|
||||
except Exception:
|
||||
pass
|
||||
@@ -196,8 +196,8 @@ class LLMClient:
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
"""
|
||||
Synchronous chat call to Ollama.
|
||||
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
|
||||
Synchronous chat call to LM Studio.
|
||||
Retries up to LM_STUDIO_MAX_RETRIES on failure or non-JSON response.
|
||||
"""
|
||||
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
||||
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
||||
@@ -205,9 +205,9 @@ class LLMClient:
|
||||
temp, tokens, force_json)
|
||||
|
||||
last_error = None
|
||||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||
for attempt in range(1, LM_STUDIO_MAX_RETRIES + 1):
|
||||
try:
|
||||
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
|
||||
logger.info("LLM call attempt %d/%d", attempt, LM_STUDIO_MAX_RETRIES)
|
||||
req = Request(
|
||||
f"{self.base_url}/chat/completions",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
@@ -239,12 +239,12 @@ class LLMClient:
|
||||
body = self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False)
|
||||
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
if attempt < LM_STUDIO_MAX_RETRIES:
|
||||
import time
|
||||
time.sleep(2 * attempt) # Simple backoff
|
||||
|
||||
raise ConnectionError(
|
||||
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
|
||||
f"LLM call failed after {LM_STUDIO_MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
async def chat(self, system_prompt: str, user_prompt: str,
|
||||
@@ -263,7 +263,7 @@ class LLMClient:
|
||||
|
||||
def check_connection(self) -> dict:
|
||||
"""
|
||||
Check if Ollama is reachable. Returns status dict.
|
||||
Check if LM Studio is reachable. Returns status dict.
|
||||
"""
|
||||
try:
|
||||
req = Request(
|
||||
|
||||
@@ -38,7 +38,7 @@ def generate_deck_reports(results_dir: str, reports_dir: str = None) -> List[str
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load all batch results
|
||||
batch_files = sorted(results_path.glob("ml-decision-*.json"))
|
||||
batch_files = sorted(results_path.glob("batch-*.json"))
|
||||
if not batch_files:
|
||||
logger.info("No batch result files found in %s", results_dir)
|
||||
return []
|
||||
@@ -108,7 +108,7 @@ def generate_single_deck_report(batch_json_path: str, reports_dir: str = None) -
|
||||
# Also load existing batch results for comprehensive reports
|
||||
results_dir = Path(batch_json_path).parent
|
||||
all_batches = []
|
||||
for bf in sorted(results_dir.glob("ml-decision-*.json")):
|
||||
for bf in sorted(results_dir.glob("batch-*.json")):
|
||||
try:
|
||||
with open(bf, "r", encoding="utf-8") as f:
|
||||
all_batches.append(json.load(f))
|
||||
|
||||
@@ -25,9 +25,6 @@ class CardAlternative(BaseModel):
|
||||
|
||||
class DeckCardWithStatus(BaseModel):
|
||||
"""A deck card enriched with ownership and substitution data."""
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
name: str
|
||||
count: int = 1
|
||||
category: str = ""
|
||||
|
||||
@@ -247,7 +247,7 @@ class DeckGeneratorV3:
|
||||
truncated = card_count < 90 # Commander decks should have ~100 cards
|
||||
logger.info("Deck generated: %d cards, bracket %d%s",
|
||||
card_count,
|
||||
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
|
||||
raw_deck.get("bracket", {}).get("level", 0),
|
||||
" (TRUNCATED — response may have hit token limit)" if truncated else "")
|
||||
|
||||
# Step 5: Check ownership and build enriched card list
|
||||
@@ -256,19 +256,6 @@ class DeckGeneratorV3:
|
||||
# Step 5b: Fill basic lands to hit exactly 100 cards
|
||||
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
|
||||
|
||||
# Step 5c: Secondary safety-net deduplication
|
||||
seen = {}
|
||||
deduped = []
|
||||
for c in cards_with_status:
|
||||
key = c.name.lower()
|
||||
if key in seen:
|
||||
deduped[seen[key]].count += c.count
|
||||
logger.warning("Secondary dedupe caught duplicate: '%s'", c.name)
|
||||
else:
|
||||
seen[key] = len(deduped)
|
||||
deduped.append(c)
|
||||
cards_with_status = deduped
|
||||
|
||||
# Step 6: Compute stats
|
||||
stats = self._compute_stats(cards_with_status)
|
||||
|
||||
@@ -297,26 +284,15 @@ class DeckGeneratorV3:
|
||||
"""
|
||||
Cross-reference generated cards against the collection DB.
|
||||
Sets owned/scryfall_id/owned_qty and real price.
|
||||
Deduplicates cards by name (case-insensitive) — if the LLM
|
||||
returns the same card twice, the counts are merged.
|
||||
"""
|
||||
conn = self.db_conn_factory()
|
||||
enriched = []
|
||||
seen_names: dict[str, int] = {} # name_lower → index in enriched
|
||||
|
||||
for card in cards:
|
||||
name = card.get("name", "")
|
||||
if not name:
|
||||
continue
|
||||
|
||||
name_lower = name.lower()
|
||||
|
||||
if name_lower in seen_names:
|
||||
idx = seen_names[name_lower]
|
||||
enriched[idx].count += card.get("count", 1)
|
||||
logger.warning("Duplicate card '%s' merged (LLM returned it twice)", name)
|
||||
continue
|
||||
|
||||
# Look up in collection
|
||||
row = conn.execute(
|
||||
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
|
||||
@@ -342,7 +318,6 @@ class DeckGeneratorV3:
|
||||
scryfall_id=row["scryfall_id"] if row else "",
|
||||
status="owned" if is_owned else "missing",
|
||||
)
|
||||
seen_names[name_lower] = len(enriched)
|
||||
enriched.append(enriched_card)
|
||||
|
||||
return enriched
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
name,quantity,set_code
|
||||
Sol Ring,1,C21
|
||||
Command Tower,1,C21
|
||||
Arcane Signet,1,C21
|
||||
@@ -7,10 +7,6 @@ Currently returns a hardcoded sample for end-to-end wiring.
|
||||
Real implementation will call the Archidekt API.
|
||||
"""
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.deckgen.archidekt")
|
||||
|
||||
|
||||
|
||||
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
|
||||
@@ -30,5 +26,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
|
||||
# 1. Search Archidekt for popular decks with this commander
|
||||
# 2. Fetch the top 3-5 decklists
|
||||
# 3. Normalize card names and return as TemplateDeck
|
||||
log.info(f" Stub: No template decks for '{commander_name}' (not yet implemented)")
|
||||
print(f" [ARCHIDEKT] Stub: No template decks for '{commander_name}' (not yet implemented)")
|
||||
return []
|
||||
@@ -7,10 +7,6 @@ Currently returns a hardcoded sample for end-to-end wiring.
|
||||
Real implementation will parse EDHREC commander pages.
|
||||
"""
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.deckgen.edhrec")
|
||||
|
||||
|
||||
|
||||
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
|
||||
@@ -31,5 +27,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
|
||||
# 2. Fetch the "Average Deck" or "Top Cards" list
|
||||
# 3. Parse card names and inclusion percentages
|
||||
# 4. Return as a single TemplateDeck with weighted cards
|
||||
log.info(f" Stub: No recommendations for '{commander_name}' (not yet implemented)")
|
||||
print(f" [EDHREC] Stub: No recommendations for '{commander_name}' (not yet implemented)")
|
||||
return []
|
||||
@@ -1,133 +1,17 @@
|
||||
"""
|
||||
Moxfield Adapter
|
||||
================
|
||||
Moxfield Adapter (stub)
|
||||
════════════════════════
|
||||
|
||||
Fetches template decklists from Moxfield for a given commander.
|
||||
Uses the undocumented but usable api2.moxfield.com API.
|
||||
Rate-limited to <=10 req/sec. Errors are caught and logged.
|
||||
Currently returns empty (experimental — not yet implemented).
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.deckgen.moxfield")
|
||||
|
||||
MOXFIELD_API = "https://api2.moxfield.com"
|
||||
HEADERS = {
|
||||
"User-Agent": "CommanderAILab/1.0",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
# Minimum seconds between requests (<=10 req/sec)
|
||||
_RATE_LIMIT_DELAY = 0.12
|
||||
|
||||
|
||||
def _slugify_commander(name: str) -> str:
|
||||
"""Return the commander name as-is for the Moxfield search query."""
|
||||
return name.strip()
|
||||
|
||||
|
||||
def _build_cards_from_deck(deck_data: dict) -> list:
|
||||
"""Extract commander + mainboard cards from a full Moxfield deck response."""
|
||||
cards = []
|
||||
# Commanders first
|
||||
for card_name, card_obj in deck_data.get("commanders", {}).items():
|
||||
qty = card_obj.get("quantity", 1)
|
||||
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
|
||||
# Mainboard
|
||||
for card_name, card_obj in deck_data.get("mainboard", {}).items():
|
||||
qty = card_obj.get("quantity", 1)
|
||||
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
|
||||
return cards
|
||||
|
||||
|
||||
def fetch_template_decks(
|
||||
commander_name: str,
|
||||
color_identity: list,
|
||||
config: dict = None,
|
||||
) -> list:
|
||||
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
|
||||
"""
|
||||
Fetch top Commander decks from Moxfield for the given commander.
|
||||
|
||||
Returns a list of TemplateDeck objects. Returns empty list on any
|
||||
API failure — errors are logged, never raised.
|
||||
|
||||
Args:
|
||||
commander_name: The commander's card name.
|
||||
color_identity: List of color symbols (unused, kept for interface compat).
|
||||
config: Optional dict. Supported key: ``max_decks`` (default 3).
|
||||
|
||||
Returns:
|
||||
List of TemplateDeck objects populated with commander + mainboard cards.
|
||||
Fetch template decks from Moxfield for the given commander.
|
||||
Experimental — not yet implemented.
|
||||
"""
|
||||
max_decks = (config or {}).get("max_decks", 3)
|
||||
results = []
|
||||
|
||||
try:
|
||||
# Step 1 — Search for top decks by commander name
|
||||
search_url = f"{MOXFIELD_API}/v2/decks/search"
|
||||
params = {
|
||||
"q": "",
|
||||
"fmt": "commander",
|
||||
"commander": _slugify_commander(commander_name),
|
||||
"sortType": "views",
|
||||
"sortDirection": "Descending",
|
||||
"pageSize": max_decks,
|
||||
}
|
||||
log.info(f" Moxfield: Searching for '{commander_name}' (max={max_decks})")
|
||||
resp = httpx.get(search_url, params=params, headers=HEADERS, timeout=15)
|
||||
resp.raise_for_status()
|
||||
search_data = resp.json()
|
||||
|
||||
deck_summaries = search_data.get("data", [])[:max_decks]
|
||||
if not deck_summaries:
|
||||
log.info(f" Moxfield: No decks found for '{commander_name}'")
|
||||
return []
|
||||
|
||||
log.info(f" Moxfield: Found {len(deck_summaries)} deck(s) for '{commander_name}', fetching details...")
|
||||
|
||||
# Step 2 — Fetch full detail for each deck
|
||||
for summary in deck_summaries:
|
||||
deck_id = summary.get("publicId")
|
||||
deck_name = summary.get("name", f"Moxfield {commander_name}")
|
||||
if not deck_id:
|
||||
log.warning(" Moxfield: Skipping deck with no publicId")
|
||||
continue
|
||||
|
||||
time.sleep(_RATE_LIMIT_DELAY) # Rate limit: <=10 req/sec
|
||||
|
||||
try:
|
||||
deck_url = f"{MOXFIELD_API}/v3/decks/all/{deck_id}"
|
||||
deck_resp = httpx.get(deck_url, headers=HEADERS, timeout=15)
|
||||
deck_resp.raise_for_status()
|
||||
deck_data = deck_resp.json()
|
||||
except Exception as deck_err:
|
||||
log.warning(f" Moxfield: Failed to fetch deck '{deck_id}': {deck_err}")
|
||||
continue
|
||||
|
||||
cards = _build_cards_from_deck(deck_data)
|
||||
if not cards:
|
||||
log.warning(f" Moxfield: Deck '{deck_name}' ({deck_id}) had no cards, skipping")
|
||||
continue
|
||||
|
||||
results.append(TemplateDeck(
|
||||
name=f"{deck_name} (Moxfield)",
|
||||
source=f"https://moxfield.com/decks/{deck_id}",
|
||||
cards=cards,
|
||||
))
|
||||
log.info(f" Moxfield: Added deck '{deck_name}' ({len(cards)} cards)")
|
||||
|
||||
log.info(f" Moxfield: Returning {len(results)} deck(s) for '{commander_name}'")
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
log.warning(
|
||||
f" Moxfield fetch failed for '{commander_name}': "
|
||||
f"HTTP {e.response.status_code} — {e}"
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
log.warning(f" Moxfield request error for '{commander_name}': {e}")
|
||||
except Exception as e:
|
||||
log.warning(f" Moxfield unexpected error for '{commander_name}': {e}")
|
||||
|
||||
return results
|
||||
print(f" [MOXFIELD] Stub: Not yet implemented for '{commander_name}'")
|
||||
return []
|
||||
@@ -6,10 +6,6 @@ Fetches template decklists from MTGGoldfish for a given commander.
|
||||
Currently returns empty (experimental — not yet implemented).
|
||||
"""
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.deckgen.mtggoldfish")
|
||||
|
||||
|
||||
|
||||
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
|
||||
@@ -17,5 +13,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
|
||||
Fetch template decks from MTGGoldfish for the given commander.
|
||||
Experimental — not yet implemented.
|
||||
"""
|
||||
log.info(f" Stub: Not yet implemented for '{commander_name}'")
|
||||
print(f" [MTGGOLDFISH] Stub: Not yet implemented for '{commander_name}'")
|
||||
return []
|
||||
@@ -1,129 +0,0 @@
|
||||
# Closed-Loop Iterative Distillation — Implementation Roadmap
|
||||
|
||||
> **Goal:** Create a feedback loop where the PPO self-play agent exports its game decisions
|
||||
> as JSONL files, which the supervised training pipeline incorporates alongside Forge
|
||||
> heuristic data. The supervised baseline gradually improves as the RL agent discovers
|
||||
> better play patterns — the RL agent teaches the supervised model.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — PPO Decision Exporter
|
||||
|
||||
**Goal:** PPO self-play writes decisions in the same JSONL format as Forge batch sims.
|
||||
|
||||
- [ ] Add a `DecisionExporter` class in `ml/training/self_play.py` that captures each
|
||||
(state, action, reward, game_context) during PPO rollouts
|
||||
- [ ] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl` using the exact same
|
||||
schema as the Forge `DecisionSnapshot` files
|
||||
- [ ] Include metadata fields to distinguish PPO-generated data:
|
||||
- `"source": "ppo"`
|
||||
- `"model_version"`
|
||||
- `"reward"`
|
||||
- `"episode_return"`
|
||||
- [ ] Only export decisions from **winning** games initially (higher signal-to-noise)
|
||||
|
||||
**Files touched:** `ml/training/self_play.py`, new `ml/training/decision_exporter.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Dataset Builder Source Awareness
|
||||
|
||||
**Goal:** `dataset_builder.py` can ingest both Forge and PPO decision files with
|
||||
configurable mixing.
|
||||
|
||||
- [ ] Update `build_dataset()` to scan for both `ml-decisions-sim-*.jsonl` (Forge) and
|
||||
`ml-decisions-ppo-*.jsonl` (PPO) files
|
||||
- [ ] Add a `source_weights` config param — e.g., `{"forge": 1.0, "ppo": 0.5}` — to
|
||||
control sampling ratios so PPO data doesn't overwhelm the Forge baseline early on
|
||||
- [ ] Add a `min_reward_threshold` filter for PPO data — only include decisions from
|
||||
games above a certain reward score
|
||||
- [ ] Tag each sample with its source for downstream analysis
|
||||
|
||||
**Files touched:** `ml/data/dataset_builder.py`, `ml/config/scope.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Quality Gate & Validation
|
||||
|
||||
**Goal:** Prevent bad PPO data from degrading the supervised model.
|
||||
|
||||
- [ ] Add a validation split in `dataset_builder.py` (hold out 10-15% of Forge data as
|
||||
a fixed eval set)
|
||||
- [ ] After each supervised training run, evaluate on the held-out Forge set — if
|
||||
accuracy drops below a threshold compared to the previous model, reject the PPO
|
||||
data batch
|
||||
- [ ] Track win rate of the PPO agent — only export decisions once the PPO model exceeds
|
||||
a configurable win rate (e.g., >30% in 4-player pods, above the 25% random baseline)
|
||||
- [ ] Log data quality metrics: action distribution entropy, reward distribution of
|
||||
exported games
|
||||
|
||||
**Files touched:** `ml/training/trainer.py`, `ml/eval/`, new `ml/data/quality_gate.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Distillation Loop Orchestrator
|
||||
|
||||
**Goal:** Automate the full cycle: supervised train -> PPO self-play -> export -> retrain.
|
||||
|
||||
- [ ] New `ml/training/distillation_loop.py` that orchestrates:
|
||||
1. Supervised train on current dataset (Forge + any existing PPO data)
|
||||
2. Run N episodes of PPO self-play using the new supervised model as starting policy
|
||||
3. Export winning PPO decisions to JSONL
|
||||
4. Quality gate check
|
||||
5. If passed, merge into dataset and go to step 1
|
||||
6. If failed, discard PPO batch, adjust hyperparameters, retry
|
||||
- [ ] Add `max_iterations` and `convergence_check` (stop if win rate plateaus across
|
||||
iterations)
|
||||
- [ ] Each iteration is a "generation" — tag data and models with generation number
|
||||
|
||||
**Files touched:** new `ml/training/distillation_loop.py`, `ml/config/scope.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — UI Integration & Monitoring
|
||||
|
||||
**Goal:** Expose the distillation loop in the Commander AI Lab web UI.
|
||||
|
||||
- [ ] Add a "Distillation" tab or section in `ui/ai-lab.js` showing:
|
||||
- Current generation number
|
||||
- Per-generation win rate trend chart
|
||||
- Data composition pie chart (% Forge vs % PPO per generation)
|
||||
- Start / stop / pause controls for the loop
|
||||
- [ ] API endpoints in `routes/ml.py`:
|
||||
- `POST /api/ml/distill/start`
|
||||
- `GET /api/ml/distill/status`
|
||||
- `POST /api/ml/distill/stop`
|
||||
- [ ] History of each generation's metrics stored as JSON for the trends view
|
||||
|
||||
**Files touched:** `routes/ml.py`, `ui/ai-lab.js`, `ui/ai-lab.css`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Advanced: ELO Tracking & Model Tournament
|
||||
|
||||
**Goal:** Quantify improvement across generations.
|
||||
|
||||
- [ ] Pit each generation's model against previous generations and the Forge heuristic
|
||||
in batch sims
|
||||
- [ ] Compute ELO ratings per generation
|
||||
- [ ] Store in `data/elo_history.json` and visualize in the UI as a line chart
|
||||
- [ ] Use ELO delta as a convergence signal — stop the loop when ELO gain per generation
|
||||
drops below a threshold
|
||||
|
||||
**Files touched:** new `ml/eval/elo_tracker.py`, `routes/ml.py`, `ui/ai-lab.js`
|
||||
|
||||
---
|
||||
|
||||
## Priority
|
||||
|
||||
| Phase | Priority | Description |
|
||||
|-------|----------|-------------|
|
||||
| 1 | **Critical** | PPO decision export — foundation for everything |
|
||||
| 2 | **Critical** | Dataset builder ingests both sources |
|
||||
| 3 | **Critical** | Quality gate prevents model regression |
|
||||
| 4 | High | Automation of the full loop |
|
||||
| 5 | Medium | UI visibility and controls |
|
||||
| 6 | Low | ELO tracking and tournament system |
|
||||
|
||||
> **Phases 1-3** are the core — get those working and you have a functional closed loop.
|
||||
> **Phase 4** automates it. **Phases 5-6** are polish and observability.
|
||||
@@ -1,93 +0,0 @@
|
||||
# Phase 1 — Self-Play Integration Checklist
|
||||
|
||||
> **Issue:** #65 — PPO Decision Exporter
|
||||
> **Ref:** `docs/CLOSED-LOOP-DISTILLATION.md`
|
||||
> **Status:** In Progress
|
||||
|
||||
---
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Create `ml/training/decision_exporter.py` with `DecisionExporter` class
|
||||
- [x] `ExporterConfig` dataclass (output_dir, only_wins, model_version, min_episode_steps)
|
||||
- [x] `build_decision_record()` — converts PPO step data into Forge-compatible JSONL format
|
||||
- [x] `DecisionExporter.begin_episode()` / `record_step()` / `end_episode()` / `flush()` lifecycle
|
||||
- [x] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl`
|
||||
- [x] Include PPO metadata fields: `source`, `model_version`, `reward`, `episode_return`
|
||||
- [x] Win-only filtering (only export decisions from winning games)
|
||||
- [x] Short-episode gating (`min_episode_steps` threshold)
|
||||
- [x] `_macro_action_to_forge_type()` mapping (MacroAction enum → Forge action type strings)
|
||||
- [x] Export stats tracking (`stats` property)
|
||||
|
||||
---
|
||||
|
||||
## Remaining — Integrate into `ml/training/self_play.py`
|
||||
|
||||
### Import
|
||||
|
||||
- [ ] Add import: `from ml.training.decision_exporter import DecisionExporter`
|
||||
|
||||
### Modify `run_self_play_episode()`
|
||||
|
||||
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
|
||||
- [ ] After each `trajectory.append(...)` block (3 locations), add:
|
||||
```python
|
||||
if exporter is not None:
|
||||
exporter.record_step(
|
||||
game_state_snapshot=prev_snapshot,
|
||||
action_idx=action_idx,
|
||||
reward=reward,
|
||||
)
|
||||
```
|
||||
|
||||
### Modify `collect_rollouts()`
|
||||
|
||||
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
|
||||
- [ ] Before each episode, call:
|
||||
```python
|
||||
if exporter is not None:
|
||||
exporter.begin_episode(agent_seat=agent_seat, playstyle=playstyle)
|
||||
```
|
||||
- [ ] Pass `exporter=exporter` to `run_self_play_episode()`
|
||||
- [ ] After outcome tracking, call:
|
||||
```python
|
||||
if exporter is not None:
|
||||
won = trajectory and trajectory[-1]["done"] and trajectory[-1]["reward"] > 0.5
|
||||
ep_return = sum(s["reward"] for s in trajectory)
|
||||
exporter.end_episode(won=won, episode_return=ep_return)
|
||||
```
|
||||
|
||||
### Commit
|
||||
|
||||
- [ ] `git add ml/training/self_play.py`
|
||||
- [ ] `git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"`
|
||||
- [ ] `git push origin main`
|
||||
|
||||
---
|
||||
|
||||
## Integration Pattern
|
||||
|
||||
```
|
||||
collect_rollouts(exporter=exporter)
|
||||
└─ for each episode:
|
||||
├─ exporter.begin_episode(seat, playstyle)
|
||||
├─ run_self_play_episode(..., exporter=exporter)
|
||||
│ └─ for each step:
|
||||
│ ├─ trajectory.append(step_data)
|
||||
│ └─ exporter.record_step(snapshot, action, reward)
|
||||
└─ exporter.end_episode(won, episode_return)
|
||||
# Caller does: exporter.flush()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Terminal Commands
|
||||
|
||||
```bash
|
||||
cd commander-ai-lab
|
||||
git pull origin main
|
||||
# Make edits to ml/training/self_play.py per checklist above
|
||||
git add ml/training/self_play.py
|
||||
git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"
|
||||
git push origin main
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>commander-ai-lab-ui</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+4266
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "commander-ai-lab-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^3.8.0",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { PageLayout } from './components/layout/PageLayout'
|
||||
import { BatchSimPage } from './pages/BatchSimPage'
|
||||
import { CollectionPage } from './pages/CollectionPage'
|
||||
import { DecksPage } from './pages/DecksPage'
|
||||
import { AutoGenPage } from './pages/AutoGenPage'
|
||||
import { SimulatorPage } from './pages/SimulatorPage'
|
||||
import { CoachPage } from './pages/CoachPage'
|
||||
import { TrainingPage } from './pages/TrainingPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<PageLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<BatchSimPage />} />
|
||||
<Route path="/collection" element={<CollectionPage />} />
|
||||
<Route path="/decks" element={<DecksPage />} />
|
||||
<Route path="/autogen" element={<AutoGenPage />} />
|
||||
<Route path="/simulator" element={<SimulatorPage />} />
|
||||
<Route path="/coach" element={<CoachPage />} />
|
||||
<Route path="/training" element={<TrainingPage />} />
|
||||
</Routes>
|
||||
</PageLayout>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Base API client — all fetch calls go through here
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
const BASE = '' // same origin; Vite proxy handles /api in dev
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponse<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
let message = `HTTP ${res.status}`
|
||||
try {
|
||||
const json = JSON.parse(body)
|
||||
message = json.detail || json.error || message
|
||||
} catch {
|
||||
if (body) message = body
|
||||
}
|
||||
throw new ApiError(res.status, message)
|
||||
}
|
||||
// Handle 204 No Content
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
const url = new URL(BASE + path, window.location.origin)
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== '') url.searchParams.set(k, String(v))
|
||||
})
|
||||
}
|
||||
const res = await fetch(url.toString())
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
|
||||
export async function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
|
||||
export async function postForm<T>(path: string, formData: FormData): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
|
||||
export async function put<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
|
||||
export async function patch<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
|
||||
export async function del<T>(path: string): Promise<T> {
|
||||
const res = await fetch(BASE + path, { method: 'DELETE' })
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { get, post } from './client'
|
||||
import type {
|
||||
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
|
||||
CoachChatResponse, CoachApplyResult, CoachCardLikeResult
|
||||
} from '../types'
|
||||
|
||||
export async function getCoachStatus() {
|
||||
return get<CoachStatus>('/api/coach/status')
|
||||
}
|
||||
|
||||
export async function getCoachDecks() {
|
||||
return get<CoachDeck[]>('/api/coach/decks')
|
||||
}
|
||||
|
||||
export async function getCoachDeckReport(deckId: string) {
|
||||
return get<Record<string, unknown>>(`/api/coach/decks/${deckId}/report`)
|
||||
}
|
||||
|
||||
export async function runCoachSession(deckId: string, goals?: Record<string, unknown>) {
|
||||
return post<CoachSession>(`/api/coach/decks/${deckId}`, goals ? { goals } : undefined)
|
||||
}
|
||||
|
||||
export async function getCoachSessions(deckId?: string) {
|
||||
const params: Record<string, string> = {}
|
||||
if (deckId) params.deck_id = deckId
|
||||
return get<{ sessions: CoachSessionSummary[] }>('/api/coach/sessions', params)
|
||||
}
|
||||
|
||||
export async function getCoachSession(sessionId: string) {
|
||||
return get<CoachSession>(`/api/coach/sessions/${sessionId}`)
|
||||
}
|
||||
|
||||
export async function downloadEmbeddings() {
|
||||
return post<{ success: boolean; cards: number }>('/api/coach/embeddings/download')
|
||||
}
|
||||
|
||||
export async function coachChat(
|
||||
deckId: string,
|
||||
messages: { role: string; content: string }[],
|
||||
goals?: Record<string, unknown>
|
||||
) {
|
||||
return post<CoachChatResponse>('/api/coach/chat', {
|
||||
deck_id: deckId,
|
||||
messages,
|
||||
goals,
|
||||
stream: false,
|
||||
})
|
||||
}
|
||||
|
||||
export async function coachChatStream(
|
||||
deckId: string,
|
||||
messages: { role: string; content: string }[],
|
||||
goals?: Record<string, unknown>,
|
||||
onChunk?: (text: string) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
const res = await fetch('/api/coach/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
deck_id: deckId,
|
||||
messages,
|
||||
goals,
|
||||
stream: true,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(body || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) throw new Error('No response body')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let fullText = ''
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim()
|
||||
if (data === '[DONE]') break
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.content) {
|
||||
fullText += parsed.content
|
||||
onChunk?.(fullText)
|
||||
}
|
||||
} catch { /* skip bad chunks */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fullText
|
||||
}
|
||||
|
||||
export async function applyCoachSuggestions(
|
||||
sessionId: string,
|
||||
deckId: number,
|
||||
acceptedCuts: string[],
|
||||
acceptedAdds: string[]
|
||||
) {
|
||||
return post<CoachApplyResult>('/api/coach/apply', {
|
||||
session_id: sessionId,
|
||||
deck_id: deckId,
|
||||
accepted_cuts: acceptedCuts,
|
||||
accepted_adds: acceptedAdds,
|
||||
})
|
||||
}
|
||||
|
||||
export async function searchCardsLike(card: string, colors?: string, topN = 10) {
|
||||
const params: Record<string, string | number> = { card, top_n: topN }
|
||||
if (colors) params.colors = colors
|
||||
return get<{ query: string; results: CoachCardLikeResult[] }>('/api/coach/cards-like', params)
|
||||
}
|
||||
|
||||
export async function generateReport(deckId?: number) {
|
||||
return post<{ status: string; decksUpdated: string[]; count: number }>('/api/coach/reports/generate',
|
||||
deckId ? { deck_id: deckId } : undefined
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { get, post, patch, postForm } from './client'
|
||||
import type { CollectionCard, CollectionFilters, SetInfo, ScanResult } from '../types'
|
||||
|
||||
export async function searchCollection(filters: CollectionFilters) {
|
||||
// Map frontend snake_case filter names to backend camelCase param names
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
if (filters.q) params.q = filters.q
|
||||
if (filters.page) params.page = filters.page
|
||||
if (filters.page_size) params.pageSize = filters.page_size
|
||||
if (filters.sort_by) params.sortField = filters.sort_by
|
||||
if (filters.sort_dir) params.sortDir = filters.sort_dir
|
||||
if (filters.color) params.colors = filters.color
|
||||
if (filters.type_line) params.types = filters.type_line
|
||||
if (filters.rarity) params.rarity = filters.rarity
|
||||
if (filters.set_code) params.setCode = filters.set_code
|
||||
if (filters.cmc_min !== undefined) params.cmcMin = filters.cmc_min
|
||||
if (filters.cmc_max !== undefined) params.cmcMax = filters.cmc_max
|
||||
if (filters.price_min !== undefined) params.priceMin = filters.price_min
|
||||
if (filters.price_max !== undefined) params.priceMax = filters.price_max
|
||||
if (filters.owned_only !== undefined) params.owned_only = filters.owned_only
|
||||
|
||||
const res = await get<{ items?: CollectionCard[]; cards?: CollectionCard[]; total: number; page: number; pageSize?: number; page_size?: number }>(
|
||||
'/api/collection', params
|
||||
)
|
||||
return {
|
||||
cards: res.items || res.cards || [],
|
||||
total: res.total || 0,
|
||||
page: res.page || 1,
|
||||
page_size: res.pageSize || res.page_size || 48,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCard(cardId: number) {
|
||||
return get<CollectionCard>(`/api/collection/${cardId}`)
|
||||
}
|
||||
|
||||
export async function updateCard(cardId: number, updates: Partial<CollectionCard>) {
|
||||
return patch<CollectionCard>(`/api/collection/${cardId}`, updates)
|
||||
}
|
||||
|
||||
export async function getSets() {
|
||||
const res = await get<SetInfo[] | { sets: SetInfo[] }>('/api/collection/sets')
|
||||
return Array.isArray(res) ? res : (res as { sets: SetInfo[] }).sets || []
|
||||
}
|
||||
|
||||
export async function getKeywords() {
|
||||
const res = await get<string[] | { keywords: string[] }>('/api/collection/keywords')
|
||||
return Array.isArray(res) ? res : (res as { keywords: string[] }).keywords || []
|
||||
}
|
||||
|
||||
export async function exportCollection(format: string = 'csv') {
|
||||
const res = await fetch(`/api/collection/export?format=${format}`)
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
export async function importCollection(file: File) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return postForm<{ imported: number; errors: string[] }>('/api/collection/import', fd)
|
||||
}
|
||||
|
||||
export async function scanCard(file: File) {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return postForm<ScanResult>('/api/collection/scan', fd)
|
||||
}
|
||||
|
||||
export async function addScanResult(match: { name: string; set_code: string; scryfall_id: string; quantity?: number }) {
|
||||
return post<{ success: boolean; card_id: number }>('/api/collection/scan/add', match)
|
||||
}
|
||||
|
||||
export async function reEnrichCards() {
|
||||
return post<{ updated: number }>('/api/collection/re-enrich')
|
||||
}
|
||||
|
||||
export async function autoClassify(cardId: number) {
|
||||
return post<{ classifications: string[] }>(`/api/collection/auto-classify`, { card_id: cardId })
|
||||
}
|
||||
|
||||
export async function autoClassifyAll() {
|
||||
return post<{ processed: number }>('/api/collection/auto-classify-all')
|
||||
}
|
||||
|
||||
export async function getEdhrecData(cardId: number) {
|
||||
return get<Record<string, unknown>>(`/api/collection/${cardId}/edhrec`)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { get, post } from './client'
|
||||
import type { DeckGenV3Request, DeckGenV3Status, DeckGenV3Result, CommanderSearchResult } from '../types'
|
||||
|
||||
export async function getV3Status() {
|
||||
return get<DeckGenV3Status>('/api/deck/v3/status')
|
||||
}
|
||||
|
||||
export async function searchCommanders(q: string) {
|
||||
return get<CommanderSearchResult[]>('/api/deck-generator/commander-search', { q })
|
||||
}
|
||||
|
||||
export async function generateDeckV3(req: DeckGenV3Request) {
|
||||
return post<DeckGenV3Result>('/api/deck/v3/generate', req)
|
||||
}
|
||||
|
||||
export async function commitDeckV3(req: DeckGenV3Request) {
|
||||
return post<DeckGenV3Result & { deck_id: number; dck_path: string }>('/api/deck/v3/commit', req)
|
||||
}
|
||||
|
||||
export async function exportDeckV3(format: 'csv' | 'dck' | 'moxfield' | 'shopping', req: DeckGenV3Request) {
|
||||
return post<Record<string, unknown>>(`/api/deck/v3/export/${format}`, req)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { get, post, put, del, patch } from './client'
|
||||
import type { Deck, DeckCard, DeckAnalysis, EdhRecsResponse, CollectionRecsResponse, PplxStatus } from '../types'
|
||||
|
||||
export async function listDecks() {
|
||||
const res = await get<{ decks: Record<string, unknown>[] } | Record<string, unknown>[]>('/api/decks')
|
||||
const raw = Array.isArray(res) ? res : (res as { decks: Record<string, unknown>[] }).decks || []
|
||||
return raw.map(d => ({
|
||||
...d,
|
||||
commander: d.commander || d.commander_name || '',
|
||||
card_count: d.card_count ?? d.total_cards ?? 0,
|
||||
total_price: d.total_price ?? 0,
|
||||
color_identity: d.color_identity || [],
|
||||
created_date: d.created_date || d.created_at || '',
|
||||
updated_date: d.updated_date || d.updated_at || '',
|
||||
})) as Deck[]
|
||||
}
|
||||
|
||||
export async function getDeck(deckId: number) {
|
||||
return get<Deck>(`/api/decks/${deckId}`)
|
||||
}
|
||||
|
||||
export async function createDeck(deck: { name: string; commander?: string; format?: string }) {
|
||||
return post<Deck>('/api/decks', deck)
|
||||
}
|
||||
|
||||
export async function updateDeck(deckId: number, updates: Partial<Deck>) {
|
||||
return put<Deck>(`/api/decks/${deckId}`, updates)
|
||||
}
|
||||
|
||||
export async function deleteDeck(deckId: number) {
|
||||
return del<void>(`/api/decks/${deckId}`)
|
||||
}
|
||||
|
||||
export async function deleteAllDecks() {
|
||||
return del<void>('/api/decks')
|
||||
}
|
||||
|
||||
export async function getDeckCards(deckId: number) {
|
||||
const res = await get<{ cards: Record<string, unknown>[]; total: number } | Record<string, unknown>[]>(`/api/decks/${deckId}/cards`)
|
||||
const raw = Array.isArray(res) ? res : (res as { cards: Record<string, unknown>[] }).cards || []
|
||||
return raw.map(c => ({
|
||||
...c,
|
||||
name: c.name || c.card_name || '',
|
||||
category: c.category || c.role_tag || '',
|
||||
})) as DeckCard[]
|
||||
}
|
||||
|
||||
export async function addCardToDeck(deckId: number, card: { name: string; scryfall_id?: string; quantity?: number; category?: string }) {
|
||||
return post<DeckCard>(`/api/decks/${deckId}/cards`, card)
|
||||
}
|
||||
|
||||
export async function removeCardFromDeck(deckId: number, cardId: number) {
|
||||
return del<void>(`/api/decks/${deckId}/cards/${cardId}`)
|
||||
}
|
||||
|
||||
export async function updateDeckCard(deckId: number, cardId: number, updates: Partial<DeckCard>) {
|
||||
return patch<DeckCard>(`/api/decks/${deckId}/cards/${cardId}`, updates)
|
||||
}
|
||||
|
||||
export async function getDeckAnalysis(deckId: number) {
|
||||
const raw = await get<Record<string, unknown>>(`/api/decks/${deckId}/analysis`)
|
||||
// Backend returns: counts_by_type, targets, deltas, mana_curve, color_pips, total_cards, roles
|
||||
// Frontend DeckAnalysis expects: card_count, land_count, creature_count, noncreature_count, avg_cmc, mana_curve, color_distribution, type_distribution, total_price, owned_count, missing_count
|
||||
const counts = (raw.counts_by_type || {}) as Record<string, number>
|
||||
const totalCards = (raw.total_cards as number) || 0
|
||||
const landCount = counts['Land'] || 0
|
||||
const creatureCount = counts['Creature'] || 0
|
||||
const manaCurve = (raw.mana_curve || {}) as Record<string | number, number>
|
||||
const colorPips = (raw.color_pips || {}) as Record<string, number>
|
||||
// Compute avg CMC from mana curve
|
||||
let totalMana = 0, nonLandCards = 0
|
||||
for (const [cmc, count] of Object.entries(manaCurve)) {
|
||||
const cmcNum = cmc === '6+' ? 6 : Number(cmc)
|
||||
totalMana += cmcNum * count
|
||||
nonLandCards += count
|
||||
}
|
||||
return {
|
||||
card_count: totalCards,
|
||||
land_count: landCount,
|
||||
creature_count: creatureCount,
|
||||
noncreature_count: totalCards - landCount - creatureCount,
|
||||
avg_cmc: nonLandCards > 0 ? totalMana / nonLandCards : 0,
|
||||
mana_curve: manaCurve as Record<number, number>,
|
||||
color_distribution: colorPips,
|
||||
type_distribution: counts,
|
||||
total_price: 0,
|
||||
owned_count: totalCards,
|
||||
missing_count: 0,
|
||||
} as DeckAnalysis
|
||||
}
|
||||
|
||||
export async function getCollectionRecs(deckId: number) {
|
||||
return get<CollectionRecsResponse>(`/api/decks/${deckId}/recommended-from-collection`)
|
||||
}
|
||||
|
||||
export async function getEdhRecs(deckId: number, onlyOwned = false, maxResults = 30) {
|
||||
return get<EdhRecsResponse>(`/api/decks/${deckId}/edh-recs?only_owned=${onlyOwned}&max_results=${maxResults}`)
|
||||
}
|
||||
|
||||
export async function bulkAddCards(deckId: number, cards: { name: string; quantity?: number }[]) {
|
||||
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add`, { cards })
|
||||
}
|
||||
|
||||
export async function bulkAddRecommended(deckId: number, cards: string[]) {
|
||||
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add-recommended`, { card_names: cards })
|
||||
}
|
||||
|
||||
export async function exportDeckToSim(deckId: number) {
|
||||
return post<{ path: string }>(`/api/decks/${deckId}/export-to-sim`)
|
||||
}
|
||||
|
||||
export async function importDeckToDeck(deckId: number, data: { text?: string; url?: string }) {
|
||||
return post<{ imported: number }>(`/api/decks/${deckId}/import`, data)
|
||||
}
|
||||
|
||||
export async function importNewDeck(data: { text?: string; url?: string; name?: string }) {
|
||||
return post<Deck>('/api/decks/import-new', data)
|
||||
}
|
||||
|
||||
export async function getPplxStatus() {
|
||||
return get<PplxStatus>('/api/pplx/status')
|
||||
}
|
||||
|
||||
export async function deckResearch(payload: { commander: string; strategy?: string }) {
|
||||
return post<{ research: string }>('/api/deck-research', payload)
|
||||
}
|
||||
|
||||
export async function deckGenerate(payload: { commander: string; strategy?: string; bracket?: number }) {
|
||||
return post<{ cards: Array<{ name: string; quantity: number; category: string }> }>('/api/deck-generate', payload)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * as collectionApi from './collection'
|
||||
export * as decksApi from './decks'
|
||||
export * as deckgenApi from './deckgen'
|
||||
export * as labApi from './lab'
|
||||
export * as simApi from './sim'
|
||||
export * as coachApi from './coach'
|
||||
export * as trainingApi from './training'
|
||||
@@ -0,0 +1,65 @@
|
||||
import { get, post } from './client'
|
||||
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
|
||||
|
||||
export async function getLabDecks() {
|
||||
const res = await get<{ decks: LabDeck[] } | LabDeck[]>('/api/lab/decks')
|
||||
return Array.isArray(res) ? res : (res as { decks: LabDeck[] }).decks || []
|
||||
}
|
||||
|
||||
export async function startBatchSim(config: { decks: string[]; games: number; threads?: number; use_deepseek?: boolean; deepseek_deck?: string }) {
|
||||
return post<{ run_id: string }>('/api/lab/start', config)
|
||||
}
|
||||
|
||||
export async function startDeepSeekSim(config: { decks: string[]; games: number; deepseek_deck: string }) {
|
||||
return post<{ run_id: string }>('/api/lab/start-deepseek', config)
|
||||
}
|
||||
|
||||
export async function getLabStatus() {
|
||||
return get<LabStatus>('/api/lab/status')
|
||||
}
|
||||
|
||||
export async function getLabResult() {
|
||||
return get<LabResult>('/api/lab/result')
|
||||
}
|
||||
|
||||
export async function getLabHistory() {
|
||||
const res = await get<{ results: LabHistoryEntry[] } | LabHistoryEntry[]>('/api/lab/history')
|
||||
return Array.isArray(res) ? res : (res as { results: LabHistoryEntry[] }).results || []
|
||||
}
|
||||
|
||||
export async function getPrecons() {
|
||||
const res = await get<{ precons: PreconDeck[] } | PreconDeck[]>('/api/lab/precons')
|
||||
return Array.isArray(res) ? res : (res as { precons: PreconDeck[] }).precons || []
|
||||
}
|
||||
|
||||
export async function installPrecon(name: string) {
|
||||
return post<{ installed: boolean }>('/api/lab/precons/install', { name })
|
||||
}
|
||||
|
||||
export async function installPreconBatch(names: string[]) {
|
||||
return post<{ installed: number }>('/api/lab/precons/install-batch', { names })
|
||||
}
|
||||
|
||||
export async function getMetaCommanders() {
|
||||
return get<Array<{ name: string; color_identity: string[] }>>('/api/lab/meta/commanders')
|
||||
}
|
||||
|
||||
export async function searchMetaCommanders(q: string) {
|
||||
return get<Array<{ name: string; color_identity: string[]; source: string }>>('/api/lab/meta/search', { q })
|
||||
}
|
||||
|
||||
export async function fetchMetaDeck(commander: string) {
|
||||
return post<{ deck_name: string; cards: Array<{ name: string; quantity: number }> }>('/api/lab/meta/fetch', { commander })
|
||||
}
|
||||
|
||||
export async function importDeckFromUrl(url: string) {
|
||||
return post<{ deck_name: string }>('/api/lab/import/url', { url })
|
||||
}
|
||||
|
||||
export async function importDeckFromText(text: string, name?: string) {
|
||||
return post<{ deck_name: string }>('/api/lab/import/text', { text, name })
|
||||
}
|
||||
|
||||
export async function getLabLog() {
|
||||
return get<{ log: string }>('/api/lab/log')
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { get, post } from './client'
|
||||
import type { SimStatus, SimResult, DeepSeekStatus } from '../types'
|
||||
|
||||
export async function runSim(config: { deck_a: string; deck_b: string; games: number }) {
|
||||
return post<{ sim_id: string }>('/api/sim/run', config)
|
||||
}
|
||||
|
||||
export async function runSimFromDeck(deckId: number, config: { opponent?: string; games?: number }) {
|
||||
return post<{ sim_id: string }>('/api/sim/run-from-deck', { deck_id: deckId, ...config })
|
||||
}
|
||||
|
||||
export async function runDeepSeekSim(config: { deck_a: string; deck_b: string; games: number; deepseek_deck: string }) {
|
||||
return post<{ sim_id: string }>('/api/sim/run-deepseek', config)
|
||||
}
|
||||
|
||||
export async function getSimStatus(simId: string) {
|
||||
return get<SimStatus>('/api/sim/status', { simId })
|
||||
}
|
||||
|
||||
export async function getSimResult(simId: string) {
|
||||
return get<SimResult>('/api/sim/result', { simId })
|
||||
}
|
||||
|
||||
export async function connectDeepSeek() {
|
||||
return post<DeepSeekStatus>('/api/deepseek/connect')
|
||||
}
|
||||
|
||||
export async function getDeepSeekStatus() {
|
||||
return get<DeepSeekStatus>('/api/deepseek/status')
|
||||
}
|
||||
|
||||
export async function configureDeepSeek(config: { model?: string; base_url?: string }) {
|
||||
return post<DeepSeekStatus>('/api/deepseek/configure', config)
|
||||
}
|
||||
|
||||
export async function getDeepSeekLogs() {
|
||||
return get<Array<{ filename: string; size: number; modified: string }>>('/api/deepseek/logs')
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { get, post } from './client'
|
||||
import type {
|
||||
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
|
||||
TournamentStatus, TournamentResults, MLModelInfo
|
||||
} from '../types'
|
||||
|
||||
// ── ML Status & Toggle ──────────────────────────────────────
|
||||
export async function getMLStatus() {
|
||||
return get<MLStatus>('/api/ml/status')
|
||||
}
|
||||
|
||||
export async function toggleML(enable = true) {
|
||||
return post<{ ml_logging_enabled: boolean; message: string }>(`/api/ml/toggle?enable=${enable}`)
|
||||
}
|
||||
|
||||
// ── Data & Model Info ───────────────────────────────────────
|
||||
export async function getDataStatus() {
|
||||
return get<MLDataStatus>('/api/ml/data/status')
|
||||
}
|
||||
|
||||
export async function getModelInfo() {
|
||||
return get<MLModelInfo>('/api/ml/model')
|
||||
}
|
||||
|
||||
export async function reloadModel(checkpoint?: string) {
|
||||
return post<{ success: boolean; status: MLModelInfo }>('/api/ml/reload', checkpoint ? { checkpoint } : undefined)
|
||||
}
|
||||
|
||||
// ── Supervised Training ─────────────────────────────────────
|
||||
export async function startTraining(config: {
|
||||
epochs?: number
|
||||
lr?: number
|
||||
batchSize?: number
|
||||
patience?: number
|
||||
rebuildDataset?: boolean
|
||||
}) {
|
||||
return post<{ status: string; config: Record<string, unknown> }>('/api/ml/train', config)
|
||||
}
|
||||
|
||||
export async function getTrainStatus() {
|
||||
return get<MLTrainStatus>('/api/ml/train/status')
|
||||
}
|
||||
|
||||
// ── PPO Training ────────────────────────────────────────────
|
||||
export async function startPPOTraining(config: {
|
||||
iterations?: number
|
||||
episodesPerIter?: number
|
||||
ppoEpochs?: number
|
||||
batchSize?: number
|
||||
lr?: number
|
||||
clipEpsilon?: number
|
||||
entropyCoeff?: number
|
||||
opponent?: string
|
||||
playstyle?: string
|
||||
loadSupervised?: string
|
||||
}) {
|
||||
return post<{ status: string; iterations: number }>('/api/ml/train/ppo', config)
|
||||
}
|
||||
|
||||
export async function getPPOStatus() {
|
||||
return get<PPOTrainStatus>('/api/ml/train/ppo/status')
|
||||
}
|
||||
|
||||
// ── Tournament ──────────────────────────────────────────────
|
||||
export async function startTournament(config: {
|
||||
episodes?: number
|
||||
playstyle?: string
|
||||
}) {
|
||||
return post<{ status: string; episodes: number }>('/api/ml/tournament', config)
|
||||
}
|
||||
|
||||
export async function getTournamentStatus() {
|
||||
return get<TournamentStatus>('/api/ml/tournament/status')
|
||||
}
|
||||
|
||||
export async function getTournamentResults() {
|
||||
return get<TournamentResults>('/api/ml/tournament/results')
|
||||
}
|
||||
|
||||
// ── Decision Files ──────────────────────────────────────────
|
||||
export async function getDecisionFile(filename: string, limit = 100, offset = 0) {
|
||||
return get<{ file: string; offset: number; limit: number; count: number; decisions: Record<string, unknown>[] }>(
|
||||
`/api/ml/decisions/${filename}?limit=${limit}&offset=${offset}`
|
||||
)
|
||||
}
|
||||
|
||||
// ── Prediction ──────────────────────────────────────────────
|
||||
export async function predict(state: Record<string, unknown>) {
|
||||
return post<{ action: string; action_index: number; confidence: number; probabilities: Record<string, number>; inference_ms: number }>(
|
||||
'/api/ml/predict', state
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface CardImageProps {
|
||||
src: string
|
||||
alt: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: 'w-16 h-[22px]', // tiny inline
|
||||
md: 'w-[146px] h-[204px]', // grid thumbnail
|
||||
lg: 'w-[244px] h-[340px]', // detail view
|
||||
xl: 'w-[336px] h-[468px]', // full size
|
||||
}
|
||||
|
||||
export function CardImage({ src, alt, size = 'md', className = '', onClick }: CardImageProps) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
// Build Scryfall URL if needed — ensure we get the right size
|
||||
const imgUrl = src || ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-lg bg-bg-tertiary ${SIZES[size]} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-accent-blue/50 transition-all' : ''} ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{!error ? (
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full text-text-tertiary text-xs text-center p-2">
|
||||
{alt}
|
||||
</div>
|
||||
)}
|
||||
{!loaded && !error && (
|
||||
<div className="absolute inset-0 bg-bg-tertiary animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Icon className="w-12 h-12 text-text-tertiary mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-1">{title}</h3>
|
||||
{description && <p className="text-sm text-text-secondary max-w-md mb-4">{description}</p>}
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Parse MTG mana cost strings like "{2}{W}{U}" into colored symbols
|
||||
|
||||
const MANA_COLORS: Record<string, string> = {
|
||||
W: 'bg-mana-white text-black',
|
||||
U: 'bg-mana-blue text-white',
|
||||
B: 'bg-mana-black text-white',
|
||||
R: 'bg-mana-red text-white',
|
||||
G: 'bg-mana-green text-white',
|
||||
C: 'bg-mana-colorless text-white',
|
||||
}
|
||||
|
||||
interface ManaSymbolsProps {
|
||||
cost: string
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export function ManaSymbols({ cost, size = 'sm' }: ManaSymbolsProps) {
|
||||
if (!cost) return null
|
||||
|
||||
const symbols = cost.match(/\{([^}]+)\}/g)
|
||||
if (!symbols) return <span className="text-text-tertiary">{cost}</span>
|
||||
|
||||
const sz = size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs'
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{symbols.map((s, i) => {
|
||||
const val = s.replace(/[{}]/g, '')
|
||||
const colorClass = MANA_COLORS[val] || 'bg-mana-colorless text-white'
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`${sz} inline-flex items-center justify-center rounded-full font-bold ${colorClass}`}
|
||||
>
|
||||
{val}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Color identity dots
|
||||
interface ColorDotsProps {
|
||||
colors: string[]
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export function ColorDots({ colors, size = 'sm' }: ColorDotsProps) {
|
||||
if (!colors || colors.length === 0) return <span className="text-text-tertiary text-xs">Colorless</span>
|
||||
|
||||
const sz = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'
|
||||
|
||||
const DOT_COLORS: Record<string, string> = {
|
||||
W: 'bg-mana-white',
|
||||
U: 'bg-mana-blue',
|
||||
B: 'bg-mana-black border border-border-secondary',
|
||||
R: 'bg-mana-red',
|
||||
G: 'bg-mana-green',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{colors.map(c => (
|
||||
<span key={c} className={`${sz} rounded-full ${DOT_COLORS[c] || 'bg-mana-colorless'}`} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
interface SpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const SIZES = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-8 h-8' }
|
||||
|
||||
export function Spinner({ size = 'md', className = '' }: SpinnerProps) {
|
||||
return (
|
||||
<svg
|
||||
className={`animate-spin ${SIZES[size]} ${className}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
cx="12" cy="12" r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" className="text-accent-blue" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
|
||||
type Variant = 'success' | 'error' | 'warning' | 'info' | 'neutral'
|
||||
|
||||
interface StatusBadgeProps {
|
||||
variant: Variant
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const STYLES: Record<Variant, string> = {
|
||||
success: 'bg-status-success/15 text-status-success border-status-success/30',
|
||||
error: 'bg-status-error/15 text-status-error border-status-error/30',
|
||||
warning: 'bg-status-warning/15 text-status-warning border-status-warning/30',
|
||||
info: 'bg-status-info/15 text-status-info border-status-info/30',
|
||||
neutral: 'bg-bg-tertiary text-text-secondary border-border-primary',
|
||||
}
|
||||
|
||||
const ICONS: Record<Variant, typeof CheckCircle> = {
|
||||
success: CheckCircle,
|
||||
error: XCircle,
|
||||
warning: AlertTriangle,
|
||||
info: Info,
|
||||
neutral: Info,
|
||||
}
|
||||
|
||||
export function StatusBadge({ variant, label, className = '' }: StatusBadgeProps) {
|
||||
const Icon = ICONS[variant]
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md border ${STYLES[variant]} ${className}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { Spinner, PageSpinner } from './Spinner'
|
||||
export { StatusBadge } from './StatusBadge'
|
||||
export { CardImage } from './CardImage'
|
||||
export { ManaSymbols, ColorDots } from './ManaSymbols'
|
||||
export { EmptyState } from './EmptyState'
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function PageLayout({ children }: PageLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary">
|
||||
<Sidebar />
|
||||
<main className="ml-56 min-h-screen">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Batch Sim', icon: '⚗️' },
|
||||
{ to: '/collection', label: 'Collection', icon: '📦' },
|
||||
{ to: '/decks', label: 'Decks', icon: '🃏' },
|
||||
{ to: '/autogen', label: 'Auto Gen', icon: '⚡' },
|
||||
{ to: '/simulator', label: 'Simulator', icon: '🎮' },
|
||||
{ to: '/coach', label: 'Coach', icon: '🧠' },
|
||||
{ to: '/training', label: 'Training', icon: '📈' },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 bottom-0 w-56 bg-bg-secondary border-r border-border-primary flex flex-col z-30">
|
||||
{/* Logo */}
|
||||
<div className="px-5 py-5 border-b border-border-primary">
|
||||
<h1 className="text-base font-bold text-text-primary tracking-tight">
|
||||
Commander AI Lab
|
||||
</h1>
|
||||
<p className="text-xs text-text-tertiary mt-0.5">MTG Intelligence Platform</p>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-3 overflow-y-auto">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors duration-150 ${
|
||||
isActive
|
||||
? 'bg-accent-blue/10 text-accent-blue border-r-2 border-accent-blue'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-hover'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-border-primary">
|
||||
<p className="text-xs text-text-tertiary">v4.0 — React + TypeScript</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/** Polls a function at the given interval while `enabled` is true */
|
||||
export function usePolling(fn: () => void, intervalMs: number, enabled: boolean) {
|
||||
const savedFn = useRef(fn)
|
||||
savedFn.current = fn
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
const id = setInterval(() => savedFn.current(), intervalMs)
|
||||
return () => clearInterval(id)
|
||||
}, [intervalMs, enabled])
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Commander AI Lab — Design Tokens (Tailwind v4 CSS Theme)
|
||||
Fresh dark theme with MTG-inspired accents
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
@theme {
|
||||
/* ── Backgrounds ── */
|
||||
--color-bg-primary: #0f1117;
|
||||
--color-bg-secondary: #161921;
|
||||
--color-bg-tertiary: #1c2030;
|
||||
--color-bg-elevated: #222738;
|
||||
--color-bg-hover: #2a3048;
|
||||
|
||||
/* ── Borders ── */
|
||||
--color-border-primary: #2a2f42;
|
||||
--color-border-secondary: #363c54;
|
||||
--color-border-accent: #4f6ef7;
|
||||
|
||||
/* ── Text ── */
|
||||
--color-text-primary: #e8eaf0;
|
||||
--color-text-secondary: #9ba1b8;
|
||||
--color-text-tertiary: #6b7190;
|
||||
--color-text-inverse: #0f1117;
|
||||
|
||||
/* ── Accents ── */
|
||||
--color-accent-blue: #4f6ef7;
|
||||
--color-accent-blue-hover: #6b84ff;
|
||||
--color-accent-teal: #2dd4bf;
|
||||
--color-accent-teal-hover: #5eead4;
|
||||
--color-accent-purple: #a78bfa;
|
||||
--color-accent-amber: #fbbf24;
|
||||
--color-accent-red: #f87171;
|
||||
--color-accent-green: #4ade80;
|
||||
|
||||
/* ── MTG Mana Colors ── */
|
||||
--color-mana-white: #f9faf4;
|
||||
--color-mana-blue: #0e67ab;
|
||||
--color-mana-black: #2b2424;
|
||||
--color-mana-red: #d3202a;
|
||||
--color-mana-green: #00733e;
|
||||
--color-mana-colorless: #706f6f;
|
||||
|
||||
/* ── Status ── */
|
||||
--color-status-success: #4ade80;
|
||||
--color-status-warning: #fbbf24;
|
||||
--color-status-error: #f87171;
|
||||
--color-status-info: #60a5fa;
|
||||
|
||||
/* ── Radius ── */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
|
||||
/* ── Shadows ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow-blue: 0 0 20px rgba(79, 110, 247, 0.15);
|
||||
--shadow-glow-teal: 0 0 20px rgba(45, 212, 191, 0.15);
|
||||
}
|
||||
|
||||
/* ── Base Styles ── */
|
||||
html {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Utility animations ── */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(79, 110, 247, 0.2); }
|
||||
50% { box-shadow: 0 0 16px 4px rgba(79, 110, 247, 0.15); }
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Search, Wand2, Save, AlertCircle,
|
||||
ChevronDown, Sparkles, RefreshCw, Check
|
||||
} from 'lucide-react'
|
||||
import { Spinner, StatusBadge, ManaSymbols, CardImage, ColorDots } from '../components/common'
|
||||
import { deckgenApi } from '../api'
|
||||
import type { DeckGenV3Status, DeckGenV3Result, DeckGenV3Card, CommanderSearchResult } from '../types'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
|
||||
// ── Commander Search ──────────────────────────────────────────
|
||||
function CommanderSearch({ onSelect }: { onSelect: (c: CommanderSearchResult) => void }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<CommanderSearchResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
function handleSearch(q: string) {
|
||||
setQuery(q)
|
||||
clearTimeout(debounceRef.current)
|
||||
if (q.length < 2) { setResults([]); return }
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setSearching(true)
|
||||
try {
|
||||
const r = await deckgenApi.searchCommanders(q)
|
||||
setResults(r)
|
||||
} catch { setResults([]) }
|
||||
setSearching(false)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for a commander..."
|
||||
value={query}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
|
||||
/>
|
||||
{searching && <Spinner size="sm" className="absolute right-3 top-1/2 -translate-y-1/2 text-accent-blue" />}
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute z-20 top-full mt-1 w-full bg-bg-elevated border border-border-primary rounded-xl shadow-lg max-h-80 overflow-y-auto">
|
||||
{results.map(c => (
|
||||
<button
|
||||
key={c.scryfall_id}
|
||||
onClick={() => { onSelect(c); setQuery(c.name); setResults([]) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-bg-hover text-left transition-colors"
|
||||
>
|
||||
<CardImage src={c.image_url} alt={c.name} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{c.name}</p>
|
||||
<p className="text-xs text-text-secondary truncate">{c.type_line}</p>
|
||||
</div>
|
||||
<ColorDots colors={c.color_identity} />
|
||||
{c.in_collection && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-accent-green/15 text-accent-green rounded">Owned</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Generated Deck View ───────────────────────────────────────
|
||||
function GeneratedDeckView({ result, onCommit }: { result: DeckGenV3Result; onCommit: () => void }) {
|
||||
const [view, setView] = useState<'visual' | 'list'>('visual')
|
||||
const [committing, setCommitting] = useState(false)
|
||||
const [selectedCard, setSelectedCard] = useState<DeckGenV3Card | null>(null)
|
||||
|
||||
// Group by category
|
||||
const grouped = result.cards.reduce<Record<string, DeckGenV3Card[]>>((acc, c) => {
|
||||
const cat = c.category || 'Other'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(c)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const curveData = Object.entries(result.stats.mana_curve)
|
||||
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
|
||||
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
|
||||
|
||||
async function handleCommit() {
|
||||
setCommitting(true)
|
||||
try { onCommit() } catch { /* ignore */ }
|
||||
setCommitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
{/* Commander header */}
|
||||
<div className="flex gap-5 bg-bg-secondary rounded-xl border border-border-primary p-5">
|
||||
<CardImage src={result.commander.image_url} alt={result.commander.name} size="lg" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary">{result.commander.name}</h2>
|
||||
<p className="text-sm text-text-secondary">{result.commander.type_line}</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<ManaSymbols cost={result.commander.mana_cost} size="md" />
|
||||
<ColorDots colors={result.commander.color_identity} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-text-primary whitespace-pre-wrap">{result.commander.oracle_text}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-text-secondary">
|
||||
<span>Strategy: <span className="text-accent-blue font-medium">{result.strategy}</span></span>
|
||||
<span>Bracket: <span className="text-accent-amber font-medium">{result.bracket}</span></span>
|
||||
<span>Model: <span className="text-accent-purple font-medium">{result.model}</span></span>
|
||||
</div>
|
||||
{result.substitution && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-accent-green">{result.substitution.owned} owned</span>
|
||||
<span className="text-accent-amber">{result.substitution.substituted} substituted</span>
|
||||
<span className="text-accent-red">{result.substitution.missing} missing</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handleCommit} disabled={committing}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent-green/15 text-accent-green text-sm font-medium rounded-lg hover:bg-accent-green/25 border border-accent-green/30 transition-colors disabled:opacity-50">
|
||||
{committing ? <Spinner size="sm" /> : <Save className="w-4 h-4" />}
|
||||
Save to Decks
|
||||
</button>
|
||||
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
|
||||
<button onClick={() => setView('visual')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'visual' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
|
||||
Visual
|
||||
</button>
|
||||
<button onClick={() => setView('list')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
{/* Stats summary */}
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-text-tertiary">{result.stats.card_count} cards</span>
|
||||
<span className="text-xs text-accent-teal font-medium">${result.stats.total_price.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{/* Mana curve mini */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">Mana Curve</h4>
|
||||
<div className="h-28">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={curveData} barSize={20}>
|
||||
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 10 }} />
|
||||
<YAxis hide />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 11 }} />
|
||||
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
|
||||
{curveData.map((_, i) => <Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card grid/list */}
|
||||
{view === 'visual' ? (
|
||||
<div className="space-y-5">
|
||||
{Object.entries(grouped).map(([cat, cards]) => (
|
||||
<div key={cat}>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-3">{cat} ({cards.length})</h4>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-2">
|
||||
{cards.map(card => (
|
||||
<div key={card.name} className="group cursor-pointer relative" onClick={() => setSelectedCard(card)}>
|
||||
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
|
||||
{card.is_substitute && (
|
||||
<div className="absolute top-1 right-1 w-4 h-4 bg-accent-amber rounded-full flex items-center justify-center">
|
||||
<RefreshCw className="w-2.5 h-2.5 text-bg-primary" />
|
||||
</div>
|
||||
)}
|
||||
{card.owned && (
|
||||
<div className="absolute top-1 left-1 w-4 h-4 bg-accent-green rounded-full flex items-center justify-center">
|
||||
<Check className="w-2.5 h-2.5 text-bg-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-primary text-left">
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Category</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Role</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-primary">
|
||||
{result.cards.map(card => (
|
||||
<tr key={card.name} className="hover:bg-bg-hover transition-colors">
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="font-medium text-text-primary">{card.quantity}x {card.name}</span>
|
||||
{card.is_substitute && (
|
||||
<span className="ml-2 text-[10px] text-accent-amber">sub for {card.original_name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.category}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.role}</td>
|
||||
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
|
||||
<td className="px-4 py-2.5 text-right text-accent-teal text-xs">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded ${card.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||
{card.owned ? `Owned (${card.owned_qty})` : 'Missing'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card detail popup */}
|
||||
{selectedCard && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={() => setSelectedCard(null)}>
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-2xl w-full mx-4 p-6 animate-fade-in" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex gap-5">
|
||||
<CardImage src={selectedCard.image_url} alt={selectedCard.name} size="lg" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<h3 className="text-lg font-bold text-text-primary">{selectedCard.name}</h3>
|
||||
<p className="text-sm text-text-secondary">{selectedCard.type_line}</p>
|
||||
<ManaSymbols cost={selectedCard.mana_cost} size="md" />
|
||||
<p className="text-sm text-text-primary whitespace-pre-wrap">{selectedCard.reason}</p>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-text-tertiary">Role: <span className="text-accent-blue">{selectedCard.role}</span></span>
|
||||
{selectedCard.is_substitute && (
|
||||
<span className="text-accent-amber">Substituted for {selectedCard.original_name} ({(selectedCard.similarity_score ?? 0 * 100).toFixed(0)}%)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Auto Gen Page ────────────────────────────────────────
|
||||
export function AutoGenPage() {
|
||||
const [status, setStatus] = useState<DeckGenV3Status | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [result, setResult] = useState<DeckGenV3Result | null>(null)
|
||||
const [commander, setCommander] = useState<CommanderSearchResult | null>(null)
|
||||
const [strategy, setStrategy] = useState('')
|
||||
const [bracket, setBracket] = useState(3)
|
||||
const [budget, setBudget] = useState<string>('')
|
||||
const [useCollection, setUseCollection] = useState(true)
|
||||
const [runSub, setRunSub] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
deckgenApi.getV3Status().then(s => { setStatus(s); setLoading(false) }).catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!commander) return
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await deckgenApi.generateDeckV3({
|
||||
commander_name: commander.name,
|
||||
strategy: strategy || undefined,
|
||||
target_bracket: bracket,
|
||||
budget_usd: budget ? Number(budget) : undefined,
|
||||
use_collection: useCollection,
|
||||
run_substitution: runSub,
|
||||
})
|
||||
setResult(res)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Generation failed')
|
||||
}
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
async function handleCommit() {
|
||||
if (!commander || !result) return
|
||||
try {
|
||||
await deckgenApi.commitDeckV3({
|
||||
commander_name: commander.name,
|
||||
strategy: strategy || undefined,
|
||||
target_bracket: bracket,
|
||||
budget_usd: budget ? Number(budget) : undefined,
|
||||
use_collection: useCollection,
|
||||
run_substitution: runSub,
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<Sparkles className="w-6 h-6 text-accent-purple" />
|
||||
Auto Gen V3
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">AI-powered deck generation with Perplexity</p>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge
|
||||
variant={status?.initialized ? 'success' : 'error'}
|
||||
label={status?.initialized ? 'Ready' : 'Not Initialized'}
|
||||
/>
|
||||
<StatusBadge
|
||||
variant={status?.pplx_configured ? 'success' : 'warning'}
|
||||
label={status?.pplx_configured ? 'Perplexity Connected' : 'Perplexity Not Set'}
|
||||
/>
|
||||
{status?.embeddings_loaded && (
|
||||
<StatusBadge variant="info" label={`${status.embedding_cards.toLocaleString()} embeddings`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!result ? (
|
||||
/* Config form */
|
||||
<div className="max-w-2xl space-y-5">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-primary block mb-2">Commander</label>
|
||||
<CommanderSearch onSelect={setCommander} />
|
||||
{commander && (
|
||||
<div className="mt-2 flex items-center gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
|
||||
<CardImage src={commander.image_url} alt={commander.name} size="sm" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{commander.name}</p>
|
||||
<p className="text-xs text-text-secondary">{commander.type_line}</p>
|
||||
</div>
|
||||
<ColorDots colors={commander.color_identity} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-primary block mb-2">Strategy (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Voltron, Aristocrats, Storm..."
|
||||
value={strategy}
|
||||
onChange={e => setStrategy(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-primary block mb-2">Target Bracket</label>
|
||||
<select
|
||||
value={bracket}
|
||||
onChange={e => setBracket(Number(e.target.value))}
|
||||
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
>
|
||||
<option value={1}>1 — Casual</option>
|
||||
<option value={2}>2 — Low Power</option>
|
||||
<option value={3}>3 — Mid Power</option>
|
||||
<option value={4}>4 — High Power</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-primary block mb-2">Budget (USD, optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="No limit"
|
||||
value={budget}
|
||||
onChange={e => setBudget(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={useCollection} onChange={e => setUseCollection(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
|
||||
<span className="text-sm text-text-secondary">Use collection cards</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={runSub} onChange={e => setRunSub(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
|
||||
<span className="text-sm text-text-secondary">Run substitution pass</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!commander || generating || !status?.initialized}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-accent-blue text-white text-sm font-semibold rounded-lg hover:bg-accent-blue-hover shadow-glow-blue transition-all disabled:opacity-50 disabled:shadow-none"
|
||||
>
|
||||
{generating ? (
|
||||
<><Spinner size="sm" /> Generating...</>
|
||||
) : (
|
||||
<><Wand2 className="w-4 h-4" /> Generate Deck</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Results */
|
||||
<div>
|
||||
<button onClick={() => setResult(null)}
|
||||
className="flex items-center gap-2 mb-4 text-sm text-text-secondary hover:text-text-primary transition-colors">
|
||||
<ChevronDown className="w-4 h-4 rotate-90" />
|
||||
Back to Config
|
||||
</button>
|
||||
<GeneratedDeckView result={result} onCommit={handleCommit} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
FlaskConical, Play, Clock, Trophy,
|
||||
Upload, Link, FileText, History, ChevronRight, AlertCircle,
|
||||
Check
|
||||
} from 'lucide-react'
|
||||
import { Spinner } from '../components/common'
|
||||
import { labApi } from '../api'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
|
||||
// ── Results Chart ─────────────────────────────────────────────
|
||||
function ResultsChart({ result }: { result: LabResult }) {
|
||||
const data = result.decks
|
||||
.sort((a, b) => b.win_rate - a.win_rate)
|
||||
.map(d => ({
|
||||
name: d.name.length > 20 ? d.name.slice(0, 18) + '...' : d.name,
|
||||
win_rate: Math.round(d.win_rate * 100),
|
||||
fullName: d.name,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} layout="vertical" barSize={20} margin={{ left: 10, right: 20 }}>
|
||||
<XAxis type="number" domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis type="category" dataKey="name" width={150} tick={{ fill: '#9ba1b8', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
|
||||
formatter={(value: unknown) => [`${value}%`, 'Win Rate']}
|
||||
labelFormatter={(label: unknown) => data.find(d => d.name === String(label))?.fullName || String(label)}
|
||||
/>
|
||||
<Bar dataKey="win_rate" radius={[0, 4, 4, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={i === 0 ? '#4ade80' : i === 1 ? '#4f6ef7' : '#363c54'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Deck Selector ─────────────────────────────────────────────
|
||||
function DeckSelector({
|
||||
decks,
|
||||
selected,
|
||||
onToggle,
|
||||
precons,
|
||||
onInstallPrecon,
|
||||
}: {
|
||||
decks: LabDeck[]
|
||||
selected: Set<string>
|
||||
onToggle: (name: string) => void
|
||||
precons: PreconDeck[]
|
||||
onInstallPrecon: (name: string) => void
|
||||
}) {
|
||||
const [showPrecons, setShowPrecons] = useState(false)
|
||||
const [installing, setInstalling] = useState<string | null>(null)
|
||||
|
||||
async function handleInstall(name: string) {
|
||||
setInstalling(name)
|
||||
await onInstallPrecon(name)
|
||||
setInstalling(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Available Decks</h3>
|
||||
{decks.length === 0 ? (
|
||||
<p className="text-xs text-text-tertiary">No decks available. Import or install precons.</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{decks.map(d => (
|
||||
<label key={d.name} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover cursor-pointer transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(d.name)}
|
||||
onChange={() => onToggle(d.name)}
|
||||
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-text-primary truncate block">{d.name}</span>
|
||||
<span className="text-[10px] text-text-tertiary">{d.source} · {d.card_count} cards</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowPrecons(!showPrecons)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium bg-bg-tertiary rounded-lg text-text-secondary hover:bg-bg-hover border border-border-primary transition-colors"
|
||||
>
|
||||
Precon Decks
|
||||
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showPrecons ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showPrecons && (
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto animate-fade-in">
|
||||
{precons.map(p => (
|
||||
<div key={p.name} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-bg-tertiary">
|
||||
<span className="flex-1 text-xs text-text-secondary truncate">{p.name}</span>
|
||||
{p.installed ? (
|
||||
<span className="text-[10px] text-accent-green flex items-center gap-1"><Check className="w-3 h-3" /> Installed</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleInstall(p.name)}
|
||||
disabled={installing === p.name}
|
||||
className="text-[10px] text-accent-blue hover:text-accent-blue-hover transition-colors"
|
||||
>
|
||||
{installing === p.name ? <Spinner size="sm" /> : 'Install'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Import Panel ──────────────────────────────────────────────
|
||||
function ImportPanel({ onImported }: { onImported: () => void }) {
|
||||
const [importUrl, setImportUrl] = useState('')
|
||||
const [importText, setImportText] = useState('')
|
||||
const [importName, setImportName] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importMode, setImportMode] = useState<'url' | 'text'>('url')
|
||||
|
||||
async function handleImport() {
|
||||
setImporting(true)
|
||||
try {
|
||||
if (importMode === 'url' && importUrl.trim()) {
|
||||
await labApi.importDeckFromUrl(importUrl.trim())
|
||||
} else if (importMode === 'text' && importText.trim()) {
|
||||
await labApi.importDeckFromText(importText.trim(), importName || undefined)
|
||||
}
|
||||
setImportUrl('')
|
||||
setImportText('')
|
||||
setImportName('')
|
||||
onImported()
|
||||
} catch { /* ignore */ }
|
||||
setImporting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<Upload className="w-4 h-4 text-accent-blue" />
|
||||
Import Deck
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setImportMode('url')}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'url' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||
<Link className="w-3 h-3 inline mr-1" />URL
|
||||
</button>
|
||||
<button onClick={() => setImportMode('text')}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'text' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||
<FileText className="w-3 h-3 inline mr-1" />Text
|
||||
</button>
|
||||
</div>
|
||||
{importMode === 'url' ? (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Moxfield, Archidekt, or deck URL..."
|
||||
value={importUrl}
|
||||
onChange={e => setImportUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Deck name"
|
||||
value={importName}
|
||||
onChange={e => setImportName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
<textarea
|
||||
placeholder={"1x Sol Ring\n1x Command Tower\n..."}
|
||||
value={importText}
|
||||
onChange={e => setImportText(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleImport} disabled={importing}
|
||||
className="w-full px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
|
||||
{importing ? <Spinner size="sm" /> : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Batch Sim Page ───────────────────────────────────────
|
||||
export function BatchSimPage() {
|
||||
const [decks, setDecks] = useState<LabDeck[]>([])
|
||||
const [precons, setPrecons] = useState<PreconDeck[]>([])
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [games, setGames] = useState(100)
|
||||
const [status, setStatus] = useState<LabStatus | null>(null)
|
||||
const [result, setResult] = useState<LabResult | null>(null)
|
||||
const [history, setHistory] = useState<LabHistoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
async function fetchDecks() {
|
||||
try {
|
||||
const [d, p] = await Promise.all([labApi.getLabDecks(), labApi.getPrecons()])
|
||||
setDecks(d)
|
||||
setPrecons(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchDecks()
|
||||
labApi.getLabStatus().then(setStatus).catch(() => {})
|
||||
labApi.getLabHistory().then(setHistory).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Poll while running
|
||||
usePolling(async () => {
|
||||
try {
|
||||
const s = await labApi.getLabStatus()
|
||||
setStatus(s)
|
||||
if (!s.running && s.run_id) {
|
||||
const r = await labApi.getLabResult()
|
||||
setResult(r)
|
||||
labApi.getLabHistory().then(setHistory).catch(() => {})
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000, status?.running || false)
|
||||
|
||||
function toggleDeck(name: string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
if (selected.size < 2) { setError('Select at least 2 decks'); return }
|
||||
setStarting(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
await labApi.startBatchSim({ decks: [...selected], games })
|
||||
const s = await labApi.getLabStatus()
|
||||
setStatus(s)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start')
|
||||
}
|
||||
setStarting(false)
|
||||
}
|
||||
|
||||
async function handleInstallPrecon(name: string) {
|
||||
try {
|
||||
await labApi.installPrecon(name)
|
||||
fetchDecks()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<FlaskConical className="w-6 h-6 text-accent-teal" />
|
||||
Batch Simulator
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">Run mass simulations between decks</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5">
|
||||
{/* Left sidebar: deck selection + import */}
|
||||
<div className="w-72 flex-shrink-0 space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32"><Spinner size="md" className="text-accent-blue" /></div>
|
||||
) : (
|
||||
<DeckSelector
|
||||
decks={decks}
|
||||
selected={selected}
|
||||
onToggle={toggleDeck}
|
||||
precons={precons}
|
||||
onInstallPrecon={handleInstallPrecon}
|
||||
/>
|
||||
)}
|
||||
<ImportPanel onImported={fetchDecks} />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 space-y-5">
|
||||
{/* Config + Start */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
max={10000}
|
||||
step={10}
|
||||
value={games}
|
||||
onChange={e => setGames(Number(e.target.value))}
|
||||
className="mt-1 w-28 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-text-tertiary">Selected: {selected.size} decks</p>
|
||||
</div>
|
||||
{status?.running ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" className="text-accent-blue" />
|
||||
<span className="text-sm text-text-secondary">
|
||||
{status.games_completed} / {status.total_games} games
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-48 h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-blue rounded-full transition-all"
|
||||
style={{ width: `${status.total_games ? (status.games_completed / status.total_games) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleStart} disabled={starting || selected.size < 2}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-accent-teal/15 text-accent-teal text-sm font-semibold rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50">
|
||||
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||
Start Simulation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-3 flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||
<AlertCircle className="w-4 h-4" />{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-accent-amber" />
|
||||
Results
|
||||
</h3>
|
||||
<span className="text-xs text-text-tertiary">{result.total_games} games · {new Date(result.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
<ResultsChart result={result} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-primary">
|
||||
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium uppercase tracking-wider">Deck</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Wins</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Games</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Win Rate</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Avg Turns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-primary">
|
||||
{result.decks.sort((a, b) => b.win_rate - a.win_rate).map((d, i) => (
|
||||
<tr key={d.name} className="hover:bg-bg-hover transition-colors">
|
||||
<td className="px-3 py-2.5 flex items-center gap-2">
|
||||
{i === 0 && <Trophy className="w-3.5 h-3.5 text-accent-amber" />}
|
||||
<span className="font-medium text-text-primary">{d.name}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right text-text-secondary">{d.wins}</td>
|
||||
<td className="px-3 py-2.5 text-right text-text-secondary">{d.games}</td>
|
||||
<td className="px-3 py-2.5 text-right font-semibold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</td>
|
||||
<td className="px-3 py-2.5 text-right text-text-secondary">{d.avg_turns.toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<div>
|
||||
<button onClick={() => setShowHistory(!showHistory)}
|
||||
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
|
||||
<History className="w-4 h-4" />
|
||||
Run History ({history.length})
|
||||
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showHistory ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
{showHistory && history.length > 0 && (
|
||||
<div className="mt-3 space-y-2 animate-fade-in">
|
||||
{history.map(h => (
|
||||
<div key={h.run_id} className="flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary">
|
||||
<Clock className="w-4 h-4 text-text-tertiary" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-text-primary">{h.decks.join(' vs ')}</p>
|
||||
<p className="text-xs text-text-tertiary">{new Date(h.timestamp).toLocaleString()} · {h.total_games} games</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,949 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
GraduationCap, MessageSquare, Send, Sparkles, AlertCircle, X, Check,
|
||||
ChevronRight, ChevronDown, Download, Settings2, Search, Zap,
|
||||
Minus, Plus, History, ArrowRightLeft, Lightbulb, DollarSign, Loader2,
|
||||
ClipboardList, Brain, BarChart3
|
||||
} from 'lucide-react'
|
||||
import { Spinner, StatusBadge, EmptyState } from '../components/common'
|
||||
import { coachApi } from '../api'
|
||||
import type {
|
||||
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
|
||||
CoachMessage, CoachGoals, CoachApplyResult, CoachCardLikeResult
|
||||
} from '../types'
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Goals Config Panel
|
||||
// ════════════════════════════════════════════════════════════
|
||||
function GoalsPanel({
|
||||
goals, onChange, collapsed, onToggle
|
||||
}: {
|
||||
goals: CoachGoals
|
||||
onChange: (g: CoachGoals) => void
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const strategies = ['aggro', 'control', 'combo', 'midrange', 'stax', 'voltron', 'aristocrats', 'tokens']
|
||||
const budgets = [
|
||||
{ value: 'budget', label: 'Budget', desc: 'Under $5/card' },
|
||||
{ value: 'medium', label: 'Medium', desc: 'Under $20/card' },
|
||||
{ value: 'no-limit', label: 'No Limit', desc: 'Any price' },
|
||||
]
|
||||
const focusOptions = ['ramp', 'card draw', 'removal', 'protection', 'threats', 'mana base', 'combos', 'synergy']
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-bg-hover transition-colors rounded-xl"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-accent-teal" />
|
||||
<span className="text-sm font-medium text-text-primary flex-1">Coaching Goals</span>
|
||||
{collapsed ? <ChevronRight className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-4 pb-4 space-y-4 border-t border-border-primary pt-3">
|
||||
{/* Power Level Slider */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-text-secondary mb-1 block">
|
||||
Power Level: {goals.targetPowerLevel ?? 'Any'}
|
||||
</label>
|
||||
<input
|
||||
type="range" min={1} max={10}
|
||||
value={goals.targetPowerLevel ?? 5}
|
||||
onChange={e => onChange({ ...goals, targetPowerLevel: parseInt(e.target.value) })}
|
||||
className="w-full accent-accent-teal"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-text-tertiary">
|
||||
<span>Casual</span><span>Focused</span><span>cEDH</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategy */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Strategy Focus</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{strategies.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onChange({ ...goals, metaFocus: goals.metaFocus === s ? null : s })}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||
goals.metaFocus === s
|
||||
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
|
||||
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Budget</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{budgets.map(b => (
|
||||
<button
|
||||
key={b.value}
|
||||
onClick={() => onChange({ ...goals, budget: goals.budget === b.value ? null : b.value })}
|
||||
className={`px-3 py-2 text-xs rounded-lg border transition-colors ${
|
||||
goals.budget === b.value
|
||||
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
|
||||
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{b.label}</div>
|
||||
<div className="text-[10px] opacity-60">{b.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Focus Areas */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Focus Areas</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{focusOptions.map(f => {
|
||||
const active = (goals.focusAreas || []).includes(f)
|
||||
return (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => {
|
||||
const areas = goals.focusAreas || []
|
||||
onChange({
|
||||
...goals,
|
||||
focusAreas: active ? areas.filter(a => a !== f) : [...areas, f]
|
||||
})
|
||||
}}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||
active
|
||||
? 'bg-accent-blue/20 border-accent-blue/40 text-accent-blue'
|
||||
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Session View — Shows coaching results with apply functionality
|
||||
// ════════════════════════════════════════════════════════════
|
||||
function SessionView({
|
||||
session, deckId, onClose, onApplied
|
||||
}: {
|
||||
session: CoachSession
|
||||
deckId: number
|
||||
onClose: () => void
|
||||
onApplied?: () => void
|
||||
}) {
|
||||
const [selectedCuts, setSelectedCuts] = useState<Set<string>>(new Set())
|
||||
const [selectedAdds, setSelectedAdds] = useState<Set<string>>(new Set())
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [applyResult, setApplyResult] = useState<CoachApplyResult | null>(null)
|
||||
|
||||
const toggleCut = (name: string) => {
|
||||
const s = new Set(selectedCuts)
|
||||
s.has(name) ? s.delete(name) : s.add(name)
|
||||
setSelectedCuts(s)
|
||||
}
|
||||
const toggleAdd = (name: string) => {
|
||||
const s = new Set(selectedAdds)
|
||||
s.has(name) ? s.delete(name) : s.add(name)
|
||||
setSelectedAdds(s)
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
if (selectedCuts.size === 0 && selectedAdds.size === 0) return
|
||||
setApplying(true)
|
||||
try {
|
||||
const result = await coachApi.applyCoachSuggestions(
|
||||
session.sessionId, deckId,
|
||||
[...selectedCuts], [...selectedAdds]
|
||||
)
|
||||
setApplyResult(result)
|
||||
onApplied?.()
|
||||
} catch (e) {
|
||||
console.error('Apply failed:', e)
|
||||
}
|
||||
setApplying(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-primary">Coaching Session</h3>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{new Date(session.timestamp).toLocaleString()} · {session.modelUsed}
|
||||
{session.promptTokens > 0 && ` · ${session.promptTokens + session.completionTokens} tokens`}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{session.summary && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
|
||||
<h4 className="text-xs font-medium text-text-secondary mb-2 flex items-center gap-1.5">
|
||||
<Brain className="w-3.5 h-3.5" /> Summary
|
||||
</h4>
|
||||
<p className="text-sm text-text-primary leading-relaxed">{session.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heuristic Hints */}
|
||||
{session.heuristicHints?.length > 0 && (
|
||||
<div className="bg-accent-amber/5 rounded-xl border border-accent-amber/20 p-4">
|
||||
<h4 className="text-xs font-medium text-accent-amber mb-2 flex items-center gap-1.5">
|
||||
<Lightbulb className="w-3.5 h-3.5" /> Strategic Tips
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{session.heuristicHints.map((h, i) => (
|
||||
<li key={i} className="text-sm text-text-primary flex items-start gap-2">
|
||||
<span className="text-accent-amber mt-0.5">•</span> {h}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mana Base Advice */}
|
||||
{session.manaBaseAdvice && (
|
||||
<div className="bg-accent-blue/5 rounded-xl border border-accent-blue/20 p-4">
|
||||
<h4 className="text-xs font-medium text-accent-blue mb-2 flex items-center gap-1.5">
|
||||
<BarChart3 className="w-3.5 h-3.5" /> Mana Base
|
||||
</h4>
|
||||
<p className="text-sm text-text-primary">{session.manaBaseAdvice}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested Cuts */}
|
||||
{session.suggestedCuts?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-status-error mb-2 flex items-center gap-1.5">
|
||||
<Minus className="w-4 h-4" /> Suggested Cuts ({session.suggestedCuts.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{session.suggestedCuts.map((cut, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => toggleCut(cut.cardName)}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedCuts.has(cut.cardName)
|
||||
? 'bg-status-error/10 border-status-error/30'
|
||||
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
selectedCuts.has(cut.cardName)
|
||||
? 'bg-status-error border-status-error text-white'
|
||||
: 'border-border-secondary'
|
||||
}`}>
|
||||
{selectedCuts.has(cut.cardName) && <Check className="w-3 h-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary">{cut.cardName}</p>
|
||||
<p className="text-xs text-text-secondary mt-0.5">{cut.reason}</p>
|
||||
{cut.replacementOptions?.length > 0 && (
|
||||
<p className="text-[10px] text-text-tertiary mt-1">
|
||||
Replace with: {cut.replacementOptions.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested Adds */}
|
||||
{session.suggestedAdds?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
|
||||
<Plus className="w-4 h-4" /> Suggested Adds ({session.suggestedAdds.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{session.suggestedAdds.map((add, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => toggleAdd(add.cardName)}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedAdds.has(add.cardName)
|
||||
? 'bg-status-success/10 border-status-success/30'
|
||||
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
selectedAdds.has(add.cardName)
|
||||
? 'bg-status-success border-status-success text-white'
|
||||
: 'border-border-secondary'
|
||||
}`}>
|
||||
{selectedAdds.has(add.cardName) && <Check className="w-3 h-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-text-primary">{add.cardName}</p>
|
||||
{add.role && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-accent-blue/15 text-accent-blue rounded">
|
||||
{add.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary mt-0.5">{add.reason}</p>
|
||||
{add.synergyWith?.length > 0 && (
|
||||
<p className="text-[10px] text-text-tertiary mt-1">
|
||||
Synergy: {add.synergyWith.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apply Button */}
|
||||
{!applyResult && (selectedCuts.size > 0 || selectedAdds.size > 0) && (
|
||||
<div className="sticky bottom-0 bg-bg-primary/95 backdrop-blur-sm border-t border-border-primary py-3 -mx-6 px-6">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applying}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{applying ? <Spinner size="sm" /> : <ArrowRightLeft className="w-4 h-4" />}
|
||||
Apply {selectedCuts.size > 0 && `${selectedCuts.size} cuts`}
|
||||
{selectedCuts.size > 0 && selectedAdds.size > 0 && ' + '}
|
||||
{selectedAdds.size > 0 && `${selectedAdds.size} adds`} to Deck
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apply Results */}
|
||||
{applyResult && (
|
||||
<div className="bg-status-success/5 rounded-xl border border-status-success/20 p-4">
|
||||
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
|
||||
<Check className="w-4 h-4" /> Changes Applied
|
||||
</h4>
|
||||
<p className="text-sm text-text-primary">
|
||||
{applyResult.total_cuts > 0 && `Removed ${applyResult.total_cuts} card(s). `}
|
||||
{applyResult.total_adds > 0 && `Added ${applyResult.total_adds} card(s). `}
|
||||
{applyResult.errors.length > 0 && `${applyResult.errors.length} error(s).`}
|
||||
</p>
|
||||
{applyResult.errors.length > 0 && (
|
||||
<ul className="mt-2 text-xs text-status-error">
|
||||
{applyResult.errors.map((e, i) => (
|
||||
<li key={i}>{e.name}: {e.error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw Explanation */}
|
||||
{session.rawTextExplanation && (
|
||||
<details className="group">
|
||||
<summary className="text-xs text-text-tertiary cursor-pointer hover:text-text-secondary">
|
||||
Full analysis...
|
||||
</summary>
|
||||
<p className="mt-2 text-sm text-text-secondary whitespace-pre-wrap leading-relaxed bg-bg-secondary rounded-lg p-4 border border-border-primary">
|
||||
{session.rawTextExplanation}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Cards Like Search
|
||||
// ════════════════════════════════════════════════════════════
|
||||
function CardsLikePanel() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<CoachCardLikeResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
try {
|
||||
const data = await coachApi.searchCardsLike(query.trim(), undefined, 12)
|
||||
setResults(data.results)
|
||||
} catch { /* ignore */ }
|
||||
setSearching(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Enter a card name..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !query.trim()}
|
||||
className="px-4 py-2 bg-accent-teal/15 text-accent-teal text-sm font-medium rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{searching ? <Spinner size="sm" /> : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{results.map((card, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{card.name}</p>
|
||||
<span className="text-[10px] text-text-tertiary whitespace-nowrap">
|
||||
{(card.similarity * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-secondary mt-0.5">{card.types}</p>
|
||||
{card.text && (
|
||||
<p className="text-[10px] text-text-tertiary mt-1 line-clamp-2">{card.text}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1.5 text-[10px]">
|
||||
{card.tcg_price != null && (
|
||||
<span className="text-accent-amber flex items-center gap-0.5">
|
||||
<DollarSign className="w-2.5 h-2.5" />{card.tcg_price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{card.owned_qty > 0 && (
|
||||
<span className="text-status-success">Owned: {card.owned_qty}</span>
|
||||
)}
|
||||
<span className="text-text-tertiary">MV: {card.mana_value}</span>
|
||||
</div>
|
||||
</div>
|
||||
{card.image_url && (
|
||||
<img src={card.image_url} alt={card.name} className="w-14 h-auto rounded" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Live Chat View
|
||||
// ════════════════════════════════════════════════════════════
|
||||
function LiveChat({
|
||||
deckId, deckName, goals, onClose
|
||||
}: {
|
||||
deckId: string
|
||||
deckName: string
|
||||
goals: CoachGoals
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [messages, setMessages] = useState<CoachMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [streamText, setStreamText] = useState('')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, streamText])
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!input.trim() || streaming) return
|
||||
const userMsg: CoachMessage = { role: 'user', content: input.trim() }
|
||||
const allMessages = [...messages, userMsg]
|
||||
setMessages(allMessages)
|
||||
setInput('')
|
||||
setStreaming(true)
|
||||
setStreamText('')
|
||||
|
||||
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
|
||||
? {
|
||||
targetPowerLevel: goals.targetPowerLevel,
|
||||
metaFocus: goals.metaFocus,
|
||||
budget: goals.budget,
|
||||
focusAreas: goals.focusAreas,
|
||||
}
|
||||
: undefined
|
||||
|
||||
try {
|
||||
abortRef.current = new AbortController()
|
||||
const fullText = await coachApi.coachChatStream(
|
||||
deckId,
|
||||
allMessages.map(m => ({ role: m.role, content: m.content })),
|
||||
goalsDict,
|
||||
(text) => setStreamText(text),
|
||||
abortRef.current.signal
|
||||
)
|
||||
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: fullText }])
|
||||
setStreamText('')
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== 'AbortError') {
|
||||
// Fallback to non-streaming
|
||||
try {
|
||||
const resp = await coachApi.coachChat(
|
||||
deckId,
|
||||
allMessages.map(m => ({ role: m.role, content: m.content })),
|
||||
goalsDict
|
||||
)
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: resp.content }])
|
||||
} catch (e2) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: `Error: ${(e2 as Error).message || 'Failed to get response'}`
|
||||
}])
|
||||
}
|
||||
}
|
||||
setStreamText('')
|
||||
}
|
||||
setStreaming(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-180px)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border-primary">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-accent-blue" />
|
||||
Chat: {deckName}
|
||||
</h3>
|
||||
<p className="text-[10px] text-text-tertiary">
|
||||
{goals.metaFocus && `${goals.metaFocus} · `}
|
||||
{goals.targetPowerLevel && `Power ${goals.targetPowerLevel} · `}
|
||||
{goals.budget && `${goals.budget}`}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.length === 0 && !streaming && (
|
||||
<div className="text-center py-12">
|
||||
<GraduationCap className="w-10 h-10 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-sm text-text-secondary">Ask your coach anything about this deck.</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2 justify-center">
|
||||
{[
|
||||
'What should I cut?',
|
||||
'How is my mana curve?',
|
||||
'Suggest win conditions',
|
||||
'Rate my removal suite',
|
||||
].map(q => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => { setInput(q); }}
|
||||
className="px-3 py-1.5 text-xs bg-bg-secondary border border-border-primary rounded-full text-text-secondary hover:text-text-primary hover:border-border-secondary transition-colors"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? 'bg-accent-blue/15 text-text-primary border border-accent-blue/20'
|
||||
: 'bg-bg-secondary text-text-primary border border-border-primary'
|
||||
}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{streaming && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed bg-bg-secondary text-text-primary border border-border-primary">
|
||||
{streamText || (
|
||||
<span className="flex items-center gap-2 text-text-tertiary">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Thinking...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-border-primary p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()}
|
||||
placeholder="Ask the coach..."
|
||||
disabled={streaming}
|
||||
className="flex-1 px-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={streaming || !input.trim()}
|
||||
className="px-3 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Main Coach Page
|
||||
// ════════════════════════════════════════════════════════════
|
||||
type Tab = 'decks' | 'chat' | 'session' | 'history' | 'search'
|
||||
|
||||
export function CoachPage() {
|
||||
const [status, setStatus] = useState<CoachStatus | null>(null)
|
||||
const [decks, setDecks] = useState<CoachDeck[]>([])
|
||||
const [sessions, setSessions] = useState<CoachSessionSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// UI state
|
||||
const [tab, setTab] = useState<Tab>('decks')
|
||||
const [selectedDeck, setSelectedDeck] = useState<CoachDeck | null>(null)
|
||||
const [selectedSession, setSelectedSession] = useState<CoachSession | null>(null)
|
||||
const [goals, setGoals] = useState<CoachGoals>({})
|
||||
const [goalsCollapsed, setGoalsCollapsed] = useState(true)
|
||||
const [generatingReport, setGeneratingReport] = useState(false)
|
||||
const [runningSession, setRunningSession] = useState<number | null>(null)
|
||||
const [downloadingEmbeddings, setDownloadingEmbeddings] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [s, d, sess] = await Promise.all([
|
||||
coachApi.getCoachStatus(),
|
||||
coachApi.getCoachDecks().catch(() => []),
|
||||
coachApi.getCoachSessions().catch(() => ({ sessions: [] })),
|
||||
])
|
||||
setStatus(s)
|
||||
setDecks(Array.isArray(d) ? d : [])
|
||||
setSessions(Array.isArray(sess) ? sess : (sess as { sessions: CoachSessionSummary[] }).sessions || [])
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const handleDownloadEmbeddings = async () => {
|
||||
setDownloadingEmbeddings(true)
|
||||
try {
|
||||
await coachApi.downloadEmbeddings()
|
||||
const s = await coachApi.getCoachStatus()
|
||||
setStatus(s)
|
||||
} catch { /* ignore */ }
|
||||
setDownloadingEmbeddings(false)
|
||||
}
|
||||
|
||||
const handleGenerateReports = async () => {
|
||||
setGeneratingReport(true)
|
||||
setError(null)
|
||||
try {
|
||||
await coachApi.generateReport()
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Report generation failed')
|
||||
}
|
||||
setGeneratingReport(false)
|
||||
}
|
||||
|
||||
const handleRunSession = async (deck: CoachDeck) => {
|
||||
setRunningSession(deck.deck_id)
|
||||
setError(null)
|
||||
try {
|
||||
const deckSlug = deck.deck_name.toLowerCase().replace(/\s+/g, '-')
|
||||
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
|
||||
? goals : undefined
|
||||
const session = await coachApi.runCoachSession(deckSlug, goalsDict as Record<string, unknown>)
|
||||
setSelectedSession(session)
|
||||
setSelectedDeck(deck)
|
||||
setTab('session')
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Session failed')
|
||||
}
|
||||
setRunningSession(null)
|
||||
}
|
||||
|
||||
const handleOpenChat = (deck: CoachDeck) => {
|
||||
setSelectedDeck(deck)
|
||||
setTab('chat')
|
||||
}
|
||||
|
||||
const handleViewSession = async (sessionId: string) => {
|
||||
try {
|
||||
const s = await coachApi.getCoachSession(sessionId)
|
||||
setSelectedSession(s)
|
||||
// Find the matching deck
|
||||
const matchDeck = decks.find(d =>
|
||||
d.deck_name.toLowerCase().replace(/\s+/g, '-') === s.deckId.toLowerCase() ||
|
||||
d.deck_name.toLowerCase() === s.deckId.toLowerCase()
|
||||
)
|
||||
if (matchDeck) setSelectedDeck(matchDeck)
|
||||
setTab('session')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-64">
|
||||
<Spinner size="lg" className="text-accent-blue" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<GraduationCap className="w-6 h-6 text-accent-amber" />
|
||||
Deck Coach
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">
|
||||
AI-powered deck analysis, coaching, and live chat
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<StatusBadge
|
||||
variant={status?.llmConnected ? 'success' : 'warning'}
|
||||
label={status?.llmConnected ? `LLM: ${status.llmModel || 'Connected'}` : 'LLM: Disconnected'}
|
||||
/>
|
||||
<StatusBadge
|
||||
variant={status?.embeddingsLoaded ? 'success' : 'neutral'}
|
||||
label={status?.embeddingsLoaded ? `${(status.embeddingCards || 0).toLocaleString()} embeddings` : 'No embeddings'}
|
||||
/>
|
||||
{status?.deckReportsAvailable != null && status.deckReportsAvailable > 0 && (
|
||||
<StatusBadge variant="success" label={`${status.deckReportsAvailable} reports`} />
|
||||
)}
|
||||
{!status?.embeddingsLoaded && (
|
||||
<button onClick={handleDownloadEmbeddings} disabled={downloadingEmbeddings}
|
||||
className="flex items-center gap-1.5 text-xs text-accent-blue hover:text-accent-blue-hover transition-colors disabled:opacity-50">
|
||||
{downloadingEmbeddings ? <Spinner size="sm" /> : <Download className="w-3 h-3" />}
|
||||
Download Embeddings
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />{error}
|
||||
<button onClick={() => setError(null)} className="ml-auto"><X className="w-3 h-3" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Nav */}
|
||||
<div className="flex gap-1 bg-bg-secondary rounded-lg p-1 border border-border-primary">
|
||||
{([
|
||||
{ id: 'decks', label: 'Decks', icon: ClipboardList },
|
||||
{ id: 'history', label: 'Sessions', icon: History },
|
||||
{ id: 'search', label: 'Cards Like', icon: Search },
|
||||
] as { id: Tab; label: string; icon: typeof ClipboardList }[]).map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
tab === t.id
|
||||
? 'bg-bg-primary text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
{t.label}
|
||||
{t.id === 'history' && sessions.length > 0 && (
|
||||
<span className="text-[10px] bg-bg-tertiary px-1.5 rounded-full">{sessions.length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{tab === 'chat' && selectedDeck && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-blue shadow-sm">
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
Chat
|
||||
</div>
|
||||
)}
|
||||
{tab === 'session' && selectedSession && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-amber shadow-sm">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Results
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Goals Panel (shown when on decks tab) */}
|
||||
{tab === 'decks' && (
|
||||
<GoalsPanel
|
||||
goals={goals}
|
||||
onChange={setGoals}
|
||||
collapsed={goalsCollapsed}
|
||||
onToggle={() => setGoalsCollapsed(!goalsCollapsed)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Decks Tab ─────────────────────────────────────── */}
|
||||
{tab === 'decks' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-text-primary">Your Decks</h2>
|
||||
<button
|
||||
onClick={handleGenerateReports}
|
||||
disabled={generatingReport}
|
||||
className="flex items-center gap-1.5 text-xs text-accent-amber hover:text-accent-amber transition-colors disabled:opacity-50"
|
||||
>
|
||||
{generatingReport ? <Spinner size="sm" /> : <Zap className="w-3 h-3" />}
|
||||
Generate Reports
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{decks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={GraduationCap}
|
||||
title="No decks available"
|
||||
description="Create decks in the Deck Builder to start coaching."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{decks.map(deck => (
|
||||
<div key={deck.deck_id} className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{deck.deck_name}</h3>
|
||||
<p className="text-xs text-text-secondary">{deck.commander}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-text-tertiary">
|
||||
<span>{deck.card_count} cards</span>
|
||||
{deck.has_report && (
|
||||
<span className="flex items-center gap-1 text-status-success">
|
||||
<Check className="w-2.5 h-2.5" /> Report
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleRunSession(deck)}
|
||||
disabled={runningSession === deck.deck_id || !status?.llmConnected}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-amber/15 text-accent-amber text-xs font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{runningSession === deck.deck_id ? <Spinner size="sm" /> : <Sparkles className="w-3 h-3" />}
|
||||
Analyze
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenChat(deck)}
|
||||
disabled={!status?.llmConnected}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-blue/15 text-accent-blue text-xs font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chat Tab ──────────────────────────────────────── */}
|
||||
{tab === 'chat' && selectedDeck && (
|
||||
<LiveChat
|
||||
deckId={selectedDeck.deck_name.toLowerCase().replace(/\s+/g, '-')}
|
||||
deckName={selectedDeck.deck_name}
|
||||
goals={goals}
|
||||
onClose={() => setTab('decks')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Session Results Tab ───────────────────────────── */}
|
||||
{tab === 'session' && selectedSession && selectedDeck && (
|
||||
<SessionView
|
||||
session={selectedSession}
|
||||
deckId={selectedDeck.deck_id}
|
||||
onClose={() => setTab('decks')}
|
||||
onApplied={() => loadData()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── History Tab ───────────────────────────────────── */}
|
||||
{tab === 'history' && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-text-primary">Coaching Sessions</h2>
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={History}
|
||||
title="No sessions yet"
|
||||
description="Run an analysis on a deck to create your first coaching session."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sessions.map(s => (
|
||||
<button
|
||||
key={s.sessionId}
|
||||
onClick={() => handleViewSession(s.sessionId)}
|
||||
className="w-full flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary hover:border-border-secondary text-left transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-accent-amber flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-text-primary">{s.deckId}</p>
|
||||
<span className="text-[10px] text-text-tertiary">
|
||||
{s.cutsCount > 0 && `${s.cutsCount} cuts`}
|
||||
{s.cutsCount > 0 && s.addsCount > 0 && ' · '}
|
||||
{s.addsCount > 0 && `${s.addsCount} adds`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary mt-0.5 truncate">{s.summary || 'No summary'}</p>
|
||||
<p className="text-[10px] text-text-tertiary mt-0.5">{new Date(s.timestamp).toLocaleString()}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-text-tertiary flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Cards Like Tab ────────────────────────────────── */}
|
||||
{tab === 'search' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-1">Cards Like This</h2>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Find similar cards using AI embeddings. Search by card name to discover alternatives.
|
||||
</p>
|
||||
</div>
|
||||
<CardsLikePanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Search, Filter, Upload, Download, Camera, RefreshCw,
|
||||
ChevronLeft, ChevronRight, X, Package, SlidersHorizontal,
|
||||
Plus, Grid3x3, List
|
||||
} from 'lucide-react'
|
||||
import { Spinner, EmptyState, ManaSymbols, CardImage } from '../components/common'
|
||||
import { collectionApi } from '../api'
|
||||
import type { CollectionCard, CollectionFilters, SetInfo } from '../types'
|
||||
|
||||
// ── Card Detail Modal ─────────────────────────────────────────
|
||||
function CardDetailModal({ card, onClose }: { card: CollectionCard; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
|
||||
<div
|
||||
className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto animate-fade-in"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between p-5 border-b border-border-primary">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary">{card.name}</h2>
|
||||
<p className="text-sm text-text-secondary mt-0.5">{card.type_line}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-bg-hover text-text-tertiary">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row gap-6 p-5">
|
||||
{/* Large card image */}
|
||||
<div className="flex-shrink-0">
|
||||
<CardImage src={card.image_url} alt={card.name} size="xl" />
|
||||
</div>
|
||||
{/* Card info */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Mana Cost</label>
|
||||
<div className="mt-1"><ManaSymbols cost={card.mana_cost} size="md" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Oracle Text</label>
|
||||
<p className="mt-1 text-sm text-text-primary whitespace-pre-wrap leading-relaxed">{card.oracle_text}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
|
||||
<p className="mt-1 text-sm text-text-primary">{card.set_name} ({card.set_code.toUpperCase()})</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
|
||||
<p className={`mt-1 text-sm font-medium capitalize ${
|
||||
card.rarity === 'mythic' ? 'text-accent-amber' :
|
||||
card.rarity === 'rare' ? 'text-accent-amber/80' :
|
||||
card.rarity === 'uncommon' ? 'text-accent-teal' : 'text-text-secondary'
|
||||
}`}>{card.rarity}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC</label>
|
||||
<p className="mt-1 text-sm text-text-primary">{card.cmc}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">EDHREC Rank</label>
|
||||
<p className="mt-1 text-sm text-text-primary">{card.edhrec_rank ?? 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Quantity</label>
|
||||
<p className="mt-1 text-sm text-text-primary">{card.quantity} (+ {card.foil_quantity} foil)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Price</label>
|
||||
<p className="mt-1 text-sm text-accent-teal font-semibold">
|
||||
${card.tcg_price?.toFixed(2) ?? '—'}
|
||||
{card.foil_price ? <span className="text-text-tertiary ml-2">Foil: ${card.foil_price.toFixed(2)}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Scanner Panel ─────────────────────────────────────────────
|
||||
function ScannerPanel({ onCardAdded }: { onCardAdded: () => void }) {
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [results, setResults] = useState<import('../types').ScanMatch[]>([])
|
||||
const [adding, setAdding] = useState<string | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function handleScan(file: File) {
|
||||
setScanning(true)
|
||||
setResults([])
|
||||
try {
|
||||
const res = await collectionApi.scanCard(file)
|
||||
setResults(res.matches || [])
|
||||
} catch {
|
||||
setResults([])
|
||||
}
|
||||
setScanning(false)
|
||||
}
|
||||
|
||||
async function handleAdd(match: import('../types').ScanMatch) {
|
||||
setAdding(match.scryfall_id)
|
||||
try {
|
||||
await collectionApi.addScanResult({
|
||||
name: match.name,
|
||||
set_code: match.set_code,
|
||||
scryfall_id: match.scryfall_id,
|
||||
})
|
||||
onCardAdded()
|
||||
} catch { /* ignore */ }
|
||||
setAdding(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2 mb-3">
|
||||
<Camera className="w-4 h-4 text-accent-blue" />
|
||||
Card Scanner
|
||||
</h3>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => e.target.files?.[0] && handleScan(e.target.files[0])}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={scanning}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue/10 text-accent-blue border border-accent-blue/30 rounded-lg text-sm font-medium hover:bg-accent-blue/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{scanning ? <Spinner size="sm" /> : <Camera className="w-4 h-4" />}
|
||||
{scanning ? 'Scanning...' : 'Scan a Card'}
|
||||
</button>
|
||||
{results.length > 0 && (
|
||||
<div className="mt-3 space-y-2 max-h-60 overflow-y-auto">
|
||||
{results.map(m => (
|
||||
<div key={m.scryfall_id} className="flex items-center gap-3 p-2 rounded-lg bg-bg-tertiary">
|
||||
<CardImage src={m.image_url} alt={m.name} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{m.name}</p>
|
||||
<p className="text-xs text-text-tertiary">{m.set_name} — {(m.confidence * 100).toFixed(0)}%</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAdd(m)}
|
||||
disabled={adding === m.scryfall_id}
|
||||
className="p-1.5 rounded-lg bg-accent-green/15 text-accent-green hover:bg-accent-green/25 transition-colors"
|
||||
>
|
||||
{adding === m.scryfall_id ? <Spinner size="sm" /> : <Plus className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Collection Page ──────────────────────────────────────
|
||||
export function CollectionPage() {
|
||||
const [cards, setCards] = useState<CollectionCard[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sets, setSets] = useState<SetInfo[]>([])
|
||||
const [selectedCard, setSelectedCard] = useState<CollectionCard | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [enriching, setEnriching] = useState(false)
|
||||
const importRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [filters, setFilters] = useState<CollectionFilters>({
|
||||
q: '',
|
||||
page: 1,
|
||||
page_size: 48,
|
||||
sort_by: 'name',
|
||||
sort_dir: 'asc',
|
||||
})
|
||||
|
||||
const fetchCards = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await collectionApi.searchCollection(filters)
|
||||
setCards(res.cards)
|
||||
setTotal(res.total)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [filters])
|
||||
|
||||
useEffect(() => { fetchCards() }, [fetchCards])
|
||||
|
||||
useEffect(() => {
|
||||
collectionApi.getSets().then(setSets).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const totalPages = Math.ceil(total / (filters.page_size || 48))
|
||||
|
||||
function updateFilter(key: keyof CollectionFilters, value: string | number | boolean | undefined) {
|
||||
setFilters(prev => ({ ...prev, [key]: value, page: 1 }))
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const blob = await collectionApi.exportCollection('csv')
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'collection.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function handleImport(file: File) {
|
||||
setImporting(true)
|
||||
try {
|
||||
await collectionApi.importCollection(file)
|
||||
fetchCards()
|
||||
} catch { /* ignore */ }
|
||||
setImporting(false)
|
||||
}
|
||||
|
||||
async function handleReEnrich() {
|
||||
setEnriching(true)
|
||||
try {
|
||||
await collectionApi.reEnrichCards()
|
||||
fetchCards()
|
||||
} catch { /* ignore */ }
|
||||
setEnriching(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Collection</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">{total.toLocaleString()} cards in your collection</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleReEnrich} disabled={enriching}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
|
||||
{enriching ? <Spinner size="sm" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Re-Enrich
|
||||
</button>
|
||||
<input ref={importRef} type="file" accept=".csv,.txt" className="hidden"
|
||||
onChange={e => e.target.files?.[0] && handleImport(e.target.files[0])} />
|
||||
<button onClick={() => importRef.current?.click()} disabled={importing}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
|
||||
{importing ? <Spinner size="sm" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
Import
|
||||
</button>
|
||||
<button onClick={handleExport}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5">
|
||||
{/* Left sidebar: search + filters + scanner */}
|
||||
<div className="w-72 flex-shrink-0 space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search cards..."
|
||||
value={filters.q || ''}
|
||||
onChange={e => updateFilter('q', e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-secondary hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
Filters
|
||||
</span>
|
||||
<Filter className={`w-3.5 h-3.5 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showFilters && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3 animate-fade-in">
|
||||
{/* Rarity */}
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
|
||||
<select
|
||||
value={filters.rarity || ''}
|
||||
onChange={e => updateFilter('rarity', e.target.value || undefined)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="common">Common</option>
|
||||
<option value="uncommon">Uncommon</option>
|
||||
<option value="rare">Rare</option>
|
||||
<option value="mythic">Mythic</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Color</label>
|
||||
<select
|
||||
value={filters.color || ''}
|
||||
onChange={e => updateFilter('color', e.target.value || undefined)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="W">White</option>
|
||||
<option value="U">Blue</option>
|
||||
<option value="B">Black</option>
|
||||
<option value="R">Red</option>
|
||||
<option value="G">Green</option>
|
||||
<option value="C">Colorless</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Set */}
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
|
||||
<select
|
||||
value={filters.set_code || ''}
|
||||
onChange={e => updateFilter('set_code', e.target.value || undefined)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
>
|
||||
<option value="">All Sets</option>
|
||||
{sets.map(s => (
|
||||
<option key={s.code} value={s.code}>{s.name} ({s.card_count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* CMC Range */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Min</label>
|
||||
<input type="number" min="0" max="20"
|
||||
value={filters.cmc_min ?? ''}
|
||||
onChange={e => updateFilter('cmc_min', e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Max</label>
|
||||
<input type="number" min="0" max="20"
|
||||
value={filters.cmc_max ?? ''}
|
||||
onChange={e => updateFilter('cmc_max', e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sort */}
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Sort By</label>
|
||||
<select
|
||||
value={filters.sort_by || 'name'}
|
||||
onChange={e => updateFilter('sort_by', e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="cmc">CMC</option>
|
||||
<option value="tcg_price">Price</option>
|
||||
<option value="rarity">Rarity</option>
|
||||
<option value="set_code">Set</option>
|
||||
<option value="added_date">Date Added</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scanner */}
|
||||
<ScannerPanel onCardAdded={fetchCards} />
|
||||
</div>
|
||||
|
||||
{/* Main content: card grid/list */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* View toggle + pagination */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-1.5 rounded-md transition-colors ${viewMode === 'grid' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
|
||||
>
|
||||
<Grid3x3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-1.5 rounded-md transition-colors ${viewMode === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>Page {filters.page} of {totalPages || 1}</span>
|
||||
<button
|
||||
onClick={() => setFilters(p => ({ ...p, page: Math.max(1, (p.page || 1) - 1) }))}
|
||||
disabled={!filters.page || filters.page <= 1}
|
||||
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilters(p => ({ ...p, page: Math.min(totalPages, (p.page || 1) + 1) }))}
|
||||
disabled={(filters.page || 1) >= totalPages}
|
||||
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" className="text-accent-blue" />
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="No cards found"
|
||||
description="Try adjusting your search or filters, or import a collection."
|
||||
/>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
{cards.map(card => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => setSelectedCard(card)}
|
||||
>
|
||||
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
|
||||
<div className="mt-1.5 px-0.5">
|
||||
<p className="text-xs font-medium text-text-primary truncate group-hover:text-accent-blue transition-colors">{card.name}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-text-tertiary">{card.set_code.toUpperCase()}</span>
|
||||
{card.tcg_price != null && (
|
||||
<span className="text-[10px] text-accent-teal font-medium">${card.tcg_price.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-primary text-left">
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Type</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Set</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Qty</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-primary">
|
||||
{cards.map(card => (
|
||||
<tr key={card.id} className="hover:bg-bg-hover cursor-pointer transition-colors" onClick={() => setSelectedCard(card)}>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="font-medium text-text-primary">{card.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.type_line}</td>
|
||||
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
|
||||
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.set_code.toUpperCase()}</td>
|
||||
<td className="px-4 py-2.5 text-text-secondary">{card.quantity}</td>
|
||||
<td className="px-4 py-2.5 text-right text-accent-teal font-medium">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card detail modal */}
|
||||
{selectedCard && <CardDetailModal card={selectedCard} onClose={() => setSelectedCard(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,967 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Plus, Trash2, Download, ChevronRight,
|
||||
BarChart3, Lightbulb, Layers, X, Library,
|
||||
Globe, XCircle, ShoppingCart,
|
||||
ChevronDown, ChevronUp, Sparkles
|
||||
} from 'lucide-react'
|
||||
import { Spinner, EmptyState, ManaSymbols, ColorDots } from '../components/common'
|
||||
import { decksApi } from '../api'
|
||||
import type {
|
||||
Deck, DeckCard, DeckAnalysis,
|
||||
EdhRecCard, EdhRecsResponse,
|
||||
CollectionRecsResponse
|
||||
} from '../types'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
|
||||
// ── Role Badge ──────────────────────────────────────────────
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
Ramp: 'bg-green-500/15 text-green-400 border-green-500/25',
|
||||
Draw: 'bg-blue-500/15 text-blue-400 border-blue-500/25',
|
||||
Removal: 'bg-red-500/15 text-red-400 border-red-500/25',
|
||||
BoardWipe: 'bg-orange-500/15 text-orange-400 border-orange-500/25',
|
||||
Tutor: 'bg-purple-500/15 text-purple-400 border-purple-500/25',
|
||||
Protection: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/25',
|
||||
Finisher: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/25',
|
||||
Recursion: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/25',
|
||||
Other: 'bg-slate-500/15 text-slate-400 border-slate-500/25',
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
const colors = ROLE_COLORS[role] || ROLE_COLORS.Other
|
||||
return (
|
||||
<span className={'text-[10px] font-medium px-1.5 py-0.5 rounded border ' + colors}>
|
||||
{role}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Deck Card List ────────────────────────────────────────────
|
||||
function DeckCardList({ deckId, cards, onUpdate }: { deckId: number; cards: DeckCard[]; onUpdate: () => void }) {
|
||||
const [removing, setRemoving] = useState<number | null>(null)
|
||||
|
||||
// Group cards by category
|
||||
const grouped = cards.reduce<Record<string, DeckCard[]>>((acc, c) => {
|
||||
const cat = c.category || 'Other'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(c)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const categoryOrder = ['Commander', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
|
||||
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
||||
const ia = categoryOrder.indexOf(a)
|
||||
const ib = categoryOrder.indexOf(b)
|
||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
|
||||
})
|
||||
|
||||
async function handleRemove(cardId: number) {
|
||||
setRemoving(cardId)
|
||||
try {
|
||||
await decksApi.removeCardFromDeck(deckId, cardId)
|
||||
onUpdate()
|
||||
} catch { /* ignore */ }
|
||||
setRemoving(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sortedCategories.map(cat => (
|
||||
<div key={cat}>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">
|
||||
{cat} ({grouped[cat].length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{grouped[cat].map(card => (
|
||||
<div key={card.id} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover group transition-colors">
|
||||
<span className="text-xs text-text-tertiary w-5 text-right">{card.quantity}x</span>
|
||||
<ManaSymbols cost={card.mana_cost} />
|
||||
<span className="flex-1 text-sm text-text-primary truncate">{card.name}</span>
|
||||
{card.tcg_price != null && (
|
||||
<span className="text-xs text-accent-teal">${card.tcg_price.toFixed(2)}</span>
|
||||
)}
|
||||
{!card.is_commander && (
|
||||
<button
|
||||
onClick={() => handleRemove(card.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-text-tertiary hover:text-accent-red transition-all"
|
||||
>
|
||||
{removing === card.id ? <Spinner size="sm" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Mana Curve Chart ──────────────────────────────────────────
|
||||
function ManaCurveChart({ analysis }: { analysis: DeckAnalysis }) {
|
||||
const data = Object.entries(analysis.mana_curve)
|
||||
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
|
||||
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
|
||||
|
||||
return (
|
||||
<div className="h-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} barSize={24}>
|
||||
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 11 }} />
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
|
||||
labelStyle={{ color: '#9ba1b8' }}
|
||||
itemStyle={{ color: '#e8eaf0' }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── EDHREC Recommendations Sub-tab ──────────────────────────
|
||||
function EdhRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
|
||||
const [data, setData] = useState<EdhRecsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [onlyOwned, setOnlyOwned] = useState(false)
|
||||
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
|
||||
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
|
||||
const [bulkAdding, setBulkAdding] = useState(false)
|
||||
const [hoveredCard, setHoveredCard] = useState<EdhRecCard | null>(null)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
async function fetchRecs() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await decksApi.getEdhRecs(deckId, onlyOwned)
|
||||
setData(result)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load EDHREC recommendations')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchRecs() }, [deckId, onlyOwned])
|
||||
|
||||
function toggleSelect(name: string) {
|
||||
setSelectedCards(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
if (!data) return
|
||||
setSelectedCards(new Set(data.recommendations.map(r => r.name)))
|
||||
}
|
||||
|
||||
function selectNone() { setSelectedCards(new Set()) }
|
||||
|
||||
function selectOwned() {
|
||||
if (!data) return
|
||||
setSelectedCards(new Set(data.recommendations.filter(r => r.owned).map(r => r.name)))
|
||||
}
|
||||
|
||||
async function handleAddSingle(name: string) {
|
||||
setAddingCards(prev => new Set(prev).add(name))
|
||||
try {
|
||||
await decksApi.bulkAddRecommended(deckId, [name])
|
||||
onAddedCards()
|
||||
// Remove from list
|
||||
if (data) {
|
||||
setData({
|
||||
...data,
|
||||
recommendations: data.recommendations.filter(r => r.name !== name),
|
||||
total: data.total - 1,
|
||||
})
|
||||
}
|
||||
selectedCards.delete(name)
|
||||
setSelectedCards(new Set(selectedCards))
|
||||
} catch { /* ignore */ }
|
||||
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
|
||||
}
|
||||
|
||||
async function handleBulkAdd() {
|
||||
if (selectedCards.size === 0) return
|
||||
setBulkAdding(true)
|
||||
try {
|
||||
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
|
||||
onAddedCards()
|
||||
if (data) {
|
||||
setData({
|
||||
...data,
|
||||
recommendations: data.recommendations.filter(r => !selectedCards.has(r.name)),
|
||||
total: data.total - selectedCards.size,
|
||||
})
|
||||
}
|
||||
setSelectedCards(new Set())
|
||||
} catch { /* ignore */ }
|
||||
setBulkAdding(false)
|
||||
}
|
||||
|
||||
function toggleGroup(groupName: string) {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(groupName)) next.delete(groupName)
|
||||
else next.add(groupName)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Group recommendations by role
|
||||
function groupByRole(recs: EdhRecCard[]): Record<string, EdhRecCard[]> {
|
||||
const groups: Record<string, EdhRecCard[]> = {}
|
||||
for (const rec of recs) {
|
||||
const role = rec.role || 'Other'
|
||||
if (!groups[role]) groups[role] = []
|
||||
groups[role].push(rec)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
|
||||
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
|
||||
<p className="text-sm text-accent-red font-medium">{error}</p>
|
||||
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.recommendations.length === 0) {
|
||||
return <EmptyState icon={Globe} title="No EDHREC data" description="No recommendations found for this commander on EDHREC." />
|
||||
}
|
||||
|
||||
const grouped = groupByRole(data.recommendations)
|
||||
const roleOrder = ['Ramp', 'Draw', 'Removal', 'BoardWipe', 'Tutor', 'Protection', 'Finisher', 'Recursion', 'Other']
|
||||
const sortedRoles = Object.keys(grouped).sort((a, b) => {
|
||||
const ia = roleOrder.indexOf(a)
|
||||
const ib = roleOrder.indexOf(b)
|
||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
|
||||
})
|
||||
|
||||
const ownedCount = data.recommendations.filter(r => r.owned).length
|
||||
const missingCount = data.recommendations.length - ownedCount
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Sparkles className="w-4 h-4 text-accent-blue" />
|
||||
<span className="text-text-primary font-medium">{data.commander}</span>
|
||||
<span className="text-text-tertiary">via {data.source}</span>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green">{ownedCount} owned</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-bg-tertiary text-text-tertiary">{missingCount} missing</span>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyOwned}
|
||||
onChange={e => setOnlyOwned(e.target.checked)}
|
||||
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5"
|
||||
/>
|
||||
Show only owned
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Selection toolbar */}
|
||||
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-tertiary">Select:</span>
|
||||
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
|
||||
<span className="text-text-tertiary">/</span>
|
||||
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
|
||||
<span className="text-text-tertiary">/</span>
|
||||
<button onClick={selectOwned} className="text-xs text-accent-blue hover:underline">Owned</button>
|
||||
{selectedCards.size > 0 && (
|
||||
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBulkAdd}
|
||||
disabled={selectedCards.size === 0 || bulkAdding}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
|
||||
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grouped cards */}
|
||||
{sortedRoles.map(role => {
|
||||
const cards = grouped[role]
|
||||
const isCollapsed = collapsedGroups.has(role)
|
||||
return (
|
||||
<div key={role} className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(role)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RoleBadge role={role} />
|
||||
<span className="text-xs text-text-tertiary">({cards.length})</span>
|
||||
</div>
|
||||
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
|
||||
</button>
|
||||
{/* Card list */}
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-border-primary divide-y divide-border-primary">
|
||||
{cards.map(rec => (
|
||||
<div
|
||||
key={rec.name}
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group relative"
|
||||
onMouseEnter={() => setHoveredCard(rec)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCards.has(rec.name)}
|
||||
onChange={() => toggleSelect(rec.name)}
|
||||
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
|
||||
/>
|
||||
{/* Card image mini */}
|
||||
{rec.image_url && (
|
||||
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
|
||||
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
{/* Card info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] text-text-tertiary truncate">{rec.type_line}</span>
|
||||
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Owned badge */}
|
||||
<span className={'text-xs px-2 py-0.5 rounded flex-shrink-0 ' + (rec.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary')}>
|
||||
{rec.owned ? (rec.owned_qty + 'x owned') : 'Missing'}
|
||||
</span>
|
||||
{/* Add button */}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
|
||||
disabled={addingCards.has(rec.name)}
|
||||
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Hover preview */}
|
||||
{hoveredCard && hoveredCard.image_url && (
|
||||
<div className="fixed top-20 right-8 z-50 pointer-events-none animate-fade-in">
|
||||
<img
|
||||
src={hoveredCard.image_url}
|
||||
alt={hoveredCard.name}
|
||||
className="w-[244px] rounded-xl shadow-2xl shadow-black/60 border border-border-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Collection Recommendations Sub-tab ──────────────────────
|
||||
function CollectionRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
|
||||
const [data, setData] = useState<CollectionRecsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
|
||||
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
|
||||
const [bulkAdding, setBulkAdding] = useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
async function fetchRecs() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await decksApi.getCollectionRecs(deckId)
|
||||
setData(result)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load collection recommendations')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchRecs() }, [deckId])
|
||||
|
||||
function toggleSelect(name: string) {
|
||||
setSelectedCards(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
if (!data) return
|
||||
const all = new Set<string>()
|
||||
Object.values(data.grouped).flat().forEach(c => all.add(c.name))
|
||||
setSelectedCards(all)
|
||||
}
|
||||
|
||||
function selectNone() { setSelectedCards(new Set()) }
|
||||
|
||||
async function handleAddSingle(name: string) {
|
||||
setAddingCards(prev => new Set(prev).add(name))
|
||||
try {
|
||||
await decksApi.bulkAddRecommended(deckId, [name])
|
||||
onAddedCards()
|
||||
// Remove from groupings
|
||||
if (data) {
|
||||
const newGrouped = { ...data.grouped }
|
||||
for (const key of Object.keys(newGrouped)) {
|
||||
newGrouped[key] = newGrouped[key].filter(c => c.name !== name)
|
||||
if (newGrouped[key].length === 0) delete newGrouped[key]
|
||||
}
|
||||
setData({ ...data, grouped: newGrouped, total: data.total - 1 })
|
||||
}
|
||||
selectedCards.delete(name)
|
||||
setSelectedCards(new Set(selectedCards))
|
||||
} catch { /* ignore */ }
|
||||
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
|
||||
}
|
||||
|
||||
async function handleBulkAdd() {
|
||||
if (selectedCards.size === 0) return
|
||||
setBulkAdding(true)
|
||||
try {
|
||||
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
|
||||
onAddedCards()
|
||||
if (data) {
|
||||
const newGrouped = { ...data.grouped }
|
||||
for (const key of Object.keys(newGrouped)) {
|
||||
newGrouped[key] = newGrouped[key].filter(c => !selectedCards.has(c.name))
|
||||
if (newGrouped[key].length === 0) delete newGrouped[key]
|
||||
}
|
||||
setData({ ...data, grouped: newGrouped, total: data.total - selectedCards.size })
|
||||
}
|
||||
setSelectedCards(new Set())
|
||||
} catch { /* ignore */ }
|
||||
setBulkAdding(false)
|
||||
}
|
||||
|
||||
function toggleGroup(groupName: string) {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(groupName)) next.delete(groupName)
|
||||
else next.add(groupName)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
|
||||
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
|
||||
<p className="text-sm text-accent-red font-medium">{error}</p>
|
||||
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.total === 0) {
|
||||
return <EmptyState icon={Library} title="No collection matches" description="No cards in your collection match this deck's needs." />
|
||||
}
|
||||
|
||||
const typeOrder = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
|
||||
const sortedTypes = Object.keys(data.grouped).sort((a, b) => {
|
||||
const ia = typeOrder.indexOf(a)
|
||||
const ib = typeOrder.indexOf(b)
|
||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Shortfall alert */}
|
||||
{data.shortfall_types.length > 0 && (
|
||||
<div className="flex items-start gap-3 bg-accent-blue/8 border border-accent-blue/20 rounded-xl px-4 py-3">
|
||||
<Lightbulb className="w-4 h-4 text-accent-blue mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-accent-blue">Deck needs more:</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{data.shortfall_types.map(t => (
|
||||
<span key={t} className="text-[10px] font-medium px-2 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection toolbar */}
|
||||
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-tertiary">Select:</span>
|
||||
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
|
||||
<span className="text-text-tertiary">/</span>
|
||||
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
|
||||
{selectedCards.size > 0 && (
|
||||
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBulkAdd}
|
||||
disabled={selectedCards.size === 0 || bulkAdding}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
|
||||
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grouped cards */}
|
||||
{sortedTypes.map(type => {
|
||||
const cards = data.grouped[type]
|
||||
const isCollapsed = collapsedGroups.has(type)
|
||||
const isShortfall = data.shortfall_types.includes(type)
|
||||
return (
|
||||
<div key={type} className={'bg-bg-secondary rounded-xl border overflow-hidden ' + (isShortfall ? 'border-accent-blue/30' : 'border-border-primary')}>
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(type)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-text-primary uppercase tracking-wider">{type}</span>
|
||||
<span className="text-xs text-text-tertiary">({cards.length})</span>
|
||||
{isShortfall && <span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">Shortfall</span>}
|
||||
</div>
|
||||
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
|
||||
</button>
|
||||
{/* Card list */}
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-border-primary divide-y divide-border-primary">
|
||||
{cards.map(rec => (
|
||||
<div key={rec.scryfall_id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group">
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCards.has(rec.name)}
|
||||
onChange={() => toggleSelect(rec.name)}
|
||||
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
|
||||
/>
|
||||
{/* Card image mini */}
|
||||
{rec.image_url && (
|
||||
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
|
||||
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
{/* Card info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] text-text-tertiary">{rec.cmc} CMC</span>
|
||||
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Score */}
|
||||
<span className="text-xs text-accent-teal font-medium flex-shrink-0">Score: {rec.score}</span>
|
||||
{/* Owned qty */}
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green flex-shrink-0">{rec.owned_qty}x</span>
|
||||
{/* Add button */}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
|
||||
disabled={addingCards.has(rec.name)}
|
||||
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Deck Detail View ──────────────────────────────────────────
|
||||
function DeckDetail({ deck, onBack }: { deck: Deck; onBack: () => void }) {
|
||||
const [cards, setCards] = useState<DeckCard[]>([])
|
||||
const [analysis, setAnalysis] = useState<DeckAnalysis | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tab, setTab] = useState<'cards' | 'analysis' | 'recs'>('cards')
|
||||
const [recSource, setRecSource] = useState<'edhrec' | 'collection'>('edhrec')
|
||||
const [addingCard, setAddingCard] = useState('')
|
||||
const [bulkText, setBulkText] = useState('')
|
||||
const [showBulk, setShowBulk] = useState(false)
|
||||
|
||||
async function fetchAll() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [c, a] = await Promise.all([
|
||||
decksApi.getDeckCards(deck.id),
|
||||
decksApi.getDeckAnalysis(deck.id),
|
||||
])
|
||||
setCards(c)
|
||||
setAnalysis(a)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchAll() }, [deck.id])
|
||||
|
||||
async function handleAddCard() {
|
||||
if (!addingCard.trim()) return
|
||||
try {
|
||||
await decksApi.addCardToDeck(deck.id, { name: addingCard.trim() })
|
||||
setAddingCard('')
|
||||
fetchAll()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleBulkAdd() {
|
||||
if (!bulkText.trim()) return
|
||||
const cardLines = bulkText.split('\n').filter(l => l.trim())
|
||||
const cardParsed = cardLines.map(l => {
|
||||
const match = l.match(/^(\d+)x?\s+(.+)$/)
|
||||
return match ? { name: match[2].trim(), quantity: Number(match[1]) } : { name: l.trim() }
|
||||
})
|
||||
try {
|
||||
await decksApi.bulkAddCards(deck.id, cardParsed)
|
||||
setBulkText('')
|
||||
setShowBulk(false)
|
||||
fetchAll()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleExportToSim() {
|
||||
try {
|
||||
await decksApi.exportDeckToSim(deck.id)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={onBack} className="p-2 rounded-lg hover:bg-bg-hover text-text-secondary transition-colors">
|
||||
<ChevronRight className="w-5 h-5 rotate-180" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-text-primary">{deck.name}</h2>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-sm text-text-secondary">{deck.commander}</span>
|
||||
<ColorDots colors={deck.color_identity} />
|
||||
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
|
||||
<span className="text-xs text-accent-teal">${deck.total_price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleExportToSim}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Export to Sim
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-border-primary">
|
||||
{(['cards', 'analysis', 'recs'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors capitalize ${
|
||||
tab === t
|
||||
? 'border-accent-blue text-accent-blue'
|
||||
: 'border-transparent text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{t === 'recs' ? 'Recommendations' : t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
) : tab === 'cards' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Add card */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add card by name..."
|
||||
value={addingCard}
|
||||
onChange={e => setAddingCard(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCard()}
|
||||
className="flex-1 px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
<button onClick={handleAddCard}
|
||||
className="px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
|
||||
Add
|
||||
</button>
|
||||
<button onClick={() => setShowBulk(!showBulk)}
|
||||
className="px-3 py-2.5 bg-bg-tertiary text-text-secondary text-sm rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||
<Layers className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{showBulk && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 animate-fade-in space-y-3">
|
||||
<textarea
|
||||
placeholder={"1x Sol Ring\n1x Command Tower\n2x Counterspell"}
|
||||
value={bulkText}
|
||||
onChange={e => setBulkText(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
|
||||
/>
|
||||
<button onClick={handleBulkAdd}
|
||||
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
|
||||
Bulk Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<DeckCardList deckId={deck.id} cards={cards} onUpdate={fetchAll} />
|
||||
</div>
|
||||
) : tab === 'analysis' && analysis ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Stats */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-accent-blue" />
|
||||
Deck Stats
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'Cards', value: analysis.card_count },
|
||||
{ label: 'Lands', value: analysis.land_count },
|
||||
{ label: 'Creatures', value: analysis.creature_count },
|
||||
{ label: 'Non-Creature', value: analysis.noncreature_count },
|
||||
{ label: 'Avg CMC', value: analysis.avg_cmc.toFixed(2) },
|
||||
{ label: 'Owned', value: `${analysis.owned_count} / ${analysis.card_count}` },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="px-3 py-2 rounded-lg bg-bg-tertiary">
|
||||
<p className="text-xs text-text-tertiary">{stat.label}</p>
|
||||
<p className="text-lg font-bold text-text-primary">{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mana Curve */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Mana Curve</h3>
|
||||
<ManaCurveChart analysis={analysis} />
|
||||
</div>
|
||||
{/* Type Distribution */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-3 md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Type Distribution</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(analysis.type_distribution).map(([type, count]) => (
|
||||
<span key={type} className="px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs text-text-secondary">
|
||||
{type}: <span className="font-semibold text-text-primary">{count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : tab === 'recs' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Sub-tab toggle */}
|
||||
<div className="flex items-center gap-1 bg-bg-tertiary rounded-lg p-1 w-fit">
|
||||
<button
|
||||
onClick={() => setRecSource('edhrec')}
|
||||
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
|
||||
(recSource === 'edhrec'
|
||||
? 'bg-accent-blue text-white shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary')}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
EDHREC
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRecSource('collection')}
|
||||
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
|
||||
(recSource === 'collection'
|
||||
? 'bg-accent-blue text-white shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary')}
|
||||
>
|
||||
<Library className="w-3.5 h-3.5" />
|
||||
From Collection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sub-tab content */}
|
||||
{recSource === 'edhrec' ? (
|
||||
<EdhRecTab deckId={deck.id} onAddedCards={fetchAll} />
|
||||
) : (
|
||||
<CollectionRecTab deckId={deck.id} onAddedCards={fetchAll} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Decks Page ───────────────────────────────────────────
|
||||
export function DecksPage() {
|
||||
const [decks, setDecks] = useState<Deck[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedDeck, setSelectedDeck] = useState<Deck | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newDeckName, setNewDeckName] = useState('')
|
||||
const [newCommander, setNewCommander] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
async function fetchDecks() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const d = await decksApi.listDecks()
|
||||
setDecks(d)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchDecks() }, [])
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newDeckName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const deck = await decksApi.createDeck({ name: newDeckName.trim(), commander: newCommander.trim() || undefined })
|
||||
setNewDeckName('')
|
||||
setNewCommander('')
|
||||
setShowCreate(false)
|
||||
setSelectedDeck(deck)
|
||||
fetchDecks()
|
||||
} catch { /* ignore */ }
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
async function handleDelete(deckId: number) {
|
||||
try {
|
||||
await decksApi.deleteDeck(deckId)
|
||||
fetchDecks()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (selectedDeck) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<DeckDetail deck={selectedDeck} onBack={() => { setSelectedDeck(null); fetchDecks() }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Decks</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">{decks.length} deck{decks.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Deck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 animate-fade-in space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Create New Deck</h3>
|
||||
<button onClick={() => setShowCreate(false)} className="text-text-tertiary hover:text-text-secondary">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Deck name"
|
||||
value={newDeckName}
|
||||
onChange={e => setNewDeckName(e.target.value)}
|
||||
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Commander (optional)"
|
||||
value={newCommander}
|
||||
onChange={e => setNewCommander(e.target.value)}
|
||||
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleCreate} disabled={creating}
|
||||
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
|
||||
{creating ? <Spinner size="sm" /> : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deck list */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
) : decks.length === 0 ? (
|
||||
<EmptyState icon={Layers} title="No decks yet" description="Create your first deck to get started." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{decks.map(deck => (
|
||||
<div
|
||||
key={deck.id}
|
||||
className="bg-bg-secondary rounded-xl border border-border-primary hover:border-border-secondary p-5 cursor-pointer transition-all group"
|
||||
onClick={() => setSelectedDeck(deck)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-text-primary truncate group-hover:text-accent-blue transition-colors">{deck.name}</h3>
|
||||
<p className="text-sm text-text-secondary mt-0.5 truncate">{deck.commander}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDelete(deck.id) }}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 text-text-tertiary hover:text-accent-red transition-all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorDots colors={deck.color_identity} />
|
||||
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-accent-teal">${deck.total_price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Gamepad2, Play, Wifi, WifiOff, Settings,
|
||||
AlertCircle, Trophy, Clock, Brain
|
||||
} from 'lucide-react'
|
||||
import { Spinner, StatusBadge } from '../components/common'
|
||||
import { simApi, labApi } from '../api'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import type { SimStatus, SimResult, DeepSeekStatus, LabDeck } from '../types'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
|
||||
export function SimulatorPage() {
|
||||
const [decks, setDecks] = useState<LabDeck[]>([])
|
||||
const [deckA, setDeckA] = useState('')
|
||||
const [deckB, setDeckB] = useState('')
|
||||
const [games, setGames] = useState(50)
|
||||
const [useDeepSeek, setUseDeepSeek] = useState(false)
|
||||
const [deepSeekDeck, setDeepSeekDeck] = useState('')
|
||||
const [dsStatus, setDsStatus] = useState<DeepSeekStatus | null>(null)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [simId, setSimId] = useState<string | null>(null)
|
||||
const [simStatus, setSimStatus] = useState<SimStatus | null>(null)
|
||||
const [result, setResult] = useState<SimResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [starting, setStarting] = useState(false)
|
||||
|
||||
// DS config
|
||||
const [showDsConfig, setShowDsConfig] = useState(false)
|
||||
const [dsModel, setDsModel] = useState('')
|
||||
const [dsUrl, setDsUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
labApi.getLabDecks().then(setDecks).catch(() => {})
|
||||
simApi.getDeepSeekStatus().then(s => {
|
||||
setDsStatus(s)
|
||||
if (s.model) setDsModel(s.model)
|
||||
if (s.base_url) setDsUrl(s.base_url)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Poll while running
|
||||
usePolling(async () => {
|
||||
if (!simId) return
|
||||
try {
|
||||
const s = await simApi.getSimStatus(simId)
|
||||
setSimStatus(s)
|
||||
if (!s.running) {
|
||||
const r = await simApi.getSimResult(simId)
|
||||
setResult(r)
|
||||
setRunning(false)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000, running)
|
||||
|
||||
async function handleStart() {
|
||||
if (!deckA || !deckB) { setError('Select both decks'); return }
|
||||
setStarting(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
let res
|
||||
if (useDeepSeek && deepSeekDeck) {
|
||||
res = await simApi.runDeepSeekSim({ deck_a: deckA, deck_b: deckB, games, deepseek_deck: deepSeekDeck })
|
||||
} else {
|
||||
res = await simApi.runSim({ deck_a: deckA, deck_b: deckB, games })
|
||||
}
|
||||
setSimId(res.sim_id)
|
||||
setRunning(true)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start')
|
||||
}
|
||||
setStarting(false)
|
||||
}
|
||||
|
||||
async function handleDsConnect() {
|
||||
try {
|
||||
const s = await simApi.connectDeepSeek()
|
||||
setDsStatus(s)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleDsConfigure() {
|
||||
try {
|
||||
const s = await simApi.configureDeepSeek({
|
||||
model: dsModel || undefined,
|
||||
base_url: dsUrl || undefined,
|
||||
})
|
||||
setDsStatus(s)
|
||||
setShowDsConfig(false)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<Gamepad2 className="w-6 h-6 text-accent-purple" />
|
||||
Simulator
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">Run head-to-head deck simulations</p>
|
||||
</div>
|
||||
|
||||
{/* DeepSeek status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge
|
||||
variant={dsStatus?.connected ? 'success' : 'warning'}
|
||||
label={dsStatus?.connected ? `DeepSeek: ${dsStatus.model}` : 'DeepSeek: Disconnected'}
|
||||
/>
|
||||
<button onClick={handleDsConnect}
|
||||
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
|
||||
{dsStatus?.connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
||||
{dsStatus?.connected ? 'Reconnect' : 'Connect'}
|
||||
</button>
|
||||
<button onClick={() => setShowDsConfig(!showDsConfig)}
|
||||
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
|
||||
<Settings className="w-3 h-3" />
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDsConfig && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 max-w-md animate-fade-in space-y-3">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-accent-purple" />
|
||||
DeepSeek Configuration
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Model name"
|
||||
value={dsModel}
|
||||
onChange={e => setDsModel(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Base URL (e.g. http://192.168.0.122:1234)"
|
||||
value={dsUrl}
|
||||
onChange={e => setDsUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
<button onClick={handleDsConfigure}
|
||||
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sim config */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck A</label>
|
||||
<select
|
||||
value={deckA}
|
||||
onChange={e => setDeckA(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
>
|
||||
<option value="">Select deck...</option>
|
||||
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck B</label>
|
||||
<select
|
||||
value={deckB}
|
||||
onChange={e => setDeckB(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
>
|
||||
<option value="">Select deck...</option>
|
||||
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
|
||||
<input
|
||||
type="number" min={1} max={1000}
|
||||
value={games}
|
||||
onChange={e => setGames(Number(e.target.value))}
|
||||
className="mt-1 w-24 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer mt-5">
|
||||
<input type="checkbox" checked={useDeepSeek} onChange={e => setUseDeepSeek(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-purple focus:ring-accent-purple/30" />
|
||||
<span className="text-sm text-text-secondary">Use DeepSeek AI pilot</span>
|
||||
</label>
|
||||
{useDeepSeek && (
|
||||
<div className="mt-5">
|
||||
<select
|
||||
value={deepSeekDeck}
|
||||
onChange={e => setDeepSeekDeck(e.target.value)}
|
||||
className="px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
>
|
||||
<option value="">AI pilots which deck?</option>
|
||||
{[deckA, deckB].filter(Boolean).map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||
<AlertCircle className="w-4 h-4" />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{running ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="md" className="text-accent-blue" />
|
||||
<div>
|
||||
<p className="text-sm text-text-primary">Simulation running...</p>
|
||||
{simStatus && (
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{simStatus.games_completed ?? 0} / {simStatus.total_games ?? games} games
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleStart} disabled={starting || !deckA || !deckB}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-semibold rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-50">
|
||||
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||
Run Simulation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-accent-amber" />
|
||||
Results
|
||||
</h3>
|
||||
<span className="text-xs text-text-tertiary flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{(result.duration_ms / 1000).toFixed(1)}s · {result.total_games} games
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={result.decks.map(d => ({ name: d.name, win_rate: Math.round(d.win_rate * 100) }))} barSize={40}>
|
||||
<XAxis dataKey="name" tick={{ fill: '#9ba1b8', fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
|
||||
formatter={(value: unknown) => [`${value}%`, 'Win Rate']} />
|
||||
<Bar dataKey="win_rate" radius={[4, 4, 0, 0]}>
|
||||
{result.decks.map((d, i) => (
|
||||
<Cell key={i} fill={d.win_rate === Math.max(...result.decks.map(x => x.win_rate)) ? '#4ade80' : '#4f6ef7'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{result.decks.map(d => (
|
||||
<div key={d.name} className="px-4 py-3 rounded-lg bg-bg-tertiary">
|
||||
<p className="text-sm font-semibold text-text-primary">{d.name}</p>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
<div>
|
||||
<p className="text-[10px] text-text-tertiary uppercase">Wins</p>
|
||||
<p className="text-lg font-bold text-accent-green">{d.wins}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-text-tertiary uppercase">Win Rate</p>
|
||||
<p className="text-lg font-bold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-text-tertiary uppercase">Avg Turn</p>
|
||||
<p className="text-lg font-bold text-text-primary">{d.avg_turn.toFixed(1)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Brain, Play, RotateCw, Database, Trophy, AlertCircle,
|
||||
Activity, Zap, Swords, FileText, Cpu, CheckCircle,
|
||||
ChevronDown, ChevronUp, Settings
|
||||
} from 'lucide-react'
|
||||
import { Spinner, StatusBadge } from '../components/common'
|
||||
import { trainingApi } from '../api'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import type {
|
||||
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
|
||||
TournamentStatus, MLModelInfo
|
||||
} from '../types'
|
||||
|
||||
// ── Progress Bar ──────────────────────────────────────────────
|
||||
function ProgressBar({ value, max, label, color = 'bg-accent-blue' }: { value: number; max: number; label: string; color?: string }) {
|
||||
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-text-secondary">{label}</span>
|
||||
<span className="text-text-tertiary">{value} / {max} ({pct.toFixed(0)}%)</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div className={'h-full rounded-full transition-all duration-500 ' + color} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Phase Badge ──────────────────────────────────────────────
|
||||
const PHASE_STYLES: Record<string, string> = {
|
||||
idle: 'bg-bg-tertiary text-text-tertiary',
|
||||
starting: 'bg-accent-blue/15 text-accent-blue animate-pulse',
|
||||
building: 'bg-accent-purple/15 text-accent-purple animate-pulse',
|
||||
training: 'bg-accent-blue/15 text-accent-blue animate-pulse',
|
||||
evaluating: 'bg-accent-teal/15 text-accent-teal animate-pulse',
|
||||
running: 'bg-accent-amber/15 text-accent-amber animate-pulse',
|
||||
done: 'bg-accent-green/15 text-accent-green',
|
||||
error: 'bg-accent-red/15 text-accent-red',
|
||||
}
|
||||
|
||||
function PhaseBadge({ phase }: { phase: string }) {
|
||||
const cls = PHASE_STYLES[phase] || PHASE_STYLES.idle
|
||||
return (
|
||||
<span className={'text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded ' + cls}>
|
||||
{phase}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Metric Card ──────────────────────────────────────────────
|
||||
function MetricCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||
return (
|
||||
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
|
||||
<p className="text-[10px] text-text-tertiary">{label}</p>
|
||||
<p className={'text-sm font-bold ' + (accent || 'text-text-primary')}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Collapsible Section ─────────────────────────────────────
|
||||
function Section({ title, icon, children, defaultOpen = true, color = 'text-accent-blue' }: {
|
||||
title: string; icon: React.ReactNode; children: React.ReactNode; defaultOpen?: boolean; color?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<span className={color}>{icon}</span>
|
||||
{title}
|
||||
</h3>
|
||||
{open ? <ChevronUp className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
|
||||
</button>
|
||||
{open && <div className="px-5 pb-5 space-y-4 border-t border-border-primary pt-4">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Main Training Page
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
export function TrainingPage() {
|
||||
// Status states
|
||||
const [mlStatus, setMlStatus] = useState<MLStatus | null>(null)
|
||||
const [dataStatus, setDataStatus] = useState<MLDataStatus | null>(null)
|
||||
const [modelInfo, setModelInfo] = useState<MLModelInfo | null>(null)
|
||||
const [trainStatus, setTrainStatus] = useState<MLTrainStatus | null>(null)
|
||||
const [ppoStatus, setPpoStatus] = useState<PPOTrainStatus | null>(null)
|
||||
const [tournStatus, setTournStatus] = useState<TournamentStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Supervised training config
|
||||
const [epochs, setEpochs] = useState(50)
|
||||
const [lr, setLr] = useState(0.001)
|
||||
const [batchSize, setBatchSize] = useState(256)
|
||||
const [patience, setPatience] = useState(10)
|
||||
const [rebuildDataset, setRebuildDataset] = useState(true)
|
||||
|
||||
// PPO config
|
||||
const [ppoIterations, setPpoIterations] = useState(100)
|
||||
const [ppoEpisodes, setPpoEpisodes] = useState(64)
|
||||
const [ppoEpochs, setPpoEpochs] = useState(4)
|
||||
const [ppoBatchSize, setPpoBatchSize] = useState(256)
|
||||
const [ppoLr, setPpoLr] = useState(0.0003)
|
||||
const [ppoClipEps, setPpoClipEps] = useState(0.2)
|
||||
const [ppoEntropy, setPpoEntropy] = useState(0.01)
|
||||
const [ppoOpponent, setPpoOpponent] = useState('heuristic')
|
||||
const [ppoPlaystyle, _setPpoPlaystyle] = useState('midrange') // eslint-disable-line
|
||||
|
||||
// Tournament config
|
||||
const [tournEpisodes, setTournEpisodes] = useState(50)
|
||||
const [tournPlaystyle, setTournPlaystyle] = useState('midrange')
|
||||
|
||||
// Action states
|
||||
const [startingTrain, setStartingTrain] = useState(false)
|
||||
const [startingPpo, setStartingPpo] = useState(false)
|
||||
const [startingTourn, setStartingTourn] = useState(false)
|
||||
const [reloading, setReloading] = useState(false)
|
||||
const [showSupAdvanced, setShowSupAdvanced] = useState(false)
|
||||
const [showPpoAdvanced, setShowPpoAdvanced] = useState(false)
|
||||
|
||||
// ── Initial Load ──────────────────────────────────────────
|
||||
const loadAll = useCallback(async () => {
|
||||
try {
|
||||
const [ml, data, model, train, ppo, tourn] = await Promise.all([
|
||||
trainingApi.getMLStatus().catch(() => null),
|
||||
trainingApi.getDataStatus().catch(() => null),
|
||||
trainingApi.getModelInfo().catch(() => null),
|
||||
trainingApi.getTrainStatus().catch(() => null),
|
||||
trainingApi.getPPOStatus().catch(() => null),
|
||||
trainingApi.getTournamentStatus().catch(() => null),
|
||||
])
|
||||
if (ml) setMlStatus(ml)
|
||||
if (data) setDataStatus(data)
|
||||
if (model) setModelInfo(model)
|
||||
if (train) setTrainStatus(train)
|
||||
if (ppo) setPpoStatus(ppo)
|
||||
if (tourn) setTournStatus(tourn)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadAll() }, [loadAll])
|
||||
|
||||
// ── Polling ───────────────────────────────────────────────
|
||||
const isTraining = trainStatus?.running || false
|
||||
const isPpo = ppoStatus?.running || false
|
||||
const isTourn = tournStatus?.running || false
|
||||
const anyRunning = isTraining || isPpo || isTourn
|
||||
|
||||
usePolling(async () => {
|
||||
try {
|
||||
const s = await trainingApi.getTrainStatus()
|
||||
setTrainStatus(s)
|
||||
if (!s.running && s.phase === 'done') {
|
||||
// Refresh model info + data when training completes
|
||||
const [model, data] = await Promise.all([
|
||||
trainingApi.getModelInfo().catch(() => null),
|
||||
trainingApi.getDataStatus().catch(() => null),
|
||||
])
|
||||
if (model) setModelInfo(model)
|
||||
if (data) setDataStatus(data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000, isTraining)
|
||||
|
||||
usePolling(async () => {
|
||||
try {
|
||||
const s = await trainingApi.getPPOStatus()
|
||||
setPpoStatus(s)
|
||||
if (!s.running && s.phase === 'done') {
|
||||
const [model, data] = await Promise.all([
|
||||
trainingApi.getModelInfo().catch(() => null),
|
||||
trainingApi.getDataStatus().catch(() => null),
|
||||
])
|
||||
if (model) setModelInfo(model)
|
||||
if (data) setDataStatus(data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000, isPpo)
|
||||
|
||||
usePolling(async () => {
|
||||
try {
|
||||
const s = await trainingApi.getTournamentStatus()
|
||||
setTournStatus(s)
|
||||
} catch { /* ignore */ }
|
||||
}, 2000, isTourn)
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────
|
||||
async function handleToggleLogging() {
|
||||
try {
|
||||
const newState = !(mlStatus?.ml_logging_enabled ?? false)
|
||||
await trainingApi.toggleML(newState)
|
||||
const ml = await trainingApi.getMLStatus()
|
||||
setMlStatus(ml)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleReloadModel() {
|
||||
setReloading(true)
|
||||
try {
|
||||
const res = await trainingApi.reloadModel()
|
||||
setModelInfo(res.status)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to reload model')
|
||||
}
|
||||
setReloading(false)
|
||||
}
|
||||
|
||||
async function handleStartTrain() {
|
||||
setStartingTrain(true)
|
||||
setError(null)
|
||||
try {
|
||||
await trainingApi.startTraining({ epochs, lr, batchSize, patience, rebuildDataset })
|
||||
const s = await trainingApi.getTrainStatus()
|
||||
setTrainStatus(s)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start training')
|
||||
}
|
||||
setStartingTrain(false)
|
||||
}
|
||||
|
||||
async function handleStartPpo() {
|
||||
setStartingPpo(true)
|
||||
setError(null)
|
||||
try {
|
||||
await trainingApi.startPPOTraining({
|
||||
iterations: ppoIterations,
|
||||
episodesPerIter: ppoEpisodes,
|
||||
ppoEpochs,
|
||||
batchSize: ppoBatchSize,
|
||||
lr: ppoLr,
|
||||
clipEpsilon: ppoClipEps,
|
||||
entropyCoeff: ppoEntropy,
|
||||
opponent: ppoOpponent,
|
||||
playstyle: ppoPlaystyle,
|
||||
})
|
||||
const s = await trainingApi.getPPOStatus()
|
||||
setPpoStatus(s)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start PPO training')
|
||||
}
|
||||
setStartingPpo(false)
|
||||
}
|
||||
|
||||
async function handleStartTournament() {
|
||||
setStartingTourn(true)
|
||||
setError(null)
|
||||
try {
|
||||
await trainingApi.startTournament({ episodes: tournEpisodes, playstyle: tournPlaystyle })
|
||||
const s = await trainingApi.getTournamentStatus()
|
||||
setTournStatus(s)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start tournament')
|
||||
}
|
||||
setStartingTourn(false)
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
}
|
||||
|
||||
const trainDataset = dataStatus?.datasets?.['train']
|
||||
const hasDecisions = (mlStatus?.total_decisions ?? 0) > 0
|
||||
const hasModel = modelInfo?.loaded === true
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<Brain className="w-6 h-6 text-accent-green" />
|
||||
ML Training Pipeline
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">Collect data, train models, and evaluate performance</p>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-accent-red/10 border border-accent-red/30 rounded-lg text-sm text-accent-red">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1">{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-accent-red/60 hover:text-accent-red text-xs">dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline overview bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 bg-bg-secondary rounded-xl border border-border-primary px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
|
||||
<span className="text-xs text-text-secondary">Logging {mlStatus?.ml_logging_enabled ? 'On' : 'Off'}</span>
|
||||
</div>
|
||||
<span className="text-border-primary">|</span>
|
||||
<span className="text-xs text-text-secondary">{mlStatus?.total_decisions ?? 0} decisions</span>
|
||||
<span className="text-border-primary">|</span>
|
||||
<span className="text-xs text-text-secondary">{trainDataset ? `${trainDataset.samples.toLocaleString()} training samples` : 'No dataset'}</span>
|
||||
<span className="text-border-primary">|</span>
|
||||
<span className="text-xs text-text-secondary">{(dataStatus?.checkpoints?.length ?? 0)} checkpoints</span>
|
||||
<span className="text-border-primary">|</span>
|
||||
<StatusBadge
|
||||
variant={hasModel ? 'success' : 'neutral'}
|
||||
label={hasModel ? `Model on ${modelInfo?.device ?? '?'}` : 'No model'}
|
||||
/>
|
||||
{anyRunning && (
|
||||
<>
|
||||
<span className="text-border-primary">|</span>
|
||||
<span className="text-xs font-medium text-accent-blue animate-pulse">Pipeline running...</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ Phase 1: Data Collection ═══ */}
|
||||
<Section title="Phase 1 — Data Collection" icon={<FileText className="w-4 h-4" />} color="text-accent-teal">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-text-secondary">
|
||||
Run batch simulations with ML logging enabled to collect decision data.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleToggleLogging}
|
||||
className={'flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ' +
|
||||
(mlStatus?.ml_logging_enabled
|
||||
? 'bg-accent-green/15 text-accent-green border-accent-green/30 hover:bg-accent-green/25'
|
||||
: 'bg-bg-tertiary text-text-secondary border-border-primary hover:bg-bg-hover'
|
||||
)}
|
||||
>
|
||||
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
|
||||
{mlStatus?.ml_logging_enabled ? 'Logging Enabled' : 'Enable Logging'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Decision files */}
|
||||
{(dataStatus?.decisionFiles?.length ?? 0) > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Decision Files</h4>
|
||||
<div className="max-h-36 overflow-y-auto space-y-1">
|
||||
{dataStatus!.decisionFiles.map(f => (
|
||||
<div key={f.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
|
||||
<span className="text-text-secondary font-mono truncate">{f.name}</span>
|
||||
<div className="flex items-center gap-3 text-text-tertiary">
|
||||
<span>{f.decisions.toLocaleString()} decisions</span>
|
||||
<span>{(f.size / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-tertiary italic">No decision files yet. Run batch sims with logging enabled.</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ═══ Phase 2: Dataset & Checkpoints ═══ */}
|
||||
<Section title="Phase 2 — Dataset & Model Artifacts" icon={<Database className="w-4 h-4" />} color="text-accent-purple">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Dataset splits */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Dataset Splits</h4>
|
||||
{Object.keys(dataStatus?.datasets ?? {}).length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['train', 'val', 'test'] as const).map(split => {
|
||||
const ds = dataStatus?.datasets?.[split]
|
||||
return (
|
||||
<div key={split} className="px-3 py-2 bg-bg-tertiary rounded-lg">
|
||||
<p className="text-[10px] text-text-tertiary uppercase">{split}</p>
|
||||
{ds ? (
|
||||
<>
|
||||
<p className="text-sm font-bold text-text-primary">{ds.samples.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-text-tertiary">{ds.features} features</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-text-tertiary italic">Not built</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-tertiary italic">No dataset built yet. Training will build it automatically.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Checkpoints */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Checkpoints</h4>
|
||||
{(dataStatus?.checkpoints?.length ?? 0) > 0 ? (
|
||||
<div className="max-h-28 overflow-y-auto space-y-1">
|
||||
{dataStatus!.checkpoints.map(ckpt => (
|
||||
<div key={ckpt.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
|
||||
<span className="text-text-secondary font-mono truncate">{ckpt.name}</span>
|
||||
<span className="text-text-tertiary">{(ckpt.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-tertiary italic">No checkpoints yet. Train a model to generate them.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model info + reload */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border-primary">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="w-4 h-4 text-text-tertiary" />
|
||||
<div className="text-xs">
|
||||
{hasModel ? (
|
||||
<span className="text-accent-green font-medium">
|
||||
Model loaded on {modelInfo?.device} | {modelInfo?.input_dim} features | {modelInfo?.num_actions} actions
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-tertiary">No model loaded{modelInfo?.error ? ` — ${modelInfo.error}` : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReloadModel}
|
||||
disabled={reloading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{reloading ? <Spinner size="sm" /> : <RotateCw className="w-3 h-3" />}
|
||||
Reload Model
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Eval results */}
|
||||
{dataStatus?.evalResults && (
|
||||
<div className="pt-2 border-t border-border-primary space-y-2">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||
Latest Evaluation
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{Object.entries(dataStatus.evalResults).map(([k, v]) => (
|
||||
<MetricCard
|
||||
key={k}
|
||||
label={k.replace(/_/g, ' ')}
|
||||
value={typeof v === 'number' ? (k.includes('accuracy') || k.includes('rate') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)) : String(v)}
|
||||
accent={k.includes('accuracy') ? 'text-accent-green' : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ═══ Phase 3: Supervised Training ═══ */}
|
||||
<Section title="Phase 3 — Supervised Training" icon={<Activity className="w-4 h-4" />}>
|
||||
{/* Config */}
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Epochs</label>
|
||||
<input type="number" min={1} max={1000} value={epochs} onChange={e => setEpochs(Number(e.target.value))}
|
||||
disabled={isTraining}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
|
||||
<input type="number" min={0.00001} max={1} step={0.0001} value={lr} onChange={e => setLr(Number(e.target.value))}
|
||||
disabled={isTraining}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced toggle */}
|
||||
<button onClick={() => setShowSupAdvanced(!showSupAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
|
||||
<Settings className="w-3 h-3" />
|
||||
{showSupAdvanced ? 'Hide' : 'Show'} advanced options
|
||||
</button>
|
||||
{showSupAdvanced && (
|
||||
<div className="grid grid-cols-3 gap-3 animate-fade-in">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
|
||||
<input type="number" min={16} max={2048} value={batchSize} onChange={e => setBatchSize(Number(e.target.value))}
|
||||
disabled={isTraining}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Early Stop Patience</label>
|
||||
<input type="number" min={1} max={100} value={patience} onChange={e => setPatience(Number(e.target.value))}
|
||||
disabled={isTraining}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
|
||||
<input type="checkbox" checked={rebuildDataset} onChange={e => setRebuildDataset(e.target.checked)}
|
||||
disabled={isTraining}
|
||||
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5" />
|
||||
Rebuild dataset
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Training status / start button */}
|
||||
{isTraining || (trainStatus?.phase && trainStatus.phase !== 'idle') ? (
|
||||
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<PhaseBadge phase={trainStatus!.phase} />
|
||||
<span className="text-xs text-text-secondary">{trainStatus!.message}</span>
|
||||
</div>
|
||||
{trainStatus!.started_at && (
|
||||
<span className="text-[10px] text-text-tertiary">Started: {new Date(trainStatus!.started_at).toLocaleTimeString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar — only in training phase */}
|
||||
{(trainStatus!.phase === 'training' || trainStatus!.phase === 'done') && trainStatus!.total_epochs > 0 && (
|
||||
<ProgressBar
|
||||
value={trainStatus!.current_epoch}
|
||||
max={trainStatus!.total_epochs}
|
||||
label="Epoch progress"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Live metrics during training */}
|
||||
{trainStatus!.metrics && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{Object.entries(trainStatus!.metrics).map(([k, v]) => {
|
||||
if (typeof v !== 'number') return null
|
||||
const isAcc = k.includes('accuracy') || k.includes('acc')
|
||||
const isLoss = k.includes('loss')
|
||||
return (
|
||||
<MetricCard
|
||||
key={k}
|
||||
label={k.replace(/_/g, ' ')}
|
||||
value={isAcc ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
|
||||
accent={isAcc ? 'text-accent-green' : isLoss ? 'text-accent-amber' : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final result */}
|
||||
{trainStatus!.phase === 'done' && trainStatus!.result && (
|
||||
<div className="pt-2 border-t border-border-primary space-y-2">
|
||||
<h4 className="text-xs font-semibold text-accent-green flex items-center gap-1.5">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> Training Complete
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<MetricCard label="Device" value={trainStatus!.result.device} />
|
||||
<MetricCard label="Checkpoint" value={trainStatus!.result.checkpoint.split('/').pop() || '—'} />
|
||||
{trainStatus!.result.evaluation && Object.entries(trainStatus!.result.evaluation).map(([k, v]) => (
|
||||
typeof v === 'number' ? (
|
||||
<MetricCard key={k} label={k.replace(/_/g, ' ')}
|
||||
value={k.includes('accuracy') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
|
||||
accent={k.includes('accuracy') ? 'text-accent-green' : undefined} />
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{trainStatus!.phase === 'error' && trainStatus!.error && (
|
||||
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
|
||||
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
|
||||
<span className="text-xs text-accent-red">{trainStatus!.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleStartTrain}
|
||||
disabled={startingTrain || !hasDecisions || isPpo}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue/15 text-accent-blue text-sm font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{startingTrain ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||
Start Training
|
||||
</button>
|
||||
{!hasDecisions && (
|
||||
<span className="text-xs text-text-tertiary italic">Enable logging and run batch sims first to generate decision data.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ═══ Phase 4: PPO Reinforcement Learning ═══ */}
|
||||
<Section title="Phase 4 — PPO Reinforcement Learning" icon={<Zap className="w-4 h-4" />} color="text-accent-purple" defaultOpen={false}>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Fine-tune the supervised model using PPO self-play against an opponent policy.
|
||||
</p>
|
||||
|
||||
{/* Config */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Iterations</label>
|
||||
<input type="number" min={1} max={1000} value={ppoIterations} onChange={e => setPpoIterations(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Opponent</label>
|
||||
<select value={ppoOpponent} onChange={e => setPpoOpponent(e.target.value)}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
|
||||
<option value="heuristic">Heuristic</option>
|
||||
<option value="random">Random</option>
|
||||
<option value="self">Self-Play</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={() => setShowPpoAdvanced(!showPpoAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
|
||||
<Settings className="w-3 h-3" />
|
||||
{showPpoAdvanced ? 'Hide' : 'Show'} advanced options
|
||||
</button>
|
||||
{showPpoAdvanced && (
|
||||
<div className="grid grid-cols-3 gap-3 animate-fade-in">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Episodes / Iter</label>
|
||||
<input type="number" min={1} max={512} value={ppoEpisodes} onChange={e => setPpoEpisodes(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">PPO Epochs</label>
|
||||
<input type="number" min={1} max={20} value={ppoEpochs} onChange={e => setPpoEpochs(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
|
||||
<input type="number" min={16} max={2048} value={ppoBatchSize} onChange={e => setPpoBatchSize(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
|
||||
<input type="number" min={0.000001} max={0.01} step={0.0001} value={ppoLr} onChange={e => setPpoLr(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Clip Epsilon</label>
|
||||
<input type="number" min={0.05} max={0.5} step={0.05} value={ppoClipEps} onChange={e => setPpoClipEps(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Entropy Coeff</label>
|
||||
<input type="number" min={0} max={0.1} step={0.005} value={ppoEntropy} onChange={e => setPpoEntropy(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PPO running status */}
|
||||
{isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle') ? (
|
||||
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
|
||||
<div className="flex items-center gap-2">
|
||||
<PhaseBadge phase={ppoStatus!.phase} />
|
||||
<span className="text-xs text-text-secondary">{ppoStatus!.message}</span>
|
||||
</div>
|
||||
|
||||
{(ppoStatus!.phase === 'training' || ppoStatus!.phase === 'done') && ppoStatus!.total_iterations > 0 && (
|
||||
<ProgressBar
|
||||
value={ppoStatus!.iteration}
|
||||
max={ppoStatus!.total_iterations}
|
||||
label="PPO iteration"
|
||||
color="bg-accent-purple"
|
||||
/>
|
||||
)}
|
||||
|
||||
{ppoStatus!.metrics && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{ppoStatus!.metrics.win_rate != null && (
|
||||
<MetricCard label="Win Rate" value={`${(ppoStatus!.metrics.win_rate * 100).toFixed(1)}%`} accent="text-accent-green" />
|
||||
)}
|
||||
{ppoStatus!.metrics.avg_reward != null && (
|
||||
<MetricCard label="Avg Reward" value={ppoStatus!.metrics.avg_reward.toFixed(3)} accent="text-accent-amber" />
|
||||
)}
|
||||
{ppoStatus!.metrics.policy_loss != null && (
|
||||
<MetricCard label="Policy Loss" value={ppoStatus!.metrics.policy_loss.toFixed(4)} />
|
||||
)}
|
||||
{ppoStatus!.metrics.entropy != null && (
|
||||
<MetricCard label="Entropy" value={ppoStatus!.metrics.entropy.toFixed(4)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ppoStatus!.phase === 'done' && ppoStatus!.result && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border-primary">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||
<span className="text-xs text-accent-green font-medium">
|
||||
PPO Training Complete — Best Win Rate: {
|
||||
typeof ppoStatus!.result.best_win_rate === 'number'
|
||||
? `${(ppoStatus!.result.best_win_rate as number * 100).toFixed(1)}%`
|
||||
: '—'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ppoStatus!.phase === 'error' && ppoStatus!.error && (
|
||||
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
|
||||
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
|
||||
<span className="text-xs text-accent-red">{ppoStatus!.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartPpo}
|
||||
disabled={startingPpo || isTraining || !hasModel}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-medium rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{startingPpo ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||
Start PPO Training
|
||||
</button>
|
||||
)}
|
||||
{!hasModel && !(isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle')) && (
|
||||
<p className="text-xs text-text-tertiary italic">Train a supervised model first (Phase 3) before running PPO.</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ═══ Phase 5: Tournament Evaluation ═══ */}
|
||||
<Section title="Phase 5 — Tournament Evaluation" icon={<Swords className="w-4 h-4" />} color="text-accent-amber" defaultOpen={false}>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Pit the trained model against heuristic, random, and other policy variants.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Episodes per Matchup</label>
|
||||
<input type="number" min={10} max={1000} value={tournEpisodes} onChange={e => setTournEpisodes(Number(e.target.value))}
|
||||
disabled={isTourn}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Playstyle</label>
|
||||
<select value={tournPlaystyle} onChange={e => setTournPlaystyle(e.target.value)}
|
||||
disabled={isTourn}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
|
||||
<option value="midrange">Midrange</option>
|
||||
<option value="aggro">Aggro</option>
|
||||
<option value="control">Control</option>
|
||||
<option value="combo">Combo</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tournament status */}
|
||||
{isTourn || (tournStatus?.phase && tournStatus.phase !== 'idle') ? (
|
||||
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
|
||||
<div className="flex items-center gap-2">
|
||||
<PhaseBadge phase={tournStatus!.phase} />
|
||||
<span className="text-xs text-text-secondary">{tournStatus!.message}</span>
|
||||
</div>
|
||||
|
||||
{tournStatus!.phase === 'error' && tournStatus!.error && (
|
||||
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
|
||||
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
|
||||
<span className="text-xs text-accent-red">{tournStatus!.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartTournament}
|
||||
disabled={startingTourn || isTraining || isPpo}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent-amber/15 text-accent-amber text-sm font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{startingTourn ? <Spinner size="sm" /> : <Trophy className="w-4 h-4" />}
|
||||
Start Tournament
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Tournament results */}
|
||||
{(tournStatus?.result?.players?.length ?? 0) > 0 && (
|
||||
<div className="pt-2 border-t border-border-primary space-y-2">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
|
||||
<Trophy className="w-3.5 h-3.5 text-accent-amber" />
|
||||
Leaderboard
|
||||
</h4>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-primary">
|
||||
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium w-8">#</th>
|
||||
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium">Policy</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Wins</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Games</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Win Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-primary">
|
||||
{tournStatus!.result!.players
|
||||
.sort((a, b) => b.win_rate - a.win_rate)
|
||||
.map((p, i) => (
|
||||
<tr key={p.name} className="hover:bg-bg-hover transition-colors">
|
||||
<td className="px-3 py-2 text-text-tertiary text-xs">
|
||||
{i === 0 ? <Trophy className="w-3.5 h-3.5 text-accent-amber" /> : i + 1}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="text-text-primary font-medium capitalize">{p.name}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-text-secondary">{p.wins}</td>
|
||||
<td className="px-3 py-2 text-right text-text-secondary">{p.games}</td>
|
||||
<td className={'px-3 py-2 text-right font-semibold ' + (i === 0 ? 'text-accent-green' : 'text-accent-blue')}>
|
||||
{(p.win_rate * 100).toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Commander AI Lab — TypeScript Type Definitions
|
||||
// Derived from the FastAPI backend's Pydantic models + SQLite schema
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// ── Cards & Collection ──────────────────────────────────────
|
||||
|
||||
export interface CollectionCard {
|
||||
id: number
|
||||
name: string
|
||||
scryfall_id: string
|
||||
set_code: string
|
||||
set_name: string
|
||||
collector_number: string
|
||||
rarity: string
|
||||
type_line: string
|
||||
mana_cost: string
|
||||
cmc: number
|
||||
oracle_text: string
|
||||
color_identity: string[]
|
||||
quantity: number
|
||||
foil_quantity: number
|
||||
condition: string
|
||||
tcg_price: number | null
|
||||
foil_price: number | null
|
||||
image_url: string
|
||||
edhrec_rank: number | null
|
||||
keywords: string
|
||||
added_date: string
|
||||
updated_date: string
|
||||
}
|
||||
|
||||
export interface CollectionFilters {
|
||||
q?: string
|
||||
type_line?: string
|
||||
color?: string
|
||||
rarity?: string
|
||||
set_code?: string
|
||||
cmc_min?: number
|
||||
cmc_max?: number
|
||||
price_min?: number
|
||||
price_max?: number
|
||||
owned_only?: boolean
|
||||
sort_by?: string
|
||||
sort_dir?: 'asc' | 'desc'
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface CollectionStats {
|
||||
total_cards: number
|
||||
total_unique: number
|
||||
total_value: number
|
||||
sets_count: number
|
||||
}
|
||||
|
||||
export interface SetInfo {
|
||||
code: string
|
||||
name: string
|
||||
card_count?: number
|
||||
}
|
||||
|
||||
// ── Scan ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ScanResult {
|
||||
matches: ScanMatch[]
|
||||
image_url?: string
|
||||
}
|
||||
|
||||
export interface ScanMatch {
|
||||
name: string
|
||||
set_code: string
|
||||
set_name: string
|
||||
collector_number: string
|
||||
scryfall_id: string
|
||||
confidence: number
|
||||
image_url: string
|
||||
type_line: string
|
||||
oracle_text: string
|
||||
mana_cost: string
|
||||
tcg_price: number | null
|
||||
}
|
||||
|
||||
// ── Decks ────────────────────────────────────────────────────
|
||||
|
||||
export interface Deck {
|
||||
id: number
|
||||
name: string
|
||||
commander: string
|
||||
commander_scryfall_id?: string
|
||||
commander_image_url?: string
|
||||
color_identity: string[]
|
||||
format: string
|
||||
card_count: number
|
||||
total_price: number
|
||||
created_date: string
|
||||
updated_date: string
|
||||
}
|
||||
|
||||
export interface DeckCard {
|
||||
id: number
|
||||
deck_id: number
|
||||
name: string
|
||||
scryfall_id: string
|
||||
quantity: number
|
||||
is_commander: boolean
|
||||
category: string
|
||||
type_line: string
|
||||
mana_cost: string
|
||||
cmc: number
|
||||
oracle_text: string
|
||||
tcg_price: number | null
|
||||
image_url: string
|
||||
owned_qty: number
|
||||
set_code: string
|
||||
}
|
||||
|
||||
export interface DeckAnalysis {
|
||||
card_count: number
|
||||
land_count: number
|
||||
creature_count: number
|
||||
noncreature_count: number
|
||||
avg_cmc: number
|
||||
mana_curve: Record<number, number>
|
||||
color_distribution: Record<string, number>
|
||||
type_distribution: Record<string, number>
|
||||
total_price: number
|
||||
owned_count: number
|
||||
missing_count: number
|
||||
}
|
||||
|
||||
export interface DeckRecommendation {
|
||||
name: string
|
||||
scryfall_id: string
|
||||
type_line: string
|
||||
mana_cost: string
|
||||
oracle_text: string
|
||||
image_url: string
|
||||
tcg_price: number | null
|
||||
reason: string
|
||||
score: number
|
||||
owned: boolean
|
||||
}
|
||||
|
||||
// ── EDHREC Recommendations ──────────────────────────────────
|
||||
|
||||
export interface EdhRecCard {
|
||||
name: string
|
||||
type_line: string
|
||||
role: string
|
||||
roles: string[]
|
||||
inclusion_pct: number | null
|
||||
synergy_score: number | null
|
||||
owned: boolean
|
||||
owned_qty: number
|
||||
scryfall_id: string
|
||||
image_url: string | null
|
||||
}
|
||||
|
||||
export interface EdhRecsResponse {
|
||||
commander: string
|
||||
source: string
|
||||
total: number
|
||||
recommendations: EdhRecCard[]
|
||||
}
|
||||
|
||||
// ── Collection Recommendations ──────────────────────────────
|
||||
|
||||
export interface CollectionRecCard {
|
||||
id: number
|
||||
scryfall_id: string
|
||||
name: string
|
||||
type_line: string
|
||||
card_type: string
|
||||
cmc: number
|
||||
color_identity: string[]
|
||||
owned_qty: number
|
||||
roles: string[]
|
||||
score: number
|
||||
image_url: string | null
|
||||
}
|
||||
|
||||
export interface CollectionRecsResponse {
|
||||
shortfall_types: string[]
|
||||
role_filter: string[]
|
||||
grouped: Record<string, CollectionRecCard[]>
|
||||
total: number
|
||||
}
|
||||
|
||||
// ── V3 Deck Generator ────────────────────────────────────────
|
||||
|
||||
export interface DeckGenV3Request {
|
||||
commander_name: string
|
||||
strategy?: string
|
||||
target_bracket: number
|
||||
budget_usd?: number
|
||||
budget_mode?: 'total' | 'per_card'
|
||||
omit_cards?: string[]
|
||||
use_collection?: boolean
|
||||
run_substitution?: boolean
|
||||
model?: string
|
||||
deck_name?: string
|
||||
}
|
||||
|
||||
export interface DeckGenV3Status {
|
||||
initialized: boolean
|
||||
pplx_configured: boolean
|
||||
model: string | null
|
||||
embeddings_loaded: boolean
|
||||
embedding_cards: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface DeckGenV3Card {
|
||||
name: string
|
||||
quantity: number
|
||||
category: string
|
||||
type_line: string
|
||||
mana_cost: string
|
||||
cmc: number
|
||||
role: string
|
||||
reason: string
|
||||
tcg_price: number | null
|
||||
image_url: string
|
||||
owned: boolean
|
||||
owned_qty: number
|
||||
scryfall_id: string
|
||||
is_substitute: boolean
|
||||
original_name: string | null
|
||||
substitution_reason: string | null
|
||||
similarity_score: number | null
|
||||
}
|
||||
|
||||
export interface DeckGenV3Result {
|
||||
commander: {
|
||||
name: string
|
||||
image_url: string
|
||||
type_line: string
|
||||
color_identity: string[]
|
||||
mana_cost: string
|
||||
oracle_text: string
|
||||
}
|
||||
cards: DeckGenV3Card[]
|
||||
stats: DeckAnalysis
|
||||
strategy: string
|
||||
bracket: number
|
||||
model: string
|
||||
substitution?: {
|
||||
owned: number
|
||||
substituted: number
|
||||
missing: number
|
||||
}
|
||||
}
|
||||
|
||||
// ── Simulation ───────────────────────────────────────────────
|
||||
|
||||
export interface SimConfig {
|
||||
deck_names: string[]
|
||||
num_games: number
|
||||
threads: number
|
||||
use_deepseek?: boolean
|
||||
}
|
||||
|
||||
export interface SimStatus {
|
||||
running: boolean
|
||||
sim_id: string
|
||||
progress?: number
|
||||
games_completed?: number
|
||||
total_games?: number
|
||||
}
|
||||
|
||||
export interface SimResult {
|
||||
sim_id: string
|
||||
decks: SimDeckResult[]
|
||||
total_games: number
|
||||
duration_ms: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface SimDeckResult {
|
||||
name: string
|
||||
wins: number
|
||||
losses: number
|
||||
win_rate: number
|
||||
avg_turn: number
|
||||
}
|
||||
|
||||
// ── Batch Sim (Lab) ──────────────────────────────────────────
|
||||
|
||||
export interface LabDeck {
|
||||
name: string
|
||||
source: string
|
||||
path: string
|
||||
card_count: number
|
||||
}
|
||||
|
||||
export interface LabStartRequest {
|
||||
decks: string[]
|
||||
games: number
|
||||
threads?: number
|
||||
use_deepseek?: boolean
|
||||
deepseek_deck?: string
|
||||
}
|
||||
|
||||
export interface LabStatus {
|
||||
running: boolean
|
||||
run_id: string
|
||||
games_completed: number
|
||||
total_games: number
|
||||
current_decks: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface LabResult {
|
||||
run_id: string
|
||||
decks: LabDeckResult[]
|
||||
total_games: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface LabDeckResult {
|
||||
name: string
|
||||
wins: number
|
||||
games: number
|
||||
win_rate: number
|
||||
avg_turns: number
|
||||
avg_life_remaining: number
|
||||
}
|
||||
|
||||
export interface LabHistoryEntry {
|
||||
run_id: string
|
||||
timestamp: string
|
||||
decks: string[]
|
||||
total_games: number
|
||||
}
|
||||
|
||||
export interface PreconDeck {
|
||||
name: string
|
||||
filename: string
|
||||
release_date?: string
|
||||
set_name?: string
|
||||
installed: boolean
|
||||
}
|
||||
|
||||
// ── Coach ────────────────────────────────────────────────────
|
||||
|
||||
export interface CoachStatus {
|
||||
llmConnected: boolean
|
||||
llmModel: string | null
|
||||
llmModels: string[]
|
||||
embeddingsLoaded: boolean
|
||||
embeddingCards: number
|
||||
deckReportsAvailable: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface CoachDeck {
|
||||
deck_id: number
|
||||
deck_name: string
|
||||
commander: string
|
||||
card_count: number
|
||||
has_report: boolean
|
||||
report_count: number
|
||||
last_report_date: string | null
|
||||
}
|
||||
|
||||
export interface CoachGoals {
|
||||
targetPowerLevel?: number | null
|
||||
metaFocus?: string | null
|
||||
budget?: string | null
|
||||
focusAreas?: string[]
|
||||
}
|
||||
|
||||
export interface CoachMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface CoachChatResponse {
|
||||
content: string
|
||||
model: string
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
}
|
||||
|
||||
export interface CoachSession {
|
||||
sessionId: string
|
||||
deckId: string
|
||||
timestamp: string
|
||||
summary: string
|
||||
suggestedCuts: CoachSuggestedCut[]
|
||||
suggestedAdds: CoachSuggestedAdd[]
|
||||
heuristicHints: string[]
|
||||
manaBaseAdvice: string | null
|
||||
rawTextExplanation: string
|
||||
modelUsed: string
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
goals: CoachGoals | null
|
||||
}
|
||||
|
||||
export interface CoachSuggestedCut {
|
||||
cardName: string
|
||||
reason: string
|
||||
replacementOptions: string[]
|
||||
currentImpactScore: number
|
||||
}
|
||||
|
||||
export interface CoachSuggestedAdd {
|
||||
cardName: string
|
||||
role: string
|
||||
reason: string
|
||||
synergyWith: string[]
|
||||
estimatedManaValue: number | null
|
||||
}
|
||||
|
||||
export interface CoachSessionSummary {
|
||||
sessionId: string
|
||||
deckId: string
|
||||
timestamp: string
|
||||
summary: string
|
||||
cutsCount: number
|
||||
addsCount: number
|
||||
}
|
||||
|
||||
export interface CoachApplyResult {
|
||||
cuts: { name: string; status: string }[]
|
||||
adds: { name: string; scryfall_id?: string; status: string }[]
|
||||
errors: { name: string; error: string }[]
|
||||
total_cuts: number
|
||||
total_adds: number
|
||||
}
|
||||
|
||||
export interface CoachCardLikeResult {
|
||||
name: string
|
||||
similarity: number
|
||||
types: string
|
||||
mana_value: number
|
||||
mana_cost: string
|
||||
text: string
|
||||
owned_qty: number
|
||||
image_url: string | null
|
||||
tcg_price: number | null
|
||||
}
|
||||
|
||||
// ── ML Training ──────────────────────────────────────────────
|
||||
|
||||
/** GET /api/ml/status */
|
||||
export interface MLStatus {
|
||||
ml_logging_enabled: boolean
|
||||
training_files: { file: string; decisions: number; size_kb: number }[]
|
||||
total_decisions: number
|
||||
total_files: number
|
||||
}
|
||||
|
||||
/** GET /api/ml/data/status */
|
||||
export interface MLDataStatus {
|
||||
decisionFiles: { name: string; size: number; decisions: number }[]
|
||||
totalDecisions: number
|
||||
datasets: Record<string, { samples: number; features: number; size: number }>
|
||||
checkpoints: { name: string; size: number; modified: string }[]
|
||||
evalResults: Record<string, unknown> | null
|
||||
policyLoaded: boolean
|
||||
}
|
||||
|
||||
/** GET /api/ml/train/status */
|
||||
export interface MLTrainStatus {
|
||||
running: boolean
|
||||
progress: number
|
||||
total_epochs: number
|
||||
current_epoch: number
|
||||
phase: 'idle' | 'starting' | 'building' | 'training' | 'evaluating' | 'done' | 'error'
|
||||
message: string
|
||||
metrics: Record<string, unknown> | null
|
||||
result: {
|
||||
training: Record<string, unknown>
|
||||
evaluation: Record<string, unknown> | null
|
||||
checkpoint: string
|
||||
device: string
|
||||
} | null
|
||||
error: string | null
|
||||
started_at: string | null
|
||||
}
|
||||
|
||||
/** GET /api/ml/train/ppo/status */
|
||||
export interface PPOTrainStatus {
|
||||
running: boolean
|
||||
iteration: number
|
||||
total_iterations: number
|
||||
phase: 'idle' | 'starting' | 'training' | 'done' | 'error'
|
||||
message: string
|
||||
metrics: {
|
||||
avg_reward?: number
|
||||
win_rate?: number
|
||||
policy_loss?: number
|
||||
value_loss?: number
|
||||
entropy?: number
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
result: Record<string, unknown> | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** GET /api/ml/tournament/status */
|
||||
export interface TournamentStatus {
|
||||
running: boolean
|
||||
phase: 'idle' | 'starting' | 'running' | 'done' | 'error'
|
||||
message: string
|
||||
result: TournamentResults | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface TournamentResults {
|
||||
players: TournamentPlayer[]
|
||||
total_matches: number
|
||||
matchups?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TournamentPlayer {
|
||||
name: string
|
||||
wins: number
|
||||
games: number
|
||||
win_rate: number
|
||||
}
|
||||
|
||||
/** GET /api/ml/model */
|
||||
export interface MLModelInfo {
|
||||
loaded: boolean
|
||||
device?: string
|
||||
input_dim?: number
|
||||
num_actions?: number
|
||||
checkpoint_path?: string
|
||||
error?: string
|
||||
torch_available?: boolean
|
||||
}
|
||||
|
||||
// ── DeepSeek ─────────────────────────────────────────────────
|
||||
|
||||
export interface DeepSeekStatus {
|
||||
connected: boolean
|
||||
model: string | null
|
||||
base_url: string
|
||||
}
|
||||
|
||||
// ── Commander Search ─────────────────────────────────────────
|
||||
|
||||
export interface CommanderSearchResult {
|
||||
name: string
|
||||
type_line: string
|
||||
color_identity: string[]
|
||||
mana_cost: string
|
||||
image_url: string
|
||||
oracle_text: string
|
||||
scryfall_id: string
|
||||
in_collection: boolean
|
||||
}
|
||||
|
||||
// ── Perplexity ───────────────────────────────────────────────
|
||||
|
||||
export interface PplxStatus {
|
||||
configured: boolean
|
||||
key_prefix: string
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
recharts: ['recharts'],
|
||||
react: ['react', 'react-dom', 'react-router-dom'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
+1944
-163
File diff suppressed because it is too large.
Load diff
+8519
-227
File diff suppressed because it is too large.
Load diff
@@ -240,12 +240,12 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
|
||||
dist[action.value] = count
|
||||
|
||||
total = len(labels)
|
||||
logger.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
|
||||
logger.info("─" * 43)
|
||||
print(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
|
||||
print("─" * 43)
|
||||
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
|
||||
pct = (count / total * 100) if total > 0 else 0
|
||||
logger.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
|
||||
logger.info(f"{'─' * 43}")
|
||||
logger.info(f" {'TOTAL':<23} {total:>8}")
|
||||
print(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
|
||||
print(f"{'─' * 43}")
|
||||
print(f" {'TOTAL':<23} {total:>8}")
|
||||
|
||||
return dist
|
||||
+1
-93
@@ -155,25 +155,15 @@ class StateDimensions:
|
||||
2 players = 28 global features
|
||||
+ turn_number = 1
|
||||
Total global = 29
|
||||
|
||||
Zone embedding boundary (architectural decision — Phase 5 follow-up):
|
||||
Zone dims are **always** 768 per zone slot, zero-padded when a zone
|
||||
is empty (no cards). The fixed 6144-dim zone block is the contract
|
||||
between ForgeEpisodeGenerator (upstream) and the policy network
|
||||
(downstream). All downstream consumers (PolicyNetwork, PPOTrainer,
|
||||
dataset builder) depend on this shape. If a future change adds
|
||||
attention pooling or variable-length zone representations, the
|
||||
boundary must be updated here *and* in StateEncoder._encode_zones.
|
||||
"""
|
||||
card_embedding_dim: int = 768 # From mtg-embeddings
|
||||
zone_pool_dim: int = 768 # Mean-pooled per zone (zero-vec when empty)
|
||||
zone_pool_dim: int = 768 # Mean-pooled per zone
|
||||
|
||||
# Zones per player: hand, battlefield, graveyard, command_zone
|
||||
zones_per_player: int = 4
|
||||
num_players: int = 2 # 1v1
|
||||
|
||||
# Zone vectors: 4 zones × 2 players × 768 = 6144
|
||||
# This is a fixed-size block; empty zones produce zero vectors.
|
||||
total_zone_dim: int = 4 * 2 * 768 # 6144
|
||||
|
||||
# Global scalar features
|
||||
@@ -266,85 +256,3 @@ class TrainingConfig:
|
||||
log_every: int = 100 # Log every N batches
|
||||
|
||||
TRAINING_CONFIG = TrainingConfig()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Distillation Loop Defaults (Phase 4)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
@dataclass
|
||||
class DistillationDefaults:
|
||||
"""Default hyperparameters for the closed-loop distillation pipeline.
|
||||
|
||||
These are consumed by ml.training.distillation_loop.DistillationConfig
|
||||
when no explicit override is provided.
|
||||
|
||||
Default weights: Forge-only (forge_weight=1.0, ppo_weight=0.0).
|
||||
The baseline trains exclusively on Forge simulation data. Use
|
||||
``MIXED_MODE_PRESETS`` below for blended Forge+PPO configurations.
|
||||
"""
|
||||
max_iterations: int = 10
|
||||
convergence_window: int = 3
|
||||
convergence_threshold: float = 0.01
|
||||
|
||||
# PPO self-play per generation
|
||||
ppo_iterations: int = 50
|
||||
ppo_episodes_per_iter: int = 64
|
||||
ppo_lr: float = 3e-4
|
||||
ppo_clip_epsilon: float = 0.2
|
||||
ppo_entropy_coeff: float = 0.01
|
||||
ppo_eval_episodes: int = 100
|
||||
|
||||
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
|
||||
forge_weight: float = 1.0
|
||||
ppo_weight: float = 0.0
|
||||
min_reward_threshold: float = 0.0
|
||||
|
||||
# Quality gate thresholds
|
||||
min_forge_accuracy: float = 0.35
|
||||
max_accuracy_drop: float = 0.03
|
||||
min_ppo_win_rate: float = 0.30
|
||||
|
||||
# Retry behaviour on gate failure
|
||||
max_retries_per_generation: int = 2
|
||||
retry_lr_factor: float = 0.5
|
||||
retry_entropy_boost: float = 0.005
|
||||
|
||||
# Output paths
|
||||
results_dir: str = "data/results"
|
||||
history_dir: str = "data/results/distillation-history"
|
||||
|
||||
|
||||
DISTILLATION_DEFAULTS = DistillationDefaults()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Mixed-Mode Presets (Phase 5 follow-up)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
# Pre-defined Forge/PPO weight combinations for common scenarios.
|
||||
# Select a preset by name when starting a distillation run to
|
||||
# override the Forge-only defaults without specifying raw weights.
|
||||
#
|
||||
# "forge_only" — baseline: 100% Forge data, ignore PPO
|
||||
# "forge_90_10" — light PPO blending: 90% Forge, 10% PPO
|
||||
# "forge_80_20" — heavier PPO blending: 80% Forge, 20% PPO
|
||||
MIXED_MODE_PRESETS: Dict[str, Dict[str, float]] = {
|
||||
"forge_only": {"forge_weight": 1.0, "ppo_weight": 0.0},
|
||||
"forge_90_10": {"forge_weight": 1.0, "ppo_weight": 0.11}, # ~10% PPO
|
||||
"forge_80_20": {"forge_weight": 1.0, "ppo_weight": 0.25}, # ~20% PPO
|
||||
}
|
||||
|
||||
|
||||
def get_preset_weights(preset_name: str) -> Dict[str, float]:
|
||||
"""Look up a mixed-mode preset by name.
|
||||
|
||||
Returns a dict with ``forge_weight`` and ``ppo_weight`` keys.
|
||||
Raises ``KeyError`` if the preset name is unknown.
|
||||
"""
|
||||
if preset_name not in MIXED_MODE_PRESETS:
|
||||
raise KeyError(
|
||||
f"Unknown preset '{preset_name}'. "
|
||||
f"Available: {list(MIXED_MODE_PRESETS.keys())}"
|
||||
)
|
||||
return dict(MIXED_MODE_PRESETS[preset_name])
|
||||
+61
-300
@@ -1,17 +1,9 @@
|
||||
"""
|
||||
Commander AI Lab — Training Dataset Builder
|
||||
════════════════════════════════════════════
|
||||
|
||||
Reads ML decision JSONL files, encodes states, labels actions,
|
||||
and produces a training-ready dataset saved as NPZ.
|
||||
|
||||
Phase 2: Source Awareness (Issue #66)
|
||||
- Scans both Forge sim files (ml-decisions-sim-*.jsonl) and
|
||||
PPO self-play files (ml-decisions-ppo-*.jsonl)
|
||||
- Configurable source_weights for sampling ratios
|
||||
- min_reward_threshold filter for PPO data quality
|
||||
- Each sample tagged with source for downstream analysis
|
||||
|
||||
Usage:
|
||||
python -m ml.data.dataset_builder --results-dir results/ --output ml/models/dataset.npz
|
||||
|
||||
@@ -21,7 +13,6 @@ Output NPZ structure:
|
||||
game_ids: object (N,) — game ID for each sample
|
||||
outcomes: object (N,) — game outcome per sample
|
||||
playstyles: object (N,) — deck archetype per sample
|
||||
sources: object (N,) — data source ("forge" or "ppo")
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -29,9 +20,8 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -50,167 +40,22 @@ logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
|
||||
logger = logging.getLogger("ml.dataset")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration (Phase 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class DatasetConfig:
|
||||
"""Configuration for dataset building with source awareness."""
|
||||
|
||||
results_dir: str = "results"
|
||||
embeddings_dir: Optional[str] = None
|
||||
max_samples: Optional[int] = None
|
||||
|
||||
# Source weights control sampling ratios between Forge and PPO data.
|
||||
# Default: Forge-only (ppo=0.0). Use MIXED_MODE_PRESETS from
|
||||
# ml.config.scope for blended configurations (e.g. 90/10, 80/20).
|
||||
source_weights: Dict[str, float] = field(
|
||||
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
|
||||
)
|
||||
|
||||
# Minimum reward threshold for PPO data — only include decisions
|
||||
# from games where the episode return exceeds this value.
|
||||
# Set to None to disable filtering.
|
||||
min_reward_threshold: Optional[float] = 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-aware file discovery (Phase 2 — Task 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
|
||||
"""
|
||||
Scan results directory for both Forge sim and PPO decision files.
|
||||
|
||||
Returns:
|
||||
Dict mapping source name to list of JSONL file paths.
|
||||
Keys: "forge" for ml-decisions-sim-*.jsonl
|
||||
"ppo" for ml-decisions-ppo-*.jsonl
|
||||
"""
|
||||
results_path = Path(results_dir)
|
||||
sources = {}
|
||||
|
||||
# Forge batch sim files
|
||||
forge_files = sorted(results_path.glob("ml-decisions-sim-*.jsonl"))
|
||||
if forge_files:
|
||||
sources["forge"] = forge_files
|
||||
|
||||
# PPO self-play files (from Phase 1 DecisionExporter)
|
||||
ppo_files = sorted(results_path.glob("ml-decisions-ppo-*.jsonl"))
|
||||
if ppo_files:
|
||||
sources["ppo"] = ppo_files
|
||||
|
||||
# Also pick up legacy files that don't have sim/ppo prefix
|
||||
legacy_files = sorted(
|
||||
f for f in results_path.glob("ml-decisions-*.jsonl")
|
||||
if f not in set(forge_files + ppo_files)
|
||||
)
|
||||
if legacy_files:
|
||||
sources.setdefault("forge", []).extend(legacy_files)
|
||||
|
||||
return sources
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PPO reward filter (Phase 2 — Task 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _passes_reward_filter(
|
||||
decision: dict,
|
||||
source: str,
|
||||
min_reward_threshold: Optional[float],
|
||||
) -> bool:
|
||||
"""
|
||||
Filter PPO decisions by reward threshold.
|
||||
Forge data always passes. PPO data must meet the threshold.
|
||||
"""
|
||||
if source != "ppo":
|
||||
return True
|
||||
if min_reward_threshold is None:
|
||||
return True
|
||||
episode_return = decision.get("episode_return", 0.0)
|
||||
return episode_return >= min_reward_threshold
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-aware sampling (Phase 2 — Task 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _apply_source_weights(
|
||||
samples_by_source: Dict[str, List[dict]],
|
||||
source_weights: Dict[str, float],
|
||||
rng: np.random.Generator,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Apply source weights to control the mixing ratio.
|
||||
|
||||
A weight of 1.0 keeps all samples from that source.
|
||||
A weight of 0.5 randomly keeps ~50% of samples.
|
||||
A weight > 1.0 oversamples (with replacement).
|
||||
"""
|
||||
combined = []
|
||||
for source, samples in samples_by_source.items():
|
||||
weight = source_weights.get(source, 1.0)
|
||||
if weight <= 0:
|
||||
logger.info(" Skipping %d %s samples (weight=0)", len(samples), source)
|
||||
continue
|
||||
if weight >= 1.0 and weight == 1.0:
|
||||
combined.extend(samples)
|
||||
elif weight < 1.0:
|
||||
# Subsample
|
||||
n_keep = max(1, int(len(samples) * weight))
|
||||
indices = rng.choice(len(samples), size=n_keep, replace=False)
|
||||
combined.extend(samples[i] for i in indices)
|
||||
logger.info(
|
||||
" Subsampled %s: %d → %d (weight=%.2f)",
|
||||
source, len(samples), n_keep, weight,
|
||||
)
|
||||
else:
|
||||
# Oversample (weight > 1.0)
|
||||
n_target = int(len(samples) * weight)
|
||||
indices = rng.choice(len(samples), size=n_target, replace=True)
|
||||
combined.extend(samples[i] for i in indices)
|
||||
logger.info(
|
||||
" Oversampled %s: %d → %d (weight=%.2f)",
|
||||
source, len(samples), n_target, weight,
|
||||
)
|
||||
return combined
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main dataset builder (updated for Phase 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_dataset(
|
||||
results_dir: str,
|
||||
embeddings_dir: str = None,
|
||||
max_samples: int = None,
|
||||
source_weights: Dict[str, float] = None,
|
||||
min_reward_threshold: float = 0.0,
|
||||
) -> Dict[str, np.ndarray]:
|
||||
"""
|
||||
Build complete training dataset from all ML decision files.
|
||||
|
||||
Phase 2 additions:
|
||||
- Scans both Forge and PPO JSONL files
|
||||
- Applies source_weights for configurable mixing
|
||||
- Filters PPO data by min_reward_threshold
|
||||
- Tags each sample with its source
|
||||
|
||||
Args:
|
||||
results_dir: Path to results/ directory containing JSONL files
|
||||
results_dir: Path to results/ directory containing ml-decisions-*.jsonl
|
||||
embeddings_dir: Path to embeddings/ directory (default: auto-detect)
|
||||
max_samples: Cap total samples (None = unlimited)
|
||||
source_weights: Dict of source → sampling weight (default: forge=1.0, ppo=0.5)
|
||||
min_reward_threshold: Min episode_return for PPO data (default: 0.0)
|
||||
|
||||
Returns:
|
||||
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
|
||||
Dict with keys: states, labels, game_ids, outcomes, playstyles
|
||||
"""
|
||||
if source_weights is None:
|
||||
source_weights = {"forge": 1.0, "ppo": 0.0}
|
||||
|
||||
# Load card embeddings
|
||||
card_index = CardEmbeddingIndex(embeddings_dir)
|
||||
if not card_index.load():
|
||||
@@ -225,9 +70,10 @@ def build_dataset(
|
||||
|
||||
encoder = StateEncoder(card_index)
|
||||
|
||||
# --- Phase 2 Task 1: Discover files from both sources ---
|
||||
source_files = _discover_jsonl_files(results_dir)
|
||||
if not source_files:
|
||||
# Find all JSONL files
|
||||
results_path = Path(results_dir)
|
||||
jsonl_files = sorted(results_path.glob("ml-decisions-*.jsonl"))
|
||||
if not jsonl_files:
|
||||
logger.error("No ML decision files found in %s", results_dir)
|
||||
logger.error("Run batch simulations with --ml-log or enable ML logging in the UI first.")
|
||||
raise RuntimeError(
|
||||
@@ -235,114 +81,60 @@ def build_dataset(
|
||||
"Run batch simulations with ML logging enabled first."
|
||||
)
|
||||
|
||||
total_files = sum(len(files) for files in source_files.values())
|
||||
logger.info(
|
||||
"Found %d ML decision files across %d sources: %s",
|
||||
total_files,
|
||||
len(source_files),
|
||||
{src: len(files) for src, files in source_files.items()},
|
||||
)
|
||||
logger.info("Found %d ML decision files", len(jsonl_files))
|
||||
|
||||
# --- Read and filter all decisions, grouped by source ---
|
||||
rng = np.random.default_rng(42)
|
||||
samples_by_source: Dict[str, List[dict]] = {}
|
||||
total_skipped = 0
|
||||
total_filtered = 0
|
||||
|
||||
for source, jsonl_files in source_files.items():
|
||||
source_samples = []
|
||||
|
||||
for jsonl_file in jsonl_files:
|
||||
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
|
||||
file_count = 0
|
||||
|
||||
with open(jsonl_file, "r") as f:
|
||||
for line_num, line in enumerate(f):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
decision = json.loads(line)
|
||||
|
||||
# --- Phase 2 Task 3: PPO reward filter ---
|
||||
if not _passes_reward_filter(
|
||||
decision, source, min_reward_threshold
|
||||
):
|
||||
total_filtered += 1
|
||||
continue
|
||||
|
||||
# --- Phase 2 Task 4: Tag with source ---
|
||||
decision["_source"] = source
|
||||
|
||||
source_samples.append(decision)
|
||||
file_count += 1
|
||||
|
||||
except Exception as e:
|
||||
total_skipped += 1
|
||||
if total_skipped <= 10:
|
||||
logger.warning(
|
||||
"Skipping %s line %d: %s", jsonl_file.name, line_num, e
|
||||
)
|
||||
|
||||
logger.info(" → %d samples from %s", file_count, jsonl_file.name)
|
||||
|
||||
if source_samples:
|
||||
samples_by_source[source] = source_samples
|
||||
|
||||
if not samples_by_source:
|
||||
logger.error("No valid samples found.")
|
||||
return {}
|
||||
|
||||
# Log per-source counts before weighting
|
||||
for src, samples in samples_by_source.items():
|
||||
logger.info("Source '%s': %d raw samples", src, len(samples))
|
||||
if total_filtered > 0:
|
||||
logger.info("PPO reward filter removed %d samples (threshold=%.2f)",
|
||||
total_filtered, min_reward_threshold or 0.0)
|
||||
|
||||
# --- Phase 2 Task 2: Apply source weights ---
|
||||
combined_decisions = _apply_source_weights(
|
||||
samples_by_source, source_weights, rng
|
||||
)
|
||||
|
||||
# Apply max_samples cap after weighting
|
||||
if max_samples and len(combined_decisions) > max_samples:
|
||||
rng.shuffle(combined_decisions)
|
||||
combined_decisions = combined_decisions[:max_samples]
|
||||
logger.info("Capped to %d samples", max_samples)
|
||||
|
||||
# --- Encode all decisions ---
|
||||
all_states = []
|
||||
all_labels = []
|
||||
all_game_ids = []
|
||||
all_outcomes = []
|
||||
all_playstyles = []
|
||||
all_sources = []
|
||||
encode_errors = 0
|
||||
total_skipped = 0
|
||||
|
||||
for decision in combined_decisions:
|
||||
try:
|
||||
playstyle = decision.get("archetype", "midrange")
|
||||
state_vec = encoder.encode(decision, playstyle)
|
||||
action_idx = label_action(decision)
|
||||
game_id = decision.get("game_id", "unknown")
|
||||
outcome = decision.get("game_outcome", "unknown")
|
||||
source = decision.get("_source", "forge")
|
||||
for jsonl_file in jsonl_files:
|
||||
logger.info("Processing %s ...", jsonl_file.name)
|
||||
file_count = 0
|
||||
|
||||
all_states.append(state_vec)
|
||||
all_labels.append(action_idx)
|
||||
all_game_ids.append(game_id)
|
||||
all_outcomes.append(outcome)
|
||||
all_playstyles.append(playstyle)
|
||||
all_sources.append(source)
|
||||
with open(jsonl_file, "r") as f:
|
||||
for line_num, line in enumerate(f):
|
||||
if max_samples and len(all_states) >= max_samples:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
encode_errors += 1
|
||||
if encode_errors <= 5:
|
||||
logger.warning("Encode error: %s", e)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
decision = json.loads(line)
|
||||
|
||||
# Encode state
|
||||
playstyle = decision.get("archetype", "midrange")
|
||||
state_vec = encoder.encode(decision, playstyle)
|
||||
|
||||
# Label action
|
||||
action_idx = label_action(decision)
|
||||
|
||||
# Metadata
|
||||
game_id = decision.get("game_id", f"unknown-{line_num}")
|
||||
outcome = decision.get("game_outcome", "unknown")
|
||||
|
||||
all_states.append(state_vec)
|
||||
all_labels.append(action_idx)
|
||||
all_game_ids.append(game_id)
|
||||
all_outcomes.append(outcome)
|
||||
all_playstyles.append(playstyle)
|
||||
file_count += 1
|
||||
|
||||
except Exception as e:
|
||||
total_skipped += 1
|
||||
if total_skipped <= 10:
|
||||
logger.warning(
|
||||
"Skipping %s line %d: %s", jsonl_file.name, line_num, e
|
||||
)
|
||||
|
||||
logger.info(" → %d samples from %s", file_count, jsonl_file.name)
|
||||
|
||||
if not all_states:
|
||||
logger.error("No valid samples after encoding.")
|
||||
logger.error("No valid samples found.")
|
||||
return {}
|
||||
|
||||
# Convert to arrays
|
||||
@@ -351,11 +143,10 @@ def build_dataset(
|
||||
game_ids = np.array(all_game_ids, dtype=object)
|
||||
outcomes = np.array(all_outcomes, dtype=object)
|
||||
playstyles = np.array(all_playstyles, dtype=object)
|
||||
sources = np.array(all_sources, dtype=object)
|
||||
|
||||
logger.info(
|
||||
"Dataset built: %d samples, %d features, %d actions, %d skipped, %d encode errors",
|
||||
len(states), states.shape[1], NUM_ACTIONS, total_skipped, encode_errors,
|
||||
"Dataset built: %d samples, %d features, %d actions, %d skipped",
|
||||
len(states), states.shape[1], NUM_ACTIONS, total_skipped,
|
||||
)
|
||||
|
||||
# Print label distribution
|
||||
@@ -363,25 +154,17 @@ def build_dataset(
|
||||
|
||||
# Print game outcome distribution
|
||||
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
|
||||
logger.info(f"\n{'Outcome':<25} {'Count':>8}")
|
||||
logger.info("─" * 35)
|
||||
print(f"\n{'Outcome':<25} {'Count':>8}")
|
||||
print("─" * 35)
|
||||
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
|
||||
logger.info(f" {str(o):<23} {c:>8}")
|
||||
print(f" {str(o):<23} {c:>8}")
|
||||
|
||||
# Print playstyle distribution
|
||||
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
|
||||
logger.info(f"\n{'Playstyle':<25} {'Count':>8}")
|
||||
logger.info("─" * 35)
|
||||
print(f"\n{'Playstyle':<25} {'Count':>8}")
|
||||
print("─" * 35)
|
||||
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
|
||||
logger.info(f" {str(s):<23} {c:>8}")
|
||||
|
||||
# --- Phase 2 Task 4: Print source distribution ---
|
||||
unique_sources, source_counts = np.unique(sources, return_counts=True)
|
||||
logger.info(f"\n{'Source':<25} {'Count':>8} {'%':>7}")
|
||||
logger.info("─" * 42)
|
||||
for s, c in sorted(zip(unique_sources, source_counts), key=lambda x: -x[1]):
|
||||
pct = 100.0 * c / len(sources)
|
||||
logger.info(f" {str(s):<23} {c:>8} {pct:>6.1f}%")
|
||||
print(f" {str(s):<23} {c:>8}")
|
||||
|
||||
return {
|
||||
"states": states,
|
||||
@@ -389,7 +172,6 @@ def build_dataset(
|
||||
"game_ids": game_ids,
|
||||
"outcomes": outcomes,
|
||||
"playstyles": playstyles,
|
||||
"sources": sources,
|
||||
}
|
||||
|
||||
|
||||
@@ -458,6 +240,7 @@ def split_dataset(
|
||||
len(train["states"]), len(val["states"]), len(test["states"]),
|
||||
split_by_game,
|
||||
)
|
||||
|
||||
return train, val, test
|
||||
|
||||
|
||||
@@ -496,38 +279,16 @@ def main():
|
||||
"--no-split", action="store_true",
|
||||
help="Don't split into train/val/test (save single file)",
|
||||
)
|
||||
# Phase 2 CLI arguments
|
||||
parser.add_argument(
|
||||
"--forge-weight", type=float, default=1.0,
|
||||
help="Sampling weight for Forge sim data (default: 1.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ppo-weight", type=float, default=0.0,
|
||||
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-reward", type=float, default=0.0,
|
||||
help="Minimum episode_return for PPO data (default: 0.0)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
source_weights = {
|
||||
"forge": args.forge_weight,
|
||||
"ppo": args.ppo_weight,
|
||||
}
|
||||
|
||||
# Build dataset
|
||||
dataset = build_dataset(
|
||||
results_dir=args.results_dir,
|
||||
embeddings_dir=args.embeddings_dir,
|
||||
max_samples=args.max_samples,
|
||||
source_weights=source_weights,
|
||||
min_reward_threshold=args.min_reward,
|
||||
)
|
||||
|
||||
if not dataset:
|
||||
logger.info("\nNo data to save. Run simulations with --ml-log first.")
|
||||
print("\nNo data to save. Run simulations with --ml-log first.")
|
||||
sys.exit(1)
|
||||
|
||||
if args.no_split:
|
||||
@@ -539,7 +300,7 @@ def main():
|
||||
save_dataset(val, f"{base}-val.npz")
|
||||
save_dataset(test, f"{base}-test.npz")
|
||||
|
||||
logger.info("\nDataset build complete.")
|
||||
print("\nDataset build complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,587 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab — Quality Gate & Validation
|
||||
═════════════════════════════════════════════
|
||||
|
||||
Phase 3 (Issue #67): Prevents bad PPO data from degrading the
|
||||
supervised model by enforcing quality gates at multiple levels:
|
||||
|
||||
1. Forge Validation Holdout — fixed eval set from Forge-only data
|
||||
2. Accuracy Gate — reject PPO batch if model accuracy drops on Forge eval
|
||||
3. Win Rate Gate — only export PPO decisions above a win rate threshold
|
||||
4. Data Quality Metrics — entropy, reward distribution logging
|
||||
|
||||
Usage:
|
||||
from ml.data.quality_gate import QualityGate, QualityGateConfig
|
||||
|
||||
gate = QualityGate(QualityGateConfig(
|
||||
min_forge_accuracy=0.40,
|
||||
min_ppo_win_rate=0.30,
|
||||
))
|
||||
|
||||
# Check if PPO batch should be accepted
|
||||
verdict = gate.evaluate_ppo_batch(
|
||||
forge_eval_accuracy=0.42,
|
||||
ppo_win_rate=0.35,
|
||||
ppo_decisions=decisions_list,
|
||||
baseline_accuracy=0.41,
|
||||
)
|
||||
if verdict.accepted:
|
||||
# merge PPO data into training set
|
||||
...
|
||||
|
||||
Ref: docs/CLOSED-LOOP-DISTILLATION.md — Phase 3
|
||||
Issue: #67
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger("ml.quality_gate")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class QualityGateConfig:
|
||||
"""Configuration for the quality gate system."""
|
||||
|
||||
# --- Forge validation holdout (Task 1) ---
|
||||
forge_holdout_ratio: float = 0.12 # 12% of Forge data held out
|
||||
forge_holdout_seed: int = 99 # Fixed seed for reproducibility
|
||||
|
||||
# --- Accuracy gate (Task 2) ---
|
||||
# If model accuracy on Forge eval drops below this absolute floor,
|
||||
# the PPO batch is rejected regardless.
|
||||
min_forge_accuracy: float = 0.35
|
||||
|
||||
# If model accuracy drops by more than this relative amount compared
|
||||
# to the baseline (pre-PPO model), reject the batch.
|
||||
max_accuracy_drop: float = 0.03 # e.g., 41% → 38% = 3% drop → reject
|
||||
|
||||
# --- Win rate gate (Task 3) ---
|
||||
# PPO agent must exceed this win rate before its decisions are exported.
|
||||
# In a 4-player pod, random baseline is 25%.
|
||||
min_ppo_win_rate: float = 0.30
|
||||
|
||||
# --- Quality metrics (Task 4) ---
|
||||
# Directory to write quality reports
|
||||
reports_dir: str = "data/results/quality-reports"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verdict dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class GateVerdict:
|
||||
"""Result of a quality gate evaluation."""
|
||||
accepted: bool
|
||||
reason: str
|
||||
metrics: Dict = field(default_factory=dict)
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"accepted": self.accepted,
|
||||
"reason": self.reason,
|
||||
"metrics": self.metrics,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forge Validation Holdout (Task 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_forge_holdout(
|
||||
states: np.ndarray,
|
||||
labels: np.ndarray,
|
||||
sources: np.ndarray,
|
||||
game_ids: np.ndarray,
|
||||
holdout_ratio: float = 0.12,
|
||||
seed: int = 99,
|
||||
) -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray]]:
|
||||
"""
|
||||
Split Forge-only data into training and holdout validation sets.
|
||||
|
||||
The holdout set is a fixed eval set used to detect accuracy regression
|
||||
when PPO data is mixed in. Split is done by game_id to prevent leakage.
|
||||
|
||||
Args:
|
||||
states, labels, sources, game_ids: Dataset arrays
|
||||
holdout_ratio: Fraction of Forge games to hold out
|
||||
seed: Fixed random seed for reproducibility
|
||||
|
||||
Returns:
|
||||
(train_data, holdout_data) — each a dict with states, labels, sources, game_ids
|
||||
"""
|
||||
# Identify Forge-only samples
|
||||
forge_mask = np.array([s == "forge" for s in sources])
|
||||
ppo_mask = ~forge_mask
|
||||
|
||||
forge_game_ids = game_ids[forge_mask]
|
||||
unique_forge_games = np.unique(forge_game_ids)
|
||||
|
||||
# Deterministic split by game_id
|
||||
rng = np.random.default_rng(seed)
|
||||
rng.shuffle(unique_forge_games)
|
||||
|
||||
n_holdout = max(1, int(len(unique_forge_games) * holdout_ratio))
|
||||
holdout_games = set(unique_forge_games[:n_holdout])
|
||||
train_games = set(unique_forge_games[n_holdout:])
|
||||
|
||||
# Build masks
|
||||
forge_holdout_mask = np.array([
|
||||
(s == "forge" and gid in holdout_games)
|
||||
for s, gid in zip(sources, game_ids)
|
||||
])
|
||||
forge_train_mask = np.array([
|
||||
(s == "forge" and gid in train_games)
|
||||
for s, gid in zip(sources, game_ids)
|
||||
])
|
||||
|
||||
# Training set = Forge train + all PPO data
|
||||
train_mask = forge_train_mask | ppo_mask
|
||||
|
||||
def _subset(mask):
|
||||
return {
|
||||
"states": states[mask],
|
||||
"labels": labels[mask],
|
||||
"sources": sources[mask],
|
||||
"game_ids": game_ids[mask],
|
||||
}
|
||||
|
||||
holdout = _subset(forge_holdout_mask)
|
||||
train = _subset(train_mask)
|
||||
|
||||
logger.info(
|
||||
"Forge holdout: %d samples (%d games) held out, %d train samples remaining",
|
||||
forge_holdout_mask.sum(), n_holdout, train_mask.sum(),
|
||||
)
|
||||
|
||||
return train, holdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accuracy Gate (Task 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_accuracy_gate(
|
||||
current_accuracy: float,
|
||||
baseline_accuracy: float,
|
||||
config: QualityGateConfig,
|
||||
) -> GateVerdict:
|
||||
"""
|
||||
Check if the current model accuracy on the Forge holdout set
|
||||
is acceptable after training with PPO data mixed in.
|
||||
|
||||
Args:
|
||||
current_accuracy: Accuracy on Forge holdout after training with PPO data
|
||||
baseline_accuracy: Accuracy on Forge holdout from previous model (no PPO)
|
||||
config: Quality gate configuration
|
||||
|
||||
Returns:
|
||||
GateVerdict — accepted=True if accuracy is acceptable
|
||||
"""
|
||||
metrics = {
|
||||
"current_accuracy": round(current_accuracy, 4),
|
||||
"baseline_accuracy": round(baseline_accuracy, 4),
|
||||
"accuracy_drop": round(baseline_accuracy - current_accuracy, 4),
|
||||
"min_forge_accuracy": config.min_forge_accuracy,
|
||||
"max_accuracy_drop": config.max_accuracy_drop,
|
||||
}
|
||||
|
||||
# Check absolute floor
|
||||
if current_accuracy < config.min_forge_accuracy:
|
||||
return GateVerdict(
|
||||
accepted=False,
|
||||
reason=(
|
||||
f"Forge eval accuracy ({current_accuracy:.1%}) below absolute "
|
||||
f"minimum ({config.min_forge_accuracy:.1%}). PPO batch rejected."
|
||||
),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
# Check relative drop
|
||||
drop = baseline_accuracy - current_accuracy
|
||||
if drop > config.max_accuracy_drop:
|
||||
return GateVerdict(
|
||||
accepted=False,
|
||||
reason=(
|
||||
f"Forge eval accuracy dropped {drop:.1%} "
|
||||
f"(from {baseline_accuracy:.1%} to {current_accuracy:.1%}), "
|
||||
f"exceeding max allowed drop of {config.max_accuracy_drop:.1%}. "
|
||||
f"PPO batch rejected."
|
||||
),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
return GateVerdict(
|
||||
accepted=True,
|
||||
reason=(
|
||||
f"Forge eval accuracy {current_accuracy:.1%} is acceptable "
|
||||
f"(baseline={baseline_accuracy:.1%}, drop={drop:.1%}). "
|
||||
f"PPO batch accepted."
|
||||
),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Win Rate Gate (Task 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_win_rate_gate(
|
||||
ppo_win_rate: float,
|
||||
config: QualityGateConfig,
|
||||
total_episodes: int = 0,
|
||||
min_episodes: int = 20,
|
||||
) -> GateVerdict:
|
||||
"""
|
||||
Check if the PPO agent's win rate exceeds the minimum threshold
|
||||
before allowing its decisions to be exported.
|
||||
|
||||
Args:
|
||||
ppo_win_rate: Win rate of PPO agent (0.0 to 1.0)
|
||||
config: Quality gate configuration
|
||||
total_episodes: Number of episodes played
|
||||
min_episodes: Minimum episodes required for statistical significance
|
||||
|
||||
Returns:
|
||||
GateVerdict — accepted=True if win rate is sufficient
|
||||
"""
|
||||
metrics = {
|
||||
"ppo_win_rate": round(ppo_win_rate, 4),
|
||||
"min_ppo_win_rate": config.min_ppo_win_rate,
|
||||
"total_episodes": total_episodes,
|
||||
"min_episodes": min_episodes,
|
||||
}
|
||||
|
||||
# Check minimum episode count
|
||||
if total_episodes < min_episodes:
|
||||
return GateVerdict(
|
||||
accepted=False,
|
||||
reason=(
|
||||
f"Insufficient episodes ({total_episodes}/{min_episodes}). "
|
||||
f"Need at least {min_episodes} episodes for reliable win rate."
|
||||
),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
# Check win rate threshold
|
||||
if ppo_win_rate < config.min_ppo_win_rate:
|
||||
return GateVerdict(
|
||||
accepted=False,
|
||||
reason=(
|
||||
f"PPO win rate ({ppo_win_rate:.1%}) below minimum "
|
||||
f"threshold ({config.min_ppo_win_rate:.1%}). "
|
||||
f"Agent not strong enough — decisions not exported."
|
||||
),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
return GateVerdict(
|
||||
accepted=True,
|
||||
reason=(
|
||||
f"PPO win rate ({ppo_win_rate:.1%}) exceeds minimum "
|
||||
f"({config.min_ppo_win_rate:.1%}) over {total_episodes} episodes. "
|
||||
f"Decisions approved for export."
|
||||
),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Quality Metrics (Task 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_action_entropy(action_indices: np.ndarray, num_actions: int = 8) -> float:
|
||||
"""
|
||||
Compute Shannon entropy of the action distribution.
|
||||
|
||||
High entropy = diverse actions (good).
|
||||
Low entropy = collapsed to few actions (bad — possible mode collapse).
|
||||
Max entropy for 8 actions = log2(8) = 3.0 bits.
|
||||
"""
|
||||
counts = np.bincount(action_indices, minlength=num_actions).astype(np.float64)
|
||||
probs = counts / max(counts.sum(), 1)
|
||||
# Filter zero probabilities for log
|
||||
probs = probs[probs > 0]
|
||||
entropy = -np.sum(probs * np.log2(probs))
|
||||
return float(entropy)
|
||||
|
||||
|
||||
def compute_reward_stats(rewards: np.ndarray) -> Dict[str, float]:
|
||||
"""Compute summary statistics for reward/episode_return distribution."""
|
||||
if len(rewards) == 0:
|
||||
return {"mean": 0.0, "std": 0.0, "min": 0.0, "max": 0.0,
|
||||
"median": 0.0, "p25": 0.0, "p75": 0.0}
|
||||
return {
|
||||
"mean": float(np.mean(rewards)),
|
||||
"std": float(np.std(rewards)),
|
||||
"min": float(np.min(rewards)),
|
||||
"max": float(np.max(rewards)),
|
||||
"median": float(np.median(rewards)),
|
||||
"p25": float(np.percentile(rewards, 25)),
|
||||
"p75": float(np.percentile(rewards, 75)),
|
||||
}
|
||||
|
||||
|
||||
def compute_quality_metrics(
|
||||
decisions: List[dict],
|
||||
num_actions: int = 8,
|
||||
) -> Dict:
|
||||
"""
|
||||
Compute comprehensive data quality metrics for a batch of decisions.
|
||||
|
||||
Metrics:
|
||||
- action_entropy: Shannon entropy of action distribution
|
||||
- action_distribution: count per action
|
||||
- reward_stats: mean, std, min, max, median, p25, p75
|
||||
- source_breakdown: count per source
|
||||
- total_decisions: number of decisions
|
||||
- unique_games: number of unique game IDs
|
||||
|
||||
Args:
|
||||
decisions: List of decision dicts (from JSONL records)
|
||||
num_actions: Total number of possible actions
|
||||
|
||||
Returns:
|
||||
Dict of quality metrics
|
||||
"""
|
||||
if not decisions:
|
||||
return {"total_decisions": 0, "error": "empty batch"}
|
||||
|
||||
# Extract action indices
|
||||
action_indices = np.array([
|
||||
d.get("action", {}).get("action_idx", d.get("action_idx", 0))
|
||||
for d in decisions
|
||||
])
|
||||
|
||||
# Extract rewards/episode returns
|
||||
rewards = np.array([
|
||||
d.get("episode_return", d.get("reward", 0.0))
|
||||
for d in decisions
|
||||
])
|
||||
|
||||
# Action distribution
|
||||
action_counts = np.bincount(action_indices, minlength=num_actions)
|
||||
action_distribution = {
|
||||
str(i): int(c) for i, c in enumerate(action_counts)
|
||||
}
|
||||
|
||||
# Source breakdown
|
||||
sources = [d.get("source", d.get("_source", "unknown")) for d in decisions]
|
||||
source_counts = {}
|
||||
for s in sources:
|
||||
source_counts[s] = source_counts.get(s, 0) + 1
|
||||
|
||||
# Unique games
|
||||
game_ids = set(d.get("game_id", "?") for d in decisions)
|
||||
|
||||
# Entropy
|
||||
entropy = compute_action_entropy(action_indices, num_actions)
|
||||
max_entropy = np.log2(num_actions)
|
||||
|
||||
return {
|
||||
"total_decisions": len(decisions),
|
||||
"unique_games": len(game_ids),
|
||||
"action_entropy": round(entropy, 4),
|
||||
"max_entropy": round(max_entropy, 4),
|
||||
"entropy_ratio": round(entropy / max(max_entropy, 1e-8), 4),
|
||||
"action_distribution": action_distribution,
|
||||
"reward_stats": compute_reward_stats(rewards),
|
||||
"source_breakdown": source_counts,
|
||||
}
|
||||
|
||||
|
||||
def log_quality_metrics(metrics: Dict):
|
||||
"""Pretty-print quality metrics to the logger."""
|
||||
logger.info("")
|
||||
logger.info("=" * 55)
|
||||
logger.info(" Data Quality Report")
|
||||
logger.info("=" * 55)
|
||||
logger.info(" Total decisions: %d", metrics.get("total_decisions", 0))
|
||||
logger.info(" Unique games: %d", metrics.get("unique_games", 0))
|
||||
logger.info(" Action entropy: %.3f / %.3f (%.0f%%)",
|
||||
metrics.get("action_entropy", 0),
|
||||
metrics.get("max_entropy", 0),
|
||||
metrics.get("entropy_ratio", 0) * 100)
|
||||
logger.info("")
|
||||
|
||||
# Action distribution
|
||||
dist = metrics.get("action_distribution", {})
|
||||
if dist:
|
||||
total = sum(dist.values())
|
||||
logger.info(" %-20s %8s %7s", "Action", "Count", "%")
|
||||
logger.info(" " + "-" * 37)
|
||||
for action_idx, count in sorted(dist.items(), key=lambda x: -x[1]):
|
||||
pct = 100.0 * count / max(total, 1)
|
||||
logger.info(" %-20s %8d %6.1f%%", f"action_{action_idx}", count, pct)
|
||||
|
||||
# Reward stats
|
||||
rstats = metrics.get("reward_stats", {})
|
||||
if rstats:
|
||||
logger.info("")
|
||||
logger.info(" Reward distribution:")
|
||||
logger.info(" mean=%.3f std=%.3f min=%.3f max=%.3f",
|
||||
rstats.get("mean", 0), rstats.get("std", 0),
|
||||
rstats.get("min", 0), rstats.get("max", 0))
|
||||
logger.info(" p25=%.3f median=%.3f p75=%.3f",
|
||||
rstats.get("p25", 0), rstats.get("median", 0),
|
||||
rstats.get("p75", 0))
|
||||
|
||||
# Source breakdown
|
||||
sources = metrics.get("source_breakdown", {})
|
||||
if sources:
|
||||
logger.info("")
|
||||
logger.info(" Source breakdown:")
|
||||
for src, cnt in sorted(sources.items(), key=lambda x: -x[1]):
|
||||
logger.info(" %-12s %d", src, cnt)
|
||||
|
||||
logger.info("=" * 55)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrator — QualityGate class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class QualityGate:
|
||||
"""
|
||||
Orchestrates all quality checks for the closed-loop distillation pipeline.
|
||||
|
||||
Usage:
|
||||
gate = QualityGate(config)
|
||||
|
||||
# Before exporting PPO decisions:
|
||||
win_verdict = gate.check_win_rate(ppo_win_rate=0.35, total_episodes=100)
|
||||
|
||||
# After training with mixed data:
|
||||
acc_verdict = gate.check_accuracy(
|
||||
current_accuracy=0.42,
|
||||
baseline_accuracy=0.41,
|
||||
)
|
||||
|
||||
# Log quality metrics for a batch:
|
||||
gate.report_batch_quality(decisions)
|
||||
"""
|
||||
|
||||
def __init__(self, config: QualityGateConfig = None):
|
||||
self.config = config or QualityGateConfig()
|
||||
self.verdicts: List[GateVerdict] = []
|
||||
|
||||
def check_win_rate(
|
||||
self,
|
||||
ppo_win_rate: float,
|
||||
total_episodes: int = 0,
|
||||
min_episodes: int = 20,
|
||||
) -> GateVerdict:
|
||||
"""Check if PPO agent meets win rate threshold for export."""
|
||||
verdict = check_win_rate_gate(
|
||||
ppo_win_rate, self.config, total_episodes, min_episodes
|
||||
)
|
||||
self.verdicts.append(verdict)
|
||||
if verdict.accepted:
|
||||
logger.info("WIN RATE GATE: PASS — %s", verdict.reason)
|
||||
else:
|
||||
logger.warning("WIN RATE GATE: FAIL — %s", verdict.reason)
|
||||
return verdict
|
||||
|
||||
def check_accuracy(
|
||||
self,
|
||||
current_accuracy: float,
|
||||
baseline_accuracy: float,
|
||||
) -> GateVerdict:
|
||||
"""Check if model accuracy on Forge holdout is acceptable."""
|
||||
verdict = check_accuracy_gate(
|
||||
current_accuracy, baseline_accuracy, self.config
|
||||
)
|
||||
self.verdicts.append(verdict)
|
||||
if verdict.accepted:
|
||||
logger.info("ACCURACY GATE: PASS — %s", verdict.reason)
|
||||
else:
|
||||
logger.warning("ACCURACY GATE: FAIL — %s", verdict.reason)
|
||||
return verdict
|
||||
|
||||
def report_batch_quality(self, decisions: List[dict]) -> Dict:
|
||||
"""Compute and log quality metrics for a decision batch."""
|
||||
metrics = compute_quality_metrics(decisions)
|
||||
log_quality_metrics(metrics)
|
||||
|
||||
# Save report to disk
|
||||
os.makedirs(self.config.reports_dir, exist_ok=True)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
report_path = os.path.join(
|
||||
self.config.reports_dir, f"quality_{timestamp}.json"
|
||||
)
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(metrics, f, indent=2)
|
||||
logger.info("Quality report saved: %s", report_path)
|
||||
|
||||
return metrics
|
||||
|
||||
def evaluate_ppo_batch(
|
||||
self,
|
||||
forge_eval_accuracy: float,
|
||||
ppo_win_rate: float,
|
||||
ppo_decisions: List[dict],
|
||||
baseline_accuracy: float,
|
||||
total_episodes: int = 0,
|
||||
) -> GateVerdict:
|
||||
"""
|
||||
Run all quality gates on a PPO batch. Returns a combined verdict.
|
||||
|
||||
This is the main entry point for the distillation pipeline to decide
|
||||
whether to accept or reject a batch of PPO-generated training data.
|
||||
"""
|
||||
# 1. Win rate gate
|
||||
win_verdict = self.check_win_rate(ppo_win_rate, total_episodes)
|
||||
if not win_verdict.accepted:
|
||||
return win_verdict
|
||||
|
||||
# 2. Accuracy gate
|
||||
acc_verdict = self.check_accuracy(forge_eval_accuracy, baseline_accuracy)
|
||||
if not acc_verdict.accepted:
|
||||
return acc_verdict
|
||||
|
||||
# 3. Log quality metrics (informational, doesn't block)
|
||||
quality_metrics = self.report_batch_quality(ppo_decisions)
|
||||
|
||||
# 4. Check for extreme entropy collapse (soft warning)
|
||||
entropy_ratio = quality_metrics.get("entropy_ratio", 1.0)
|
||||
if entropy_ratio < 0.3:
|
||||
logger.warning(
|
||||
"LOW ENTROPY WARNING: action entropy ratio %.2f — "
|
||||
"possible mode collapse in PPO agent",
|
||||
entropy_ratio,
|
||||
)
|
||||
|
||||
return GateVerdict(
|
||||
accepted=True,
|
||||
reason="All quality gates passed.",
|
||||
metrics={
|
||||
"win_rate": win_verdict.metrics,
|
||||
"accuracy": acc_verdict.metrics,
|
||||
"quality": quality_metrics,
|
||||
},
|
||||
)
|
||||
|
||||
def save_verdict_history(self, path: str = None):
|
||||
"""Save all verdict history to a JSON file."""
|
||||
if path is None:
|
||||
os.makedirs(self.config.reports_dir, exist_ok=True)
|
||||
path = os.path.join(self.config.reports_dir, "verdict_history.json")
|
||||
|
||||
history = [v.to_dict() for v in self.verdicts]
|
||||
with open(path, "w") as f:
|
||||
json.dump(history, f, indent=2)
|
||||
logger.info("Verdict history saved: %s (%d entries)", path, len(history))
|
||||
@@ -276,13 +276,6 @@ class StateEncoder:
|
||||
"""
|
||||
Encode zone contents as mean-pooled card embeddings.
|
||||
Shape: (4 zones × 2 players × 768) = (6144,)
|
||||
|
||||
Zone embedding boundary: each zone slot is exactly ``zone_pool_dim``
|
||||
(768) floats. Empty zones produce a zero vector of that size. This
|
||||
fixed-size contract is relied upon by every downstream consumer
|
||||
(PolicyNetwork, PPOTrainer, dataset builder). See
|
||||
``StateDimensions`` in ``ml/config/scope.py`` for the canonical
|
||||
definition.
|
||||
"""
|
||||
zone_keys = ["hand", "battlefield", "graveyard", "command_zone"]
|
||||
vectors = []
|
||||
@@ -290,8 +283,6 @@ class StateEncoder:
|
||||
for p in players[:2]:
|
||||
for zone_key in zone_keys:
|
||||
cards = p.get(zone_key, [])
|
||||
# mean_pool_zone returns a zero vector when cards is empty,
|
||||
# preserving the fixed 768-dim slot size.
|
||||
pooled = self.card_index.mean_pool_zone(cards)
|
||||
vectors.append(pooled)
|
||||
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab — ELO Tracker & Cross-Generation Tournament
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Phase 6 (Issue #70): Quantify improvement across distillation
|
||||
generations using ELO ratings.
|
||||
|
||||
- Runs round-robin tournaments between generation checkpoints
|
||||
and baselines (heuristic, random)
|
||||
- Computes ELO ratings using the standard formula
|
||||
- Persists ELO history to `data/elo_history.json`
|
||||
- Provides ELO delta for convergence detection
|
||||
|
||||
Reuses `run_match`, `LearnedPolicy`, `HeuristicPolicy`,
|
||||
`RandomPolicy` from `ml/eval/tournament.py`.
|
||||
|
||||
Usage:
|
||||
python -m ml.eval.elo_tracker
|
||||
python -m ml.eval.elo_tracker --episodes 50 --output data/elo_history.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from itertools import combinations
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
project_root = str(Path(__file__).parent.parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from ml.eval.tournament import (
|
||||
run_match, LearnedPolicy, HeuristicPolicy, RandomPolicy,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ml.eval.elo")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# ELO Rating Engine
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
DEFAULT_K = 32
|
||||
DEFAULT_START_ELO = 1200.0
|
||||
|
||||
|
||||
class EloTracker:
|
||||
"""Standard ELO rating tracker."""
|
||||
|
||||
def __init__(self, k_factor: float = DEFAULT_K, start_elo: float = DEFAULT_START_ELO):
|
||||
self.k = k_factor
|
||||
self.start_elo = start_elo
|
||||
self.ratings: Dict[str, float] = {}
|
||||
|
||||
def ensure_player(self, name: str):
|
||||
if name not in self.ratings:
|
||||
self.ratings[name] = self.start_elo
|
||||
|
||||
def expected_score(self, rating_a: float, rating_b: float) -> float:
|
||||
return 1.0 / (1.0 + math.pow(10, (rating_b - rating_a) / 400.0))
|
||||
|
||||
def update(self, winner: str, loser: str):
|
||||
"""Update ratings after a decisive match."""
|
||||
self.ensure_player(winner)
|
||||
self.ensure_player(loser)
|
||||
|
||||
e_win = self.expected_score(self.ratings[winner], self.ratings[loser])
|
||||
e_lose = self.expected_score(self.ratings[loser], self.ratings[winner])
|
||||
|
||||
self.ratings[winner] += self.k * (1.0 - e_win)
|
||||
self.ratings[loser] += self.k * (0.0 - e_lose)
|
||||
|
||||
def update_draw(self, player_a: str, player_b: str):
|
||||
"""Update ratings after a draw."""
|
||||
self.ensure_player(player_a)
|
||||
self.ensure_player(player_b)
|
||||
|
||||
e_a = self.expected_score(self.ratings[player_a], self.ratings[player_b])
|
||||
e_b = self.expected_score(self.ratings[player_b], self.ratings[player_a])
|
||||
|
||||
self.ratings[player_a] += self.k * (0.5 - e_a)
|
||||
self.ratings[player_b] += self.k * (0.5 - e_b)
|
||||
|
||||
def get_ratings(self) -> Dict[str, float]:
|
||||
"""Return a copy of current ratings, rounded to 1 decimal."""
|
||||
return {k: round(v, 1) for k, v in self.ratings.items()}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# ELO Result
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@dataclass
|
||||
class EloResult:
|
||||
"""Result of an ELO-rated tournament."""
|
||||
ratings: Dict[str, float] = field(default_factory=dict)
|
||||
match_count: int = 0
|
||||
total_time_s: float = 0.0
|
||||
policies: List[str] = field(default_factory=list)
|
||||
rating_progression: List[Dict[str, float]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"ratings": self.ratings,
|
||||
"match_count": self.match_count,
|
||||
"total_time_s": round(self.total_time_s, 1),
|
||||
"policies": self.policies,
|
||||
"rating_progression": self.rating_progression,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# ELO History (persistence)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
class EloHistory:
|
||||
"""Persists ELO ratings across tournament runs."""
|
||||
|
||||
def __init__(self, path: str = "data/elo_history.json"):
|
||||
self.path = path
|
||||
self.entries: List[dict] = []
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if os.path.exists(self.path):
|
||||
try:
|
||||
with open(self.path, "r") as f:
|
||||
data = json.load(f)
|
||||
self.entries = data.get("entries", [])
|
||||
except Exception:
|
||||
self.entries = []
|
||||
|
||||
def save(self):
|
||||
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
||||
with open(self.path, "w") as f:
|
||||
json.dump({"entries": self.entries}, f, indent=2)
|
||||
|
||||
def append(self, generation: int, ratings: Dict[str, float]):
|
||||
"""Add a new ELO snapshot after a tournament."""
|
||||
self.entries.append({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"generation": generation,
|
||||
"ratings": {k: round(v, 1) for k, v in ratings.items()},
|
||||
})
|
||||
self.save()
|
||||
|
||||
def get_elo_delta(self, window: int = 2) -> Optional[float]:
|
||||
"""
|
||||
Compute average ELO gain of the latest generation model
|
||||
over the last `window` entries.
|
||||
|
||||
Returns None if insufficient data.
|
||||
"""
|
||||
if len(self.entries) < window:
|
||||
return None
|
||||
|
||||
recent = self.entries[-window:]
|
||||
deltas = []
|
||||
|
||||
for i in range(1, len(recent)):
|
||||
prev_ratings = recent[i - 1]["ratings"]
|
||||
curr_ratings = recent[i]["ratings"]
|
||||
|
||||
# Find the latest generation model name in current entry
|
||||
gen_models = [k for k in curr_ratings if k.startswith("gen-")]
|
||||
if not gen_models:
|
||||
continue
|
||||
|
||||
latest_gen = sorted(gen_models)[-1]
|
||||
curr_elo = curr_ratings.get(latest_gen, DEFAULT_START_ELO)
|
||||
|
||||
# Compare to previous entry's latest gen model, or heuristic
|
||||
prev_gen_models = [k for k in prev_ratings if k.startswith("gen-")]
|
||||
if prev_gen_models:
|
||||
prev_latest = sorted(prev_gen_models)[-1]
|
||||
prev_elo = prev_ratings.get(prev_latest, DEFAULT_START_ELO)
|
||||
else:
|
||||
prev_elo = prev_ratings.get("heuristic", DEFAULT_START_ELO)
|
||||
|
||||
deltas.append(curr_elo - prev_elo)
|
||||
|
||||
if not deltas:
|
||||
return None
|
||||
|
||||
return sum(deltas) / len(deltas)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"entries": self.entries}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict, path: str = "data/elo_history.json") -> "EloHistory":
|
||||
h = cls.__new__(cls)
|
||||
h.path = path
|
||||
h.entries = data.get("entries", [])
|
||||
return h
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Tournament Functions
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def run_elo_tournament(
|
||||
policies: Dict[str, object],
|
||||
episodes_per_matchup: int = 50,
|
||||
playstyle: str = "midrange",
|
||||
k_factor: float = DEFAULT_K,
|
||||
) -> EloResult:
|
||||
"""
|
||||
Run a round-robin ELO-rated tournament.
|
||||
|
||||
Uses `run_match` from tournament.py for match execution.
|
||||
|
||||
Args:
|
||||
policies: Dict mapping name → policy object (with select_action method)
|
||||
episodes_per_matchup: Games per pair (played both sides)
|
||||
playstyle: Game playstyle
|
||||
k_factor: ELO K-factor
|
||||
|
||||
Returns:
|
||||
EloResult with final ratings, match count, and progression
|
||||
"""
|
||||
t_start = time.time()
|
||||
names = list(policies.keys())
|
||||
tracker = EloTracker(k_factor=k_factor)
|
||||
|
||||
# Initialize all players
|
||||
for name in names:
|
||||
tracker.ensure_player(name)
|
||||
|
||||
progression = [tracker.get_ratings().copy()]
|
||||
total_matches = 0
|
||||
|
||||
logger.info("ELO Tournament: %d policies, %d games/matchup", len(names), episodes_per_matchup)
|
||||
|
||||
for name_a, name_b in combinations(names, 2):
|
||||
policy_a = policies[name_a]
|
||||
policy_b = policies[name_b]
|
||||
|
||||
for ep in range(episodes_per_matchup):
|
||||
# Game 1: A as seat 0
|
||||
m1 = run_match(policy_a, name_a, policy_b, name_b, playstyle=playstyle)
|
||||
total_matches += 1
|
||||
if m1.winner == name_a:
|
||||
tracker.update(name_a, name_b)
|
||||
elif m1.winner == name_b:
|
||||
tracker.update(name_b, name_a)
|
||||
else:
|
||||
tracker.update_draw(name_a, name_b)
|
||||
|
||||
# Game 2: B as seat 0
|
||||
m2 = run_match(policy_b, name_b, policy_a, name_a, playstyle=playstyle)
|
||||
total_matches += 1
|
||||
if m2.winner == name_a:
|
||||
tracker.update(name_a, name_b)
|
||||
elif m2.winner == name_b:
|
||||
tracker.update(name_b, name_a)
|
||||
else:
|
||||
tracker.update_draw(name_a, name_b)
|
||||
|
||||
# Record progression after each matchup
|
||||
progression.append(tracker.get_ratings().copy())
|
||||
|
||||
logger.info(
|
||||
" %s (%.0f) vs %s (%.0f)",
|
||||
name_a, tracker.ratings[name_a],
|
||||
name_b, tracker.ratings[name_b],
|
||||
)
|
||||
|
||||
return EloResult(
|
||||
ratings=tracker.get_ratings(),
|
||||
match_count=total_matches,
|
||||
total_time_s=time.time() - t_start,
|
||||
policies=names,
|
||||
rating_progression=progression,
|
||||
)
|
||||
|
||||
|
||||
def run_generation_tournament(
|
||||
checkpoint_dir: str = "ml/models/checkpoints",
|
||||
episodes_per_matchup: int = 50,
|
||||
playstyle: str = "midrange",
|
||||
k_factor: float = DEFAULT_K,
|
||||
) -> EloResult:
|
||||
"""
|
||||
Discover generation checkpoints and run an ELO tournament.
|
||||
|
||||
Looks for `gen-*/best_policy.pt` under checkpoint_dir.
|
||||
Also includes heuristic and random baselines.
|
||||
"""
|
||||
policies: Dict[str, object] = {
|
||||
"heuristic": HeuristicPolicy(),
|
||||
"random": RandomPolicy(),
|
||||
}
|
||||
|
||||
ckpt_path = Path(checkpoint_dir)
|
||||
if ckpt_path.exists():
|
||||
for gen_dir in sorted(ckpt_path.glob("gen-*")):
|
||||
model_path = gen_dir / "best_policy.pt"
|
||||
if model_path.exists():
|
||||
name = gen_dir.name # e.g., "gen-001"
|
||||
policies[name] = LearnedPolicy(str(model_path))
|
||||
logger.info("Loaded %s: %s", name, model_path)
|
||||
|
||||
# Also check standard checkpoints
|
||||
sup_path = ckpt_path / "best_policy.pt"
|
||||
if sup_path.exists() and "supervised" not in policies:
|
||||
policies["supervised"] = LearnedPolicy(str(sup_path))
|
||||
|
||||
ppo_path = ckpt_path / "best_ppo.pt"
|
||||
if ppo_path.exists() and "ppo" not in policies:
|
||||
policies["ppo"] = LearnedPolicy(str(ppo_path))
|
||||
|
||||
if len(policies) < 2:
|
||||
logger.warning("Need at least 2 policies for ELO tournament")
|
||||
return EloResult(
|
||||
ratings={k: DEFAULT_START_ELO for k in policies},
|
||||
policies=list(policies.keys()),
|
||||
)
|
||||
|
||||
logger.info("Running ELO tournament with %d policies: %s", len(policies), list(policies.keys()))
|
||||
|
||||
return run_elo_tournament(
|
||||
policies=policies,
|
||||
episodes_per_matchup=episodes_per_matchup,
|
||||
playstyle=playstyle,
|
||||
k_factor=k_factor,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
"""CLI entry point for ELO tournament."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Commander AI Lab — ELO Tournament"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--episodes", type=int, default=50,
|
||||
help="Episodes per matchup (default: 50)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--checkpoint-dir", default="ml/models/checkpoints",
|
||||
help="Checkpoint directory (default: ml/models/checkpoints)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--playstyle", default="midrange",
|
||||
help="Playstyle (default: midrange)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", default="data/elo_history.json",
|
||||
help="Output path for ELO history (default: data/elo_history.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--k-factor", type=float, default=32,
|
||||
help="ELO K-factor (default: 32)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
result = run_generation_tournament(
|
||||
checkpoint_dir=args.checkpoint_dir,
|
||||
episodes_per_matchup=args.episodes,
|
||||
playstyle=args.playstyle,
|
||||
k_factor=args.k_factor,
|
||||
)
|
||||
|
||||
# Print results
|
||||
logger.info("")
|
||||
logger.info("=" * 50)
|
||||
logger.info(" ELO RATINGS")
|
||||
logger.info("=" * 50)
|
||||
sorted_ratings = sorted(result.ratings.items(), key=lambda x: x[1], reverse=True)
|
||||
for name, elo in sorted_ratings:
|
||||
logger.info(" %-20s %7.1f", name, elo)
|
||||
logger.info("")
|
||||
logger.info(" %d matches in %.1f s", result.match_count, result.total_time_s)
|
||||
logger.info("=" * 50)
|
||||
|
||||
# Save to history
|
||||
history = EloHistory(path=args.output)
|
||||
# Use the latest generation number, or 0 if no gen models
|
||||
gen_models = [k for k in result.ratings if k.startswith("gen-")]
|
||||
gen_num = 0
|
||||
if gen_models:
|
||||
try:
|
||||
gen_num = max(int(g.split("-")[1]) for g in gen_models)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
history.append(gen_num, result.ratings)
|
||||
logger.info("ELO history saved: %s", args.output)
|
||||
|
||||
print(json.dumps(result.to_dict(), indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,344 +0,0 @@
|
||||
"""
|
||||
update_weights.py
|
||||
=================
|
||||
Outcome-weighted reinforcement learning for Commander AI card weights.
|
||||
|
||||
Reads all decisions_*.jsonl files produced by DeepSeekBrain.flush_log(),
|
||||
applies a gradient-free reward nudge to each weight key based on the
|
||||
normalised reward signal (-1.0 to +1.0) stamped on every decision entry,
|
||||
then saves the updated weights to learned_weights.json via save_weights().
|
||||
|
||||
Usage
|
||||
-----
|
||||
# Basic run (reads logs/decisions/, updates learned_weights.json)
|
||||
python -m ml.scripts.update_weights
|
||||
|
||||
# Custom paths
|
||||
python -m ml.scripts.update_weights \\
|
||||
--log-dir logs/decisions \\
|
||||
--weights-out src/commander_ai_lab/sim/learned_weights.json \\
|
||||
--lr 0.05 \\
|
||||
--min-games 10
|
||||
|
||||
# Dry-run (print proposed changes, don't write)
|
||||
python -m ml.scripts.update_weights --dry-run
|
||||
|
||||
# Reset to defaults then exit
|
||||
python -m ml.scripts.update_weights --reset
|
||||
|
||||
Algorithm
|
||||
---------
|
||||
For each decision entry that has a non-null game_result:
|
||||
|
||||
delta = learning_rate * reward * action_weight_for(action)
|
||||
|
||||
Each weight key is nudged by the mean delta across all decisions that
|
||||
touched that key. Keys with no decisions are left unchanged.
|
||||
All values are clamped to [-20.0, 20.0] by save_weights().
|
||||
|
||||
Reward is the normalised life-delta from flush_log():
|
||||
reward = (my_final_life - avg_opp_final_life) / 40.0 in [-1.0, 1.0]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allow running as a script without installing the package
|
||||
# ---------------------------------------------------------------------------
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_REPO_ROOT / "src") not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT / "src"))
|
||||
|
||||
from commander_ai_lab.sim.rules import (
|
||||
AI_DEFAULT_WEIGHTS,
|
||||
load_weights,
|
||||
reset_weights,
|
||||
save_weights,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("update_weights")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action → weight key mapping
|
||||
# Maps each VALID_ACTION to the weight(s) it most directly influences.
|
||||
# A single action may touch multiple keys (listed as a tuple).
|
||||
# ---------------------------------------------------------------------------
|
||||
ACTION_WEIGHT_MAP: dict[str, tuple[str, ...]] = {
|
||||
"play_land": (), # no weight key — always correct
|
||||
"cast_ramp": ("spell_ramp",),
|
||||
"cast_removal": ("spell_destroy",),
|
||||
"cast_board_wipe": ("spell_counter",), # reuse counter as board-control proxy
|
||||
"cast_creature": ("card_ptBonus",),
|
||||
"cast_spell": ("spell_enchantment", "spell_draw"),
|
||||
"attack_all": ("kw_haste", "kw_flying"), # aggression / evasion proxy
|
||||
"attack_safe": ("kw_flying", "kw_trample"),
|
||||
"hold": (), # no weight key — passing is neutral
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSONL helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _iter_log_entries(log_dir: str) -> Iterator[dict]:
|
||||
"""Yield every entry from all decisions_*.jsonl files in log_dir."""
|
||||
log_path = Path(log_dir)
|
||||
if not log_path.exists():
|
||||
logger.warning("Log directory not found: %s", log_dir)
|
||||
return
|
||||
|
||||
files = sorted(log_path.glob("decisions_*.jsonl"))
|
||||
if not files:
|
||||
logger.warning("No decisions_*.jsonl files found in %s", log_dir)
|
||||
return
|
||||
|
||||
logger.info("Found %d log file(s) in %s", len(files), log_dir)
|
||||
for fpath in files:
|
||||
with open(fpath, encoding="utf-8") as f:
|
||||
for lineno, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Skipping malformed line %d in %s: %s", lineno, fpath.name, exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core learning loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_weight_updates(
|
||||
log_dir: str,
|
||||
current_weights: dict[str, float],
|
||||
learning_rate: float = 0.05,
|
||||
min_games: int = 1,
|
||||
) -> tuple[dict[str, float], dict]:
|
||||
"""
|
||||
Read decision logs and compute updated weights.
|
||||
|
||||
Returns
|
||||
-------
|
||||
updated_weights : dict[str, float]
|
||||
New weight dict after applying mean reward nudges.
|
||||
stats : dict
|
||||
Diagnostic info (games seen, decisions processed, keys changed, etc.)
|
||||
"""
|
||||
# Accumulators: key → list of (reward * lr) deltas
|
||||
key_deltas: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
total_entries = 0
|
||||
labelled_entries = 0 # entries with non-null game_result
|
||||
game_ids_seen: set[str] = set()
|
||||
action_counts: dict[str, int] = defaultdict(int)
|
||||
|
||||
for entry in _iter_log_entries(log_dir):
|
||||
total_entries += 1
|
||||
|
||||
game_result = entry.get("game_result")
|
||||
if not game_result:
|
||||
continue # no outcome stamped — skip (pre-flush or incomplete game)
|
||||
|
||||
labelled_entries += 1
|
||||
game_ids_seen.add(entry.get("game_id", "unknown"))
|
||||
|
||||
reward: float = float(game_result.get("reward", 0.0))
|
||||
action: str = entry.get("decision", {}).get("action", "hold")
|
||||
action_counts[action] += 1
|
||||
|
||||
weight_keys = ACTION_WEIGHT_MAP.get(action, ())
|
||||
for key in weight_keys:
|
||||
if key in current_weights:
|
||||
key_deltas[key].append(learning_rate * reward)
|
||||
|
||||
num_games = len(game_ids_seen)
|
||||
logger.info(
|
||||
"Processed %d entries | %d labelled | %d unique games",
|
||||
total_entries, labelled_entries, num_games,
|
||||
)
|
||||
|
||||
if num_games < min_games:
|
||||
logger.warning(
|
||||
"Only %d game(s) found — minimum is %d. Skipping update.",
|
||||
num_games, min_games,
|
||||
)
|
||||
return dict(current_weights), {
|
||||
"skipped": True,
|
||||
"reason": f"need {min_games} games, have {num_games}",
|
||||
"total_entries": total_entries,
|
||||
"labelled_entries": labelled_entries,
|
||||
"games": num_games,
|
||||
}
|
||||
|
||||
# Apply mean delta per key
|
||||
updated = dict(current_weights)
|
||||
changed_keys: dict[str, tuple[float, float]] = {} # key → (old, new)
|
||||
|
||||
for key, deltas in key_deltas.items():
|
||||
if not deltas:
|
||||
continue
|
||||
mean_delta = sum(deltas) / len(deltas)
|
||||
old_val = updated[key]
|
||||
new_val = round(old_val + mean_delta, 6)
|
||||
updated[key] = new_val
|
||||
if abs(new_val - old_val) >= 0.0001:
|
||||
changed_keys[key] = (old_val, new_val)
|
||||
|
||||
stats = {
|
||||
"skipped": False,
|
||||
"total_entries": total_entries,
|
||||
"labelled_entries": labelled_entries,
|
||||
"games": num_games,
|
||||
"keys_changed": len(changed_keys),
|
||||
"changes": changed_keys,
|
||||
"action_counts": dict(action_counts),
|
||||
}
|
||||
return updated, stats
|
||||
|
||||
|
||||
def print_diff(stats: dict, current: dict, updated: dict) -> None:
|
||||
"""Pretty-print weight changes to stdout."""
|
||||
changes = stats.get("changes", {})
|
||||
if not changes:
|
||||
print(" (no weight changes above threshold)")
|
||||
return
|
||||
|
||||
col_w = max(len(k) for k in changes) + 2
|
||||
print(f" {'Key':<{col_w}} {'Before':>10} {'After':>10} {'Delta':>10}")
|
||||
print(" " + "-" * (col_w + 36))
|
||||
for key, (old, new) in sorted(changes.items(), key=lambda x: abs(x[1][1] - x[1][0]), reverse=True):
|
||||
delta = new - old
|
||||
arrow = "▲" if delta > 0 else "▼"
|
||||
print(f" {key:<{col_w}} {old:>10.4f} {new:>10.4f} {arrow} {abs(delta):>8.4f}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_log_dir() -> str:
|
||||
return str(_REPO_ROOT / "logs" / "decisions")
|
||||
|
||||
|
||||
def _default_weights_out() -> str:
|
||||
return str(_REPO_ROOT / "src" / "commander_ai_lab" / "sim" / "learned_weights.json")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Update AI card weights from simulation decision logs."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-dir",
|
||||
default=_default_log_dir(),
|
||||
help="Directory containing decisions_*.jsonl files (default: logs/decisions/)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--weights-out",
|
||||
default=_default_weights_out(),
|
||||
help="Path to write learned_weights.json (default: src/.../sim/learned_weights.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lr",
|
||||
type=float,
|
||||
default=0.05,
|
||||
help="Learning rate — how much each game nudges a weight (default: 0.05)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-games",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Minimum number of completed games required before updating (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print proposed changes without writing learned_weights.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reset",
|
||||
action="store_true",
|
||||
help="Delete learned_weights.json and restore AI_DEFAULT_WEIGHTS, then exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Show action distribution and per-key delta counts",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# ── Reset mode ──────────────────────────────────────────────
|
||||
if args.reset:
|
||||
reset_weights(args.weights_out)
|
||||
print(f"Reset: deleted {args.weights_out} (if it existed). Defaults restored.")
|
||||
return 0
|
||||
|
||||
# ── Load current weights ─────────────────────────────────────
|
||||
current = load_weights(args.weights_out)
|
||||
logger.info("Loaded %d weight keys from %s", len(current), args.weights_out)
|
||||
|
||||
# ── Compute updates ──────────────────────────────────────────
|
||||
updated, stats = compute_weight_updates(
|
||||
log_dir=args.log_dir,
|
||||
current_weights=current,
|
||||
learning_rate=args.lr,
|
||||
min_games=args.min_games,
|
||||
)
|
||||
|
||||
# ── Report ───────────────────────────────────────────────────
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(" Commander AI — Weight Update Report")
|
||||
print("=" * 60)
|
||||
print(f" Log dir : {args.log_dir}")
|
||||
print(f" Weights out: {args.weights_out}")
|
||||
print(f" Games : {stats['games']}")
|
||||
print(f" Entries : {stats['total_entries']} ({stats['labelled_entries']} labelled)")
|
||||
print(f" LR : {args.lr}")
|
||||
print()
|
||||
|
||||
if stats.get("skipped"):
|
||||
print(f" SKIPPED — {stats['reason']}")
|
||||
print()
|
||||
return 0
|
||||
|
||||
print(f" Keys changed: {stats['keys_changed']}")
|
||||
print()
|
||||
print_diff(stats, current, updated)
|
||||
print()
|
||||
|
||||
if args.verbose:
|
||||
print(" Action distribution:")
|
||||
for action, count in sorted(stats["action_counts"].items(), key=lambda x: -x[1]):
|
||||
print(f" {action:<20} {count:>6} decisions")
|
||||
print()
|
||||
|
||||
# ── Write ────────────────────────────────────────────────────
|
||||
if args.dry_run:
|
||||
print(" DRY RUN — no file written.")
|
||||
else:
|
||||
out_path = save_weights(updated, args.weights_out)
|
||||
print(f" Saved updated weights to: {out_path}")
|
||||
|
||||
print("=" * 60)
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,210 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab — Online Learning Store (SQLite WAL)
|
||||
═════════════════════════════════════════════════════
|
||||
|
||||
Single-machine backing store for online learning data collected during
|
||||
policy inference (``/api/ml/predict`` and ``/api/policy/decide``).
|
||||
|
||||
Each prediction request can optionally log the state snapshot and the
|
||||
chosen action so that future training runs can incorporate online
|
||||
decision data alongside Forge and PPO batches.
|
||||
|
||||
Design decisions (Phase 5 architecture follow-up):
|
||||
- SQLite in WAL mode for concurrent readers + single writer, which is
|
||||
the right fit for a single-machine baseline (one API server process).
|
||||
- Separate DB file (``online_learning.db``) to avoid contention with
|
||||
the collection database.
|
||||
- Append-only writes; reads are batched by the dataset builder.
|
||||
- Thread-safe via the Python sqlite3 module's ``check_same_thread=False``
|
||||
and WAL's built-in reader/writer concurrency.
|
||||
|
||||
Usage::
|
||||
|
||||
store = OnlineLearningStore() # uses default path
|
||||
store.init_db()
|
||||
|
||||
# At prediction time:
|
||||
store.record_decision(snapshot_json, action_index, confidence, playstyle)
|
||||
|
||||
# At training time:
|
||||
decisions = store.fetch_decisions(since_rowid=last_seen, limit=10000)
|
||||
store.mark_exported(max_rowid)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("ml.serving.online_store")
|
||||
|
||||
_DEFAULT_DB_PATH = str(
|
||||
Path(__file__).parent.parent.parent / "online_learning.db"
|
||||
)
|
||||
|
||||
|
||||
class OnlineLearningStore:
|
||||
"""SQLite WAL-backed store for online policy decision collection."""
|
||||
|
||||
def __init__(self, db_path: str = None):
|
||||
self.db_path = db_path or _DEFAULT_DB_PATH
|
||||
self._local = threading.local()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
"""Return a thread-local connection with WAL mode enabled."""
|
||||
if not hasattr(self._local, "conn") or self._local.conn is None:
|
||||
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
# Enable WAL for concurrent read/write on single machine.
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._local.conn = conn
|
||||
return self._local.conn
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the thread-local connection."""
|
||||
conn = getattr(self._local, "conn", None)
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._local.conn = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schema
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def init_db(self) -> None:
|
||||
"""Create the decisions table if it doesn't exist."""
|
||||
conn = self._get_conn()
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS online_decisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot TEXT NOT NULL,
|
||||
action_idx INTEGER NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0.0,
|
||||
playstyle TEXT NOT NULL DEFAULT 'midrange',
|
||||
temperature REAL NOT NULL DEFAULT 1.0,
|
||||
greedy INTEGER NOT NULL DEFAULT 0,
|
||||
exported INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_od_exported
|
||||
ON online_decisions(exported);
|
||||
CREATE INDEX IF NOT EXISTS idx_od_created
|
||||
ON online_decisions(created_at);
|
||||
""")
|
||||
conn.commit()
|
||||
logger.info("Online learning store ready: %s (WAL mode)", self.db_path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Write path (called during prediction)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_decision(
|
||||
self,
|
||||
snapshot: Dict,
|
||||
action_idx: int,
|
||||
confidence: float,
|
||||
playstyle: str = "midrange",
|
||||
temperature: float = 1.0,
|
||||
greedy: bool = False,
|
||||
) -> int:
|
||||
"""Append a policy decision for future training.
|
||||
|
||||
Returns the row id of the inserted record.
|
||||
"""
|
||||
conn = self._get_conn()
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO online_decisions
|
||||
(snapshot, action_idx, confidence, playstyle, temperature, greedy)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
json.dumps(snapshot, separators=(",", ":")),
|
||||
action_idx,
|
||||
confidence,
|
||||
playstyle,
|
||||
temperature,
|
||||
1 if greedy else 0,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Read path (called by dataset builder / training pipeline)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def fetch_decisions(
|
||||
self,
|
||||
since_rowid: int = 0,
|
||||
limit: int = 50000,
|
||||
only_unexported: bool = True,
|
||||
) -> List[Dict]:
|
||||
"""Fetch collected decisions for training.
|
||||
|
||||
Args:
|
||||
since_rowid: Only return rows with id > this value.
|
||||
limit: Max rows to return.
|
||||
only_unexported: If True, skip rows already marked exported.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: id, snapshot (parsed), action_idx,
|
||||
confidence, playstyle, temperature, greedy, created_at.
|
||||
"""
|
||||
conn = self._get_conn()
|
||||
clauses = ["id > ?"]
|
||||
params: list = [since_rowid]
|
||||
if only_unexported:
|
||||
clauses.append("exported = 0")
|
||||
where = " AND ".join(clauses)
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM online_decisions WHERE {where} ORDER BY id LIMIT ?",
|
||||
(*params, limit),
|
||||
).fetchall()
|
||||
results = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
try:
|
||||
d["snapshot"] = json.loads(d["snapshot"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
results.append(d)
|
||||
return results
|
||||
|
||||
def mark_exported(self, up_to_rowid: int) -> int:
|
||||
"""Mark decisions as exported so they aren't fetched again.
|
||||
|
||||
Returns the number of rows updated.
|
||||
"""
|
||||
conn = self._get_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE online_decisions SET exported = 1 WHERE id <= ? AND exported = 0",
|
||||
(up_to_rowid,),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
def count(self, only_unexported: bool = False) -> int:
|
||||
"""Return the total number of stored decisions."""
|
||||
conn = self._get_conn()
|
||||
if only_unexported:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM online_decisions WHERE exported = 0"
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM online_decisions"
|
||||
).fetchone()
|
||||
return row[0]
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab - Training Subpackage
|
||||
==========================================
|
||||
Provides RL training infrastructure including PPO, rollout buffers,
|
||||
Forge episode generation, self-play, and distillation.
|
||||
"""
|
||||
|
||||
from ml.training.rollout_buffer import RolloutBuffer
|
||||
from ml.training.reward import compute_intermediate_reward, compute_terminal_reward, RewardConfig
|
||||
from ml.training.decision_exporter import DecisionExporter
|
||||
from ml.training.forge_episode_generator import (
|
||||
ForgeTransition,
|
||||
ForgeEpisode,
|
||||
ForgeEpisodeQueue,
|
||||
forge_producer,
|
||||
ppo_consumer,
|
||||
run_forge_pipeline,
|
||||
parse_forge_jsonl,
|
||||
snapshots_to_episode,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Rollout & Buffers
|
||||
"RolloutBuffer",
|
||||
# Rewards
|
||||
"compute_intermediate_reward",
|
||||
"compute_terminal_reward",
|
||||
"RewardConfig",
|
||||
# Decision export
|
||||
"DecisionExporter",
|
||||
# Forge episode generation (Phase 3)
|
||||
"ForgeTransition",
|
||||
"ForgeEpisode",
|
||||
"ForgeEpisodeQueue",
|
||||
"forge_producer",
|
||||
"ppo_consumer",
|
||||
"run_forge_pipeline",
|
||||
"parse_forge_jsonl",
|
||||
"snapshots_to_episode",
|
||||
]
|
||||
@@ -1,286 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab — PPO Decision Exporter
|
||||
════════════════════════════════════════
|
||||
|
||||
Exports PPO self-play decisions as JSONL files in the same schema
|
||||
as Forge DecisionSnapshot records. This enables the closed-loop
|
||||
distillation pipeline where PPO-discovered strategies flow back
|
||||
into the supervised training dataset.
|
||||
|
||||
Output files: data/results/ml-decisions-ppo-{batch_id}.jsonl
|
||||
|
||||
Each line is a JSON object compatible with the dataset_builder.py
|
||||
ingestion pipeline, tagged with source="ppo" metadata.
|
||||
|
||||
Ref: docs/CLOSED-LOOP-DISTILLATION.md — Phase 1
|
||||
Issue: #65
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import sys
|
||||
|
||||
project_root = str(Path(__file__).parent.parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from ml.config.scope import IDX_TO_ACTION, MacroAction
|
||||
|
||||
logger = logging.getLogger("ml.decision_exporter")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ExporterConfig:
|
||||
"""Configuration for the PPO decision exporter."""
|
||||
|
||||
output_dir: str = "data/results"
|
||||
only_wins: bool = True # Only export decisions from winning games
|
||||
model_version: str = "unknown"
|
||||
min_episode_steps: int = 3 # Skip very short episodes (likely noise)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decision Record Builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _macro_action_to_forge_type(action: MacroAction) -> str:
|
||||
"""Map a MacroAction enum to the closest Forge action type string."""
|
||||
mapping = {
|
||||
MacroAction.CAST_CREATURE: "cast_spell",
|
||||
MacroAction.CAST_REMOVAL: "cast_spell",
|
||||
MacroAction.CAST_DRAW: "cast_spell",
|
||||
MacroAction.CAST_RAMP: "cast_spell",
|
||||
MacroAction.CAST_COMMANDER: "cast_spell",
|
||||
MacroAction.ATTACK_OPPONENT: "attack",
|
||||
MacroAction.HOLD_MANA: "pass_priority",
|
||||
MacroAction.PASS: "pass_priority",
|
||||
}
|
||||
return mapping.get(action, "pass_priority")
|
||||
|
||||
|
||||
def build_decision_record(
|
||||
game_state_snapshot: dict,
|
||||
action_idx: int,
|
||||
reward: float,
|
||||
agent_seat: int,
|
||||
game_id: str,
|
||||
game_outcome: str,
|
||||
archetype: str = "midrange",
|
||||
source: str = "ppo",
|
||||
model_version: str = "unknown",
|
||||
episode_return: float = 0.0,
|
||||
) -> dict:
|
||||
"""
|
||||
Build a single decision record in Forge-compatible JSONL format.
|
||||
|
||||
The record mirrors the schema consumed by dataset_builder.py so that
|
||||
PPO-generated data can be mixed into the supervised training set.
|
||||
"""
|
||||
action = IDX_TO_ACTION[action_idx]
|
||||
players = game_state_snapshot.get("players", [])
|
||||
|
||||
# Build per-player state matching Forge snapshot layout
|
||||
player_snapshots = []
|
||||
for i, p in enumerate(players):
|
||||
player_snapshots.append({
|
||||
"seat": i,
|
||||
"life": p.get("life_total", 40),
|
||||
"handSize": p.get("cards_in_hand", 0),
|
||||
"permanentCount": p.get("creatures_on_battlefield", 0),
|
||||
"graveyardSize": p.get("cards_in_graveyard", 0),
|
||||
"manaAvailable": p.get("mana_available", 0),
|
||||
"commanderDamageTaken": p.get("commander_damage_taken", 0),
|
||||
"totalPowerOnBoard": p.get("total_power_on_board", 0),
|
||||
"commanderTax": p.get("commander_tax", 0),
|
||||
})
|
||||
|
||||
return {
|
||||
# --- Core fields consumed by dataset_builder / labeler ---
|
||||
"game_id": game_id,
|
||||
"game_outcome": game_outcome,
|
||||
"archetype": archetype,
|
||||
|
||||
# Action info (used by ml.actions.labeler)
|
||||
"action": {
|
||||
"type": _macro_action_to_forge_type(action),
|
||||
"macro_action": action.value,
|
||||
"action_idx": action_idx,
|
||||
"seat": agent_seat,
|
||||
},
|
||||
|
||||
# Board / game state
|
||||
"turn": game_state_snapshot.get("turn", 1),
|
||||
"phase": game_state_snapshot.get("phase", "main_1"),
|
||||
"active_seat": game_state_snapshot.get("active_seat", 0),
|
||||
"players": player_snapshots,
|
||||
|
||||
# --- PPO-specific metadata (distinguishes from Forge data) ---
|
||||
"source": source,
|
||||
"model_version": model_version,
|
||||
"reward": round(reward, 6),
|
||||
"episode_return": round(episode_return, 6),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DecisionExporter — accumulates episode decisions and flushes to JSONL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DecisionExporter:
|
||||
"""
|
||||
Collects decisions from PPO self-play episodes and writes them
|
||||
to JSONL files compatible with the supervised training pipeline.
|
||||
|
||||
Usage::
|
||||
|
||||
exporter = DecisionExporter(ExporterConfig(model_version="gen-0"))
|
||||
|
||||
for ep in range(num_episodes):
|
||||
exporter.begin_episode(agent_seat=0, playstyle="midrange")
|
||||
for step in episode_steps:
|
||||
exporter.record_step(state_snapshot, action_idx, reward)
|
||||
exporter.end_episode(won=True, episode_return=3.14)
|
||||
|
||||
exporter.flush() # writes JSONL to disk
|
||||
print(exporter.stats)
|
||||
"""
|
||||
|
||||
def __init__(self, config: ExporterConfig = None):
|
||||
self.config = config or ExporterConfig()
|
||||
self._batch_id = str(uuid.uuid4())[:8]
|
||||
self._records: List[dict] = []
|
||||
self._episode_buffer: List[dict] = []
|
||||
self._current_game_id: Optional[str] = None
|
||||
self._current_seat: int = 0
|
||||
self._current_playstyle: str = "midrange"
|
||||
|
||||
# Stats
|
||||
self._total_episodes = 0
|
||||
self._exported_episodes = 0
|
||||
self._exported_decisions = 0
|
||||
self._skipped_losses = 0
|
||||
self._skipped_short = 0
|
||||
|
||||
# -- Episode lifecycle ---------------------------------------------------
|
||||
|
||||
def begin_episode(self, agent_seat: int = 0, playstyle: str = "midrange"):
|
||||
"""Start tracking a new episode."""
|
||||
self._episode_buffer = []
|
||||
self._current_game_id = f"ppo-{self._batch_id}-{self._total_episodes}"
|
||||
self._current_seat = agent_seat
|
||||
self._current_playstyle = playstyle
|
||||
|
||||
def record_step(
|
||||
self,
|
||||
game_state_snapshot: dict,
|
||||
action_idx: int,
|
||||
reward: float,
|
||||
):
|
||||
"""Record a single decision step within the current episode."""
|
||||
self._episode_buffer.append({
|
||||
"snapshot": game_state_snapshot,
|
||||
"action_idx": action_idx,
|
||||
"reward": reward,
|
||||
})
|
||||
|
||||
def end_episode(self, won: bool, episode_return: float = 0.0):
|
||||
"""
|
||||
Finalize the current episode.
|
||||
|
||||
If config.only_wins is True, decisions from losing episodes
|
||||
are discarded (higher signal-to-noise for distillation).
|
||||
"""
|
||||
self._total_episodes += 1
|
||||
|
||||
# Gate: skip losses if configured
|
||||
if self.config.only_wins and not won:
|
||||
self._skipped_losses += 1
|
||||
self._episode_buffer = []
|
||||
return
|
||||
|
||||
# Gate: skip very short episodes
|
||||
if len(self._episode_buffer) < self.config.min_episode_steps:
|
||||
self._skipped_short += 1
|
||||
self._episode_buffer = []
|
||||
return
|
||||
|
||||
game_outcome = "win" if won else "loss"
|
||||
|
||||
for step in self._episode_buffer:
|
||||
record = build_decision_record(
|
||||
game_state_snapshot=step["snapshot"],
|
||||
action_idx=step["action_idx"],
|
||||
reward=step["reward"],
|
||||
agent_seat=self._current_seat,
|
||||
game_id=self._current_game_id,
|
||||
game_outcome=game_outcome,
|
||||
archetype=self._current_playstyle,
|
||||
source="ppo",
|
||||
model_version=self.config.model_version,
|
||||
episode_return=episode_return,
|
||||
)
|
||||
self._records.append(record)
|
||||
|
||||
self._exported_episodes += 1
|
||||
self._exported_decisions += len(self._episode_buffer)
|
||||
self._episode_buffer = []
|
||||
|
||||
# -- Persistence ---------------------------------------------------------
|
||||
|
||||
def flush(self) -> Optional[str]:
|
||||
"""
|
||||
Write all accumulated records to a JSONL file.
|
||||
|
||||
Returns:
|
||||
Path to the written file, or None if no records to write.
|
||||
"""
|
||||
if not self._records:
|
||||
logger.info("No PPO decisions to export (0 records).")
|
||||
return None
|
||||
|
||||
os.makedirs(self.config.output_dir, exist_ok=True)
|
||||
filename = f"ml-decisions-ppo-{self._batch_id}.jsonl"
|
||||
filepath = os.path.join(self.config.output_dir, filename)
|
||||
|
||||
with open(filepath, "w") as f:
|
||||
for record in self._records:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
|
||||
logger.info(
|
||||
"Exported %d PPO decisions (%d episodes) to %s",
|
||||
len(self._records),
|
||||
self._exported_episodes,
|
||||
filepath,
|
||||
)
|
||||
self._records = []
|
||||
return filepath
|
||||
|
||||
# -- Reporting -----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def stats(self) -> dict:
|
||||
"""Return export statistics."""
|
||||
return {
|
||||
"batch_id": self._batch_id,
|
||||
"total_episodes": self._total_episodes,
|
||||
"exported_episodes": self._exported_episodes,
|
||||
"exported_decisions": self._exported_decisions,
|
||||
"skipped_losses": self._skipped_losses,
|
||||
"skipped_short": self._skipped_short,
|
||||
"pending_records": len(self._records),
|
||||
}
|
||||
|
||||
@property
|
||||
def batch_id(self) -> str:
|
||||
return self._batch_id
|
||||
@@ -1,908 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab — Distillation Loop Orchestrator
|
||||
═══════════════════════════════════════════════════
|
||||
|
||||
Phase 4 (Issue #68): Automates the full closed-loop distillation cycle:
|
||||
|
||||
1. Supervised train on current dataset (Forge + any existing PPO data)
|
||||
2. Run N episodes of PPO self-play using the supervised model as starting policy
|
||||
3. Export winning PPO decisions to JSONL
|
||||
4. Quality gate check
|
||||
5. If passed → merge into dataset and repeat from step 1
|
||||
6. If failed → discard PPO batch, adjust hyperparameters, retry
|
||||
|
||||
Each iteration is a "generation" — data and models are tagged with generation
|
||||
number for traceability.
|
||||
|
||||
Convergence detection:
|
||||
- Stops if win rate plateaus across `convergence_window` generations
|
||||
- Hard stop at `max_iterations`
|
||||
|
||||
Ref: docs/CLOSED-LOOP-DISTILLATION.md — Phase 4
|
||||
Issue: #68
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
project_root = str(Path(__file__).parent.parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from ml.config.scope import (
|
||||
NUM_ACTIONS, STATE_DIMS, TRAINING_CONFIG,
|
||||
)
|
||||
from ml.data.quality_gate import QualityGate, QualityGateConfig, GateVerdict
|
||||
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
|
||||
|
||||
logger = logging.getLogger("ml.distillation")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Configuration
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@dataclass
|
||||
class DistillationConfig:
|
||||
"""Configuration for the distillation loop orchestrator."""
|
||||
|
||||
# --- Loop control ---
|
||||
max_iterations: int = 10
|
||||
convergence_window: int = 3 # Check last N generations for plateau
|
||||
convergence_threshold: float = 0.01 # Min win-rate improvement to not plateau
|
||||
|
||||
# --- Paths ---
|
||||
results_dir: str = "results"
|
||||
models_dir: str = "ml/models"
|
||||
checkpoint_dir: str = "ml/models/checkpoints"
|
||||
history_dir: str = "results/distillation-history"
|
||||
|
||||
# --- Supervised training ---
|
||||
supervised_epochs: int = 30
|
||||
supervised_lr: float = 1e-3
|
||||
supervised_batch_size: int = 64
|
||||
supervised_patience: int = 5
|
||||
|
||||
# --- PPO self-play ---
|
||||
ppo_iterations: int = 50
|
||||
ppo_episodes_per_iter: int = 64
|
||||
ppo_batch_size: int = 256
|
||||
ppo_lr: float = 3e-4
|
||||
ppo_clip_epsilon: float = 0.2
|
||||
ppo_entropy_coeff: float = 0.01
|
||||
ppo_eval_episodes: int = 100
|
||||
opponent: str = "heuristic"
|
||||
playstyle: str = "midrange"
|
||||
|
||||
# --- Dataset building (Forge-only default; see MIXED_MODE_PRESETS) ---
|
||||
forge_weight: float = 1.0
|
||||
ppo_weight: float = 0.0
|
||||
min_reward_threshold: float = 0.0
|
||||
|
||||
# --- Quality gate ---
|
||||
min_forge_accuracy: float = 0.35
|
||||
max_accuracy_drop: float = 0.03
|
||||
min_ppo_win_rate: float = 0.30
|
||||
|
||||
# --- Retry on gate failure ---
|
||||
max_retries_per_generation: int = 2
|
||||
retry_lr_factor: float = 0.5 # Halve PPO LR on retry
|
||||
retry_entropy_boost: float = 0.005 # Increase entropy coeff on retry
|
||||
|
||||
# --- ELO convergence (Phase 6) ---
|
||||
elo_convergence_enabled: bool = False # Off by default
|
||||
elo_convergence_threshold: float = 20.0 # Stop if ELO gain < this
|
||||
elo_tournament_episodes: int = 30 # Games per matchup for ELO mini-tournament
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Generation metadata
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@dataclass
|
||||
class GenerationRecord:
|
||||
"""Tracks metadata for a single distillation generation."""
|
||||
generation: int
|
||||
started_at: str = ""
|
||||
completed_at: str = ""
|
||||
status: str = "pending" # pending | running | passed | failed | skipped
|
||||
|
||||
# Supervised training results
|
||||
supervised_val_acc: float = 0.0
|
||||
supervised_best_epoch: int = 0
|
||||
supervised_checkpoint: str = ""
|
||||
|
||||
# PPO self-play results
|
||||
ppo_win_rate: float = 0.0
|
||||
ppo_best_win_rate: float = 0.0
|
||||
ppo_episodes: int = 0
|
||||
ppo_decisions_exported: int = 0
|
||||
ppo_export_path: str = ""
|
||||
|
||||
# Quality gate
|
||||
gate_accepted: bool = False
|
||||
gate_reason: str = ""
|
||||
baseline_accuracy: float = 0.0
|
||||
post_merge_accuracy: float = 0.0
|
||||
|
||||
# Dataset stats
|
||||
dataset_size: int = 0
|
||||
forge_samples: int = 0
|
||||
ppo_samples: int = 0
|
||||
|
||||
retries: int = 0
|
||||
error: str = ""
|
||||
elo_rating: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {k: v for k, v in self.__dict__.items()}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Distillation Loop Orchestrator
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
class DistillationLoop:
|
||||
"""
|
||||
Orchestrates the full closed-loop distillation pipeline.
|
||||
|
||||
Each "generation" runs the complete cycle:
|
||||
supervised train → PPO self-play → export → quality gate → merge
|
||||
|
||||
The loop tracks convergence by monitoring PPO win rate across
|
||||
generations and stops when improvement plateaus or max_iterations
|
||||
is reached.
|
||||
|
||||
Usage::
|
||||
|
||||
config = DistillationConfig(max_iterations=5, ppo_iterations=30)
|
||||
loop = DistillationLoop(config)
|
||||
summary = loop.run()
|
||||
"""
|
||||
|
||||
def __init__(self, config: DistillationConfig = None):
|
||||
self.config = config or DistillationConfig()
|
||||
self.generations: List[GenerationRecord] = []
|
||||
self.quality_gate = QualityGate(QualityGateConfig(
|
||||
min_forge_accuracy=self.config.min_forge_accuracy,
|
||||
max_accuracy_drop=self.config.max_accuracy_drop,
|
||||
min_ppo_win_rate=self.config.min_ppo_win_rate,
|
||||
))
|
||||
self._baseline_accuracy: Optional[float] = None
|
||||
self._device: Optional[str] = None
|
||||
self._stopped = False
|
||||
|
||||
# Ensure output directories exist
|
||||
os.makedirs(self.config.history_dir, exist_ok=True)
|
||||
os.makedirs(self.config.checkpoint_dir, exist_ok=True)
|
||||
os.makedirs(self.config.results_dir, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self, progress_callback=None) -> Dict:
|
||||
"""
|
||||
Run the full distillation loop.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callable(generation, record) for
|
||||
progress reporting (e.g. to a web UI).
|
||||
|
||||
Returns:
|
||||
Summary dict with per-generation metrics and convergence info.
|
||||
"""
|
||||
cfg = self.config
|
||||
self._device = self._detect_device()
|
||||
|
||||
logger.info("=" * 65)
|
||||
logger.info(" Commander AI Lab — Distillation Loop")
|
||||
logger.info("=" * 65)
|
||||
logger.info(" Max iterations: %d", cfg.max_iterations)
|
||||
logger.info(" Convergence window: %d", cfg.convergence_window)
|
||||
logger.info(" PPO iters/gen: %d", cfg.ppo_iterations)
|
||||
logger.info(" PPO episodes/iter: %d", cfg.ppo_episodes_per_iter)
|
||||
logger.info(" Supervised epochs: %d", cfg.supervised_epochs)
|
||||
logger.info(" Device: %s", self._device)
|
||||
logger.info(" Results dir: %s", cfg.results_dir)
|
||||
logger.info("=" * 65)
|
||||
|
||||
loop_start = time.time()
|
||||
|
||||
for gen_num in range(1, cfg.max_iterations + 1):
|
||||
if self._stopped:
|
||||
logger.info("Loop stopped by external signal.")
|
||||
break
|
||||
|
||||
logger.info("")
|
||||
logger.info("─" * 65)
|
||||
logger.info(" GENERATION %d / %d", gen_num, cfg.max_iterations)
|
||||
logger.info("─" * 65)
|
||||
|
||||
record = self._run_generation(gen_num)
|
||||
self.generations.append(record)
|
||||
|
||||
# Save generation history to disk after each round
|
||||
self._save_history()
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(gen_num, record)
|
||||
|
||||
# Check convergence
|
||||
if self._check_convergence():
|
||||
logger.info(
|
||||
"Convergence detected — win rate plateaued over "
|
||||
"last %d generations. Stopping.",
|
||||
cfg.convergence_window,
|
||||
)
|
||||
break
|
||||
|
||||
total_time = time.time() - loop_start
|
||||
|
||||
summary = self._build_summary(total_time)
|
||||
self._save_summary(summary)
|
||||
|
||||
logger.info("")
|
||||
logger.info("=" * 65)
|
||||
logger.info(" Distillation Loop Complete")
|
||||
logger.info("=" * 65)
|
||||
logger.info(" Generations run: %d", summary["generations_run"])
|
||||
logger.info(" Generations passed: %d", summary["generations_passed"])
|
||||
logger.info(" Best win rate: %.1f%%", summary["best_win_rate"] * 100)
|
||||
logger.info(" Converged: %s", summary["converged"])
|
||||
logger.info(" Total time: %.1f s", total_time)
|
||||
logger.info("=" * 65)
|
||||
|
||||
return summary
|
||||
|
||||
def stop(self):
|
||||
"""Signal the loop to stop after the current generation."""
|
||||
self._stopped = True
|
||||
logger.info("Stop signal received — will halt after current generation.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Single generation execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_generation(self, gen_num: int) -> GenerationRecord:
|
||||
"""Execute a single distillation generation (steps 1-6)."""
|
||||
record = GenerationRecord(
|
||||
generation=gen_num,
|
||||
started_at=datetime.now(timezone.utc).isoformat(),
|
||||
status="running",
|
||||
)
|
||||
|
||||
cfg = self.config
|
||||
gen_tag = f"gen-{gen_num:03d}"
|
||||
gen_checkpoint_dir = os.path.join(cfg.checkpoint_dir, gen_tag)
|
||||
os.makedirs(gen_checkpoint_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# ─── Step 1: Supervised Train ───────────────────────────
|
||||
logger.info(" [Step 1] Supervised training on current dataset...")
|
||||
sup_result = self._supervised_train(gen_checkpoint_dir, gen_tag)
|
||||
|
||||
if sup_result is None:
|
||||
record.status = "failed"
|
||||
record.error = "Supervised training failed — no dataset found"
|
||||
record.completed_at = datetime.now(timezone.utc).isoformat()
|
||||
return record
|
||||
|
||||
record.supervised_val_acc = sup_result["best_val_acc"]
|
||||
record.supervised_best_epoch = sup_result["best_epoch"]
|
||||
record.supervised_checkpoint = sup_result["checkpoint_path"]
|
||||
|
||||
# Set baseline accuracy on first generation
|
||||
if self._baseline_accuracy is None:
|
||||
self._baseline_accuracy = sup_result["best_val_acc"]
|
||||
logger.info(
|
||||
" Baseline accuracy set: %.3f (generation 1)",
|
||||
self._baseline_accuracy,
|
||||
)
|
||||
|
||||
# ─── Step 2-3: PPO Self-Play + Export ───────────────────
|
||||
retries = 0
|
||||
ppo_lr = cfg.ppo_lr
|
||||
entropy_coeff = cfg.ppo_entropy_coeff
|
||||
gate_passed = False
|
||||
|
||||
while retries <= cfg.max_retries_per_generation and not gate_passed:
|
||||
if retries > 0:
|
||||
logger.info(
|
||||
" [Retry %d/%d] Adjusting hyperparameters: "
|
||||
"lr=%.2e → %.2e, entropy=%.4f → %.4f",
|
||||
retries, cfg.max_retries_per_generation,
|
||||
ppo_lr, ppo_lr * cfg.retry_lr_factor,
|
||||
entropy_coeff, entropy_coeff + cfg.retry_entropy_boost,
|
||||
)
|
||||
ppo_lr *= cfg.retry_lr_factor
|
||||
entropy_coeff += cfg.retry_entropy_boost
|
||||
|
||||
logger.info(
|
||||
" [Step 2] PPO self-play (%d iters × %d episodes)...",
|
||||
cfg.ppo_iterations, cfg.ppo_episodes_per_iter,
|
||||
)
|
||||
ppo_result, exporter = self._ppo_self_play(
|
||||
supervised_checkpoint=sup_result["checkpoint_path"],
|
||||
gen_tag=gen_tag,
|
||||
gen_checkpoint_dir=gen_checkpoint_dir,
|
||||
learning_rate=ppo_lr,
|
||||
entropy_coeff=entropy_coeff,
|
||||
)
|
||||
|
||||
record.ppo_win_rate = ppo_result["final_win_rate"]
|
||||
record.ppo_best_win_rate = ppo_result["best_win_rate"]
|
||||
record.ppo_episodes = (
|
||||
cfg.ppo_iterations * cfg.ppo_episodes_per_iter
|
||||
)
|
||||
|
||||
logger.info(
|
||||
" [Step 3] Exporting PPO decisions (win rate: %.1f%%)...",
|
||||
ppo_result["best_win_rate"] * 100,
|
||||
)
|
||||
export_path = exporter.flush()
|
||||
record.ppo_decisions_exported = exporter.stats["exported_decisions"]
|
||||
record.ppo_export_path = export_path or ""
|
||||
|
||||
# ─── Step 4: Quality Gate ───────────────────────────
|
||||
logger.info(" [Step 4] Quality gate check...")
|
||||
verdict = self._quality_gate_check(
|
||||
ppo_win_rate=ppo_result["best_win_rate"],
|
||||
total_episodes=record.ppo_episodes,
|
||||
exporter=exporter,
|
||||
)
|
||||
|
||||
if verdict.accepted:
|
||||
gate_passed = True
|
||||
record.gate_accepted = True
|
||||
record.gate_reason = verdict.reason
|
||||
logger.info(" QUALITY GATE: PASSED — %s", verdict.reason)
|
||||
else:
|
||||
retries += 1
|
||||
record.retries = retries
|
||||
logger.warning(
|
||||
" QUALITY GATE: FAILED — %s", verdict.reason,
|
||||
)
|
||||
|
||||
if retries > cfg.max_retries_per_generation:
|
||||
# ─── Step 6: Discard PPO batch ──────────────
|
||||
logger.warning(
|
||||
" [Step 6] Max retries exceeded — "
|
||||
"discarding PPO batch for generation %d.",
|
||||
gen_num,
|
||||
)
|
||||
if export_path and os.path.exists(export_path):
|
||||
os.remove(export_path)
|
||||
logger.info(
|
||||
" Deleted rejected PPO data: %s", export_path
|
||||
)
|
||||
record.gate_accepted = False
|
||||
record.gate_reason = (
|
||||
f"Failed after {retries - 1} retries: "
|
||||
f"{verdict.reason}"
|
||||
)
|
||||
|
||||
# ─── Step 5: Merge into dataset (if passed) ─────────────
|
||||
if gate_passed:
|
||||
logger.info(
|
||||
" [Step 5] PPO data accepted — will be included "
|
||||
"in next generation's dataset build."
|
||||
)
|
||||
record.status = "passed"
|
||||
|
||||
# ─── ELO mini-tournament (Phase 6) ────────────────
|
||||
if cfg.elo_convergence_enabled and gen_num > 1:
|
||||
elo = self._run_elo_mini_tournament(
|
||||
gen_checkpoint_dir, gen_num
|
||||
)
|
||||
if elo is not None:
|
||||
record.elo_rating = elo
|
||||
logger.info(" ELO rating: %.1f", elo)
|
||||
else:
|
||||
record.status = "failed"
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(" Generation %d failed with error: %s", gen_num, e)
|
||||
record.status = "failed"
|
||||
record.error = str(e)
|
||||
|
||||
record.completed_at = datetime.now(timezone.utc).isoformat()
|
||||
return record
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1: Supervised Training
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _supervised_train(
|
||||
self,
|
||||
gen_checkpoint_dir: str,
|
||||
gen_tag: str,
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Build dataset from all available data and train supervised model.
|
||||
|
||||
Returns training summary dict or None on failure.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
except ImportError:
|
||||
raise RuntimeError("PyTorch required for distillation loop")
|
||||
|
||||
cfg = self.config
|
||||
|
||||
# Build dataset (picks up both Forge + any PPO JSONL files in results_dir)
|
||||
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
|
||||
|
||||
try:
|
||||
dataset = build_dataset(
|
||||
results_dir=cfg.results_dir,
|
||||
max_samples=None,
|
||||
source_weights={
|
||||
"forge": cfg.forge_weight,
|
||||
"ppo": cfg.ppo_weight,
|
||||
},
|
||||
min_reward_threshold=cfg.min_reward_threshold,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.warning("Dataset build failed: %s", e)
|
||||
return None
|
||||
|
||||
if not dataset or len(dataset.get("states", [])) == 0:
|
||||
logger.warning("No training data available.")
|
||||
return None
|
||||
|
||||
# Split
|
||||
train_data, val_data, test_data = split_dataset(dataset)
|
||||
|
||||
# Save splits (overwrite each generation — latest always at standard path)
|
||||
data_dir = cfg.models_dir
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
save_dataset(train_data, os.path.join(data_dir, "dataset-train.npz"))
|
||||
save_dataset(val_data, os.path.join(data_dir, "dataset-val.npz"))
|
||||
save_dataset(test_data, os.path.join(data_dir, "dataset-test.npz"))
|
||||
|
||||
# Create model
|
||||
from ml.training.policy_network import PolicyNetwork
|
||||
actual_dim = train_data["states"].shape[1]
|
||||
model = PolicyNetwork(input_dim=actual_dim)
|
||||
|
||||
# Train
|
||||
from ml.training.trainer import SupervisedTrainer, get_best_device
|
||||
device = self._device or get_best_device()
|
||||
trainer = SupervisedTrainer(
|
||||
model=model,
|
||||
device=device,
|
||||
learning_rate=cfg.supervised_lr,
|
||||
batch_size=cfg.supervised_batch_size,
|
||||
epochs=cfg.supervised_epochs,
|
||||
patience=cfg.supervised_patience,
|
||||
checkpoint_dir=gen_checkpoint_dir,
|
||||
)
|
||||
|
||||
summary = trainer.train(
|
||||
train_states=train_data["states"],
|
||||
train_labels=train_data["labels"],
|
||||
val_states=val_data["states"],
|
||||
val_labels=val_data["labels"],
|
||||
)
|
||||
|
||||
# Also copy best checkpoint to the standard location
|
||||
best_src = os.path.join(gen_checkpoint_dir, "best_policy.pt")
|
||||
best_dst = os.path.join(cfg.checkpoint_dir, "best_policy.pt")
|
||||
if os.path.exists(best_src):
|
||||
shutil.copy2(best_src, best_dst)
|
||||
|
||||
return summary
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Steps 2-3: PPO Self-Play + Decision Export
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ppo_self_play(
|
||||
self,
|
||||
supervised_checkpoint: str,
|
||||
gen_tag: str,
|
||||
gen_checkpoint_dir: str,
|
||||
learning_rate: float = None,
|
||||
entropy_coeff: float = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Run PPO self-play initialized from the supervised checkpoint.
|
||||
|
||||
Returns (ppo_summary, exporter) tuple.
|
||||
"""
|
||||
cfg = self.config
|
||||
|
||||
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
|
||||
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
|
||||
|
||||
# Set up decision exporter for this generation
|
||||
exporter = DecisionExporter(ExporterConfig(
|
||||
output_dir=cfg.results_dir,
|
||||
only_wins=True,
|
||||
model_version=gen_tag,
|
||||
))
|
||||
|
||||
ppo_config = PPOConfig(
|
||||
iterations=cfg.ppo_iterations,
|
||||
episodes_per_iter=cfg.ppo_episodes_per_iter,
|
||||
ppo_epochs=4,
|
||||
batch_size=cfg.ppo_batch_size,
|
||||
clip_epsilon=cfg.ppo_clip_epsilon,
|
||||
entropy_coeff=entropy_coeff or cfg.ppo_entropy_coeff,
|
||||
learning_rate=learning_rate or cfg.ppo_lr,
|
||||
lr_schedule="cosine",
|
||||
min_lr=1e-5,
|
||||
opponent=cfg.opponent,
|
||||
playstyle=cfg.playstyle,
|
||||
checkpoint_dir=gen_checkpoint_dir,
|
||||
save_every=max(1, cfg.ppo_iterations // 5),
|
||||
eval_every=max(1, cfg.ppo_iterations // 10),
|
||||
eval_episodes=cfg.ppo_eval_episodes,
|
||||
load_supervised=supervised_checkpoint,
|
||||
)
|
||||
|
||||
ppo_trainer = PPOTrainer(ppo_config)
|
||||
|
||||
# Monkey-patch collect_rollouts to include exporter
|
||||
# The exporter is already wired into self_play.collect_rollouts
|
||||
# via the exporter parameter, so we wrap the train loop.
|
||||
original_collect = None
|
||||
|
||||
from ml.training import self_play as sp_module
|
||||
|
||||
_original_collect_rollouts = sp_module.collect_rollouts
|
||||
|
||||
def _instrumented_collect_rollouts(
|
||||
agent_model, opponent_policy, buffer,
|
||||
num_episodes=64, playstyle="midrange",
|
||||
reward_config=None, **kwargs
|
||||
):
|
||||
return _original_collect_rollouts(
|
||||
agent_model=agent_model,
|
||||
opponent_policy=opponent_policy,
|
||||
buffer=buffer,
|
||||
num_episodes=num_episodes,
|
||||
playstyle=playstyle,
|
||||
reward_config=reward_config,
|
||||
exporter=exporter,
|
||||
)
|
||||
|
||||
# Temporarily replace module-level function
|
||||
sp_module.collect_rollouts = _instrumented_collect_rollouts
|
||||
try:
|
||||
summary = ppo_trainer.train()
|
||||
finally:
|
||||
# Restore original
|
||||
sp_module.collect_rollouts = _original_collect_rollouts
|
||||
|
||||
return summary, exporter
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 4: Quality Gate
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _quality_gate_check(
|
||||
self,
|
||||
ppo_win_rate: float,
|
||||
total_episodes: int,
|
||||
exporter: DecisionExporter,
|
||||
) -> GateVerdict:
|
||||
"""Run quality gate checks on the PPO output."""
|
||||
# Win rate gate
|
||||
win_verdict = self.quality_gate.check_win_rate(
|
||||
ppo_win_rate=ppo_win_rate,
|
||||
total_episodes=total_episodes,
|
||||
)
|
||||
if not win_verdict.accepted:
|
||||
return win_verdict
|
||||
|
||||
# If we have baseline accuracy and this isn't the first generation,
|
||||
# we could run a post-merge accuracy check here. For now, the win
|
||||
# rate gate is the primary gatekeeper during the loop. The accuracy
|
||||
# gate will be checked on the next generation's supervised training
|
||||
# by comparing against self._baseline_accuracy.
|
||||
return GateVerdict(
|
||||
accepted=True,
|
||||
reason=(
|
||||
f"Win rate gate passed ({ppo_win_rate:.1%} >= "
|
||||
f"{self.config.min_ppo_win_rate:.1%}). "
|
||||
f"PPO data accepted for merge."
|
||||
),
|
||||
metrics={
|
||||
"ppo_win_rate": ppo_win_rate,
|
||||
"total_episodes": total_episodes,
|
||||
"exported_decisions": exporter.stats["exported_decisions"],
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ELO mini-tournament (Phase 6)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_elo_mini_tournament(
|
||||
self, gen_checkpoint_dir: str, gen_num: int
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Run a small ELO tournament between current gen, previous gen,
|
||||
and heuristic baseline. Returns the current gen's ELO rating.
|
||||
"""
|
||||
cfg = self.config
|
||||
try:
|
||||
from ml.eval.elo_tracker import (
|
||||
run_elo_tournament, EloHistory, HeuristicPolicy, LearnedPolicy,
|
||||
)
|
||||
|
||||
policies = {"heuristic": HeuristicPolicy()}
|
||||
|
||||
# Current generation checkpoint
|
||||
curr_path = os.path.join(gen_checkpoint_dir, "best_policy.pt")
|
||||
curr_name = f"gen-{gen_num:03d}"
|
||||
if os.path.exists(curr_path):
|
||||
policies[curr_name] = LearnedPolicy(curr_path)
|
||||
else:
|
||||
logger.warning(" ELO: current gen checkpoint not found: %s", curr_path)
|
||||
return None
|
||||
|
||||
# Previous generation checkpoint
|
||||
if gen_num > 1:
|
||||
prev_tag = f"gen-{gen_num - 1:03d}"
|
||||
prev_dir = os.path.join(cfg.checkpoint_dir, prev_tag)
|
||||
prev_path = os.path.join(prev_dir, "best_policy.pt")
|
||||
if os.path.exists(prev_path):
|
||||
policies[prev_tag] = LearnedPolicy(prev_path)
|
||||
|
||||
logger.info(
|
||||
" [ELO] Running mini-tournament: %s (%d episodes/matchup)",
|
||||
list(policies.keys()), cfg.elo_tournament_episodes,
|
||||
)
|
||||
|
||||
result = run_elo_tournament(
|
||||
policies=policies,
|
||||
episodes_per_matchup=cfg.elo_tournament_episodes,
|
||||
playstyle=cfg.playstyle,
|
||||
)
|
||||
|
||||
# Save to ELO history
|
||||
history_path = os.path.join(
|
||||
os.path.dirname(cfg.history_dir), "elo_history.json"
|
||||
) if cfg.history_dir else "data/elo_history.json"
|
||||
history = EloHistory(path=history_path)
|
||||
history.append(gen_num, result.ratings)
|
||||
|
||||
return result.ratings.get(curr_name, 0.0)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(" ELO mini-tournament failed: %s", e)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Convergence detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_convergence(self) -> bool:
|
||||
"""
|
||||
Check if win rate has plateaued over recent generations.
|
||||
Also checks ELO delta when elo_convergence_enabled is True.
|
||||
|
||||
Returns True if the loop should stop.
|
||||
"""
|
||||
cfg = self.config
|
||||
passed = [g for g in self.generations if g.status == "passed"]
|
||||
|
||||
if len(passed) < cfg.convergence_window:
|
||||
return False
|
||||
|
||||
recent = passed[-cfg.convergence_window:]
|
||||
win_rates = [g.ppo_best_win_rate for g in recent]
|
||||
|
||||
# Check if max improvement across the window is below threshold
|
||||
improvement = max(win_rates) - min(win_rates)
|
||||
|
||||
logger.info(
|
||||
" Convergence check: last %d win rates = %s, "
|
||||
"spread = %.4f (threshold = %.4f)",
|
||||
cfg.convergence_window,
|
||||
[f"{wr:.3f}" for wr in win_rates],
|
||||
improvement,
|
||||
cfg.convergence_threshold,
|
||||
)
|
||||
|
||||
win_rate_converged = improvement < cfg.convergence_threshold
|
||||
|
||||
# ELO convergence check (Phase 6)
|
||||
if cfg.elo_convergence_enabled and len(passed) >= 2:
|
||||
elo_ratings = [g.elo_rating for g in passed[-2:] if g.elo_rating > 0]
|
||||
if len(elo_ratings) >= 2:
|
||||
elo_delta = elo_ratings[-1] - elo_ratings[-2]
|
||||
logger.info(
|
||||
" ELO convergence check: delta = %.1f (threshold = %.1f)",
|
||||
elo_delta, cfg.elo_convergence_threshold,
|
||||
)
|
||||
if elo_delta < cfg.elo_convergence_threshold:
|
||||
logger.info(" ELO delta below threshold — converged.")
|
||||
return True
|
||||
|
||||
return win_rate_converged
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Device detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _detect_device(self) -> str:
|
||||
"""Auto-detect best compute device."""
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except ImportError:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# History & summary persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _save_history(self):
|
||||
"""Save generation history to JSON."""
|
||||
path = os.path.join(self.config.history_dir, "generations.json")
|
||||
history = [g.to_dict() for g in self.generations]
|
||||
with open(path, "w") as f:
|
||||
json.dump(history, f, indent=2)
|
||||
logger.info(" Generation history saved: %s", path)
|
||||
|
||||
def _build_summary(self, total_time: float) -> Dict:
|
||||
"""Build final summary dict."""
|
||||
passed = [g for g in self.generations if g.status == "passed"]
|
||||
failed = [g for g in self.generations if g.status == "failed"]
|
||||
|
||||
best_wr = max(
|
||||
(g.ppo_best_win_rate for g in self.generations), default=0.0
|
||||
)
|
||||
|
||||
return {
|
||||
"generations_run": len(self.generations),
|
||||
"generations_passed": len(passed),
|
||||
"generations_failed": len(failed),
|
||||
"best_win_rate": best_wr,
|
||||
"converged": self._check_convergence() if len(passed) >= self.config.convergence_window else False,
|
||||
"total_time_s": round(total_time, 1),
|
||||
"device": self._device,
|
||||
"config": {
|
||||
"max_iterations": self.config.max_iterations,
|
||||
"convergence_window": self.config.convergence_window,
|
||||
"convergence_threshold": self.config.convergence_threshold,
|
||||
"ppo_iterations": self.config.ppo_iterations,
|
||||
"supervised_epochs": self.config.supervised_epochs,
|
||||
},
|
||||
"generations": [g.to_dict() for g in self.generations],
|
||||
}
|
||||
|
||||
def _save_summary(self, summary: Dict):
|
||||
"""Save final summary to JSON."""
|
||||
path = os.path.join(self.config.history_dir, "summary.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(summary, f, indent=2, default=str)
|
||||
logger.info(" Summary saved: %s", path)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# CLI Entry Point
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
"""CLI entry point for the distillation loop."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Commander AI Lab — Closed-Loop Distillation"
|
||||
)
|
||||
|
||||
# Loop control
|
||||
parser.add_argument(
|
||||
"--max-iterations", type=int, default=10,
|
||||
help="Maximum number of distillation generations (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--convergence-window", type=int, default=3,
|
||||
help="Number of generations to check for win-rate plateau (default: 3)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--convergence-threshold", type=float, default=0.01,
|
||||
help="Min win-rate spread to not be considered converged (default: 0.01)",
|
||||
)
|
||||
|
||||
# Paths
|
||||
parser.add_argument(
|
||||
"--results-dir", default="data/results",
|
||||
help="Directory containing JSONL decision files (default: data/results)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--models-dir", default="ml/models",
|
||||
help="Directory for dataset NPZ files (default: ml/models)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--checkpoint-dir", default="ml/models/checkpoints",
|
||||
help="Directory for model checkpoints (default: ml/models/checkpoints)",
|
||||
)
|
||||
|
||||
# Supervised training
|
||||
parser.add_argument("--sup-epochs", type=int, default=30)
|
||||
parser.add_argument("--sup-lr", type=float, default=1e-3)
|
||||
parser.add_argument("--sup-batch-size", type=int, default=64)
|
||||
parser.add_argument("--sup-patience", type=int, default=5)
|
||||
|
||||
# PPO self-play
|
||||
parser.add_argument("--ppo-iterations", type=int, default=50)
|
||||
parser.add_argument("--ppo-episodes", type=int, default=64)
|
||||
parser.add_argument("--ppo-lr", type=float, default=3e-4)
|
||||
parser.add_argument("--ppo-batch-size", type=int, default=256)
|
||||
parser.add_argument("--ppo-eval-episodes", type=int, default=100)
|
||||
parser.add_argument(
|
||||
"--opponent", default="heuristic",
|
||||
choices=["heuristic", "random", "self"],
|
||||
)
|
||||
parser.add_argument("--playstyle", default="midrange")
|
||||
|
||||
# Dataset
|
||||
parser.add_argument("--forge-weight", type=float, default=1.0)
|
||||
parser.add_argument("--ppo-weight", type=float, default=0.0)
|
||||
parser.add_argument("--min-reward", type=float, default=0.0)
|
||||
|
||||
# Quality gate
|
||||
parser.add_argument("--min-win-rate", type=float, default=0.30)
|
||||
parser.add_argument("--min-forge-accuracy", type=float, default=0.35)
|
||||
parser.add_argument("--max-accuracy-drop", type=float, default=0.03)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
)
|
||||
|
||||
config = DistillationConfig(
|
||||
max_iterations=args.max_iterations,
|
||||
convergence_window=args.convergence_window,
|
||||
convergence_threshold=args.convergence_threshold,
|
||||
results_dir=args.results_dir,
|
||||
models_dir=args.models_dir,
|
||||
checkpoint_dir=args.checkpoint_dir,
|
||||
supervised_epochs=args.sup_epochs,
|
||||
supervised_lr=args.sup_lr,
|
||||
supervised_batch_size=args.sup_batch_size,
|
||||
supervised_patience=args.sup_patience,
|
||||
ppo_iterations=args.ppo_iterations,
|
||||
ppo_episodes_per_iter=args.ppo_episodes,
|
||||
ppo_batch_size=args.ppo_batch_size,
|
||||
ppo_lr=args.ppo_lr,
|
||||
ppo_eval_episodes=args.ppo_eval_episodes,
|
||||
opponent=args.opponent,
|
||||
playstyle=args.playstyle,
|
||||
forge_weight=args.forge_weight,
|
||||
ppo_weight=args.ppo_weight,
|
||||
min_reward_threshold=args.min_reward,
|
||||
min_ppo_win_rate=args.min_win_rate,
|
||||
min_forge_accuracy=args.min_forge_accuracy,
|
||||
max_accuracy_drop=args.max_accuracy_drop,
|
||||
)
|
||||
|
||||
loop = DistillationLoop(config)
|
||||
summary = loop.run()
|
||||
print(json.dumps(summary, indent=2, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,352 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab — Forge Episode Generator (Phase 3.2)
|
||||
|
||||
Async producer-consumer pipeline: runs Forge batch games in parallel
|
||||
with PPO training. Forge produces episodes → shared queue → PPO
|
||||
trainer consumes.
|
||||
|
||||
Uses the existing watchdog (_global_watchdog_loop in forge_runner.py)
|
||||
to monitor Forge worker processes for stalls.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Deque, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ml.config.scope import (
|
||||
ACTION_TO_IDX,
|
||||
GAME_SCOPE,
|
||||
IDX_TO_ACTION,
|
||||
MacroAction,
|
||||
NUM_ACTIONS,
|
||||
STATE_DIMS,
|
||||
)
|
||||
from ml.training.reward import (
|
||||
RewardConfig,
|
||||
compute_intermediate_reward,
|
||||
compute_terminal_reward,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ml.forge_episode_generator")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Episode / Trajectory data structures
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
@dataclass
|
||||
class ForgeTransition:
|
||||
"""Single (s, a, r, log_prob, value, done) tuple from a Forge game."""
|
||||
state: np.ndarray
|
||||
action: int
|
||||
reward: float
|
||||
log_prob: float
|
||||
value: float
|
||||
done: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForgeEpisode:
|
||||
"""Complete trajectory from a single Forge game."""
|
||||
game_id: str
|
||||
transitions: List[ForgeTransition] = field(default_factory=list)
|
||||
winner: Optional[int] = None
|
||||
total_turns: int = 0
|
||||
deck_names: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def length(self) -> int:
|
||||
return len(self.transitions)
|
||||
|
||||
@property
|
||||
def total_reward(self) -> float:
|
||||
return sum(t.reward for t in self.transitions)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# JSONL Parser — reads Forge ML decision snapshots
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
|
||||
"""Parse a JSONL file of Forge ML decision snapshots.
|
||||
|
||||
Each line is a JSON object with keys like:
|
||||
game_id, turn, phase, active_seat, state, action, outcome
|
||||
"""
|
||||
snapshots = []
|
||||
with open(path, "r") as f:
|
||||
for lineno, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
snapshots.append(json.loads(line))
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("Skipping malformed line %d in %s: %s", lineno, path, e)
|
||||
logger.info("Parsed %d snapshots from %s", len(snapshots), path)
|
||||
return snapshots
|
||||
|
||||
|
||||
def snapshots_to_episode(
|
||||
snapshots: List[Dict[str, Any]],
|
||||
reward_config: Optional[RewardConfig] = None,
|
||||
) -> ForgeEpisode:
|
||||
"""Convert a sequence of Forge snapshots into a ForgeEpisode.
|
||||
|
||||
Maps Forge action labels → MacroAction indices and computes
|
||||
rewards from game outcomes using ml.training.reward.
|
||||
"""
|
||||
if not snapshots:
|
||||
return ForgeEpisode(game_id="empty")
|
||||
|
||||
first = snapshots[0]
|
||||
episode = ForgeEpisode(
|
||||
game_id=first.get("game_id", "unknown"),
|
||||
deck_names=first.get("deck_names", []),
|
||||
)
|
||||
|
||||
rc = reward_config or RewardConfig()
|
||||
|
||||
for i, snap in enumerate(snapshots):
|
||||
# State vector — zero-padded if not present
|
||||
state_vec = np.zeros(STATE_DIMS.total_state_dim, dtype=np.float32)
|
||||
if "state_vector" in snap:
|
||||
raw = np.array(snap["state_vector"], dtype=np.float32)
|
||||
state_vec[: min(len(raw), len(state_vec))] = raw[: len(state_vec)]
|
||||
|
||||
# Action mapping
|
||||
action_label = snap.get("action", "pass").lower().replace(" ", "_")
|
||||
try:
|
||||
macro = MacroAction(action_label)
|
||||
action_idx = ACTION_TO_IDX[macro]
|
||||
except (ValueError, KeyError):
|
||||
action_idx = ACTION_TO_IDX[MacroAction.PASS]
|
||||
|
||||
# Reward
|
||||
is_done = i == len(snapshots) - 1
|
||||
if is_done and "outcome" in snap:
|
||||
reward = compute_terminal_reward(
|
||||
won=snap["outcome"].get("won", False),
|
||||
turns=snap.get("turn", 1),
|
||||
life_remaining=snap["outcome"].get("life_remaining", 0),
|
||||
config=rc,
|
||||
)
|
||||
else:
|
||||
reward = compute_intermediate_reward(
|
||||
life_delta=snap.get("life_delta", 0),
|
||||
cards_drawn=snap.get("cards_drawn", 0),
|
||||
creatures_played=snap.get("creatures_played", 0),
|
||||
config=rc,
|
||||
)
|
||||
|
||||
episode.transitions.append(ForgeTransition(
|
||||
state=state_vec,
|
||||
action=action_idx,
|
||||
reward=reward,
|
||||
log_prob=snap.get("log_prob", 0.0),
|
||||
value=snap.get("value", 0.0),
|
||||
done=is_done,
|
||||
))
|
||||
|
||||
episode.total_turns = snapshots[-1].get("turn", len(snapshots))
|
||||
if "outcome" in snapshots[-1]:
|
||||
episode.winner = snapshots[-1]["outcome"].get("winner_seat")
|
||||
|
||||
return episode
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Async Episode Queue (producer-consumer)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
class ForgeEpisodeQueue:
|
||||
"""Thread-safe async queue bridging Forge producers and PPO consumer.
|
||||
|
||||
Forge batch workers produce ForgeEpisodes; the PPO trainer consumes
|
||||
them as they become available. Backpressure is applied when the
|
||||
queue reaches max_size.
|
||||
"""
|
||||
|
||||
def __init__(self, max_size: int = 256):
|
||||
self._queue: asyncio.Queue[ForgeEpisode] = asyncio.Queue(maxsize=max_size)
|
||||
self._produced = 0
|
||||
self._consumed = 0
|
||||
self._closed = False
|
||||
|
||||
async def put(self, episode: ForgeEpisode) -> None:
|
||||
await self._queue.put(episode)
|
||||
self._produced += 1
|
||||
|
||||
async def get(self, timeout: float = 30.0) -> Optional[ForgeEpisode]:
|
||||
try:
|
||||
ep = await asyncio.wait_for(self._queue.get(), timeout=timeout)
|
||||
self._consumed += 1
|
||||
return ep
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self._closed and self._queue.empty()
|
||||
|
||||
@property
|
||||
def stats(self) -> Dict[str, int]:
|
||||
return {
|
||||
"produced": self._produced,
|
||||
"consumed": self._consumed,
|
||||
"pending": self._queue.qsize(),
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Forge Batch Producer
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
async def forge_producer(
|
||||
queue: ForgeEpisodeQueue,
|
||||
results_dir: str = "results",
|
||||
batch_id: str = "forge-batch",
|
||||
num_games: int = 64,
|
||||
poll_interval: float = 2.0,
|
||||
reward_config: Optional[RewardConfig] = None,
|
||||
) -> int:
|
||||
"""Producer coroutine: watches for Forge JSONL output files,
|
||||
parses them into episodes, and pushes them onto the queue.
|
||||
|
||||
Returns total number of episodes produced.
|
||||
"""
|
||||
results_path = Path(results_dir)
|
||||
pattern = f"ml-decisions-forge-{batch_id}*.jsonl"
|
||||
seen_files: set = set()
|
||||
total_produced = 0
|
||||
|
||||
logger.info("[PRODUCER] Watching %s for pattern %s", results_path, pattern)
|
||||
|
||||
while total_produced < num_games and not queue.is_closed:
|
||||
for jsonl_file in sorted(results_path.glob(pattern)):
|
||||
if jsonl_file in seen_files:
|
||||
continue
|
||||
seen_files.add(jsonl_file)
|
||||
|
||||
try:
|
||||
snapshots = parse_forge_jsonl(jsonl_file)
|
||||
if not snapshots:
|
||||
continue
|
||||
|
||||
# Group by game_id
|
||||
games: Dict[str, List] = {}
|
||||
for snap in snapshots:
|
||||
gid = snap.get("game_id", "unknown")
|
||||
games.setdefault(gid, []).append(snap)
|
||||
|
||||
for gid, game_snaps in games.items():
|
||||
episode = snapshots_to_episode(game_snaps, reward_config)
|
||||
if episode.length > 0:
|
||||
await queue.put(episode)
|
||||
total_produced += 1
|
||||
logger.debug("[PRODUCER] Queued episode %s (%d transitions)",
|
||||
gid, episode.length)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[PRODUCER] Error processing %s: %s", jsonl_file, e)
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
logger.info("[PRODUCER] Done — produced %d episodes", total_produced)
|
||||
return total_produced
|
||||
|
||||
|
||||
async def ppo_consumer(
|
||||
queue: ForgeEpisodeQueue,
|
||||
buffer_size: int = 64,
|
||||
on_batch_ready=None,
|
||||
) -> int:
|
||||
"""Consumer coroutine: pulls episodes from the queue and batches
|
||||
them for PPO training updates.
|
||||
|
||||
Args:
|
||||
queue: ForgeEpisodeQueue to consume from
|
||||
buffer_size: number of episodes per training batch
|
||||
on_batch_ready: callback(episodes) when a batch is full
|
||||
|
||||
Returns total episodes consumed.
|
||||
"""
|
||||
batch: List[ForgeEpisode] = []
|
||||
total_consumed = 0
|
||||
|
||||
while not queue.is_closed:
|
||||
episode = await queue.get(timeout=10.0)
|
||||
if episode is None:
|
||||
if queue.is_closed:
|
||||
break
|
||||
continue
|
||||
|
||||
batch.append(episode)
|
||||
total_consumed += 1
|
||||
|
||||
if len(batch) >= buffer_size:
|
||||
if on_batch_ready:
|
||||
on_batch_ready(batch)
|
||||
logger.info("[CONSUMER] Batch of %d episodes ready for PPO update", len(batch))
|
||||
batch = []
|
||||
|
||||
# Flush remaining
|
||||
if batch and on_batch_ready:
|
||||
on_batch_ready(batch)
|
||||
logger.info("[CONSUMER] Final batch of %d episodes flushed", len(batch))
|
||||
|
||||
logger.info("[CONSUMER] Done — consumed %d episodes", total_consumed)
|
||||
return total_consumed
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Convenience runner
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
async def run_forge_pipeline(
|
||||
results_dir: str = "results",
|
||||
batch_id: str = "forge-batch",
|
||||
num_games: int = 64,
|
||||
buffer_size: int = 64,
|
||||
max_queue: int = 256,
|
||||
on_batch_ready=None,
|
||||
reward_config: Optional[RewardConfig] = None,
|
||||
) -> Dict[str, int]:
|
||||
"""Run the full Forge producer → PPO consumer pipeline.
|
||||
|
||||
The Forge Java batch must be started separately (via forge_runner.py
|
||||
or MultiThreadBatchRunner). This function handles the Python side:
|
||||
reading JSONL output and feeding episodes into the PPO trainer.
|
||||
"""
|
||||
queue = ForgeEpisodeQueue(max_size=max_queue)
|
||||
|
||||
producer_task = asyncio.create_task(
|
||||
forge_producer(queue, results_dir, batch_id, num_games,
|
||||
reward_config=reward_config)
|
||||
)
|
||||
consumer_task = asyncio.create_task(
|
||||
ppo_consumer(queue, buffer_size, on_batch_ready)
|
||||
)
|
||||
|
||||
produced = await producer_task
|
||||
queue.close()
|
||||
consumed = await consumer_task
|
||||
|
||||
stats = {
|
||||
"produced": produced,
|
||||
"consumed": consumed,
|
||||
**queue.stats,
|
||||
}
|
||||
logger.info("[PIPELINE] Complete: %s", stats)
|
||||
return stats
|
||||
@@ -16,10 +16,6 @@ The network is designed for:
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.ml.training")
|
||||
|
||||
|
||||
# Conditional torch import — graceful failure if not installed
|
||||
try:
|
||||
@@ -29,7 +25,7 @@ try:
|
||||
TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
TORCH_AVAILABLE = False
|
||||
log.warning("PyTorch not installed. Install with: pip install torch")
|
||||
print("[ML] PyTorch not installed. Install with: pip install torch")
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -208,7 +204,7 @@ def save_checkpoint(
|
||||
"dropout": TRAINING_CONFIG.dropout,
|
||||
},
|
||||
}, path)
|
||||
log.info(f"Checkpoint saved: {path}")
|
||||
print(f"[ML] Checkpoint saved: {path}")
|
||||
|
||||
|
||||
def load_checkpoint(path: str, device: str = "cpu"):
|
||||
|
||||
@@ -17,15 +17,9 @@ that feed directly into the RolloutBuffer for PPO training.
|
||||
For real game integration (Phase 9+), the Java sim would call the
|
||||
policy server and feed back actual game states — this synthetic
|
||||
version allows training to begin immediately.
|
||||
|
||||
.. deprecated:: Phase 3 (Issue #83)
|
||||
Synthetic training mode is DEPRECATED for production training.
|
||||
Use forge_episode_generator.py with real Forge engine games.
|
||||
Retained ONLY for quick local testing and unit tests.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
@@ -44,16 +38,9 @@ from ml.config.scope import (
|
||||
Playstyle, PLAYSTYLE_TO_IDX, STATE_DIMS, GamePhase, PHASE_TO_IDX,
|
||||
)
|
||||
from ml.training.reward import compute_intermediate_reward, compute_terminal_reward, RewardConfig
|
||||
from ml.training.decision_exporter import DecisionExporter
|
||||
|
||||
logger = logging.getLogger("ml.self_play")
|
||||
|
||||
_TRAINING_DEPRECATION_MSG = (
|
||||
"self_play.py synthetic training is deprecated for production training (Issue #83). "
|
||||
"Use forge_episode_generator.py with real Forge engine games instead. "
|
||||
"Synthetic self-play is retained for local testing and unit tests only."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerState:
|
||||
@@ -397,7 +384,6 @@ def run_self_play_episode(
|
||||
playstyle: str = "midrange",
|
||||
max_steps: int = 50,
|
||||
reward_config: RewardConfig = None,
|
||||
exporter: DecisionExporter = None,
|
||||
) -> List[dict]:
|
||||
"""Run a single self-play episode and return the trajectory.
|
||||
|
||||
@@ -409,7 +395,6 @@ def run_self_play_episode(
|
||||
playstyle: Playstyle for state encoding
|
||||
max_steps: Maximum decision steps per episode
|
||||
reward_config: Reward configuration
|
||||
exporter: Optional DecisionExporter to record steps for distillation
|
||||
|
||||
Returns:
|
||||
List of step dicts: [{state, action, reward, value, log_prob, done}, ...]
|
||||
@@ -473,12 +458,6 @@ def run_self_play_episode(
|
||||
"log_prob": log_prob_scalar,
|
||||
"done": True,
|
||||
})
|
||||
if exporter is not None:
|
||||
exporter.record_step(
|
||||
game_state_snapshot=prev_snapshot,
|
||||
action_idx=action_idx,
|
||||
reward=reward,
|
||||
)
|
||||
break
|
||||
|
||||
# Opponent's turn
|
||||
@@ -504,12 +483,6 @@ def run_self_play_episode(
|
||||
"log_prob": log_prob_scalar,
|
||||
"done": True,
|
||||
})
|
||||
if exporter is not None:
|
||||
exporter.record_step(
|
||||
game_state_snapshot=prev_snapshot,
|
||||
action_idx=action_idx,
|
||||
reward=reward,
|
||||
)
|
||||
break
|
||||
|
||||
# Intermediate reward
|
||||
@@ -526,12 +499,6 @@ def run_self_play_episode(
|
||||
"log_prob": log_prob_scalar,
|
||||
"done": False,
|
||||
})
|
||||
if exporter is not None:
|
||||
exporter.record_step(
|
||||
game_state_snapshot=prev_snapshot,
|
||||
action_idx=action_idx,
|
||||
reward=reward,
|
||||
)
|
||||
|
||||
# Advance turn
|
||||
state = advance_turn(state)
|
||||
@@ -546,7 +513,6 @@ def collect_rollouts(
|
||||
num_episodes: int = 64,
|
||||
playstyle: str = "midrange",
|
||||
reward_config: RewardConfig = None,
|
||||
exporter: DecisionExporter = None,
|
||||
) -> dict:
|
||||
"""Collect multiple self-play episodes into a rollout buffer.
|
||||
|
||||
@@ -557,7 +523,6 @@ def collect_rollouts(
|
||||
num_episodes: Number of episodes to collect
|
||||
playstyle: Agent playstyle
|
||||
reward_config: Reward configuration
|
||||
exporter: Optional DecisionExporter to record decisions for distillation
|
||||
|
||||
Returns:
|
||||
Collection statistics dict
|
||||
@@ -566,24 +531,19 @@ def collect_rollouts(
|
||||
total_wins = 0
|
||||
total_losses = 0
|
||||
total_draws = 0
|
||||
warnings.warn(_TRAINING_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
|
||||
total_steps = 0
|
||||
total_steps = 0
|
||||
total_rewards = 0.0
|
||||
|
||||
for ep in range(num_episodes):
|
||||
# Alternate agent seat for balanced training
|
||||
agent_seat = ep % 2
|
||||
|
||||
if exporter is not None:
|
||||
exporter.begin_episode(agent_seat=agent_seat, playstyle=playstyle)
|
||||
|
||||
trajectory = run_self_play_episode(
|
||||
agent_model=agent_model,
|
||||
opponent_policy=opponent_policy,
|
||||
agent_seat=agent_seat,
|
||||
playstyle=playstyle,
|
||||
reward_config=reward_config,
|
||||
exporter=exporter,
|
||||
)
|
||||
|
||||
for step in trajectory:
|
||||
@@ -609,11 +569,6 @@ def collect_rollouts(
|
||||
else:
|
||||
total_draws += 1
|
||||
|
||||
if exporter is not None:
|
||||
won = trajectory and trajectory[-1]["done"] and trajectory[-1]["reward"] > 0.5
|
||||
ep_return = sum(s["reward"] for s in trajectory)
|
||||
exporter.end_episode(won=won, episode_return=ep_return)
|
||||
|
||||
# Compute GAE after collection
|
||||
buffer.compute_returns_and_advantages(last_value=0.0)
|
||||
|
||||
|
||||
+27
-26
@@ -49,7 +49,8 @@ from ml.training.policy_network import (
|
||||
PolicyNetwork, save_checkpoint, load_checkpoint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("commander_ai_lab.ml.training")
|
||||
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
|
||||
logger = logging.getLogger("ml.trainer")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
@@ -501,40 +502,40 @@ def evaluate_model(
|
||||
|
||||
def print_evaluation(results: Dict):
|
||||
"""Pretty-print evaluation results."""
|
||||
logger.info("")
|
||||
logger.info("=" * 65)
|
||||
logger.info(" Test Set Evaluation")
|
||||
logger.info("=" * 65)
|
||||
logger.info(f" Overall Accuracy: {results['overall_accuracy']:.1%}")
|
||||
logger.info(f" Total Samples: {results['total_samples']:,}")
|
||||
logger.info("")
|
||||
logger.info(f" {'Action':<20} {'Support':>8} {'Prec':>7} {'Recall':>7} {'F1':>7}")
|
||||
logger.info(" " + "-" * 55)
|
||||
print()
|
||||
print("=" * 65)
|
||||
print(" Test Set Evaluation")
|
||||
print("=" * 65)
|
||||
print(f" Overall Accuracy: {results['overall_accuracy']:.1%}")
|
||||
print(f" Total Samples: {results['total_samples']:,}")
|
||||
print()
|
||||
print(f" {'Action':<20} {'Support':>8} {'Prec':>7} {'Recall':>7} {'F1':>7}")
|
||||
print(" " + "-" * 55)
|
||||
|
||||
for action_name, m in results["per_class"].items():
|
||||
logger.info(
|
||||
print(
|
||||
f" {action_name:<20} {m['support']:>8} "
|
||||
f"{m['precision']:>7.3f} {m['recall']:>7.3f} {m['f1']:>7.3f}"
|
||||
)
|
||||
|
||||
logger.info(" " + "-" * 55)
|
||||
print(" " + "-" * 55)
|
||||
|
||||
# Confusion matrix
|
||||
logger.info("")
|
||||
logger.info(" Confusion Matrix (rows=true, cols=predicted):")
|
||||
print()
|
||||
print(" Confusion Matrix (rows=true, cols=predicted):")
|
||||
header = " " + " " * 18
|
||||
for c in range(NUM_ACTIONS):
|
||||
short_name = IDX_TO_ACTION[c].value[:6]
|
||||
header += f" {short_name:>6}"
|
||||
logger.info(header)
|
||||
print(header)
|
||||
|
||||
for r in range(NUM_ACTIONS):
|
||||
row_name = IDX_TO_ACTION[r].value[:16]
|
||||
row_str = f" {row_name:<18}"
|
||||
for c in range(NUM_ACTIONS):
|
||||
row_str += f" {results['confusion_matrix'][r][c]:>6}"
|
||||
logger.info(row_str)
|
||||
logger.info("")
|
||||
print(row_str)
|
||||
print()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
@@ -604,8 +605,8 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
if not TORCH_AVAILABLE:
|
||||
logger.info("PyTorch not installed. Install with:")
|
||||
logger.info(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
|
||||
print("PyTorch not installed. Install with:")
|
||||
print(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
|
||||
sys.exit(1)
|
||||
|
||||
device = args.device or get_best_device()
|
||||
@@ -620,13 +621,13 @@ def main():
|
||||
if args.eval_only:
|
||||
# Evaluation only
|
||||
if not os.path.exists(test_path):
|
||||
logger.info(f"Test data not found: {test_path}")
|
||||
logger.info("Run: python -m ml.scripts.ml_cli build")
|
||||
print(f"Test data not found: {test_path}")
|
||||
print("Run: python -m ml.scripts.ml_cli build")
|
||||
sys.exit(1)
|
||||
|
||||
ckpt_path = args.checkpoint or os.path.join(ckpt_dir, "best_policy.pt")
|
||||
if not os.path.exists(ckpt_path):
|
||||
logger.info(f"Checkpoint not found: {ckpt_path}")
|
||||
print(f"Checkpoint not found: {ckpt_path}")
|
||||
sys.exit(1)
|
||||
|
||||
test_states, test_labels = load_npz_split(test_path)
|
||||
@@ -640,15 +641,15 @@ def main():
|
||||
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
||||
with open(eval_path, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
logger.info(f" Results saved: {eval_path}")
|
||||
print(f" Results saved: {eval_path}")
|
||||
return
|
||||
|
||||
# Training mode
|
||||
for path, label in [(train_path, "train"), (val_path, "val")]:
|
||||
if not os.path.exists(path):
|
||||
logger.info(f"{label} data not found: {path}")
|
||||
logger.info("Build the dataset first:")
|
||||
logger.info(" python -m ml.scripts.ml_cli build --results-dir results/")
|
||||
print(f"{label} data not found: {path}")
|
||||
print("Build the dataset first:")
|
||||
print(" python -m ml.scripts.ml_cli build --results-dir results/")
|
||||
sys.exit(1)
|
||||
|
||||
train_states, train_labels = load_npz_split(train_path)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Pydantic models for Commander AI Lab API."""
|
||||
|
||||
from models.requests import (
|
||||
StartRequest,
|
||||
ImportUrlRequest,
|
||||
ImportTextRequest,
|
||||
MetaFetchRequest,
|
||||
CreateDeckRequest,
|
||||
UpdateDeckRequest,
|
||||
AddDeckCardRequest,
|
||||
PatchDeckCardRequest,
|
||||
BulkAddRequest,
|
||||
BulkAddRecommendedRequest,
|
||||
DeckGenerationSourceConfig,
|
||||
DeckGenerationRequest,
|
||||
DeckGenV3Request,
|
||||
DeckGenV3SubstituteRequest,
|
||||
)
|
||||
from models.responses import (
|
||||
StartResponse,
|
||||
StatusResponse,
|
||||
DeckInfo,
|
||||
GeneratedDeckCard,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Requests
|
||||
"StartRequest",
|
||||
"ImportUrlRequest",
|
||||
"ImportTextRequest",
|
||||
"MetaFetchRequest",
|
||||
"CreateDeckRequest",
|
||||
"UpdateDeckRequest",
|
||||
"AddDeckCardRequest",
|
||||
"PatchDeckCardRequest",
|
||||
"BulkAddRequest",
|
||||
"BulkAddRecommendedRequest",
|
||||
"DeckGenerationSourceConfig",
|
||||
"DeckGenerationRequest",
|
||||
"DeckGenV3Request",
|
||||
"DeckGenV3SubstituteRequest",
|
||||
# Responses
|
||||
"StartResponse",
|
||||
"StatusResponse",
|
||||
"DeckInfo",
|
||||
"GeneratedDeckCard",
|
||||
]
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Pydantic request models for Commander AI Lab API."""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StartRequest(BaseModel):
|
||||
decks: list[str]
|
||||
numGames: int = 100
|
||||
threads: int = 4
|
||||
seed: Optional[int] = None
|
||||
clock: int = 300
|
||||
deckSources: Optional[list[Optional[dict]]] = None
|
||||
useLearnedPolicy: bool = False
|
||||
policyStyle: str = "midrange"
|
||||
policyGreedy: bool = False
|
||||
aiSimplified: bool = False
|
||||
aiThinkTimeMs: int = -1
|
||||
maxQueueDepth: int = -1
|
||||
|
||||
|
||||
class ImportUrlRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class ImportTextRequest(BaseModel):
|
||||
text: str
|
||||
commander: Optional[str] = None
|
||||
|
||||
|
||||
class MetaFetchRequest(BaseModel):
|
||||
commander: str
|
||||
|
||||
|
||||
class CreateDeckRequest(BaseModel):
|
||||
name: str
|
||||
commander_scryfall_id: Optional[str] = ""
|
||||
commander_name: Optional[str] = ""
|
||||
color_identity: Optional[list] = []
|
||||
strategy_tag: Optional[str] = ""
|
||||
|
||||
|
||||
class UpdateDeckRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
commander_scryfall_id: Optional[str] = None
|
||||
commander_name: Optional[str] = None
|
||||
color_identity: Optional[list] = None
|
||||
strategy_tag: Optional[str] = None
|
||||
|
||||
|
||||
class AddDeckCardRequest(BaseModel):
|
||||
scryfall_id: str
|
||||
card_name: Optional[str] = ""
|
||||
quantity: Optional[int] = 1
|
||||
is_commander: Optional[int] = 0
|
||||
role_tag: Optional[str] = ""
|
||||
|
||||
|
||||
class PatchDeckCardRequest(BaseModel):
|
||||
quantity: Optional[int] = None
|
||||
role_tag: Optional[str] = None
|
||||
|
||||
|
||||
class BulkAddRequest(BaseModel):
|
||||
cards: list[dict]
|
||||
respect_ratios: Optional[bool] = False
|
||||
|
||||
|
||||
class BulkAddRecommendedRequest(BaseModel):
|
||||
source: str = "collection"
|
||||
only_owned: Optional[bool] = True
|
||||
respect_ratios: Optional[bool] = False
|
||||
types: Optional[list[str]] = None
|
||||
roles: Optional[list[str]] = None
|
||||
|
||||
|
||||
class DeckGenerationSourceConfig(BaseModel):
|
||||
use_archidekt: bool = True
|
||||
use_edhrec: bool = True
|
||||
use_moxfield: bool = False
|
||||
use_mtggoldfish: bool = False
|
||||
archidekt_url: Optional[str] = ""
|
||||
moxfield_url: Optional[str] = ""
|
||||
mtggoldfish_url: Optional[str] = ""
|
||||
|
||||
|
||||
class DeckGenerationRequest(BaseModel):
|
||||
commander_name: Optional[str] = ""
|
||||
commander_scryfall_id: Optional[str] = ""
|
||||
color_identity: Optional[list[str]] = None
|
||||
sources: Optional[DeckGenerationSourceConfig] = None
|
||||
target_land_count: int = 37
|
||||
target_instant_count: int = 10
|
||||
target_sorcery_count: int = 8
|
||||
target_artifact_count: int = 10
|
||||
target_enchantment_count: int = 8
|
||||
target_creature_count: int = 25
|
||||
target_planeswalker_count: int = 2
|
||||
only_cards_in_collection: bool = False
|
||||
allow_proxies: bool = True
|
||||
deck_name: Optional[str] = ""
|
||||
|
||||
|
||||
class DeckGenV3Request(BaseModel):
|
||||
commander_name: str = ""
|
||||
strategy: str = ""
|
||||
target_bracket: int = 3
|
||||
budget_usd: Optional[float] = None
|
||||
budget_mode: str = "total"
|
||||
omit_cards: list[str] = []
|
||||
use_collection: bool = True
|
||||
run_substitution: bool = True
|
||||
model: Optional[str] = None
|
||||
deck_name: Optional[str] = ""
|
||||
|
||||
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Deckgen requests (moved from routes/deckgen.py)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class DeckResearchRequest(BaseModel):
|
||||
deck_id: int # Deck to research
|
||||
goal: Optional[str] = "Identify weaknesses and suggest upgrades"
|
||||
budget_usd: Optional[float] = None
|
||||
omit_cards: Optional[list[str]] = []
|
||||
use_collection: bool = True
|
||||
|
||||
|
||||
class DeckGenerateAIRequest(BaseModel):
|
||||
commander: str
|
||||
budget_usd: Optional[float] = None
|
||||
budget_mode: str = "total" # "total" or "per_card"
|
||||
omit_cards: Optional[list[str]] = []
|
||||
use_collection: bool = True
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Coach requests (moved from routes/coach.py)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class CoachRequestBody(BaseModel):
|
||||
goals: Optional[dict] = None
|
||||
|
||||
|
||||
class CoachChatMessage(BaseModel):
|
||||
role: str # "user" or "assistant"
|
||||
content: str
|
||||
|
||||
|
||||
class CoachChatRequest(BaseModel):
|
||||
deck_id: str
|
||||
messages: list[dict] # conversation history [{role, content}]
|
||||
goals: Optional[dict] = None
|
||||
stream: Optional[bool] = False
|
||||
|
||||
|
||||
class CoachApplyRequest(BaseModel):
|
||||
session_id: str
|
||||
deck_id: int # numeric deck ID in the DB
|
||||
accepted_cuts: list[str] = [] # card names to remove
|
||||
accepted_adds: list[str] = [] # card names to add
|
||||
|
||||
|
||||
class CoachGoalsRequest(BaseModel):
|
||||
target_power_level: Optional[int] = None # 1-10
|
||||
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
|
||||
budget: Optional[str] = None # budget, medium, no-limit
|
||||
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
|
||||
class DeckGenV3SubstituteRequest(BaseModel):
|
||||
card_name: str
|
||||
substitute_name: str
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Pydantic response models for Commander AI Lab API."""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StartResponse(BaseModel):
|
||||
batchId: str
|
||||
status: str = "started"
|
||||
message: str = ""
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
batchId: str = ""
|
||||
running: bool = False
|
||||
completed: int = 0
|
||||
total: int = 0
|
||||
threads: int = 0
|
||||
elapsedMs: int = 0
|
||||
error: Optional[str] = None
|
||||
simsPerSec: float = 0.0
|
||||
run_id: str = ""
|
||||
games_completed: int = 0
|
||||
total_games: int = 0
|
||||
current_decks: list = []
|
||||
|
||||
|
||||
class DeckInfo(BaseModel):
|
||||
name: str
|
||||
filename: str
|
||||
|
||||
|
||||
class GeneratedDeckCard(BaseModel):
|
||||
scryfall_id: str = ""
|
||||
name: str = ""
|
||||
type_line: str = ""
|
||||
mana_cost: str = ""
|
||||
cmc: float = 0
|
||||
card_type: str = ""
|
||||
roles: list[str] = []
|
||||
source: str = "collection"
|
||||
quantity: int = 1
|
||||
image_url: str = ""
|
||||
owned_qty: int = 0
|
||||
is_proxy: bool = False
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Configuration
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class Config:
|
||||
"""Runtime configuration -- set via CLI args or env vars."""
|
||||
forge_jar: str = ""
|
||||
forge_dir: str = ""
|
||||
forge_decks_dir: str = ""
|
||||
lab_jar: str = ""
|
||||
precon_dir: str = ""
|
||||
results_dir: str = "results"
|
||||
port: int = 8080
|
||||
ximilar_api_key: str = ""
|
||||
pplx_api_key: str = ""
|
||||
|
||||
|
||||
CFG = Config()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# In-Memory State
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class BatchState:
|
||||
def __init__(self, batch_id: str, total_games: int, threads: int):
|
||||
self.batch_id = batch_id
|
||||
self.total_games = total_games
|
||||
self.threads = threads
|
||||
self.completed_games = 0
|
||||
self.running = True
|
||||
self.start_time = datetime.now()
|
||||
self.elapsed_ms = 0
|
||||
self.result_path: Optional[str] = None
|
||||
self.error: Optional[str] = None
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.log_lines: list = []
|
||||
self.sims_per_sec: float = 0.0
|
||||
|
||||
|
||||
active_batches: dict[str, BatchState] = {}
|
||||
|
||||
|
||||
COMMANDER_META: dict = {}
|
||||
|
||||
BUILTIN_COMMANDERS: dict = {
|
||||
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
||||
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
|
||||
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
||||
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
|
||||
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
|
||||
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
|
||||
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
|
||||
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
|
||||
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
||||
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
||||
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
|
||||
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
|
||||
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
|
||||
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
|
||||
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
|
||||
}
|
||||
|
||||
|
||||
def load_commander_meta() -> None:
|
||||
"""Load commander meta mapping from file or fall back to builtins."""
|
||||
meta_path = Path(__file__).parent.parent / "commander-meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
COMMANDER_META.clear()
|
||||
COMMANDER_META.update(json.load(f))
|
||||
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
|
||||
return
|
||||
except Exception as e:
|
||||
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
|
||||
COMMANDER_META.clear()
|
||||
COMMANDER_META.update(BUILTIN_COMMANDERS)
|
||||
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# AI Profiles
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
AI_PROFILES = {
|
||||
"default": {"name": "default", "description": "Balanced \u2014 Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
|
||||
"aggro": {"name": "aggro", "description": "Aggressive \u2014 attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
|
||||
"control": {"name": "control", "description": "Control \u2014 defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
|
||||
"combo": {"name": "combo", "description": "Combo \u2014 ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
|
||||
"midrange": {"name": "midrange", "description": "Midrange \u2014 flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
|
||||
}
|
||||
+79
-121
@@ -22,15 +22,11 @@ Configuration:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── Default Config ──────────────────────────────────────────────
|
||||
@@ -55,48 +51,10 @@ DEFAULT_CONFIG = {
|
||||
}
|
||||
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────
|
||||
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
|
||||
|
||||
|
||||
def setup_overnight_logging() -> logging.Logger:
|
||||
"""Configure logging for the overnight runner.
|
||||
|
||||
Outputs to both console and a rotating log file so that
|
||||
unattended runs always have a persistent record.
|
||||
"""
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("commander_ai_lab.overnight")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s %(levelname)-5s [%(name)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(logging.INFO)
|
||||
console.setFormatter(fmt)
|
||||
logger.addHandler(console)
|
||||
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
_LOG_DIR / "overnight-run.log",
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(fmt)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
log = setup_overnight_logging()
|
||||
def log(msg):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{ts}] {msg}")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def api_get(base_url, path):
|
||||
@@ -171,8 +129,8 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
||||
while True:
|
||||
# Hard deadline check — stop waiting if we've run out of time
|
||||
if deadline and time.time() >= deadline:
|
||||
log.info(f" Time limit reached while batch {batch_id} was running.")
|
||||
log.info(f" The batch will continue on the server but we're moving on.")
|
||||
log(f" Time limit reached while batch {batch_id} was running.")
|
||||
log(f" The batch will continue on the server but we're moving on.")
|
||||
return {"completed": 0, "timed_out": True}
|
||||
|
||||
try:
|
||||
@@ -186,12 +144,12 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
||||
elapsed = status.get("elapsedMs", 0)
|
||||
|
||||
if error:
|
||||
log.error(f" Batch {batch_id} ERROR: {error}")
|
||||
log(f" Batch {batch_id} ERROR: {error}")
|
||||
return None
|
||||
|
||||
if not running and completed > 0:
|
||||
elapsed_s = elapsed / 1000 if elapsed else 0
|
||||
log.info(f" Batch {batch_id} complete: {completed} games in {elapsed_s:.0f}s")
|
||||
log(f" Batch {batch_id} complete: {completed} games in {elapsed_s:.0f}s")
|
||||
try:
|
||||
result = api_get(base_url, f"/api/lab/result?batchId={batch_id}")
|
||||
return result
|
||||
@@ -202,15 +160,15 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
||||
if not running and completed == 0:
|
||||
stall_checks += 1
|
||||
if stall_checks > 20: # 5 min with no progress
|
||||
log.info(f" Batch {batch_id} never started. Aborting.")
|
||||
log(f" Batch {batch_id} never started. Aborting.")
|
||||
return None
|
||||
|
||||
# Progress log with ETA
|
||||
if deadline:
|
||||
mins_left = (deadline - time.time()) / 60
|
||||
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
|
||||
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
|
||||
else:
|
||||
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
|
||||
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
|
||||
|
||||
# Track stall
|
||||
if completed == last_completed:
|
||||
@@ -222,9 +180,9 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
||||
except Exception as e:
|
||||
consecutive_errors += 1
|
||||
if consecutive_errors > 10:
|
||||
log.info(f" Lost connection to server after 10 retries. Aborting batch.")
|
||||
log(f" Lost connection to server after 10 retries. Aborting batch.")
|
||||
return None
|
||||
log.info(f" Connection error (retry {consecutive_errors}/10): {e}")
|
||||
log(f" Connection error (retry {consecutive_errors}/10): {e}")
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
@@ -256,26 +214,26 @@ def wait_for_training(base_url):
|
||||
error = status.get("error")
|
||||
|
||||
if error:
|
||||
log.error(f" Training ERROR: {error}")
|
||||
log(f" Training ERROR: {error}")
|
||||
return False
|
||||
|
||||
if not running and phase in ("done", "idle") and epoch > 0:
|
||||
result = status.get("result", {})
|
||||
log.info(f" Training complete!")
|
||||
log(f" Training complete!")
|
||||
if result:
|
||||
log.info(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
|
||||
log.info(f" Model saved: {result.get('model_path', '?')}")
|
||||
log(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
|
||||
log(f" Model saved: {result.get('model_path', '?')}")
|
||||
return True
|
||||
|
||||
if phase == "training" and total > 0:
|
||||
metrics = status.get("metrics", {})
|
||||
val_acc = metrics.get("val_accuracy", "?") if metrics else "?"
|
||||
log.info(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
|
||||
log(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
|
||||
else:
|
||||
log.info(f" [{phase}] {message}")
|
||||
log(f" [{phase}] {message}")
|
||||
|
||||
except Exception as e:
|
||||
log.info(f" Connection error: {e}")
|
||||
log(f" Connection error: {e}")
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
@@ -295,46 +253,46 @@ def run_overnight(cfg):
|
||||
"""Main overnight pipeline."""
|
||||
base_url = cfg["lab_url"]
|
||||
|
||||
log.info("=" * 60)
|
||||
log.info(" Commander AI Lab — Overnight AFK Runner")
|
||||
log.info("=" * 60)
|
||||
log("=" * 60)
|
||||
log(" Commander AI Lab — Overnight AFK Runner")
|
||||
log("=" * 60)
|
||||
|
||||
# ── Verify server ────────────────────────────────────────
|
||||
log.info("Checking lab server...")
|
||||
log("Checking lab server...")
|
||||
if not check_server(base_url):
|
||||
log.error(f"ERROR: Cannot reach lab server at {base_url}")
|
||||
log.info("Start the lab first: start-lab.bat")
|
||||
log(f"ERROR: Cannot reach lab server at {base_url}")
|
||||
log("Start the lab first: start-lab.bat")
|
||||
return False
|
||||
|
||||
log.info(f"Server OK at {base_url}")
|
||||
log(f"Server OK at {base_url}")
|
||||
|
||||
# ── Get decks ─────────────────────────────────────────────
|
||||
available_decks = get_available_decks(base_url)
|
||||
log.info(f"Found {len(available_decks)} decks: {', '.join(available_decks[:5])}{'...' if len(available_decks) > 5 else ''}")
|
||||
log(f"Found {len(available_decks)} decks: {', '.join(available_decks[:5])}{'...' if len(available_decks) > 5 else ''}")
|
||||
|
||||
engine = cfg["engine"]
|
||||
if engine == "deepseek":
|
||||
decks = cfg["ds_decks"] if cfg["ds_decks"] else available_decks
|
||||
if not decks:
|
||||
log.error("ERROR: No decks available. Create decks in the Deck Builder first.")
|
||||
log("ERROR: No decks available. Create decks in the Deck Builder first.")
|
||||
return False
|
||||
log.info(f"Using DeepSeek engine with {len(decks)} deck(s)")
|
||||
log(f"Using DeepSeek engine with {len(decks)} deck(s)")
|
||||
else:
|
||||
decks = cfg["java_decks"]
|
||||
if len(decks) != 3:
|
||||
log.error("ERROR: Java engine requires exactly 3 deck names.")
|
||||
log.info("Set java_decks in the config or use --decks deck1 deck2 deck3")
|
||||
log("ERROR: Java engine requires exactly 3 deck names.")
|
||||
log("Set java_decks in the config or use --decks deck1 deck2 deck3")
|
||||
return False
|
||||
log.info(f"Using Java/Forge engine with decks: {decks}")
|
||||
log(f"Using Java/Forge engine with decks: {decks}")
|
||||
|
||||
# ── ML logging ────────────────────────────────────────────
|
||||
log.info("Enabling ML logging...")
|
||||
log("Enabling ML logging...")
|
||||
try:
|
||||
api_post(base_url, "/api/ml/toggle?enable=true", {})
|
||||
log.info("ML logging enabled")
|
||||
log("ML logging enabled")
|
||||
except Exception as e:
|
||||
log.warning(f"WARNING: Could not enable ML logging: {e}")
|
||||
log.info("(DeepSeek batches always log ML data regardless)")
|
||||
log(f"WARNING: Could not enable ML logging: {e}")
|
||||
log("(DeepSeek batches always log ML data regardless)")
|
||||
# Try alternate form
|
||||
try:
|
||||
api_get(base_url, "/api/ml/toggle?enable=true")
|
||||
@@ -343,7 +301,7 @@ def run_overnight(cfg):
|
||||
|
||||
# ── Initial data stats ────────────────────────────────────
|
||||
decisions_before, files_before = get_ml_data_summary(base_url)
|
||||
log.info(f"Current ML data: {decisions_before} decisions in {files_before} files")
|
||||
log(f"Current ML data: {decisions_before} decisions in {files_before} files")
|
||||
|
||||
# ── Run batches ───────────────────────────────────────────
|
||||
games_per_batch = cfg["games_per_batch"]
|
||||
@@ -357,32 +315,32 @@ def run_overnight(cfg):
|
||||
total_games = 0
|
||||
total_decisions = 0
|
||||
|
||||
log.info("")
|
||||
log.info(f"Starting batch loop:")
|
||||
log("")
|
||||
log(f"Starting batch loop:")
|
||||
if num_batches > 0:
|
||||
log.info(f" Mode: {num_batches} batches × {games_per_batch} games")
|
||||
log(f" Mode: {num_batches} batches × {games_per_batch} games")
|
||||
else:
|
||||
log.info(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
|
||||
log.info(f" Engine: {engine}")
|
||||
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
|
||||
log.info("")
|
||||
log(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
|
||||
log(f" Engine: {engine}")
|
||||
log(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
|
||||
log("")
|
||||
|
||||
while True:
|
||||
# Check stopping conditions
|
||||
if num_batches > 0 and batch_num >= num_batches:
|
||||
log.info(f"Reached target of {num_batches} batches. Stopping.")
|
||||
log(f"Reached target of {num_batches} batches. Stopping.")
|
||||
break
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
remaining = deadline - time.time()
|
||||
if remaining < 120: # need at least 2 min for a batch
|
||||
log.info(f"Time limit approaching ({remaining:.0f}s left). Stopping sim loop.")
|
||||
log(f"Time limit approaching ({remaining:.0f}s left). Stopping sim loop.")
|
||||
break
|
||||
|
||||
batch_num += 1
|
||||
elapsed_str = str(timedelta(seconds=int(elapsed)))
|
||||
remaining_str = str(timedelta(seconds=int(remaining)))
|
||||
log.info(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
|
||||
log(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
|
||||
|
||||
try:
|
||||
if engine == "deepseek":
|
||||
@@ -394,12 +352,12 @@ def run_overnight(cfg):
|
||||
)
|
||||
|
||||
if not batch_id:
|
||||
log.error(" ERROR: Failed to start batch (no batchId returned)")
|
||||
log.info(" Waiting 60s before retry...")
|
||||
log(" ERROR: Failed to start batch (no batchId returned)")
|
||||
log(" Waiting 60s before retry...")
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
log.info(f" Started batch {batch_id}")
|
||||
log(f" Started batch {batch_id}")
|
||||
result = wait_for_batch(base_url, batch_id, engine, deadline=deadline)
|
||||
|
||||
if result:
|
||||
@@ -408,19 +366,19 @@ def run_overnight(cfg):
|
||||
for deck in result.get("decks", []):
|
||||
wr = deck.get("winRate", "?")
|
||||
name = deck.get("deckName", "?")
|
||||
log.info(f" {name}: {wr}% WR")
|
||||
log(f" {name}: {wr}% WR")
|
||||
else:
|
||||
log.info(" Batch failed, continuing to next...")
|
||||
log(" Batch failed, continuing to next...")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f" ERROR starting batch: {e}")
|
||||
log.info(" Waiting 60s before retry...")
|
||||
log(f" ERROR starting batch: {e}")
|
||||
log(" Waiting 60s before retry...")
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
# Pause between batches
|
||||
if pause_sec > 0:
|
||||
log.info(f" Cooling down {pause_sec}s...")
|
||||
log(f" Cooling down {pause_sec}s...")
|
||||
time.sleep(pause_sec)
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────
|
||||
@@ -429,26 +387,26 @@ def run_overnight(cfg):
|
||||
new_decisions = decisions_after - decisions_before
|
||||
new_files = files_after - files_before
|
||||
|
||||
log.info("")
|
||||
log.info("=" * 60)
|
||||
log.info(" BATCH SIMULATION COMPLETE")
|
||||
log.info("=" * 60)
|
||||
log.info(f" Batches run: {batch_num}")
|
||||
log.info(f" Total games: ~{total_games}")
|
||||
log.info(f" New ML decisions: {new_decisions} (in {new_files} new files)")
|
||||
log.info(f" Total ML data: {decisions_after} decisions")
|
||||
log.info(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
|
||||
log.info("")
|
||||
log("")
|
||||
log("=" * 60)
|
||||
log(" BATCH SIMULATION COMPLETE")
|
||||
log("=" * 60)
|
||||
log(f" Batches run: {batch_num}")
|
||||
log(f" Total games: ~{total_games}")
|
||||
log(f" New ML decisions: {new_decisions} (in {new_files} new files)")
|
||||
log(f" Total ML data: {decisions_after} decisions")
|
||||
log(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
|
||||
log("")
|
||||
|
||||
# ── Auto-train ────────────────────────────────────────────
|
||||
if cfg["auto_train"]:
|
||||
if decisions_after < 100:
|
||||
log.info("Skipping training — too few decisions (need at least 100)")
|
||||
log("Skipping training — too few decisions (need at least 100)")
|
||||
return True
|
||||
|
||||
log.info("Starting ML training pipeline...")
|
||||
log.info(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
|
||||
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
|
||||
log("Starting ML training pipeline...")
|
||||
log(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
|
||||
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
|
||||
|
||||
try:
|
||||
start_training(
|
||||
@@ -458,21 +416,21 @@ def run_overnight(cfg):
|
||||
batch_size=cfg["train_batch_size"],
|
||||
patience=cfg["train_patience"],
|
||||
)
|
||||
log.info("Training started, monitoring progress...")
|
||||
log("Training started, monitoring progress...")
|
||||
success = wait_for_training(base_url)
|
||||
if success:
|
||||
log.info("")
|
||||
log.info("=" * 60)
|
||||
log.info(" TRAINING COMPLETE — model updated!")
|
||||
log.info("=" * 60)
|
||||
log("")
|
||||
log("=" * 60)
|
||||
log(" TRAINING COMPLETE — model updated!")
|
||||
log("=" * 60)
|
||||
else:
|
||||
log.info("Training finished with errors. Check the Training tab for details.")
|
||||
log("Training finished with errors. Check the Training tab for details.")
|
||||
except Exception as e:
|
||||
log.error(f"ERROR starting training: {e}")
|
||||
log(f"ERROR starting training: {e}")
|
||||
return False
|
||||
|
||||
log.info("")
|
||||
log.info("Overnight run finished. You can close this window.")
|
||||
log("")
|
||||
log("Overnight run finished. You can close this window.")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -67,13 +67,6 @@
|
||||
<version>3.45.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- WebSocket server for Unity/Godot client -->
|
||||
<dependency>
|
||||
<groupId>org.java-websocket</groupId>
|
||||
<artifactId>Java-WebSocket</artifactId>
|
||||
<version>1.5.6</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Testing -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
|
||||
@@ -10,32 +10,8 @@ readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {text = "MIT"}
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.100.0",
|
||||
"uvicorn[standard]>=0.23.0",
|
||||
"pydantic>=2.0.0",
|
||||
"httpx>=0.24.0",
|
||||
"websockets>=11.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.21",
|
||||
"httpx>=0.24.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[project.scripts]
|
||||
commander-sim = "commander_ai_lab.lab.experiments:_cli_main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = [".", "src"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test* Bench*
|
||||
python_functions = test_*
|
||||
|
||||
# Asyncio mode for pytest-asyncio
|
||||
asyncio_mode = auto
|
||||
|
||||
# Markers
|
||||
markers =
|
||||
slow: marks tests as slow (deselect with -m "not slow")
|
||||
integration: marks integration tests
|
||||
benchmark: marks benchmark tests
|
||||
unit: marks fast unit tests
|
||||
|
||||
# Output
|
||||
addopts =
|
||||
--tb=short
|
||||
-q
|
||||
|
||||
# Coverage (used when running: pytest --cov)
|
||||
# pip install pytest-cov
|
||||
# pytest --cov=src/commander_ai_lab --cov-report=term-missing
|
||||
@@ -1,11 +0,0 @@
|
||||
# Commander AI Lab — Web server dependencies (Phase 5)
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
httpx>=0.27.0 # async Scryfall art proxy
|
||||
websockets>=12.0 # used by uvicorn WebSocket transport
|
||||
pydantic>=2.6.0
|
||||
|
||||
# Dev / test
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.23
|
||||
httpx>=0.27.0 # TestClient uses httpx transport
|
||||
@@ -1,19 +0,0 @@
|
||||
# Core runtime dependencies
|
||||
# Install with: pip install -r requirements.txt
|
||||
# Or for dev tools: pip install -e ".[dev]"
|
||||
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.23.0
|
||||
pydantic>=2.0.0
|
||||
httpx>=0.24.0
|
||||
websockets>=11.0
|
||||
|
||||
# Deck Builder dependencies
|
||||
requests>=2.31.0
|
||||
beautifulsoup4>=4.12.0
|
||||
ollama>=0.2.0
|
||||
scrython>=2.0.0
|
||||
|
||||
# Embeddings
|
||||
sentence-transformers>=3.0.0
|
||||
torch>=2.0.0
|
||||
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
routes package — API routers for Commander AI Lab.
|
||||
|
||||
Each module defines a FastAPI APIRouter with real endpoint implementations.
|
||||
Shared helpers live in services/, models live in models/.
|
||||
"""
|
||||
-728
@@ -1,728 +0,0 @@
|
||||
"""
|
||||
routes/coach.py
|
||||
===============
|
||||
LLM-powered deck coaching endpoints:
|
||||
GET /api/coach/status
|
||||
GET /api/coach/decks
|
||||
GET /api/coach/decks/{deck_id}/report
|
||||
POST /api/coach/decks/{deck_id}
|
||||
GET /api/coach/sessions
|
||||
GET /api/coach/sessions/{session_id}
|
||||
POST /api/coach/embeddings/download
|
||||
GET /api/coach/embeddings/search
|
||||
POST /api/coach/chat
|
||||
POST /api/coach/apply
|
||||
GET /api/coach/cards-like
|
||||
POST /api/coach/reports/generate
|
||||
|
||||
Globals:
|
||||
_coach_service, _coach_embeddings, _coach_llm
|
||||
_deck_gen_v3, _deck_gen_v3_error
|
||||
init_coach_service(), _build_deck_report_from_db()
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
|
||||
|
||||
from models.state import CFG
|
||||
from services.database import _get_db_conn
|
||||
from services.deck_service import _compute_deck_analysis
|
||||
from services.logging import log_coach, log_deckgen
|
||||
import httpx
|
||||
from coach.config import COACH_PROVIDER, PPLX_MODEL
|
||||
|
||||
LLM_TIMEOUT = 120 # seconds
|
||||
|
||||
router = APIRouter(tags=["coach"])
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Coach Service (LLM-powered deck coaching)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# Global coach instances (initialized at startup)
|
||||
_coach_service = None
|
||||
_coach_embeddings = None
|
||||
_coach_llm = None
|
||||
_deck_gen_v3 = None # V3 Deck Generator (Perplexity structured output)
|
||||
_deck_gen_v3_error = None # Error message if V3 init failed
|
||||
|
||||
|
||||
def init_coach_service():
|
||||
"""Initialize the coach service with LLM client and embeddings."""
|
||||
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
|
||||
try:
|
||||
from coach.llm_client import LLMClient
|
||||
from coach.embeddings import MTGEmbeddingIndex
|
||||
from coach.coach_service import CoachService
|
||||
from coach.config import ensure_dirs
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
_coach_llm = LLMClient()
|
||||
_coach_embeddings = MTGEmbeddingIndex()
|
||||
|
||||
# Try to load embeddings (non-blocking — download happens on first use)
|
||||
try:
|
||||
from coach.config import EMBEDDINGS_NPZ
|
||||
if EMBEDDINGS_NPZ.exists():
|
||||
_coach_embeddings.load()
|
||||
log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
|
||||
else:
|
||||
log_coach.warning(" Coach: Embeddings not yet downloaded (will download on first use)")
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings load failed: {e}")
|
||||
|
||||
_coach_service = CoachService(_coach_llm, _coach_embeddings)
|
||||
|
||||
# Check LLM connection
|
||||
llm_status = _coach_llm.check_connection()
|
||||
if llm_status.get("connected"):
|
||||
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||
else:
|
||||
log_coach.warning(f" Coach LLM: Not connected (start Ollama on localhost:11434)")
|
||||
|
||||
# Initialize V3 Deck Generator (Ollama-based)
|
||||
_deck_gen_v3_error = None
|
||||
try:
|
||||
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
|
||||
_deck_gen_v3 = DeckBuilderAdapter(
|
||||
db_conn_factory=_get_db_conn,
|
||||
model="gpt-oss:20b",
|
||||
)
|
||||
log_deckgen.info(" Deck Gen V3: Initialized (Ollama DeckBuilderAdapter)")
|
||||
except ImportError as e:
|
||||
_deck_gen_v3_error = f"Missing dependency: {e}"
|
||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
_deck_gen_v3_error = str(e)
|
||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
_deck_gen_v3 = None
|
||||
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}\n{traceback.format_exc()}")
|
||||
_coach_service = None
|
||||
|
||||
|
||||
@router.get("/api/coach/status")
|
||||
async def coach_status():
|
||||
"""Check coach subsystem health."""
|
||||
if _coach_service is None:
|
||||
return {"llmConnected": False, "embeddingsLoaded": False,
|
||||
"embeddingCards": 0, "deckReportsAvailable": 0,
|
||||
"error": "Coach service not initialized"}
|
||||
status = _coach_service.get_status()
|
||||
return status.model_dump()
|
||||
|
||||
|
||||
@router.get("/api/coach/decks")
|
||||
async def coach_list_decks():
|
||||
"""List all decks available for coaching (from deck builder DB)."""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT d.id as deck_id, d.name as deck_name, d.commander_name,
|
||||
COUNT(dc.id) as card_count
|
||||
FROM decks d
|
||||
LEFT JOIN deck_cards dc ON dc.deck_id = d.id
|
||||
GROUP BY d.id
|
||||
ORDER BY d.name"""
|
||||
).fetchall()
|
||||
|
||||
# Also get report availability from coach service
|
||||
report_ids = set()
|
||||
if _coach_service:
|
||||
report_ids = set(_coach_service.list_deck_reports())
|
||||
|
||||
decks = []
|
||||
for r in rows:
|
||||
deck_name = r["deck_name"]
|
||||
# Check if a report exists (by deck name slug match)
|
||||
has_report = any(
|
||||
deck_name.lower().replace(" ", "-") == rid.lower() or
|
||||
deck_name.lower() == rid.lower()
|
||||
for rid in report_ids
|
||||
)
|
||||
decks.append({
|
||||
"deck_id": r["deck_id"],
|
||||
"deck_name": r["deck_name"],
|
||||
"commander": r["commander_name"] or "",
|
||||
"card_count": r["card_count"],
|
||||
"has_report": has_report,
|
||||
"report_count": 1 if has_report else 0,
|
||||
"last_report_date": None,
|
||||
})
|
||||
return decks
|
||||
|
||||
|
||||
@router.get("/api/coach/decks/{deck_id}/report")
|
||||
async def coach_get_report(deck_id: str):
|
||||
"""Get the latest DeckReport for a deck."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
report = _coach_service.load_deck_report(deck_id)
|
||||
if report is None:
|
||||
raise HTTPException(404, f"Deck report not found: {deck_id}")
|
||||
return report.model_dump()
|
||||
|
||||
|
||||
@router.post("/api/coach/decks/{deck_id}")
|
||||
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
|
||||
"""Trigger a coaching session for a deck."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
|
||||
# Load embeddings on first use if not loaded
|
||||
if _coach_embeddings and not _coach_embeddings.loaded:
|
||||
try:
|
||||
_coach_embeddings.load(force_download=True)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings download failed: {e}")
|
||||
|
||||
goals = None
|
||||
if body and body.goals:
|
||||
from coach.models import CoachGoals
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
|
||||
# Build a fallback DeckReport from the DB if no simulation report exists
|
||||
fallback_report = None
|
||||
try:
|
||||
fallback_report = _build_deck_report_from_db(deck_id)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
|
||||
|
||||
try:
|
||||
session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
|
||||
return session.model_dump()
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ConnectionError as e:
|
||||
raise HTTPException(503, f"LLM connection failed: {e}")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Coach session failed: {e}")
|
||||
|
||||
|
||||
def _build_deck_report_from_db(deck_slug: str):
|
||||
"""
|
||||
Build a lightweight DeckReport from the deck builder DB when no
|
||||
simulation report exists. Allows the coach to analyze deck composition
|
||||
even without simulation data.
|
||||
"""
|
||||
from coach.models import DeckReport, CardPerformance, DeckStructure
|
||||
conn = _get_db_conn()
|
||||
|
||||
# --- Input validation ---
|
||||
import re
|
||||
if not deck_slug or len(deck_slug) > 200:
|
||||
return None
|
||||
deck_slug = re.sub(r'[^a-zA-Z0-9 _-]', '', deck_slug)
|
||||
if not deck_slug:
|
||||
return None
|
||||
|
||||
# Find the deck by parameterized query instead of full-table scan
|
||||
slug_lower = deck_slug.lower()
|
||||
slug_hyphenated = slug_lower.replace(' ', '-')
|
||||
clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
|
||||
|
||||
if slug_lower.isdigit():
|
||||
# Numeric slug -- match by ID directly
|
||||
matched_deck = conn.execute(
|
||||
"SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
|
||||
(int(slug_lower),)
|
||||
).fetchone()
|
||||
else:
|
||||
# Try exact name match, hyphenated match, or clean-slug match
|
||||
matched_deck = conn.execute(
|
||||
"""SELECT id, name, commander_name, color_identity FROM decks
|
||||
WHERE LOWER(name) = ?
|
||||
OR LOWER(REPLACE(name, ' ', '-')) = ?
|
||||
OR LOWER(REPLACE(name, ' ', '-')) = ?
|
||||
LIMIT 1""",
|
||||
(slug_lower, slug_hyphenated, clean_slug)
|
||||
).fetchone()
|
||||
|
||||
if matched_deck is None:
|
||||
return None
|
||||
|
||||
deck_id = matched_deck["id"]
|
||||
deck_name = matched_deck["name"]
|
||||
commander = matched_deck["commander_name"] or ""
|
||||
|
||||
# Parse color identity
|
||||
ci_raw = matched_deck["color_identity"] or "[]"
|
||||
try:
|
||||
color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw
|
||||
except Exception:
|
||||
color_identity = []
|
||||
|
||||
# Load all cards in this deck
|
||||
card_rows = conn.execute(
|
||||
"""SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
|
||||
ce.type_line, ce.cmc, ce.oracle_text
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name)
|
||||
WHERE dc.deck_id = ?""",
|
||||
(deck_id,)
|
||||
).fetchall()
|
||||
|
||||
cards = []
|
||||
type_counts = {}
|
||||
cmc_buckets = [0] * 8
|
||||
land_count = 0
|
||||
|
||||
for cr in card_rows:
|
||||
card_name = cr["card_name"] or ""
|
||||
type_line = cr["type_line"] or ""
|
||||
cmc = cr["cmc"] or 0
|
||||
qty = cr["quantity"] or 1
|
||||
role_tag = cr["role_tag"] or ""
|
||||
|
||||
# Build tags from type_line and role_tag
|
||||
tags = []
|
||||
if role_tag:
|
||||
tags.append(role_tag)
|
||||
if "Land" in type_line:
|
||||
tags.append("land")
|
||||
land_count += qty
|
||||
elif "Creature" in type_line:
|
||||
tags.append("creature")
|
||||
elif "Instant" in type_line:
|
||||
tags.append("instant")
|
||||
elif "Sorcery" in type_line:
|
||||
tags.append("sorcery")
|
||||
elif "Artifact" in type_line:
|
||||
tags.append("artifact")
|
||||
elif "Enchantment" in type_line:
|
||||
tags.append("enchantment")
|
||||
elif "Planeswalker" in type_line:
|
||||
tags.append("planeswalker")
|
||||
|
||||
# Type count
|
||||
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
|
||||
if t in type_line:
|
||||
type_counts[t] = type_counts.get(t, 0) + qty
|
||||
|
||||
# CMC bucket
|
||||
bucket = min(int(cmc), 7)
|
||||
cmc_buckets[bucket] += qty
|
||||
|
||||
# CardPerformance with zeroed-out sim stats
|
||||
cards.append(CardPerformance(
|
||||
name=card_name,
|
||||
drawnRate=0.0,
|
||||
castRate=0.0,
|
||||
impactScore=0.0,
|
||||
tags=tags,
|
||||
))
|
||||
|
||||
slug = deck_slug
|
||||
return DeckReport(
|
||||
deckId=slug,
|
||||
commander=commander,
|
||||
colorIdentity=color_identity,
|
||||
cards=cards,
|
||||
structure=DeckStructure(
|
||||
landCount=land_count,
|
||||
curveBuckets=cmc_buckets,
|
||||
cardTypeCounts=type_counts,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/coach/sessions")
|
||||
async def coach_list_sessions(deck_id: str = None):
|
||||
"""List all coaching sessions, optionally filtered by deck."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
return {"sessions": _coach_service.list_sessions(deck_id)}
|
||||
|
||||
|
||||
@router.get("/api/coach/sessions/{session_id}")
|
||||
async def coach_get_session(session_id: str):
|
||||
"""Get a specific coaching session."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
session = _coach_service.load_session(session_id)
|
||||
if session is None:
|
||||
raise HTTPException(404, f"Session not found: {session_id}")
|
||||
return session.model_dump()
|
||||
|
||||
|
||||
@router.post("/api/coach/embeddings/download")
|
||||
async def coach_download_embeddings():
|
||||
"""Download and convert MTG card embeddings from HuggingFace."""
|
||||
if _coach_embeddings is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
try:
|
||||
_coach_embeddings.load(force_download=True)
|
||||
return {
|
||||
"success": True,
|
||||
"cards": _coach_embeddings.card_count,
|
||||
"message": f"Loaded {_coach_embeddings.card_count} card embeddings"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to download embeddings: {e}")
|
||||
|
||||
|
||||
@router.get("/api/coach/embeddings/search")
|
||||
async def coach_search_similar(card: str, colors: str = None, top_n: int = 10):
|
||||
"""Search for similar cards by name."""
|
||||
if _coach_embeddings is None or not _coach_embeddings.loaded:
|
||||
raise HTTPException(503, "Embeddings not loaded")
|
||||
color_filter = list(colors.upper()) if colors else None
|
||||
matches = _coach_embeddings.search_similar(
|
||||
query_card=card, color_filter=color_filter, top_n=top_n
|
||||
)
|
||||
return {"query": card, "matches": [m.to_dict() for m in matches]}
|
||||
|
||||
|
||||
@router.post("/api/coach/chat")
|
||||
async def coach_chat(body: CoachChatRequest):
|
||||
"""Multi-turn coaching chat. Sends conversation history to LLM and returns response."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
|
||||
# Load deck report for context
|
||||
report = _coach_service.load_deck_report(body.deck_id)
|
||||
|
||||
# Build system prompt with report context
|
||||
from coach.prompt_template import build_system_prompt
|
||||
from coach.models import CoachGoals
|
||||
|
||||
goals = None
|
||||
if body.goals:
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
|
||||
system_prompt = ""
|
||||
if report:
|
||||
system_prompt = build_system_prompt(report, goals)
|
||||
else:
|
||||
system_prompt = (
|
||||
"You are an expert Magic: The Gathering Commander deck coach. "
|
||||
"Answer questions about deck building, strategy, card choices, and game theory. "
|
||||
"Be specific and actionable in your advice."
|
||||
)
|
||||
|
||||
# Build messages array for LLM
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
for msg in body.messages:
|
||||
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
|
||||
# ── Perplexity provider path ──────────────────────────────
|
||||
if COACH_PROVIDER == 'perplexity':
|
||||
if not CFG.pplx_api_key:
|
||||
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var.')
|
||||
pplx_payload = {
|
||||
'model': PPLX_MODEL,
|
||||
'messages': messages,
|
||||
'max_tokens': 4096,
|
||||
'temperature': 0.7,
|
||||
}
|
||||
pplx_headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {CFG.pplx_api_key}',
|
||||
}
|
||||
if body.stream:
|
||||
async def pplx_stream():
|
||||
pplx_payload['stream'] = True
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
async with client.stream('POST', 'https://api.perplexity.ai/chat/completions',
|
||||
json=pplx_payload, headers=pplx_headers) as resp:
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith('data: '):
|
||||
data = line[6:]
|
||||
if data == '[DONE]':
|
||||
yield 'data: [DONE]\n\n'
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
content = chunk.get('choices', [{}])[0].get('delta', {}).get('content', '')
|
||||
if content:
|
||||
yield f'data: {json.dumps({"content": content})}\n\n'
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return StreamingResponse(pplx_stream(), media_type='text/event-stream')
|
||||
else:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
'https://api.perplexity.ai/chat/completions',
|
||||
json=pplx_payload, headers=pplx_headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
content = raw.get('choices', [{}])[0].get('message', {}).get('content', '')
|
||||
usage = raw.get('usage', {})
|
||||
return {
|
||||
'content': content,
|
||||
'model': raw.get('model', ''),
|
||||
'prompt_tokens': usage.get('prompt_tokens', 0),
|
||||
'completion_tokens': usage.get('completion_tokens', 0),
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}')
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f'Perplexity API call failed: {e}')
|
||||
|
||||
# Call LLM with multi-turn messages
|
||||
try:
|
||||
import json
|
||||
from urllib.request import urlopen, Request as UrlRequest
|
||||
|
||||
|
||||
model_name = _coach_llm._resolve_model()
|
||||
llm_body = {
|
||||
"model": model_name,
|
||||
"messages": messages,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
|
||||
if body.stream:
|
||||
# SSE streaming response
|
||||
llm_body["stream"] = True
|
||||
|
||||
async def generate():
|
||||
import asyncio
|
||||
def _stream():
|
||||
req = UrlRequest(
|
||||
f"{_coach_llm.base_url}/chat/completions",
|
||||
data=json.dumps(llm_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
import http.client
|
||||
import urllib.parse
|
||||
parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions")
|
||||
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LLM_TIMEOUT)
|
||||
conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
resp = conn.getresponse()
|
||||
chunks = []
|
||||
while True:
|
||||
line = resp.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.decode("utf-8").strip()
|
||||
if line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
chunks.append("[DONE]")
|
||||
break
|
||||
chunks.append(data)
|
||||
conn.close()
|
||||
return chunks
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
chunks = await loop.run_in_executor(None, _stream)
|
||||
|
||||
for chunk_str in chunks:
|
||||
if chunk_str == "[DONE]":
|
||||
yield f"data: [DONE]\n\n"
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(chunk_str)
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
content = delta.get("content", "")
|
||||
if content:
|
||||
# Strip think tags from streaming chunks
|
||||
yield f"data: {json.dumps({'content': content})}\n\n"
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
else:
|
||||
# Non-streaming: regular call
|
||||
req = UrlRequest(
|
||||
f"{_coach_llm.base_url}/chat/completions",
|
||||
data=json.dumps(llm_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
def _call():
|
||||
with urlopen(req, timeout=LLM_TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
raw = await loop.run_in_executor(None, _call)
|
||||
|
||||
content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
# Strip think tags
|
||||
content = _coach_llm._strip_think_tags(content)
|
||||
usage = raw.get("usage", {})
|
||||
|
||||
return {
|
||||
"content": content,
|
||||
"model": raw.get("model", ""),
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Chat failed: {e}")
|
||||
|
||||
|
||||
@router.post("/api/coach/apply")
|
||||
async def coach_apply_suggestions(body: CoachApplyRequest):
|
||||
"""Apply accepted coaching suggestions to a deck — remove cuts, add adds."""
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Verify deck exists
|
||||
deck = conn.execute("SELECT * FROM decks WHERE id = ?", (body.deck_id,)).fetchone()
|
||||
if not deck:
|
||||
raise HTTPException(404, f"Deck {body.deck_id} not found")
|
||||
|
||||
results = {"cuts": [], "adds": [], "errors": []}
|
||||
|
||||
# Process cuts: remove cards by name
|
||||
for card_name in body.accepted_cuts:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": card_name, "status": "removed"})
|
||||
else:
|
||||
# Try case-insensitive
|
||||
row = conn.execute(
|
||||
"SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": row["card_name"], "status": "removed"})
|
||||
else:
|
||||
results["errors"].append({"name": card_name, "error": "Card not found in deck"})
|
||||
|
||||
# Process adds: look up in collection first, then Scryfall
|
||||
for card_name in body.accepted_adds:
|
||||
# Try to find in collection
|
||||
ce = conn.execute(
|
||||
"SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(card_name,)
|
||||
).fetchone()
|
||||
|
||||
scryfall_id = None
|
||||
resolved_name = card_name
|
||||
|
||||
if ce:
|
||||
scryfall_id = ce["scryfall_id"]
|
||||
resolved_name = ce["name"]
|
||||
else:
|
||||
# Try Scryfall API lookup
|
||||
try:
|
||||
import urllib.parse
|
||||
from urllib.request import urlopen, Request as UrlRequest
|
||||
encoded = urllib.parse.quote(card_name)
|
||||
scry_req = UrlRequest(
|
||||
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
|
||||
headers={"User-Agent": "commander-ai-lab/1.0"}
|
||||
)
|
||||
with urlopen(scry_req, timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
scryfall_id = card_data.get("id")
|
||||
resolved_name = card_data.get("name", card_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if scryfall_id:
|
||||
# Check if already in deck
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
|
||||
(body.deck_id, scryfall_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)",
|
||||
(body.deck_id, scryfall_id, resolved_name)
|
||||
)
|
||||
results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"})
|
||||
else:
|
||||
results["adds"].append({"name": resolved_name, "status": "already_in_deck"})
|
||||
else:
|
||||
results["errors"].append({"name": card_name, "error": "Could not resolve card"})
|
||||
|
||||
conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
|
||||
conn.commit()
|
||||
|
||||
results["total_cuts"] = len(results["cuts"])
|
||||
results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"])
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/api/coach/cards-like")
|
||||
async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
|
||||
"""Find cards similar to a given card using embeddings. UI-friendly version."""
|
||||
if _coach_embeddings is None or not _coach_embeddings.loaded:
|
||||
raise HTTPException(503, "Embeddings not loaded")
|
||||
|
||||
color_filter = list(colors.upper()) if colors else None
|
||||
matches = _coach_embeddings.search_similar(
|
||||
query_card=card, color_filter=color_filter, top_n=top_n
|
||||
)
|
||||
|
||||
conn = _get_db_conn()
|
||||
results = []
|
||||
for m in matches:
|
||||
# Check if card is in collection
|
||||
owned = conn.execute(
|
||||
"SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)",
|
||||
(m.name,)
|
||||
).fetchone()
|
||||
owned_qty = (owned["qty"] or 0) if owned else 0
|
||||
|
||||
# Get image URL and price
|
||||
ce = conn.execute(
|
||||
"SELECT scryfall_id, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(m.name,)
|
||||
).fetchone()
|
||||
|
||||
results.append({
|
||||
"name": m.name,
|
||||
"similarity": round(m.similarity, 4),
|
||||
"types": m.types,
|
||||
"mana_value": m.mana_value,
|
||||
"mana_cost": m.mana_cost,
|
||||
"text": m.text[:200] if m.text else "",
|
||||
"owned_qty": owned_qty,
|
||||
"image_url": f"https://api.scryfall.com/cards/{ce['scryfall_id']}?format=image&version=normal" if ce and ce["scryfall_id"] else None,
|
||||
"tcg_price": ce["tcg_price"] if ce else None,
|
||||
})
|
||||
|
||||
return {"query": card, "results": results}
|
||||
|
||||
|
||||
@router.post("/api/coach/reports/generate")
|
||||
async def coach_generate_reports():
|
||||
"""Rebuild all deck reports from batch result JSONs in results/."""
|
||||
try:
|
||||
from coach.report_generator import generate_deck_reports
|
||||
lab_root = Path(__file__).parent.parent
|
||||
results_dir = str(lab_root / CFG.results_dir)
|
||||
reports_dir = str(lab_root / "deck-reports")
|
||||
updated = generate_deck_reports(results_dir, reports_dir)
|
||||
return {
|
||||
"status": "ok",
|
||||
"decksUpdated": updated,
|
||||
"count": len(updated),
|
||||
"message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found",
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Report generation failed: {e}")
|
||||
@@ -1,592 +0,0 @@
|
||||
"""
|
||||
routes/collection.py
|
||||
====================
|
||||
Collection management endpoints:
|
||||
GET /api/collection
|
||||
GET /api/collection/export
|
||||
GET /api/collection/sets
|
||||
GET /api/collection/keywords
|
||||
POST /api/collection/import
|
||||
GET /api/collection/{cardId}
|
||||
PATCH /api/collection/{cardId}
|
||||
GET /api/cache/scryfall
|
||||
DELETE /api/cache/scryfall
|
||||
POST /api/cache/scryfall/evict
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from models.state import CFG
|
||||
from services.card_analysis import _detect_card_roles
|
||||
from services.database import (
|
||||
_get_db_conn, _row_to_dict, _add_image_url, _build_collection_filters,
|
||||
_snake_to_camel, VALID_SORT_FIELDS, _JSON_FIELDS,
|
||||
)
|
||||
from services.deck_service import _classify_card_type
|
||||
from services.import_service import (
|
||||
_parse_finish, _parse_text_line, _parse_csv_content, _auto_infer_mapping,
|
||||
)
|
||||
from services.logging import log_collect, log_cache
|
||||
from services.scryfall import (
|
||||
SCRYFALL_CACHE_DB_PATH, SCRYFALL_CACHE_TTL_SECONDS, _API_HEADERS,
|
||||
_scryfall_cache, _scryfall_rate_limit,
|
||||
_enrich_from_scryfall, _fetch_scryfall_api,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["collection"])
|
||||
|
||||
|
||||
@router.get("/api/collection/stats")
|
||||
async def collection_stats():
|
||||
"""Aggregate collection statistics for the dashboard."""
|
||||
conn = _get_db_conn()
|
||||
stats = {}
|
||||
|
||||
# Total cards and unique cards
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as total_rows, COALESCE(SUM(quantity),0) as total_cards, "
|
||||
"COALESCE(SUM(tcg_price * quantity),0) as total_value "
|
||||
"FROM collection_entries"
|
||||
).fetchone()
|
||||
stats["totalUniqueCards"] = row["total_rows"]
|
||||
stats["totalCards"] = row["total_cards"]
|
||||
stats["totalValue"] = round(row["total_value"], 2)
|
||||
|
||||
# CMC distribution (0-7+)
|
||||
cmc_rows = conn.execute(
|
||||
"SELECT CASE WHEN cmc >= 7 THEN 7 ELSE CAST(cmc AS INTEGER) END as cmc_bucket, "
|
||||
"SUM(quantity) as count FROM collection_entries GROUP BY cmc_bucket ORDER BY cmc_bucket"
|
||||
).fetchall()
|
||||
stats["cmcCurve"] = {str(r["cmc_bucket"]): r["count"] for r in cmc_rows}
|
||||
|
||||
# Color identity distribution
|
||||
color_rows = conn.execute(
|
||||
"SELECT color_identity, SUM(quantity) as count FROM collection_entries GROUP BY color_identity"
|
||||
).fetchall()
|
||||
color_counts = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
|
||||
for r in color_rows:
|
||||
ci = r["color_identity"] or "[]"
|
||||
try:
|
||||
colors = json.loads(ci) if isinstance(ci, str) else ci
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
colors = []
|
||||
qty = r["count"] or 0
|
||||
if not colors:
|
||||
color_counts["C"] += qty
|
||||
else:
|
||||
for c in colors:
|
||||
if c in color_counts:
|
||||
color_counts[c] += qty
|
||||
stats["colorDistribution"] = color_counts
|
||||
|
||||
# Type distribution
|
||||
type_rows = conn.execute(
|
||||
"SELECT type_line, SUM(quantity) as count FROM collection_entries GROUP BY type_line"
|
||||
).fetchall()
|
||||
type_counts = {}
|
||||
for r in type_rows:
|
||||
tl = r["type_line"] or ""
|
||||
qty = r["count"] or 0
|
||||
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
|
||||
if t in tl:
|
||||
type_counts[t] = type_counts.get(t, 0) + qty
|
||||
stats["typeDistribution"] = type_counts
|
||||
|
||||
# Category/role distribution
|
||||
cat_rows = conn.execute(
|
||||
"SELECT category, SUM(quantity) as count FROM collection_entries "
|
||||
"WHERE category != '' AND category != '[]' GROUP BY category"
|
||||
).fetchall()
|
||||
cat_counts = {}
|
||||
for r in cat_rows:
|
||||
raw = r["category"] or "[]"
|
||||
qty = r["count"] or 0
|
||||
try:
|
||||
cats = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
cats = []
|
||||
if isinstance(cats, list):
|
||||
for c in cats:
|
||||
cat_counts[c] = cat_counts.get(c, 0) + qty
|
||||
stats["categoryDistribution"] = cat_counts
|
||||
|
||||
# Rarity distribution
|
||||
rarity_rows = conn.execute(
|
||||
"SELECT LOWER(rarity) as rarity, SUM(quantity) as count FROM collection_entries "
|
||||
"WHERE rarity != '' GROUP BY LOWER(rarity)"
|
||||
).fetchall()
|
||||
stats["rarityDistribution"] = {r["rarity"]: r["count"] for r in rarity_rows}
|
||||
|
||||
# Top 10 most valuable cards
|
||||
top_value = conn.execute(
|
||||
"SELECT name, tcg_price, quantity FROM collection_entries "
|
||||
"WHERE tcg_price > 0 ORDER BY tcg_price DESC LIMIT 10"
|
||||
).fetchall()
|
||||
stats["topValueCards"] = [{"name": r["name"], "price": r["tcg_price"], "qty": r["quantity"]} for r in top_value]
|
||||
|
||||
# Average CMC (excluding lands)
|
||||
avg_row = conn.execute(
|
||||
"SELECT AVG(cmc) as avg_cmc FROM collection_entries WHERE type_line NOT LIKE '%Land%'"
|
||||
).fetchone()
|
||||
stats["avgCmc"] = round(avg_row["avg_cmc"] or 0, 2)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/api/collection/export")
|
||||
async def export_collection(
|
||||
format: str = "INTERNAL_CSV",
|
||||
q: Optional[str] = None,
|
||||
colors: Optional[str] = None,
|
||||
types: Optional[str] = None,
|
||||
isLegendary: Optional[bool] = None,
|
||||
isBasic: Optional[bool] = None,
|
||||
isGameChanger: Optional[bool] = None,
|
||||
highSalt: Optional[bool] = None,
|
||||
finish: Optional[str] = None,
|
||||
cmcMin: Optional[float] = None,
|
||||
cmcMax: Optional[float] = None,
|
||||
priceMin: Optional[float] = None,
|
||||
priceMax: Optional[float] = None,
|
||||
category: Optional[str] = None,
|
||||
rarity: Optional[str] = None,
|
||||
setCode: Optional[str] = None,
|
||||
powerMin: Optional[str] = None,
|
||||
powerMax: Optional[str] = None,
|
||||
toughMin: Optional[str] = None,
|
||||
toughMax: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
edhrecMin: Optional[int] = None,
|
||||
edhrecMax: Optional[int] = None,
|
||||
qtyMin: Optional[int] = None,
|
||||
qtyMax: Optional[int] = None,
|
||||
):
|
||||
"""Export collection in various formats with optional filters."""
|
||||
where_str, params = _build_collection_filters(
|
||||
q=q, colors=colors, types=types, isLegendary=isLegendary,
|
||||
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
|
||||
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
|
||||
priceMin=priceMin, priceMax=priceMax, category=category,
|
||||
rarity=rarity, setCode=setCode,
|
||||
powerMin=powerMin, powerMax=powerMax,
|
||||
toughMin=toughMin, toughMax=toughMax,
|
||||
keyword=keyword,
|
||||
edhrecMin=edhrecMin, edhrecMax=edhrecMax,
|
||||
qtyMin=qtyMin, qtyMax=qtyMax,
|
||||
)
|
||||
|
||||
sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC"
|
||||
conn = _get_db_conn()
|
||||
rows = [_row_to_dict(r) for r in conn.execute(sql, params).fetchall()]
|
||||
|
||||
fmt = format.upper()
|
||||
output = io.StringIO()
|
||||
|
||||
if fmt == "INTERNAL_CSV":
|
||||
if rows:
|
||||
fieldnames = list(rows[0].keys())
|
||||
else:
|
||||
fieldnames = [
|
||||
"id", "name", "type_line", "subtypes", "is_legendary", "is_basic",
|
||||
"color_identity", "cmc", "oracle_text", "keywords", "tcg_price",
|
||||
"salt_score", "is_game_changer", "category", "scryfall_id", "tcgplayer_id",
|
||||
"quantity", "finish", "condition", "language", "notes", "tags",
|
||||
"set_code", "collector_number", "created_at", "updated_at",
|
||||
]
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
filename = "collection_export.csv"
|
||||
media_type = "text/csv"
|
||||
|
||||
elif fmt == "MOXFIELD_CSV":
|
||||
fieldnames = ["Count", "Name", "Edition", "Condition", "Language", "Foil", "Collector Number"]
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
finish_val = row.get("finish", "NORMAL").upper()
|
||||
foil_val = "foil" if finish_val == "FOIL" else ("etched" if finish_val == "ETCHED" else "")
|
||||
writer.writerow({
|
||||
"Count": row.get("quantity", 1),
|
||||
"Name": row.get("name", ""),
|
||||
"Edition": row.get("set_code", "").upper(),
|
||||
"Condition": row.get("condition", ""),
|
||||
"Language": row.get("language", ""),
|
||||
"Foil": foil_val,
|
||||
"Collector Number": row.get("collector_number", ""),
|
||||
})
|
||||
filename = "collection_moxfield.csv"
|
||||
media_type = "text/csv"
|
||||
|
||||
elif fmt == "TEXT":
|
||||
lines = []
|
||||
for row in rows:
|
||||
qty = row.get("quantity", 1)
|
||||
name = row.get("name", "")
|
||||
set_code = row.get("set_code", "").upper()
|
||||
coll_num = row.get("collector_number", "")
|
||||
if set_code and coll_num:
|
||||
lines.append(f"{qty} {name} ({set_code}) {coll_num}")
|
||||
elif set_code:
|
||||
lines.append(f"{qty} {name} ({set_code})")
|
||||
else:
|
||||
lines.append(f"{qty} {name}")
|
||||
output.write("\n".join(lines))
|
||||
filename = "collection_export.txt"
|
||||
media_type = "text/plain"
|
||||
|
||||
else:
|
||||
raise HTTPException(400, f"Unknown format: {format}. Use INTERNAL_CSV, MOXFIELD_CSV, or TEXT.")
|
||||
|
||||
content = output.getvalue()
|
||||
|
||||
def iter_content():
|
||||
yield content.encode("utf-8")
|
||||
|
||||
return StreamingResponse(
|
||||
iter_content(),
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/collection/sets")
|
||||
async def collection_sets():
|
||||
"""Return distinct set codes + names in the collection (for filter autocomplete)."""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT UPPER(set_code) as code, set_name as name FROM collection_entries WHERE set_code != '' ORDER BY set_name ASC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
code = r["code"] if "code" in r.keys() else r[0]
|
||||
name = r["name"] if "name" in r.keys() else r[1]
|
||||
if code:
|
||||
result.append({"code": code, "name": name or code})
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@router.get("/api/collection/keywords")
|
||||
async def collection_keywords():
|
||||
"""Return distinct keywords found in the collection."""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute("SELECT keywords FROM collection_entries WHERE keywords != '' AND keywords != '[]'").fetchall()
|
||||
kw_set = set()
|
||||
for r in rows:
|
||||
try:
|
||||
kws = json.loads(r["keywords"]) if isinstance(r["keywords"], str) else r["keywords"]
|
||||
if isinstance(kws, list):
|
||||
for k in kws:
|
||||
if k:
|
||||
kw_set.add(k)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return JSONResponse(sorted(kw_set))
|
||||
|
||||
|
||||
@router.get("/api/collection")
|
||||
async def list_collection(
|
||||
page: int = 1,
|
||||
pageSize: int = 50,
|
||||
q: Optional[str] = None,
|
||||
sortField: Optional[str] = None,
|
||||
sortDir: Optional[str] = None,
|
||||
colors: Optional[str] = None,
|
||||
types: Optional[str] = None,
|
||||
isLegendary: Optional[bool] = None,
|
||||
isBasic: Optional[bool] = None,
|
||||
isGameChanger: Optional[bool] = None,
|
||||
highSalt: Optional[bool] = None,
|
||||
finish: Optional[str] = None,
|
||||
cmcMin: Optional[float] = None,
|
||||
cmcMax: Optional[float] = None,
|
||||
priceMin: Optional[float] = None,
|
||||
priceMax: Optional[float] = None,
|
||||
category: Optional[str] = None,
|
||||
deck_id: Optional[int] = None,
|
||||
rarity: Optional[str] = None,
|
||||
setCode: Optional[str] = None,
|
||||
powerMin: Optional[str] = None,
|
||||
powerMax: Optional[str] = None,
|
||||
toughMin: Optional[str] = None,
|
||||
toughMax: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
edhrecMin: Optional[int] = None,
|
||||
edhrecMax: Optional[int] = None,
|
||||
qtyMin: Optional[int] = None,
|
||||
qtyMax: Optional[int] = None,
|
||||
):
|
||||
"""List collection entries with search, sort, and filter.
|
||||
Optional deck_id: when provided, includes in_deck_quantity for each result.
|
||||
"""
|
||||
where_str, params = _build_collection_filters(
|
||||
q=q, colors=colors, types=types, isLegendary=isLegendary,
|
||||
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
|
||||
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
|
||||
priceMin=priceMin, priceMax=priceMax, category=category,
|
||||
rarity=rarity, setCode=setCode, powerMin=powerMin, powerMax=powerMax,
|
||||
toughMin=toughMin, toughMax=toughMax, keyword=keyword,
|
||||
edhrecMin=edhrecMin, edhrecMax=edhrecMax, qtyMin=qtyMin, qtyMax=qtyMax,
|
||||
)
|
||||
|
||||
# Validate sort
|
||||
sort_field = sortField if sortField in VALID_SORT_FIELDS else "name"
|
||||
sort_dir = "DESC" if (sortDir or "").upper() == "DESC" else "ASC"
|
||||
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Count total
|
||||
count_sql = f"SELECT COUNT(*) FROM collection_entries {where_str}"
|
||||
total = conn.execute(count_sql, params).fetchone()[0]
|
||||
|
||||
# Paginate
|
||||
offset = (max(1, page) - 1) * pageSize
|
||||
data_sql = f"""
|
||||
SELECT * FROM collection_entries {where_str}
|
||||
ORDER BY {sort_field} {sort_dir}
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
rows = conn.execute(data_sql, params + [pageSize, offset]).fetchall()
|
||||
items = [_add_image_url(_row_to_dict(r)) for r in rows]
|
||||
|
||||
# If deck_id provided, annotate each item with how many copies are already in that deck
|
||||
if deck_id is not None:
|
||||
# Build a map of scryfall_id -> quantity in deck
|
||||
deck_card_rows = conn.execute(
|
||||
"SELECT scryfall_id, SUM(quantity) as qty FROM deck_cards WHERE deck_id = ? GROUP BY scryfall_id",
|
||||
(deck_id,)
|
||||
).fetchall()
|
||||
in_deck_map = {row["scryfall_id"]: row["qty"] for row in deck_card_rows}
|
||||
for item in items:
|
||||
item["in_deck_quantity"] = in_deck_map.get(item.get("scryfall_id", ""), 0)
|
||||
|
||||
return {"items": items, "page": page, "pageSize": pageSize, "total": total}
|
||||
|
||||
|
||||
@router.get("/api/collection/{cardId}")
|
||||
async def get_collection_card(cardId: int):
|
||||
"""Get a single collection entry by ID."""
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Card with id {cardId} not found")
|
||||
return _add_image_url(_row_to_dict(row))
|
||||
|
||||
|
||||
@router.patch("/api/collection/{cardId}")
|
||||
async def update_collection_card(cardId: int, body: dict):
|
||||
"""Update mutable fields of a collection entry."""
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Card with id {cardId} not found")
|
||||
|
||||
updates = {}
|
||||
if "category" in body:
|
||||
cat = body["category"]
|
||||
if isinstance(cat, list):
|
||||
updates["category"] = json.dumps(cat)
|
||||
else:
|
||||
updates["category"] = cat
|
||||
if "tags" in body:
|
||||
updates["tags"] = str(body["tags"])
|
||||
if "notes" in body:
|
||||
updates["notes"] = str(body["notes"])
|
||||
if "finish" in body:
|
||||
finish_val = str(body["finish"]).upper()
|
||||
if finish_val not in ("NORMAL", "FOIL", "ETCHED"):
|
||||
raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED")
|
||||
updates["finish"] = finish_val
|
||||
|
||||
if not updates:
|
||||
return _add_image_url(_row_to_dict(row))
|
||||
|
||||
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||
values = list(updates.values()) + [cardId]
|
||||
conn.execute(f"UPDATE collection_entries SET {set_clause} WHERE id = ?", values)
|
||||
conn.commit()
|
||||
|
||||
updated_row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
|
||||
return _add_image_url(_row_to_dict(updated_row))
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Import Endpoint
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@router.post("/api/collection/import")
|
||||
async def import_collection(body: dict):
|
||||
"""
|
||||
Import cards into the collection from CSV, Moxfield, Archidekt, or plain text.
|
||||
|
||||
Body:
|
||||
{
|
||||
"source": "CSV" | "MOXFIELD" | "ARCHIDEKT" | "TEXT",
|
||||
"mode": "MERGE" | "REPLACE",
|
||||
"content": "...csv or text content...",
|
||||
"mapping": { ... optional column mapping ... }
|
||||
}
|
||||
"""
|
||||
source = str(body.get("source", "CSV")).upper()
|
||||
mode = str(body.get("mode", "MERGE")).upper()
|
||||
content = str(body.get("content", ""))
|
||||
mapping = body.get("mapping", {})
|
||||
|
||||
if not content.strip():
|
||||
raise HTTPException(400, "content is required")
|
||||
|
||||
imported_count = 0
|
||||
updated_count = 0
|
||||
failed_count = 0
|
||||
errors = []
|
||||
|
||||
conn = _get_db_conn()
|
||||
|
||||
# REPLACE mode: clear existing entries
|
||||
if mode == "REPLACE":
|
||||
conn.execute("DELETE FROM collection_entries")
|
||||
conn.commit()
|
||||
|
||||
# Parse the content into raw rows
|
||||
try:
|
||||
raw_rows = _parse_csv_content(content, source, mapping if mapping else None)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Failed to parse content: {str(e)}")
|
||||
|
||||
for raw in raw_rows:
|
||||
name = raw.get("name", "").strip()
|
||||
if not name:
|
||||
continue
|
||||
|
||||
quantity = int(raw.get("quantity", 1))
|
||||
set_code = raw.get("set_code", "").lower()
|
||||
collector_number = raw.get("collector_number", "")
|
||||
finish = _parse_finish(raw.get("finish", ""))
|
||||
condition = raw.get("condition", "")
|
||||
language = raw.get("language", "")
|
||||
notes = raw.get("notes", "")
|
||||
tags = raw.get("tags", "")
|
||||
|
||||
# Scryfall enrichment
|
||||
try:
|
||||
enriched = _enrich_from_scryfall(name, set_code, collector_number)
|
||||
except Exception as e:
|
||||
errors.append(f"Scryfall error for '{name}': {str(e)}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
if not enriched or "_error" in enriched:
|
||||
err_msg = enriched.get("_error", f"Card not found on Scryfall: '{name}'") if enriched else f"Card not found on Scryfall: '{name}'"
|
||||
errors.append(err_msg)
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Use Scryfall-resolved name if available
|
||||
resolved_name = enriched.get("name", name)
|
||||
|
||||
# Check for existing entry (identity key)
|
||||
existing = conn.execute(
|
||||
"""SELECT id, quantity FROM collection_entries
|
||||
WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""",
|
||||
(resolved_name, set_code, collector_number, finish),
|
||||
).fetchone()
|
||||
|
||||
if existing and mode == "MERGE":
|
||||
# Update quantity
|
||||
new_qty = existing["quantity"] + quantity
|
||||
conn.execute(
|
||||
"UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(new_qty, existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
updated_count += 1
|
||||
else:
|
||||
# Insert new row
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_entries
|
||||
(name, type_line, subtypes, is_legendary, is_basic, color_identity,
|
||||
cmc, mana_cost, oracle_text, keywords,
|
||||
power, toughness, rarity, set_name, edhrec_rank,
|
||||
tcg_price, salt_score, is_game_changer,
|
||||
category, scryfall_id, tcgplayer_id,
|
||||
quantity, finish, condition, language, notes, tags,
|
||||
set_code, collector_number)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
resolved_name,
|
||||
enriched.get("type_line", ""),
|
||||
enriched.get("subtypes", "[]"),
|
||||
enriched.get("is_legendary", 0),
|
||||
enriched.get("is_basic", 0),
|
||||
enriched.get("color_identity", "[]"),
|
||||
enriched.get("cmc", 0.0),
|
||||
enriched.get("mana_cost", ""),
|
||||
enriched.get("oracle_text", ""),
|
||||
enriched.get("keywords", "[]"),
|
||||
enriched.get("power", ""),
|
||||
enriched.get("toughness", ""),
|
||||
enriched.get("rarity", ""),
|
||||
enriched.get("set_name", ""),
|
||||
enriched.get("edhrec_rank", 0),
|
||||
enriched.get("tcg_price", 0.0),
|
||||
enriched.get("salt_score", 0.0),
|
||||
enriched.get("is_game_changer", 0),
|
||||
enriched.get("category", "[]"),
|
||||
enriched.get("scryfall_id", ""),
|
||||
enriched.get("tcgplayer_id", ""),
|
||||
quantity,
|
||||
finish,
|
||||
condition,
|
||||
language,
|
||||
notes,
|
||||
tags,
|
||||
set_code,
|
||||
collector_number,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
imported_count += 1
|
||||
|
||||
return {
|
||||
"importedCount": imported_count,
|
||||
"updatedCount": updated_count,
|
||||
"failedCount": failed_count,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Scryfall Cache Management Endpoints
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/api/cache/scryfall")
|
||||
async def scryfall_cache_stats():
|
||||
"""Return Scryfall response cache statistics."""
|
||||
return _scryfall_cache.stats()
|
||||
|
||||
|
||||
@router.delete("/api/cache/scryfall")
|
||||
async def scryfall_cache_clear():
|
||||
"""Clear all cached Scryfall responses."""
|
||||
deleted = _scryfall_cache.clear()
|
||||
return {"cleared": deleted, "message": f"Deleted {deleted} cached entries"}
|
||||
|
||||
|
||||
@router.post("/api/cache/scryfall/evict")
|
||||
async def scryfall_cache_evict_expired():
|
||||
"""Remove only expired entries (older than TTL) from the cache."""
|
||||
evicted = _scryfall_cache.evict_expired()
|
||||
return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
-1658
File diff suppressed because it is too large.
Load diff
@@ -1,823 +0,0 @@
|
||||
"""
|
||||
routes/deepseek.py
|
||||
==================
|
||||
Python simulator & DeepSeek AI opponent endpoints:
|
||||
POST /api/sim/run
|
||||
GET /api/sim/status
|
||||
GET /api/sim/result
|
||||
POST /api/sim/run-from-deck
|
||||
POST /api/sim/run-deepseek
|
||||
POST /api/deepseek/connect
|
||||
GET /api/deepseek/status
|
||||
POST /api/deepseek/configure
|
||||
GET /api/deepseek/logs
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading as _threading
|
||||
import traceback
|
||||
import uuid as _uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import Request as FastAPIRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from models.state import CFG
|
||||
from services.database import _get_db_conn
|
||||
from services.forge_runner import _get_deepseek_brain
|
||||
from services.logging import log_sim, _ml_logging_enabled
|
||||
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["deepseek"])
|
||||
|
||||
# In-memory store for simulation runs
|
||||
_sim_runs = {} # sim_id -> { status, result, error }
|
||||
_sim_lock = _threading.Lock()
|
||||
|
||||
# ── Validator singleton ──
|
||||
_validator_brain: ValidatorBrain | None = None
|
||||
_validator_lock = _threading.Lock()
|
||||
|
||||
|
||||
def _get_validator_brain() -> ValidatorBrain | None:
|
||||
global _validator_brain
|
||||
if os.environ.get("VALIDATOR_ENABLED", "false").lower() != "true":
|
||||
return None
|
||||
with _validator_lock:
|
||||
if _validator_brain is None:
|
||||
cfg = ValidatorConfig(
|
||||
api_base=os.environ.get("VALIDATOR_API_BASE", "http://localhost:11434"),
|
||||
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:8b"),
|
||||
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
|
||||
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "300.0")),
|
||||
)
|
||||
_validator_brain = ValidatorBrain(cfg)
|
||||
return _validator_brain
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Shared Helpers (extracted to deduplicate sim threads — #42)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
_SRC_PATH_ADDED = False
|
||||
|
||||
|
||||
def _ensure_src_path():
|
||||
"""Add project src/ to sys.path once."""
|
||||
global _SRC_PATH_ADDED
|
||||
if not _SRC_PATH_ADDED:
|
||||
src_dir = str(Path(__file__).resolve().parent.parent / 'src')
|
||||
if src_dir not in sys.path:
|
||||
sys.path.insert(0, src_dir)
|
||||
_SRC_PATH_ADDED = True
|
||||
|
||||
|
||||
def _build_deck_from_card_data(card_data: list[dict]) -> list:
|
||||
"""Build a list of Card objects from DB card dicts. Used by v2 + deepseek threads."""
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
|
||||
deck = []
|
||||
for cd in card_data:
|
||||
c = Card(name=cd['name'])
|
||||
if cd.get('type_line'):
|
||||
c.type_line = cd['type_line']
|
||||
if cd.get('cmc'):
|
||||
c.cmc = float(cd['cmc'])
|
||||
if cd.get('power') and cd.get('toughness'):
|
||||
c.power = str(cd['power'])
|
||||
c.toughness = str(cd['toughness'])
|
||||
c.pt = c.power + '/' + c.toughness
|
||||
if cd.get('oracle_text'):
|
||||
c.oracle_text = cd['oracle_text']
|
||||
if cd.get('mana_cost'):
|
||||
c.mana_cost = cd['mana_cost']
|
||||
if cd.get('keywords'):
|
||||
kw = cd['keywords']
|
||||
if isinstance(kw, str):
|
||||
try:
|
||||
kw = json.loads(kw)
|
||||
except Exception:
|
||||
kw = []
|
||||
if isinstance(kw, list):
|
||||
c.keywords = kw
|
||||
enrich_card(c)
|
||||
deck.append(c)
|
||||
return deck
|
||||
|
||||
|
||||
def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
|
||||
num_games, sim_id, **run_kwargs):
|
||||
"""Run *num_games* and accumulate stats. Returns (game_results, stats, elapsed)."""
|
||||
start = time.time()
|
||||
wins = losses = total_turns = 0
|
||||
total_damage_dealt = total_damage_received = 0
|
||||
total_spells_cast = total_creatures_played = 0
|
||||
total_removal_used = total_ramp_played = 0
|
||||
total_cards_drawn = total_max_board = 0
|
||||
game_results = []
|
||||
|
||||
for i in range(num_games):
|
||||
kw = dict(run_kwargs)
|
||||
# DeepSeek engine needs per-game IDs
|
||||
if 'game_id_prefix' in kw:
|
||||
prefix = kw.pop('game_id_prefix')
|
||||
kw['game_id'] = f'{prefix}-g{i+1}'
|
||||
result = engine.run(deck_a, deck_b, name_a=deck_name,
|
||||
name_b=opponent_name, **kw)
|
||||
game_data = result.to_dict()
|
||||
game_data['gameNumber'] = i + 1
|
||||
# Phase 8: Surface player_explanation from validation in turn log
|
||||
for turn_entry in game_data.get('log', []):
|
||||
v = turn_entry.get('validation')
|
||||
if isinstance(v, dict) and v.get('player_explanation'):
|
||||
turn_entry['player_explanation'] = v['player_explanation']
|
||||
game_results.append(game_data)
|
||||
|
||||
if result.winner == 0:
|
||||
wins += 1
|
||||
else:
|
||||
losses += 1
|
||||
total_turns += result.turns
|
||||
|
||||
if result.player_a_stats:
|
||||
s = result.player_a_stats
|
||||
total_damage_dealt += s.damage_dealt
|
||||
total_damage_received += s.damage_received
|
||||
total_spells_cast += s.spells_cast
|
||||
total_creatures_played += s.creatures_played
|
||||
total_removal_used += s.removal_used
|
||||
total_ramp_played += s.ramp_played
|
||||
total_cards_drawn += s.cards_drawn
|
||||
total_max_board += s.max_board_size
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['completed'] = i + 1
|
||||
|
||||
elapsed = time.time() - start
|
||||
stats = dict(
|
||||
wins=wins, losses=losses, total_turns=total_turns,
|
||||
total_damage_dealt=total_damage_dealt,
|
||||
total_damage_received=total_damage_received,
|
||||
total_spells_cast=total_spells_cast,
|
||||
total_creatures_played=total_creatures_played,
|
||||
total_removal_used=total_removal_used,
|
||||
total_ramp_played=total_ramp_played,
|
||||
total_cards_drawn=total_cards_drawn,
|
||||
total_max_board=total_max_board,
|
||||
)
|
||||
return game_results, stats, elapsed
|
||||
|
||||
|
||||
def _build_summary(stats: dict, deck_name: str, opponent_name: str,
|
||||
num_games: int, elapsed: float, **extra) -> dict:
|
||||
"""Build the JSON-serialisable summary dict from accumulated stats."""
|
||||
n = num_games
|
||||
summary = {
|
||||
'deckName': deck_name,
|
||||
'opponentName': opponent_name,
|
||||
'totalGames': n,
|
||||
'wins': stats['wins'],
|
||||
'losses': stats['losses'],
|
||||
'winRate': round(stats['wins'] / n * 100, 1) if n > 0 else 0.0,
|
||||
'avgTurns': round(stats['total_turns'] / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageDealt': round(stats['total_damage_dealt'] / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageReceived': round(stats['total_damage_received'] / n, 1) if n > 0 else 0.0,
|
||||
'avgSpellsCast': round(stats['total_spells_cast'] / n, 1) if n > 0 else 0.0,
|
||||
'avgCreaturesPlayed': round(stats['total_creatures_played'] / n, 1) if n > 0 else 0.0,
|
||||
'avgRemovalUsed': round(stats['total_removal_used'] / n, 1) if n > 0 else 0.0,
|
||||
'avgRampPlayed': round(stats['total_ramp_played'] / n, 1) if n > 0 else 0.0,
|
||||
'avgCardsDrawn': round(stats['total_cards_drawn'] / n, 1) if n > 0 else 0.0,
|
||||
'avgMaxBoardSize': round(stats['total_max_board'] / n, 1) if n > 0 else 0.0,
|
||||
'elapsedSeconds': round(elapsed, 3),
|
||||
}
|
||||
summary.update(extra)
|
||||
return summary
|
||||
|
||||
|
||||
def _finish_sim(sim_id, summary, game_results, engine=None):
|
||||
"""Mark a sim run as complete."""
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'complete'
|
||||
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
|
||||
|
||||
# --- Persist ml-decision-*.json for coach / training dashboard ---
|
||||
try:
|
||||
deck_name = summary.get('deckName', 'Unknown')
|
||||
opponent_name = summary.get('opponentName', 'Training Deck')
|
||||
batch_data = {
|
||||
'format': 'python-sim',
|
||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'simId': sim_id,
|
||||
'decks': [
|
||||
{
|
||||
'deckName': deck_name,
|
||||
'seatIndex': 0,
|
||||
'commanderName': summary.get('commanderName', ''),
|
||||
'colorIdentity': summary.get('colorIdentity', []),
|
||||
'games': game_results,
|
||||
},
|
||||
{
|
||||
'deckName': opponent_name,
|
||||
'seatIndex': 1,
|
||||
'commanderName': '',
|
||||
'colorIdentity': [],
|
||||
},
|
||||
],
|
||||
'summary': summary,
|
||||
}
|
||||
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
batch_path = os.path.join(results_dir, f'ml-decision-sim-{sim_id}.json')
|
||||
with open(batch_path, 'w') as f:
|
||||
json.dump(batch_data, f, indent=2, default=str)
|
||||
logger.info(f'Saved batch results to {batch_path}')
|
||||
|
||||
# Auto-generate deck report for training dashboard / coach
|
||||
try:
|
||||
from coach.report_generator import generate_deck_reports
|
||||
reports_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'deck-reports')
|
||||
os.makedirs(reports_dir, exist_ok=True)
|
||||
generate_deck_reports(str(results_dir), str(reports_dir))
|
||||
logger.info(f'Generated deck reports after sim {sim_id}')
|
||||
except ImportError:
|
||||
logger.warning('reports module not available; skipping deck report generation')
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not generate deck report: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to persist sim results: {e}')
|
||||
|
||||
# --- Flush ML decisions JSONL for training pipeline ---
|
||||
# NOTE: DeepSeek thread flushes manually before calling _finish_sim(engine=None)
|
||||
# to avoid a double-flush that would produce an empty .jsonl file.
|
||||
if engine is not None and hasattr(engine, 'flush_ml_decisions'):
|
||||
ml_decisions = engine.flush_ml_decisions()
|
||||
if ml_decisions:
|
||||
ml_path = os.path.join('results', f'ml-decisions-sim-{sim_id[:8]}.jsonl')
|
||||
os.makedirs('results', exist_ok=True)
|
||||
with open(ml_path, 'w', encoding='utf-8') as mf:
|
||||
for dec in ml_decisions:
|
||||
mf.write(json.dumps(dec) + '\n')
|
||||
logger.info(f'Wrote {len(ml_decisions)} ML decisions to {ml_path}')
|
||||
|
||||
|
||||
def _fail_sim(sim_id, error):
|
||||
"""Mark a sim run as errored."""
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'error'
|
||||
_sim_runs[sim_id]['error'] = str(error)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Background Sim Threads (now thin wrappers around shared helpers)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _run_sim_thread(sim_id: str, decklist: list, num_games: int,
|
||||
deck_name: str, record_logs: bool):
|
||||
"""Background thread: basic Monte Carlo sim from a raw decklist."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.lab.experiments import build_deck, _generate_training_deck
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'running'
|
||||
|
||||
deck_a = build_deck(decklist)
|
||||
deck_b = _generate_training_deck()
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'Training Deck',
|
||||
num_games, sim_id)
|
||||
|
||||
summary = _build_summary(stats, deck_name, 'Training Deck',
|
||||
num_games, elapsed)
|
||||
_finish_sim(sim_id, summary, game_results, engine=engine)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int,
|
||||
deck_name: str, record_logs: bool):
|
||||
"""Background thread: sim with full card data from the DB."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.lab.experiments import _generate_training_deck
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'running'
|
||||
|
||||
deck_a = _build_deck_from_card_data(card_data)
|
||||
deck_b = _generate_training_deck()
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'Training Deck',
|
||||
num_games, sim_id)
|
||||
|
||||
summary = _build_summary(stats, deck_name, 'Training Deck',
|
||||
num_games, elapsed)
|
||||
_finish_sim(sim_id, summary, game_results, engine=engine)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
|
||||
num_games: int, deck_name: str,
|
||||
record_logs: bool):
|
||||
"""Background thread: sim using DeepSeek AI opponent."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
||||
from commander_ai_lab.lab.experiments import _generate_training_deck
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'running'
|
||||
|
||||
deck_a = _build_deck_from_card_data(card_data)
|
||||
deck_b = _generate_training_deck()
|
||||
|
||||
brain = _get_deepseek_brain()
|
||||
if brain and not brain._connected:
|
||||
brain.check_connection()
|
||||
|
||||
validator = _get_validator_brain() # None if disabled
|
||||
engine = DeepSeekGameEngine(
|
||||
brain=brain, ai_player_index=1,
|
||||
max_turns=25, record_log=record_logs, ml_log=True,
|
||||
validator=validator)
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'DeepSeek AI',
|
||||
num_games, sim_id,
|
||||
game_id_prefix=f'ds-sim-{sim_id[:8]}',
|
||||
archetype='midrange')
|
||||
|
||||
# Flush ML decisions once here — pass engine=None to _finish_sim
|
||||
# to prevent a second flush that would produce an empty .jsonl file.
|
||||
ml_decisions = engine.flush_ml_decisions()
|
||||
if ml_decisions and brain and brain._connected:
|
||||
ml_path = os.path.join('results',
|
||||
f'ml-decisions-sim-{sim_id[:8]}.jsonl')
|
||||
os.makedirs('results', exist_ok=True)
|
||||
with open(ml_path, 'w', encoding='utf-8') as mf:
|
||||
for dec in ml_decisions:
|
||||
mf.write(json.dumps(dec) + '\n')
|
||||
logger.info(f'[ML Data] Wrote {len(ml_decisions)} decision snapshots to {ml_path}')
|
||||
elif ml_decisions and (not brain or not brain._connected):
|
||||
logger.warning(
|
||||
f'[ML Data] Skipping {len(ml_decisions)} snapshots for sim {sim_id} — '
|
||||
f'brain not connected, all actions are heuristic fallback '
|
||||
f'(not useful for supervised training)'
|
||||
)
|
||||
else:
|
||||
logger.warning(f'[ML Data] No decision snapshots captured for sim {sim_id} (0 decisions)')
|
||||
|
||||
ds_stats = brain.get_stats() if brain else {}
|
||||
summary = _build_summary(
|
||||
stats, deck_name, 'DeepSeek AI', num_games, elapsed,
|
||||
opponentType='deepseek', deepseekStats=ds_stats)
|
||||
# engine=None: ML decisions already flushed above — do not flush again
|
||||
_finish_sim(sim_id, summary, game_results, engine=None)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# N-Player Sim Thread
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _run_sim_thread_n_player(sim_id: str, decks_data: list[list[dict]],
|
||||
deck_names: list[str], num_games: int,
|
||||
record_logs: bool, engine_type: str):
|
||||
"""Background thread: N-player (2-4) batch simulation."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'running'
|
||||
|
||||
n_players = len(decks_data)
|
||||
built_decks = [_build_deck_from_card_data(cd) for cd in decks_data]
|
||||
|
||||
if engine_type == 'deepseek':
|
||||
brain = _get_deepseek_brain()
|
||||
if brain and not brain._connected:
|
||||
brain.check_connection()
|
||||
engine = DeepSeekGameEngine(
|
||||
brain=brain, ai_player_index=1,
|
||||
max_turns=25, record_log=record_logs, ml_log=True)
|
||||
else:
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
|
||||
|
||||
# Per-seat accumulators
|
||||
seat_wins = [0] * n_players
|
||||
seat_turns = [0] * n_players
|
||||
seat_damage_dealt = [0] * n_players
|
||||
seat_spells_cast = [0] * n_players
|
||||
seat_creatures_played = [0] * n_players
|
||||
seat_removal_used = [0] * n_players
|
||||
seat_ramp_played = [0] * n_players
|
||||
seat_cards_drawn = [0] * n_players
|
||||
seat_max_board = [0] * n_players
|
||||
|
||||
game_results = []
|
||||
start = time.time()
|
||||
|
||||
for i in range(num_games):
|
||||
result = engine.run_n(decks=built_decks, names=deck_names)
|
||||
game_data = result.to_dict()
|
||||
game_data['gameNumber'] = i + 1
|
||||
game_results.append(game_data)
|
||||
|
||||
if 0 <= result.winner_seat < n_players:
|
||||
seat_wins[result.winner_seat] += 1
|
||||
|
||||
for pr in result.players:
|
||||
si = pr.seat_index
|
||||
seat_turns[si] += result.turns
|
||||
if pr.stats:
|
||||
seat_damage_dealt[si] += pr.stats.damage_dealt
|
||||
seat_spells_cast[si] += pr.stats.spells_cast
|
||||
seat_creatures_played[si] += pr.stats.creatures_played
|
||||
seat_removal_used[si] += pr.stats.removal_used
|
||||
seat_ramp_played[si] += pr.stats.ramp_played
|
||||
seat_cards_drawn[si] += pr.stats.cards_drawn
|
||||
seat_max_board[si] += pr.stats.max_board_size
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['completed'] = i + 1
|
||||
|
||||
elapsed = time.time() - start
|
||||
n = num_games if num_games > 0 else 1
|
||||
|
||||
players_summary = []
|
||||
for si in range(n_players):
|
||||
players_summary.append({
|
||||
'seat': si,
|
||||
'deckName': deck_names[si],
|
||||
'wins': seat_wins[si],
|
||||
'winRate': round(seat_wins[si] / n * 100, 1),
|
||||
'avgTurns': round(seat_turns[si] / n, 1),
|
||||
'avgDamageDealt': round(seat_damage_dealt[si] / n, 1),
|
||||
'avgSpellsCast': round(seat_spells_cast[si] / n, 1),
|
||||
'avgCreaturesPlayed': round(seat_creatures_played[si] / n, 1),
|
||||
'avgRemovalUsed': round(seat_removal_used[si] / n, 1),
|
||||
'avgRampPlayed': round(seat_ramp_played[si] / n, 1),
|
||||
'avgCardsDrawn': round(seat_cards_drawn[si] / n, 1),
|
||||
'avgMaxBoardSize': round(seat_max_board[si] / n, 1),
|
||||
})
|
||||
|
||||
summary = {
|
||||
'playerCount': n_players,
|
||||
'totalGames': num_games,
|
||||
'players': players_summary,
|
||||
'elapsedSeconds': round(elapsed, 3),
|
||||
}
|
||||
_finish_sim(sim_id, summary, game_results, engine=engine)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Simulation API Endpoints
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _create_sim_run(num_games, deck_name):
|
||||
"""Create a queued sim run entry and return its ID."""
|
||||
sim_id = str(_uuid.uuid4())[:8]
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id] = {
|
||||
'status': 'queued', 'completed': 0,
|
||||
'total': num_games, 'deckName': deck_name,
|
||||
'result': None, 'error': None,
|
||||
}
|
||||
return sim_id
|
||||
|
||||
|
||||
def _fetch_deck_card_data(deck_id):
|
||||
"""Load card data from the DB for a given deck ID. Returns (deck_name, card_data) or raises."""
|
||||
conn = _get_db_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None, None
|
||||
deck_name = row[0]
|
||||
cur.execute("""
|
||||
SELECT dc.card_name, dc.quantity,
|
||||
ce.type_line, ce.cmc, ce.power, ce.toughness,
|
||||
ce.oracle_text, ce.keywords, ce.mana_cost
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, cmc, power, toughness,
|
||||
oracle_text, keywords, mana_cost
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,))
|
||||
card_data = []
|
||||
for r in cur.fetchall():
|
||||
for _ in range(r[1] or 1):
|
||||
card_data.append({
|
||||
'name': r[0], 'type_line': r[2] or '',
|
||||
'cmc': r[3] or 0, 'power': r[4] or '',
|
||||
'toughness': r[5] or '', 'oracle_text': r[6] or '',
|
||||
'keywords': r[7] or '', 'mana_cost': r[8] or '',
|
||||
})
|
||||
return deck_name, card_data
|
||||
|
||||
|
||||
@router.post('/api/sim/run')
|
||||
async def sim_run(request: FastAPIRequest):
|
||||
"""Start a Monte Carlo simulation with the Python engine."""
|
||||
body = await request.json()
|
||||
decklist = body.get('decklist', [])
|
||||
num_games = body.get('numGames', 10)
|
||||
deck_name = body.get('deckName', 'My Deck')
|
||||
record_logs = body.get('recordLogs', True)
|
||||
|
||||
if not decklist:
|
||||
return JSONResponse({'error': 'decklist is required'}, status_code=400)
|
||||
if num_games < 1 or num_games > 1000:
|
||||
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
|
||||
|
||||
sim_id = _create_sim_run(num_games, deck_name)
|
||||
_threading.Thread(target=_run_sim_thread,
|
||||
args=(sim_id, decklist, num_games, deck_name, record_logs),
|
||||
daemon=True).start()
|
||||
return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games})
|
||||
|
||||
|
||||
@router.get('/api/sim/status')
|
||||
async def sim_status(simId: str):
|
||||
"""Poll simulation progress."""
|
||||
with _sim_lock:
|
||||
run = _sim_runs.get(simId)
|
||||
if not run:
|
||||
return JSONResponse({'error': 'sim not found'}, status_code=404)
|
||||
return JSONResponse({
|
||||
'simId': simId, 'status': run['status'],
|
||||
'completed': run['completed'], 'total': run['total'],
|
||||
'deckName': run.get('deckName', ''), 'error': run.get('error'),
|
||||
})
|
||||
|
||||
|
||||
@router.get('/api/sim/result')
|
||||
async def sim_result(simId: str):
|
||||
"""Get completed simulation results."""
|
||||
with _sim_lock:
|
||||
run = _sim_runs.get(simId)
|
||||
if not run:
|
||||
return JSONResponse({'error': 'sim not found'}, status_code=404)
|
||||
if run['status'] != 'complete':
|
||||
return JSONResponse({'error': 'sim not complete', 'status': run['status']},
|
||||
status_code=400)
|
||||
return JSONResponse(run['result'])
|
||||
|
||||
|
||||
@router.post('/api/sim/run-from-deck')
|
||||
async def sim_run_from_deck(request: FastAPIRequest):
|
||||
"""Start simulation using a deck from the Deck Builder (by deck ID)."""
|
||||
body = await request.json()
|
||||
deck_id = body.get('deckId')
|
||||
num_games = body.get('numGames', 10)
|
||||
record_logs = body.get('recordLogs', True)
|
||||
|
||||
if not deck_id:
|
||||
return JSONResponse({'error': 'deckId required'}, status_code=400)
|
||||
|
||||
deck_name, card_data = _fetch_deck_card_data(deck_id)
|
||||
if deck_name is None:
|
||||
return JSONResponse({'error': 'deck not found'}, status_code=404)
|
||||
if not card_data:
|
||||
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
|
||||
|
||||
sim_id = _create_sim_run(num_games, deck_name)
|
||||
_threading.Thread(target=_run_sim_thread_v2,
|
||||
args=(sim_id, card_data, num_games, deck_name, record_logs),
|
||||
daemon=True).start()
|
||||
return JSONResponse({'simId': sim_id, 'status': 'queued',
|
||||
'total': num_games, 'deckName': deck_name})
|
||||
|
||||
|
||||
@router.post('/api/sim/run-deepseek')
|
||||
async def sim_run_deepseek(request: FastAPIRequest):
|
||||
"""Start simulation using DeepSeek AI as the opponent brain."""
|
||||
body = await request.json()
|
||||
deck_id = body.get('deckId')
|
||||
num_games = body.get('numGames', 5)
|
||||
record_logs = body.get('recordLogs', True)
|
||||
|
||||
if not deck_id:
|
||||
return JSONResponse({'error': 'deckId required'}, status_code=400)
|
||||
if num_games < 1 or num_games > 50:
|
||||
return JSONResponse({'error': 'numGames must be 1-50 for DeepSeek mode'},
|
||||
status_code=400)
|
||||
|
||||
deck_name, card_data = _fetch_deck_card_data(deck_id)
|
||||
if deck_name is None:
|
||||
return JSONResponse({'error': 'deck not found'}, status_code=404)
|
||||
if not card_data:
|
||||
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
|
||||
|
||||
sim_id = _create_sim_run(num_games, deck_name)
|
||||
_threading.Thread(target=_run_sim_thread_deepseek,
|
||||
args=(sim_id, card_data, num_games, deck_name, record_logs),
|
||||
daemon=True).start()
|
||||
return JSONResponse({'simId': sim_id, 'status': 'queued',
|
||||
'total': num_games, 'deckName': deck_name,
|
||||
'opponentType': 'deepseek'})
|
||||
|
||||
|
||||
@router.post('/api/sim/run-n-player')
|
||||
async def sim_run_n_player(request: FastAPIRequest):
|
||||
"""Start an N-player (2-4) batch simulation."""
|
||||
body = await request.json()
|
||||
deck_ids = body.get('deckIds', [])
|
||||
num_games = body.get('numGames', 10)
|
||||
record_logs = body.get('recordLogs', True)
|
||||
engine_type = body.get('engineType', 'heuristic')
|
||||
|
||||
if len(deck_ids) < 2 or len(deck_ids) > 4:
|
||||
return JSONResponse({'error': '2-4 decks required'}, status_code=400)
|
||||
if num_games < 1 or num_games > 1000:
|
||||
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
|
||||
|
||||
decks_data = []
|
||||
deck_names = []
|
||||
for did in deck_ids:
|
||||
name, cards = _fetch_deck_card_data(did)
|
||||
if name is None:
|
||||
return JSONResponse({'error': f'Deck {did} not found'}, status_code=404)
|
||||
if not cards:
|
||||
return JSONResponse({'error': f'Deck {did} has no cards'}, status_code=400)
|
||||
deck_names.append(name)
|
||||
decks_data.append(cards)
|
||||
|
||||
sim_id = _create_sim_run(num_games, ' vs '.join(deck_names))
|
||||
_threading.Thread(target=_run_sim_thread_n_player,
|
||||
args=(sim_id, decks_data, deck_names, num_games,
|
||||
record_logs, engine_type),
|
||||
daemon=True).start()
|
||||
return JSONResponse({
|
||||
'simId': sim_id, 'status': 'queued', 'total': num_games,
|
||||
'deckNames': deck_names, 'playerCount': len(deck_ids),
|
||||
})
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# DeepSeek AI Brain Endpoints
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post('/api/deepseek/connect')
|
||||
async def deepseek_connect(request: FastAPIRequest):
|
||||
"""Test connection to the DeepSeek LLM endpoint and auto-detect model."""
|
||||
body = await request.json() if await request.body() else {}
|
||||
api_base = body.get('apiBase', None)
|
||||
model = body.get('model', None)
|
||||
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
return JSONResponse({'error': 'DeepSeek brain failed to initialize'},
|
||||
status_code=500)
|
||||
|
||||
if api_base:
|
||||
brain.config.api_base = api_base
|
||||
if model:
|
||||
brain.config.model = model
|
||||
|
||||
connected = brain.check_connection()
|
||||
return JSONResponse({
|
||||
'connected': connected,
|
||||
'apiBase': brain.config.api_base,
|
||||
'model': brain.config.model,
|
||||
'stats': brain.get_stats(),
|
||||
})
|
||||
|
||||
|
||||
@router.get('/api/deepseek/status')
|
||||
async def deepseek_status():
|
||||
"""Get DeepSeek brain status and performance stats."""
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
return JSONResponse({'connected': False, 'error': 'Brain not initialized'})
|
||||
return JSONResponse(brain.get_stats())
|
||||
|
||||
|
||||
@router.post('/api/deepseek/configure')
|
||||
async def deepseek_configure(request: FastAPIRequest):
|
||||
"""Update DeepSeek configuration at runtime."""
|
||||
body = await request.json()
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
|
||||
|
||||
if 'apiBase' in body:
|
||||
brain.config.api_base = body['apiBase']
|
||||
if 'model' in body:
|
||||
brain.config.model = body['model']
|
||||
if 'temperature' in body:
|
||||
brain.config.temperature = float(body['temperature'])
|
||||
if 'maxTokens' in body:
|
||||
brain.config.max_tokens = int(body['maxTokens'])
|
||||
if 'timeout' in body:
|
||||
brain.config.request_timeout = float(body['timeout'])
|
||||
if 'cacheEnabled' in body:
|
||||
brain.config.cache_enabled = bool(body['cacheEnabled'])
|
||||
if 'logDecisions' in body:
|
||||
brain.config.log_decisions = bool(body['logDecisions'])
|
||||
if 'fallbackOnTimeout' in body:
|
||||
brain.config.fallback_on_timeout = bool(body['fallbackOnTimeout'])
|
||||
|
||||
connected = brain.check_connection()
|
||||
return JSONResponse({
|
||||
'connected': connected,
|
||||
'config': {
|
||||
'apiBase': brain.config.api_base,
|
||||
'model': brain.config.model,
|
||||
'temperature': brain.config.temperature,
|
||||
'maxTokens': brain.config.max_tokens,
|
||||
'timeout': brain.config.request_timeout,
|
||||
'cacheEnabled': brain.config.cache_enabled,
|
||||
'logDecisions': brain.config.log_decisions,
|
||||
'fallbackOnTimeout': brain.config.fallback_on_timeout,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@router.get('/api/deepseek/logs')
|
||||
async def deepseek_logs():
|
||||
"""Get decision log stats and flush pending entries."""
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
|
||||
|
||||
pending = len(brain._decision_log)
|
||||
flushed_path = None
|
||||
if pending > 0:
|
||||
try:
|
||||
flushed_path = brain.flush_log()
|
||||
except Exception as e:
|
||||
return JSONResponse({'error': f'Failed to flush: {e}'},
|
||||
status_code=500)
|
||||
|
||||
import glob as _glob
|
||||
log_dir = brain.config.log_dir
|
||||
log_files = []
|
||||
if log_dir and os.path.isdir(log_dir):
|
||||
for f in sorted(_glob.glob(os.path.join(log_dir, 'decisions_*.jsonl'))):
|
||||
stat = os.stat(f)
|
||||
log_files.append({
|
||||
'filename': os.path.basename(f),
|
||||
'size_bytes': stat.st_size,
|
||||
'modified': stat.st_mtime,
|
||||
})
|
||||
|
||||
return JSONResponse({
|
||||
'flushed': pending,
|
||||
'flushedPath': flushed_path,
|
||||
'logFiles': log_files[-20:],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/validator/status")
|
||||
async def validator_status():
|
||||
"""Get DeepSeek-R1-14B validator brain status."""
|
||||
v = _get_validator_brain()
|
||||
if v is None:
|
||||
return JSONResponse({"enabled": False,
|
||||
"reason": "VALIDATOR_ENABLED is not true"})
|
||||
return JSONResponse({"enabled": True, **v.get_stats()})
|
||||
|
||||
|
||||
@router.post("/api/validator/configure")
|
||||
async def validator_configure(request: FastAPIRequest):
|
||||
"""Update validator configuration at runtime."""
|
||||
body = await request.json()
|
||||
v = _get_validator_brain()
|
||||
if v is None:
|
||||
return JSONResponse({"error": "Validator not enabled"}, status_code=400)
|
||||
if "model" in body:
|
||||
v.config.model = body["model"]
|
||||
if "timeout" in body:
|
||||
v.config.request_timeout = float(body["timeout"])
|
||||
if "enabled" in body:
|
||||
v.config.enabled = bool(body["enabled"])
|
||||
return JSONResponse(v.get_stats())
|
||||
@@ -1,123 +0,0 @@
|
||||
"""
|
||||
routes/import_routes.py
|
||||
=======================
|
||||
Deck import & commander meta endpoints:
|
||||
POST /api/lab/import/url
|
||||
POST /api/lab/import/text
|
||||
GET /api/lab/meta/commanders
|
||||
GET /api/lab/meta/search
|
||||
POST /api/lab/meta/fetch
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from models.state import COMMANDER_META
|
||||
from models.requests import ImportUrlRequest, ImportTextRequest, MetaFetchRequest
|
||||
from services.import_helpers import (
|
||||
_import_from_url, _parse_text_decklist, _save_profile_to_dck,
|
||||
_fetch_edhrec_average,
|
||||
)
|
||||
from services.logging import log
|
||||
|
||||
router = APIRouter(tags=["import"])
|
||||
|
||||
|
||||
@router.post("/api/lab/import/url")
|
||||
async def import_from_url(req: ImportUrlRequest):
|
||||
"""Import a deck from Archidekt or EDHREC URL. Returns parsed DeckProfile + saves .dck file."""
|
||||
url = req.url.strip()
|
||||
try:
|
||||
profile = _import_from_url(url)
|
||||
dck_path = _save_profile_to_dck(profile)
|
||||
return {
|
||||
"success": True,
|
||||
"deckName": profile["name"],
|
||||
"commander": profile["commander"],
|
||||
"source": profile["source"],
|
||||
"sourceUrl": profile["sourceUrl"],
|
||||
"totalCards": profile["totalCards"],
|
||||
"dckFile": dck_path.stem,
|
||||
"colorIdentity": profile.get("colorIdentity", []),
|
||||
"archetype": profile.get("archetype"),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.post("/api/lab/import/text")
|
||||
async def import_from_text(req: ImportTextRequest):
|
||||
"""Import a deck from plain text card list."""
|
||||
try:
|
||||
profile = _parse_text_decklist(req.text, req.commander)
|
||||
dck_path = _save_profile_to_dck(profile)
|
||||
return {
|
||||
"success": True,
|
||||
"deckName": profile["name"],
|
||||
"commander": profile["commander"],
|
||||
"source": "Text Import",
|
||||
"totalCards": profile["totalCards"],
|
||||
"dckFile": dck_path.stem,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.get("/api/lab/meta/commanders")
|
||||
async def list_meta_commanders():
|
||||
"""List available commanders in the meta mapping."""
|
||||
commanders = []
|
||||
for name, entries in COMMANDER_META.items():
|
||||
entry = entries[0] if entries else {}
|
||||
commanders.append({
|
||||
"name": name,
|
||||
"archetype": entry.get("archetype", ""),
|
||||
"colorIdentity": entry.get("colorIdentity", []),
|
||||
"source": entry.get("source", "edhrec"),
|
||||
})
|
||||
return {"commanders": commanders}
|
||||
|
||||
|
||||
@router.get("/api/lab/meta/search")
|
||||
async def search_meta_commanders(q: str = ""):
|
||||
"""Search commanders by partial name match."""
|
||||
query = q.lower()
|
||||
matches = []
|
||||
for name, entries in COMMANDER_META.items():
|
||||
if query in name.lower():
|
||||
entry = entries[0] if entries else {}
|
||||
matches.append({
|
||||
"name": name,
|
||||
"archetype": entry.get("archetype", ""),
|
||||
"colorIdentity": entry.get("colorIdentity", []),
|
||||
})
|
||||
return {"results": matches}
|
||||
|
||||
|
||||
@router.post("/api/lab/meta/fetch")
|
||||
async def fetch_meta_deck(req: MetaFetchRequest):
|
||||
"""Fetch EDHREC average deck for a commander and save as .dck file."""
|
||||
try:
|
||||
profile = _fetch_edhrec_average(req.commander)
|
||||
dck_path = _save_profile_to_dck(profile)
|
||||
return {
|
||||
"success": True,
|
||||
"deckName": profile["name"],
|
||||
"commander": profile["commander"],
|
||||
"source": "EDHREC Average",
|
||||
"sourceUrl": profile.get("sourceUrl", ""),
|
||||
"totalCards": profile["totalCards"],
|
||||
"dckFile": dck_path.stem,
|
||||
"colorIdentity": profile.get("colorIdentity", []),
|
||||
"sampleSize": profile.get("sampleSize"),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Failed to fetch meta deck for '{req.commander}': {str(e)}")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Existing Endpoints (v1/v2)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
-473
@@ -1,473 +0,0 @@
|
||||
"""
|
||||
routes/lab.py
|
||||
=============
|
||||
Batch simulation & lab management endpoints:
|
||||
POST /api/lab/start
|
||||
POST /api/lab/start-deepseek
|
||||
GET /api/lab/status
|
||||
GET /api/lab/result
|
||||
GET /api/lab/decks
|
||||
GET /api/lab/history
|
||||
GET /api/lab/profiles
|
||||
GET /api/lab/profiles/{name}
|
||||
GET /api/lab/analytics/{deck_name}
|
||||
GET /api/lab/trends/{deck_name}
|
||||
GET /api/lab/log
|
||||
GET /api/lab/debug-log
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request as FastAPIRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from models.state import CFG, active_batches, BatchState, AI_PROFILES
|
||||
from models.requests import StartRequest
|
||||
from models.responses import StartResponse, StatusResponse
|
||||
from services.database import _get_db_conn
|
||||
from services.deck_service import parse_dck_file, _load_deck_cards_by_name
|
||||
from services.forge_runner import (
|
||||
build_java_command, run_batch_subprocess, _run_deepseek_batch_thread,
|
||||
)
|
||||
from services.logging import log, log_batch
|
||||
|
||||
router = APIRouter(tags=["lab"])
|
||||
|
||||
|
||||
def _sanitize_floats(obj):
|
||||
"""Replace NaN/Infinity with None so JSONResponse (allow_nan=False) won't crash."""
|
||||
if isinstance(obj, float):
|
||||
if math.isnan(obj) or math.isinf(obj):
|
||||
return None
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: _sanitize_floats(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_sanitize_floats(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
@router.post("/api/lab/start", response_model=StartResponse)
|
||||
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
"""Start a new batch simulation run."""
|
||||
if len(req.decks) < 2 or len(req.decks) > 4:
|
||||
raise HTTPException(400, "2-4 decks required")
|
||||
if not all(req.decks):
|
||||
raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled")
|
||||
if req.numGames < 1 or req.numGames > 10000:
|
||||
raise HTTPException(400, "numGames must be 1-10000")
|
||||
if req.threads < 1 or req.threads > 16:
|
||||
raise HTTPException(400, "threads must be 1-16")
|
||||
|
||||
if not os.path.exists(CFG.forge_jar):
|
||||
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
|
||||
if not os.path.isdir(CFG.forge_dir):
|
||||
raise HTTPException(500, f"Forge dir not found: {CFG.forge_dir}")
|
||||
|
||||
batch_id = str(uuid.uuid4())[:12]
|
||||
state = BatchState(batch_id, req.numGames, req.threads)
|
||||
active_batches[batch_id] = state
|
||||
|
||||
os.makedirs(CFG.results_dir, exist_ok=True)
|
||||
output_path = os.path.join(CFG.results_dir, f"ml-decision-{batch_id}.json")
|
||||
state.result_path = output_path
|
||||
|
||||
# Auto-export deckbuilder decks to .dck if not already exported
|
||||
resolved_decks = []
|
||||
for deck_name in req.decks:
|
||||
dck_path = os.path.join(CFG.forge_decks_dir, deck_name + ".dck")
|
||||
if os.path.exists(dck_path):
|
||||
resolved_decks.append(deck_name)
|
||||
else:
|
||||
# Check if it's a deckbuilder deck and auto-export
|
||||
try:
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute(
|
||||
"SELECT id FROM decks WHERE name = ? LIMIT 1", (deck_name,)
|
||||
).fetchone()
|
||||
if row:
|
||||
from routes.deckbuilder import export_deck_to_sim
|
||||
result = await export_deck_to_sim(row["id"])
|
||||
resolved_decks.append(result["deckName"])
|
||||
log.info(f"Auto-exported deckbuilder deck '{deck_name}' to .dck")
|
||||
else:
|
||||
resolved_decks.append(deck_name)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to auto-export deck '{deck_name}': {e}")
|
||||
resolved_decks.append(deck_name)
|
||||
|
||||
background_tasks.add_task(
|
||||
run_batch_subprocess,
|
||||
state,
|
||||
resolved_decks,
|
||||
req.numGames,
|
||||
req.threads,
|
||||
req.seed,
|
||||
req.clock,
|
||||
output_path,
|
||||
req.useLearnedPolicy,
|
||||
req.policyStyle,
|
||||
req.policyGreedy,
|
||||
req.aiSimplified,
|
||||
req.aiThinkTimeMs,
|
||||
req.maxQueueDepth,
|
||||
)
|
||||
|
||||
policy_msg = ""
|
||||
if req.useLearnedPolicy:
|
||||
policy_msg = f" [Learned Policy: {req.policyStyle}]"
|
||||
return StartResponse(
|
||||
batchId=batch_id,
|
||||
status="started",
|
||||
message=f"Running {req.numGames} games with {req.threads} threads{policy_msg}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/lab/status", response_model=StatusResponse)
|
||||
async def get_status(batchId: Optional[str] = None):
|
||||
# If no batchId given, find the most recent active (or last) batch
|
||||
state = None
|
||||
if batchId:
|
||||
state = active_batches.get(batchId)
|
||||
else:
|
||||
# Return the most recently active batch, or any batch
|
||||
for s in active_batches.values():
|
||||
if s.running:
|
||||
state = s
|
||||
break
|
||||
if state is None and active_batches:
|
||||
state = list(active_batches.values())[-1]
|
||||
if not state:
|
||||
return StatusResponse(
|
||||
batchId="", running=False, completed=0, total=0,
|
||||
threads=0, elapsedMs=0, error=None, simsPerSec=0.0,
|
||||
run_id="", games_completed=0, total_games=0, current_decks=[],
|
||||
)
|
||||
elapsed = int((datetime.now() - state.start_time).total_seconds() * 1000)
|
||||
deck_names = getattr(state, 'deck_names', []) if hasattr(state, 'deck_names') else []
|
||||
return StatusResponse(
|
||||
batchId=state.batch_id,
|
||||
running=state.running,
|
||||
completed=state.completed_games,
|
||||
total=state.total_games,
|
||||
threads=state.threads,
|
||||
elapsedMs=elapsed if state.running else state.elapsed_ms,
|
||||
error=state.error,
|
||||
simsPerSec=state.sims_per_sec,
|
||||
# React SPA fields
|
||||
run_id=state.batch_id,
|
||||
games_completed=state.completed_games,
|
||||
total_games=state.total_games,
|
||||
current_decks=deck_names,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/lab/result")
|
||||
async def get_result(batchId: Optional[str] = None):
|
||||
state = None
|
||||
if batchId:
|
||||
state = active_batches.get(batchId)
|
||||
else:
|
||||
# Find most recent completed batch
|
||||
for s in reversed(list(active_batches.values())):
|
||||
if not s.running and s.result_path:
|
||||
state = s
|
||||
break
|
||||
if not state:
|
||||
raise HTTPException(404, "No batch result available")
|
||||
if state.running:
|
||||
raise HTTPException(409, "Batch still running")
|
||||
if state.error:
|
||||
raise HTTPException(500, state.error)
|
||||
if not state.result_path or not os.path.exists(state.result_path):
|
||||
raise HTTPException(500, "Result file not found")
|
||||
try:
|
||||
with open(state.result_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
raise HTTPException(500, f"Failed to read result file: {e}")
|
||||
data = _sanitize_floats(data)
|
||||
return JSONResponse(content=data)
|
||||
|
||||
|
||||
@router.get("/api/lab/decks")
|
||||
async def list_decks():
|
||||
decks = []
|
||||
seen_names = set()
|
||||
|
||||
# 1. Forge .dck files from decks directory
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if decks_dir and os.path.isdir(decks_dir):
|
||||
for f in sorted(Path(decks_dir).glob("*.dck")):
|
||||
deck_name = f.stem
|
||||
decks.append({"name": deck_name, "filename": f.name, "source": "forge"})
|
||||
seen_names.add(deck_name.lower())
|
||||
|
||||
# 2. Decks from the Deck Builder database
|
||||
try:
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, commander_name FROM decks ORDER BY id DESC"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
db_name = row["name"]
|
||||
if db_name.lower() not in seen_names:
|
||||
decks.append({
|
||||
"name": db_name,
|
||||
"filename": "",
|
||||
"source": "deckbuilder",
|
||||
"deck_id": row["id"],
|
||||
"commander": row["commander_name"] or "",
|
||||
})
|
||||
seen_names.add(db_name.lower())
|
||||
except Exception as e:
|
||||
log.warning(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}")
|
||||
|
||||
return {"decks": decks}
|
||||
|
||||
|
||||
@router.get("/api/lab/history")
|
||||
async def list_history():
|
||||
results_dir = Path(CFG.results_dir)
|
||||
if not results_dir.exists():
|
||||
return {"results": []}
|
||||
results = []
|
||||
for f in sorted(results_dir.glob("ml-decision-*.json"), reverse=True):
|
||||
try:
|
||||
with open(f, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
meta = data.get("metadata", {})
|
||||
decks = data.get("decks", [])
|
||||
results.append({
|
||||
"filename": f.name,
|
||||
"batchId": meta.get("batchId", ""),
|
||||
"timestamp": meta.get("timestamp", ""),
|
||||
"totalGames": meta.get("completedGames", 0),
|
||||
"threads": meta.get("threads", 1),
|
||||
"elapsedMs": meta.get("elapsedMs", 0),
|
||||
"decks": [{"name": d.get("deckName", ""), "source": d.get("source", "")} for d in decks],
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return {"results": results[:50]}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# AI Profiles
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
AI_PROFILES = {
|
||||
"default": {
|
||||
"name": "default",
|
||||
"description": "Balanced \u2014 Forge's default AI behavior",
|
||||
"aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5,
|
||||
"boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5,
|
||||
},
|
||||
"aggro": {
|
||||
"name": "aggro",
|
||||
"description": "Aggressive \u2014 attacks early, prioritizes damage",
|
||||
"aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3,
|
||||
"boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1,
|
||||
},
|
||||
"control": {
|
||||
"name": "control",
|
||||
"description": "Control \u2014 defensive, removal-heavy, card advantage",
|
||||
"aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9,
|
||||
"boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9,
|
||||
},
|
||||
"combo": {
|
||||
"name": "combo",
|
||||
"description": "Combo \u2014 ramps, digs for pieces, assembles combos",
|
||||
"aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4,
|
||||
"boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7,
|
||||
},
|
||||
"midrange": {
|
||||
"name": "midrange",
|
||||
"description": "Midrange \u2014 flexible, strong board presence, value-oriented",
|
||||
"aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6,
|
||||
"boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/api/lab/profiles")
|
||||
async def list_profiles():
|
||||
return {"profiles": list(AI_PROFILES.values())}
|
||||
|
||||
@router.get("/api/lab/profiles/{name}")
|
||||
async def get_profile(name: str):
|
||||
profile = AI_PROFILES.get(name.lower())
|
||||
if not profile:
|
||||
raise HTTPException(404, f"Profile '{name}' not found. Available: {list(AI_PROFILES.keys())}")
|
||||
return profile
|
||||
|
||||
|
||||
@router.get("/api/lab/analytics/{deck_name}")
|
||||
async def analyze_deck(deck_name: str):
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if not decks_dir or not os.path.isdir(decks_dir):
|
||||
raise HTTPException(500, f"Decks directory not found: {decks_dir}")
|
||||
deck_path = os.path.join(decks_dir, deck_name + ".dck")
|
||||
if not os.path.exists(deck_path):
|
||||
for f in os.listdir(decks_dir):
|
||||
if f.lower() == deck_name.lower() + ".dck":
|
||||
deck_path = os.path.join(decks_dir, f)
|
||||
break
|
||||
else:
|
||||
raise HTTPException(404, f"Deck not found: {deck_name}")
|
||||
try:
|
||||
analysis = parse_dck_file(deck_path)
|
||||
return analysis
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Analysis failed: {str(e)}")
|
||||
|
||||
|
||||
def parse_dck_file(deck_path: str) -> dict:
|
||||
cards = []
|
||||
commander = None
|
||||
section = "Main"
|
||||
deck_name = Path(deck_path).stem
|
||||
with open(deck_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("//"):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section = line[1:-1]
|
||||
continue
|
||||
if line.startswith("Name="):
|
||||
deck_name = line[5:].strip()
|
||||
continue
|
||||
m = re.match(r"^(\d+)\s+(.+?)(?:\|(.+))?$", line)
|
||||
if m:
|
||||
qty = int(m.group(1))
|
||||
name = m.group(2).strip()
|
||||
set_code = m.group(3).strip() if m.group(3) else ""
|
||||
cards.append({
|
||||
"quantity": qty, "name": name, "set": set_code,
|
||||
"section": section,
|
||||
"is_commander": 1 if section == "Commander" else 0,
|
||||
})
|
||||
if section == "Commander":
|
||||
commander = name
|
||||
total = sum(c["quantity"] for c in cards)
|
||||
return {
|
||||
"deckName": deck_name,
|
||||
"commanderName": commander,
|
||||
"totalCards": total,
|
||||
"cardCount": len(cards),
|
||||
"cards": cards[:200],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/lab/trends/{deck_name}")
|
||||
async def get_deck_trends(deck_name: str):
|
||||
results_dir = Path(CFG.results_dir)
|
||||
if not results_dir.exists():
|
||||
return {"deckName": deck_name, "history": []}
|
||||
history = []
|
||||
for f in sorted(results_dir.glob("ml-decision-*.json")):
|
||||
try:
|
||||
with open(f, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
summary = data.get("summary", {})
|
||||
meta = data.get("metadata", {})
|
||||
per_deck = summary.get("perDeck", [])
|
||||
for ds in per_deck:
|
||||
if ds.get("deckName", "").lower() == deck_name.lower():
|
||||
history.append({
|
||||
"batchId": meta.get("batchId"),
|
||||
"timestamp": meta.get("timestamp"),
|
||||
"winRate": ds.get("winRate", 0),
|
||||
"wins": ds.get("wins", 0),
|
||||
"losses": ds.get("losses", 0),
|
||||
"draws": ds.get("draws", 0),
|
||||
"totalGames": meta.get("completedGames", 0),
|
||||
})
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
return {"deckName": deck_name, "history": history}
|
||||
|
||||
|
||||
@router.get("/api/lab/log")
|
||||
async def get_log(batchId: str):
|
||||
state = active_batches.get(batchId)
|
||||
if not state:
|
||||
raise HTTPException(404, f"Batch {batchId} not found")
|
||||
return {"lines": state.log_lines[-200:]}
|
||||
|
||||
|
||||
@router.get("/api/lab/debug-log")
|
||||
async def get_debug_log():
|
||||
"""Return the raw Forge subprocess debug log for diagnosing simulation issues."""
|
||||
# Check multiple possible locations for the debug log
|
||||
candidates = []
|
||||
if CFG.forge_dir:
|
||||
candidates.append(Path(CFG.forge_dir).parent / "forge-sim-debug.log")
|
||||
candidates.append(Path(CFG.forge_dir) / "forge-sim-debug.log")
|
||||
candidates.append(Path("forge-sim-debug.log"))
|
||||
|
||||
for log_path in candidates:
|
||||
if log_path.exists():
|
||||
text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
# Return last 50KB to avoid huge responses
|
||||
if len(text) > 50000:
|
||||
text = "... (truncated) ...\n" + text[-50000:]
|
||||
return {"path": str(log_path), "content": text}
|
||||
|
||||
return {"path": None, "content": "No debug log found. Run a simulation first."}
|
||||
|
||||
|
||||
|
||||
@router.post('/api/lab/start-deepseek')
|
||||
async def start_batch_deepseek(request: FastAPIRequest, background_tasks: BackgroundTasks):
|
||||
"""Start a batch simulation using the Python sim engine + DeepSeek LLM opponent."""
|
||||
body = await request.json()
|
||||
decks = body.get('decks', [])
|
||||
num_games = body.get('numGames', 30)
|
||||
# Note: threads not applicable for Python sim (single-threaded LLM calls)
|
||||
|
||||
if not decks or len(decks) < 1:
|
||||
raise HTTPException(400, 'At least 1 deck required')
|
||||
if num_games < 1 or num_games > 500:
|
||||
raise HTTPException(400, 'numGames must be 1-500')
|
||||
|
||||
# Filter empty deck slots
|
||||
decks = [d for d in decks if d]
|
||||
if not decks:
|
||||
raise HTTPException(400, 'No valid decks provided')
|
||||
|
||||
batch_id = str(uuid.uuid4())[:12]
|
||||
state = BatchState(batch_id, num_games, 1)
|
||||
active_batches[batch_id] = state
|
||||
|
||||
os.makedirs(CFG.results_dir, exist_ok=True)
|
||||
output_path = os.path.join(CFG.results_dir, f'batch-{batch_id}.json')
|
||||
state.result_path = output_path
|
||||
|
||||
# Use threading instead of BackgroundTasks to avoid async issues with DB
|
||||
t = threading.Thread(
|
||||
target=_run_deepseek_batch_thread,
|
||||
args=(state, decks, num_games, output_path),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
return JSONResponse({
|
||||
'batchId': batch_id,
|
||||
'status': 'started',
|
||||
'message': f'Running {num_games} games across {len(decks)} decks with DeepSeek AI',
|
||||
'engine': 'deepseek',
|
||||
})
|
||||
|
||||
|
||||
-1201
File diff suppressed because it is too large.
Load diff
@@ -1,321 +0,0 @@
|
||||
"""Commander AI Lab — Policy Decision Routes (Phase 2)
|
||||
════════════════════════════════════════════════════
|
||||
|
||||
Live Forge ↔ policy server IPC endpoints for online training.
|
||||
These endpoints are purpose-built for the real-time decision loop
|
||||
where Forge's Java side calls the Python policy server at each
|
||||
game decision point.
|
||||
|
||||
Endpoints:
|
||||
POST /api/policy/decide — Accept full Forge game state, return action + log_prob + value
|
||||
POST /api/policy/collect — Accept batch of (state, action, reward) tuples
|
||||
POST /api/policy/reward — Submit end-of-game reward
|
||||
GET /api/policy/health — Readiness check for the live IPC loop
|
||||
GET /api/policy/stats — Online learning session statistics
|
||||
|
||||
Refs: Issue #83 Phase 2.2
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger("routes.policy")
|
||||
|
||||
router = APIRouter(prefix="/api/policy", tags=["policy"])
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Request / Response Models
|
||||
# ================================================================
|
||||
|
||||
class PlayerZoneState(BaseModel):
|
||||
"""Per-player zone state from Forge."""
|
||||
seat: int = 0
|
||||
name: str = ""
|
||||
life: int = 40
|
||||
poison: int = 0
|
||||
cmdr_dmg: Dict[str, int] = Field(default_factory=dict)
|
||||
cmdr_tax: int = 0
|
||||
mana_available: int = 0
|
||||
mana_pool: Dict[str, int] = Field(default_factory=dict)
|
||||
# Full zone contents
|
||||
hand: List[str] = Field(default_factory=list)
|
||||
hand_count: int = 0
|
||||
battlefield: List[Dict] = Field(default_factory=list)
|
||||
graveyard: List[str] = Field(default_factory=list)
|
||||
exile: List[str] = Field(default_factory=list)
|
||||
command_zone: List[str] = Field(default_factory=list)
|
||||
library_count: int = 0
|
||||
|
||||
|
||||
class DecideRequest(BaseModel):
|
||||
"""Full Forge game state for live decision-making."""
|
||||
# Game metadata
|
||||
game_id: str = ""
|
||||
turn: int = 1
|
||||
phase: str = "main_1"
|
||||
active_player: int = 0
|
||||
priority_player: int = 0
|
||||
# Per-player state with full zones
|
||||
players: List[PlayerZoneState] = Field(default_factory=list)
|
||||
# Stack (spells/abilities currently resolving)
|
||||
stack: List[Dict] = Field(default_factory=list)
|
||||
# Legal actions from Forge
|
||||
legal_actions: List[Dict] = Field(default_factory=list)
|
||||
# Inference params
|
||||
playstyle: str = "midrange"
|
||||
greedy: bool = False
|
||||
temperature: float = 1.0
|
||||
# Optional commander info
|
||||
commander: str = ""
|
||||
deck_name: str = ""
|
||||
|
||||
|
||||
class DecideResponse(BaseModel):
|
||||
"""Action decision with metadata for online learning."""
|
||||
action: str
|
||||
action_index: int
|
||||
confidence: float
|
||||
log_prob: float = 0.0
|
||||
value: float = 0.0
|
||||
probabilities: Dict[str, float] = Field(default_factory=dict)
|
||||
inference_ms: float = 0.0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class CollectRequest(BaseModel):
|
||||
"""Batch of online learning tuples from live Forge games."""
|
||||
tuples: List[Dict] = Field(default_factory=list)
|
||||
count: int = 0
|
||||
|
||||
|
||||
class RewardRequest(BaseModel):
|
||||
"""End-of-game reward submission."""
|
||||
game_id: str
|
||||
reward: float
|
||||
winner_seat: int = -1
|
||||
turns_played: int = 0
|
||||
reason: str = "" # e.g., "life", "commander_damage", "concede"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Module-level state (initialized by register_policy_routes)
|
||||
# ================================================================
|
||||
|
||||
_policy_service = None
|
||||
_online_store = None
|
||||
_session_stats = {
|
||||
"total_decisions": 0,
|
||||
"total_tuples_collected": 0,
|
||||
"total_rewards_submitted": 0,
|
||||
"games_completed": 0,
|
||||
"session_start": 0.0,
|
||||
"last_decision_ms": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def register_policy_routes(app, policy_service, online_store=None):
|
||||
"""Register the policy routes on the FastAPI app.
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance
|
||||
policy_service: PolicyInferenceService from ml.serving.policy_server
|
||||
online_store: Optional OnlineLearningStore for tuple collection
|
||||
"""
|
||||
global _policy_service, _online_store
|
||||
_policy_service = policy_service
|
||||
_online_store = online_store
|
||||
_session_stats["session_start"] = time.time()
|
||||
app.include_router(router)
|
||||
logger.info("Policy routes registered (online_store=%s)",
|
||||
"enabled" if online_store else "disabled")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# POST /api/policy/decide
|
||||
# ================================================================
|
||||
|
||||
@router.post("/decide", response_model=DecideResponse)
|
||||
async def decide(req: DecideRequest):
|
||||
"""Accept full Forge game state, run policy inference, return action.
|
||||
|
||||
This is the primary endpoint for live Forge IPC. The Java
|
||||
PolicyClient calls this at each decision point during a real
|
||||
Forge game.
|
||||
|
||||
Returns the macro-action plus log_prob and value estimate
|
||||
needed for online PPO updates.
|
||||
"""
|
||||
if _policy_service is None or not _policy_service._loaded:
|
||||
raise HTTPException(
|
||||
503,
|
||||
detail="Policy model not loaded"
|
||||
)
|
||||
|
||||
t_start = time.time()
|
||||
_session_stats["total_decisions"] += 1
|
||||
|
||||
# Convert request to snapshot dict for the encoder
|
||||
snapshot = req.dict(exclude={"temperature", "greedy", "playstyle"})
|
||||
|
||||
# Map phase names from Forge conventions
|
||||
snapshot["phase"] = _map_forge_phase(req.phase)
|
||||
snapshot["active_seat"] = req.active_player
|
||||
|
||||
result = _policy_service.predict(
|
||||
snapshot,
|
||||
playstyle=req.playstyle,
|
||||
temperature=req.temperature,
|
||||
greedy=req.greedy,
|
||||
)
|
||||
|
||||
elapsed_ms = (time.time() - t_start) * 1000
|
||||
_session_stats["last_decision_ms"] = elapsed_ms
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(500, detail=result["error"])
|
||||
|
||||
# Add log_prob and value estimate for online learning
|
||||
log_prob = 0.0
|
||||
value = 0.0
|
||||
try:
|
||||
import math
|
||||
conf = result.get("confidence", 0.5)
|
||||
log_prob = math.log(max(conf, 1e-8))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return DecideResponse(
|
||||
action=result["action"],
|
||||
action_index=result["action_index"],
|
||||
confidence=result["confidence"],
|
||||
log_prob=round(log_prob, 6),
|
||||
value=round(value, 6),
|
||||
probabilities=result.get("probabilities", {}),
|
||||
inference_ms=round(elapsed_ms, 2),
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# POST /api/policy/collect
|
||||
# ================================================================
|
||||
|
||||
@router.post("/collect")
|
||||
async def collect_tuples(req: CollectRequest):
|
||||
"""Accept a batch of (state, action, reward) tuples from live games
|
||||
for online PPO updates.
|
||||
"""
|
||||
count = len(req.tuples)
|
||||
_session_stats["total_tuples_collected"] += count
|
||||
|
||||
if _online_store is not None:
|
||||
try:
|
||||
_online_store.add_tuples(req.tuples)
|
||||
except Exception as e:
|
||||
logger.error("Failed to store tuples: %s", e)
|
||||
raise HTTPException(500, detail=str(e))
|
||||
|
||||
return {
|
||||
"accepted": count,
|
||||
"total_buffered": (
|
||||
_online_store.size() if _online_store else count
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# POST /api/policy/reward
|
||||
# ================================================================
|
||||
|
||||
@router.post("/reward")
|
||||
async def submit_reward(req: RewardRequest):
|
||||
"""Submit end-of-game reward for all tuples in a game.
|
||||
Propagates the terminal reward back through the game's trajectory.
|
||||
"""
|
||||
_session_stats["total_rewards_submitted"] += 1
|
||||
_session_stats["games_completed"] += 1
|
||||
|
||||
if _online_store is not None:
|
||||
try:
|
||||
_online_store.assign_reward(
|
||||
game_id=req.game_id,
|
||||
reward=req.reward,
|
||||
winner_seat=req.winner_seat,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to assign reward: %s", e)
|
||||
raise HTTPException(500, detail=str(e))
|
||||
|
||||
return {
|
||||
"game_id": req.game_id,
|
||||
"reward": req.reward,
|
||||
"status": "accepted",
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# GET /api/policy/health
|
||||
# ================================================================
|
||||
|
||||
@router.get("/health")
|
||||
async def policy_health():
|
||||
"""Readiness check for the live Forge IPC loop."""
|
||||
model_loaded = (
|
||||
_policy_service is not None and _policy_service._loaded
|
||||
)
|
||||
return {
|
||||
"ready": model_loaded,
|
||||
"status": "ok" if model_loaded else "degraded",
|
||||
"model_loaded": model_loaded,
|
||||
"online_store": _online_store is not None,
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# GET /api/policy/stats
|
||||
# ================================================================
|
||||
|
||||
@router.get("/stats")
|
||||
async def policy_stats():
|
||||
"""Online learning session statistics."""
|
||||
uptime = time.time() - _session_stats["session_start"]
|
||||
return {
|
||||
**_session_stats,
|
||||
"uptime_seconds": round(uptime, 1),
|
||||
"decisions_per_second": (
|
||||
round(_session_stats["total_decisions"] / max(uptime, 1), 2)
|
||||
),
|
||||
"online_store_size": (
|
||||
_online_store.size() if _online_store else 0
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Helpers
|
||||
# ================================================================
|
||||
|
||||
_FORGE_PHASE_MAP = {
|
||||
"UNTAP": "main_1",
|
||||
"UPKEEP": "main_1",
|
||||
"DRAW": "main_1",
|
||||
"MAIN1": "main_1",
|
||||
"BEGIN_COMBAT": "combat",
|
||||
"DECLARE_ATTACKERS": "combat",
|
||||
"DECLARE_BLOCKERS": "combat",
|
||||
"DAMAGE": "combat",
|
||||
"COMBAT_DAMAGE": "combat",
|
||||
"END_COMBAT": "combat",
|
||||
"MAIN2": "main_2",
|
||||
"END": "end",
|
||||
"END_STEP": "end",
|
||||
"CLEANUP": "end",
|
||||
}
|
||||
|
||||
|
||||
def _map_forge_phase(phase: str) -> str:
|
||||
"""Map Forge phase string to ML GamePhase enum value."""
|
||||
return _FORGE_PHASE_MAP.get(phase.upper(), "main_1")
|
||||
@@ -1,97 +0,0 @@
|
||||
"""
|
||||
routes/precon.py
|
||||
================
|
||||
Precon deck management endpoints:
|
||||
GET /api/lab/precons
|
||||
POST /api/lab/precons/install
|
||||
POST /api/lab/precons/install-batch
|
||||
POST /api/lab/precons/refresh
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from models.state import CFG
|
||||
from services.precon_service import download_precon_database, PRECON_INDEX
|
||||
from services.logging import log
|
||||
from services.precon_service import _get_precon_dir
|
||||
|
||||
router = APIRouter(tags=["precon"])
|
||||
|
||||
|
||||
@router.get("/api/lab/precons")
|
||||
async def list_precons():
|
||||
"""List all available precon decks."""
|
||||
return {"precons": PRECON_INDEX} # reads current value every call
|
||||
|
||||
|
||||
@router.post("/api/lab/precons/install")
|
||||
async def install_precon(req: dict):
|
||||
"""Install a precon deck to the Forge decks directory.
|
||||
Body: {"fileName": "Elven_Empire.dck"} or {"name": "Elven_Empire.dck"}
|
||||
"""
|
||||
file_name = req.get("fileName", "") or req.get("name", "")
|
||||
if not file_name:
|
||||
raise HTTPException(400, "fileName is required")
|
||||
|
||||
src = _get_precon_dir() / file_name
|
||||
if not src.exists():
|
||||
raise HTTPException(404, f"Precon not found: {file_name}")
|
||||
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if not decks_dir or not os.path.isdir(decks_dir):
|
||||
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
|
||||
|
||||
dst = Path(decks_dir) / file_name
|
||||
shutil.copy2(str(src), str(dst))
|
||||
|
||||
deck_name = file_name.replace(".dck", "")
|
||||
return {
|
||||
"installed": True,
|
||||
"deckName": deck_name,
|
||||
"destination": str(dst),
|
||||
"message": f"Installed {deck_name} to Forge decks",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/lab/precons/install-batch")
|
||||
async def install_precons_batch(req: dict):
|
||||
"""Install multiple precon decks at once.
|
||||
Body: {"fileNames": ["Elven_Empire.dck", ...]} or {"names": ["Elven_Empire.dck", ...]}
|
||||
"""
|
||||
file_names = req.get("fileNames", []) or req.get("names", [])
|
||||
if not file_names:
|
||||
raise HTTPException(400, "fileNames list is required")
|
||||
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if not decks_dir or not os.path.isdir(decks_dir):
|
||||
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
|
||||
|
||||
results = []
|
||||
for file_name in file_names:
|
||||
src = _get_precon_dir() / file_name
|
||||
if not src.exists():
|
||||
results.append({"fileName": file_name, "installed": False, "error": "not found"})
|
||||
continue
|
||||
dst = Path(decks_dir) / file_name
|
||||
shutil.copy2(str(src), str(dst))
|
||||
deck_name = file_name.replace(".dck", "")
|
||||
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
|
||||
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@router.post("/api/lab/precons/refresh")
|
||||
async def refresh_precons():
|
||||
"""Force re-download of all Commander precon decks from GitHub."""
|
||||
result = download_precon_database(force=True)
|
||||
if result.get("error"):
|
||||
raise HTTPException(502, result["error"])
|
||||
return {
|
||||
"message": f"Downloaded {result['downloaded']} Commander precon decks",
|
||||
"total": result.get("total", 0),
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
"""
|
||||
routes/scanner.py
|
||||
=================
|
||||
Card scanner & collection enrichment endpoints:
|
||||
POST /api/collection/scan
|
||||
POST /api/collection/scan/add
|
||||
POST /api/collection/re-enrich
|
||||
POST /api/collection/auto-classify
|
||||
POST /api/collection/auto-classify-all
|
||||
GET /api/collection/{cardId}/edhrec
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import Request as FastAPIRequest
|
||||
|
||||
from models.state import CFG
|
||||
from services.card_analysis import _detect_card_roles
|
||||
from services.database import _get_db_conn, _row_to_dict
|
||||
from services.deck_service import _to_edhrec_slug
|
||||
from services.import_helpers import _edhrec_cache_get, _edhrec_cache_set
|
||||
from services.logging import log_scan, log_deckgen
|
||||
from services.scryfall import _API_HEADERS, _enrich_from_scryfall, _scryfall_fuzzy_lookup
|
||||
|
||||
router = APIRouter(tags=["scanner"])
|
||||
|
||||
|
||||
@router.post("/api/collection/scan")
|
||||
async def scan_card_image(request: FastAPIRequest):
|
||||
"""
|
||||
Scan a card image and return recognized card name(s).
|
||||
|
||||
Uses Ximilar Visual AI for card recognition.
|
||||
|
||||
Accepts multipart/form-data with:
|
||||
- file: image file (JPEG/PNG)
|
||||
- mode: "single" (default) or "multi"
|
||||
|
||||
Returns:
|
||||
{ "results": [ { raw_ocr, matched_name, set_code, scryfall_id, confidence, image_uri, error, collector_number, rarity, tcgplayer_url }, ... ] }
|
||||
"""
|
||||
try:
|
||||
from scanner.pipeline import scan_single, scan_multi
|
||||
except ImportError as e:
|
||||
raise HTTPException(500, f"Scanner module not available: {e}")
|
||||
|
||||
# Try CFG first, then fall back to env var
|
||||
api_key = CFG.ximilar_api_key or os.environ.get("XIMILAR_API_KEY", "")
|
||||
if not api_key:
|
||||
raise HTTPException(500, "Card scanner not configured. Start the server with --ximilar-key YOUR_KEY or set XIMILAR_API_KEY env var.")
|
||||
|
||||
# Parse multipart form
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None:
|
||||
raise HTTPException(400, "No file uploaded. Send an image as 'file' in multipart/form-data.")
|
||||
|
||||
mode = str(form.get("mode", "single")).lower()
|
||||
image_bytes = await upload.read()
|
||||
|
||||
if len(image_bytes) == 0:
|
||||
raise HTTPException(400, "Uploaded file is empty")
|
||||
|
||||
# Limit file size (20 MB)
|
||||
if len(image_bytes) > 20 * 1024 * 1024:
|
||||
raise HTTPException(400, "Image file too large (max 20 MB)")
|
||||
|
||||
log_scan.info(f" Processing {len(image_bytes)} bytes, mode={mode} (Ximilar AI)")
|
||||
|
||||
if mode == "multi":
|
||||
results = scan_multi(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
|
||||
else:
|
||||
result = scan_single(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
|
||||
results = [result]
|
||||
|
||||
return {
|
||||
"results": [r.to_dict() for r in results]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/collection/scan/add")
|
||||
async def scan_add_cards(body: dict):
|
||||
"""
|
||||
Add scanned cards to the collection.
|
||||
|
||||
Body:
|
||||
{
|
||||
"cards": [
|
||||
{ "name": "Lightning Bolt", "set_code": "m11", "quantity": 1 },
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
cards = body.get("cards", [])
|
||||
if not cards:
|
||||
raise HTTPException(400, "No cards provided")
|
||||
|
||||
conn = _get_db_conn()
|
||||
imported = 0
|
||||
updated = 0
|
||||
errors = []
|
||||
|
||||
for card_req in cards:
|
||||
name = str(card_req.get("name", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
|
||||
quantity = int(card_req.get("quantity", 1))
|
||||
set_code = str(card_req.get("set_code", "")).lower()
|
||||
collector_number = str(card_req.get("collector_number", ""))
|
||||
finish = "NORMAL"
|
||||
|
||||
try:
|
||||
enriched = _enrich_from_scryfall(name, set_code, collector_number)
|
||||
except Exception as e:
|
||||
errors.append(f"Scryfall error for '{name}': {e}")
|
||||
continue
|
||||
|
||||
if not enriched or "_error" in enriched:
|
||||
err_msg = enriched.get("_error", f"Not found: '{name}'") if enriched else f"Not found: '{name}'"
|
||||
errors.append(err_msg)
|
||||
continue
|
||||
|
||||
resolved_name = enriched.get("name", name)
|
||||
|
||||
# Check for existing entry
|
||||
existing = conn.execute(
|
||||
"""SELECT id, quantity FROM collection_entries
|
||||
WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""",
|
||||
(resolved_name, set_code, collector_number, finish),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
new_qty = existing["quantity"] + quantity
|
||||
conn.execute(
|
||||
"UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(new_qty, existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
updated += 1
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_entries
|
||||
(name, type_line, subtypes, is_legendary, is_basic, color_identity,
|
||||
cmc, mana_cost, oracle_text, keywords,
|
||||
power, toughness, rarity, set_name, edhrec_rank,
|
||||
tcg_price, salt_score, is_game_changer,
|
||||
category, scryfall_id, tcgplayer_id,
|
||||
quantity, finish, condition, language, notes, tags,
|
||||
set_code, collector_number)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
resolved_name,
|
||||
enriched.get("type_line", ""),
|
||||
enriched.get("subtypes", "[]"),
|
||||
enriched.get("is_legendary", 0),
|
||||
enriched.get("is_basic", 0),
|
||||
enriched.get("color_identity", "[]"),
|
||||
enriched.get("cmc", 0.0),
|
||||
enriched.get("mana_cost", ""),
|
||||
enriched.get("oracle_text", ""),
|
||||
enriched.get("keywords", "[]"),
|
||||
enriched.get("power", ""),
|
||||
enriched.get("toughness", ""),
|
||||
enriched.get("rarity", ""),
|
||||
enriched.get("set_name", ""),
|
||||
enriched.get("edhrec_rank", 0),
|
||||
enriched.get("tcg_price", 0.0),
|
||||
enriched.get("salt_score", 0.0),
|
||||
enriched.get("is_game_changer", 0),
|
||||
enriched.get("category", "[]"),
|
||||
enriched.get("scryfall_id", ""),
|
||||
enriched.get("tcgplayer_id", ""),
|
||||
quantity,
|
||||
finish,
|
||||
"", # condition
|
||||
"", # language
|
||||
"", # notes
|
||||
"", # tags
|
||||
set_code,
|
||||
collector_number,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
imported += 1
|
||||
|
||||
return {
|
||||
"importedCount": imported,
|
||||
"updatedCount": updated,
|
||||
"failedCount": len(errors),
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Re-enrich Collection from Scryfall
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@router.post("/api/collection/re-enrich")
|
||||
async def re_enrich_collection():
|
||||
"""
|
||||
Re-fetch Scryfall data for all collection entries to backfill
|
||||
missing fields (mana_cost, power, toughness, rarity, set_name, edhrec_rank).
|
||||
Also refreshes: type_line, oracle_text, keywords, cmc, color_identity, prices.
|
||||
Runs synchronously — may take a while for large collections.
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, set_code, collector_number FROM collection_entries ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
total = len(rows)
|
||||
enriched_count = 0
|
||||
skipped_count = 0
|
||||
errors = []
|
||||
|
||||
for row in rows:
|
||||
card_id = row["id"]
|
||||
name = row["name"]
|
||||
set_code = row["set_code"] or ""
|
||||
collector_number = row["collector_number"] or ""
|
||||
|
||||
try:
|
||||
data = _enrich_from_scryfall(name, set_code, collector_number)
|
||||
except Exception as e:
|
||||
errors.append(f"Error for '{name}': {e}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if not data or "_error" in data:
|
||||
err_msg = data.get("_error", f"Not found: '{name}'") if data else f"Not found: '{name}'"
|
||||
errors.append(err_msg)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_entries SET
|
||||
type_line = ?, subtypes = ?, is_legendary = ?, is_basic = ?,
|
||||
color_identity = ?, cmc = ?, mana_cost = ?,
|
||||
oracle_text = ?, keywords = ?,
|
||||
power = ?, toughness = ?,
|
||||
rarity = ?, set_name = ?, edhrec_rank = ?,
|
||||
tcg_price = ?, scryfall_id = ?, tcgplayer_id = ?,
|
||||
category = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?""",
|
||||
(
|
||||
data.get("type_line", ""),
|
||||
data.get("subtypes", "[]"),
|
||||
data.get("is_legendary", 0),
|
||||
data.get("is_basic", 0),
|
||||
data.get("color_identity", "[]"),
|
||||
data.get("cmc", 0.0),
|
||||
data.get("mana_cost", ""),
|
||||
data.get("oracle_text", ""),
|
||||
data.get("keywords", "[]"),
|
||||
data.get("power", ""),
|
||||
data.get("toughness", ""),
|
||||
data.get("rarity", ""),
|
||||
data.get("set_name", ""),
|
||||
data.get("edhrec_rank", 0),
|
||||
data.get("tcg_price", 0.0),
|
||||
data.get("scryfall_id", ""),
|
||||
data.get("tcgplayer_id", ""),
|
||||
data.get("category", "[]"),
|
||||
card_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
enriched_count += 1
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"enrichedCount": enriched_count,
|
||||
"skippedCount": skipped_count,
|
||||
"errors": errors[:50], # cap error list
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Auto-Classify Collection
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@router.post("/api/collection/auto-classify")
|
||||
async def auto_classify_collection():
|
||||
"""
|
||||
Run auto-classification on all collection entries.
|
||||
Uses oracle_text, type_line, and keywords to detect functional roles
|
||||
(Ramp, Draw, Removal, Board Wipe, Anthem, Stax, etc.).
|
||||
Only updates cards whose current category is empty or '[]'.
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT id, oracle_text, type_line, keywords, category FROM collection_entries ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
total = len(rows)
|
||||
classified_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for row in rows:
|
||||
card_id = row["id"]
|
||||
existing_cats = row["category"] or "[]"
|
||||
|
||||
# Parse existing categories
|
||||
try:
|
||||
cats = json.loads(existing_cats)
|
||||
except Exception:
|
||||
cats = []
|
||||
|
||||
# Skip cards that already have manually-set categories
|
||||
if cats:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
oracle_text = row["oracle_text"] or ""
|
||||
type_line = row["type_line"] or ""
|
||||
keywords = row["keywords"] or "[]"
|
||||
|
||||
roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
|
||||
if roles:
|
||||
conn.execute(
|
||||
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(json.dumps(roles), card_id),
|
||||
)
|
||||
classified_count += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"classifiedCount": classified_count,
|
||||
"skippedCount": skipped_count,
|
||||
"message": f"Classified {classified_count} cards, skipped {skipped_count} (already had categories)",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/collection/auto-classify-all")
|
||||
async def auto_classify_all_collection():
|
||||
"""
|
||||
Force re-classify ALL collection entries, overwriting existing categories.
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT id, oracle_text, type_line, keywords FROM collection_entries ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
total = len(rows)
|
||||
classified_count = 0
|
||||
|
||||
for row in rows:
|
||||
card_id = row["id"]
|
||||
oracle_text = row["oracle_text"] or ""
|
||||
type_line = row["type_line"] or ""
|
||||
keywords = row["keywords"] or "[]"
|
||||
|
||||
roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
conn.execute(
|
||||
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(json.dumps(roles), card_id),
|
||||
)
|
||||
classified_count += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"classifiedCount": classified_count,
|
||||
"message": f"Re-classified all {classified_count} cards",
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# EDHREC Recommendations Cache + Endpoint
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/api/collection/{cardId}/edhrec")
|
||||
async def get_card_edhrec(cardId: int):
|
||||
"""
|
||||
Get EDHREC recommendations for a collection card.
|
||||
For legendary creatures (potential commanders): fetch commander synergy data.
|
||||
For other cards: fetch 'also played with' data.
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Card with id {cardId} not found")
|
||||
|
||||
card = _row_to_dict(row)
|
||||
name = card["name"]
|
||||
type_line = card.get("type_line", "")
|
||||
is_legendary = card.get("is_legendary", 0)
|
||||
|
||||
# Determine if it's a legendary creature (potential commander)
|
||||
is_commander = bool(
|
||||
is_legendary and
|
||||
re.search(r"\bCreature\b", type_line, re.IGNORECASE)
|
||||
)
|
||||
|
||||
slug = _to_edhrec_slug(name)
|
||||
cache_key = f"edhrec:{'cmd' if is_commander else 'card'}:{slug}"
|
||||
|
||||
cached = _edhrec_cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
recommendations = []
|
||||
links = {
|
||||
"edhrecPage": (
|
||||
f"https://edhrec.com/commanders/{slug}"
|
||||
if is_commander
|
||||
else f"https://edhrec.com/cards/{slug}"
|
||||
),
|
||||
"archidektSearch": f"https://archidekt.com/search/cards?q={name.replace(' ', '+')}",
|
||||
"moxfieldSearch": f"https://www.moxfield.com/search?q={name.replace(' ', '+')}",
|
||||
}
|
||||
|
||||
try:
|
||||
if is_commander:
|
||||
url = f"https://json.edhrec.com/pages/commanders/{slug}.json"
|
||||
else:
|
||||
url = f"https://json.edhrec.com/pages/cards/{slug}.json"
|
||||
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if is_commander:
|
||||
# Extract top cards from cardlists
|
||||
container = data.get("container", {})
|
||||
json_dict = container.get("json_dict", {})
|
||||
for cardlist in json_dict.get("cardlists", []):
|
||||
tag = cardlist.get("tag", "")
|
||||
for cv in cardlist.get("cardviews", [])[:10]:
|
||||
rec_name = cv.get("name", "")
|
||||
if not rec_name:
|
||||
continue
|
||||
synergy = cv.get("synergy", 0.0)
|
||||
recommendations.append({
|
||||
"name": rec_name,
|
||||
"synergy": synergy,
|
||||
"role": tag or "Recommended",
|
||||
})
|
||||
else:
|
||||
# Extract "also played with" cards
|
||||
container = data.get("container", {})
|
||||
json_dict = container.get("json_dict", {})
|
||||
for cardlist in json_dict.get("cardlists", []):
|
||||
tag = cardlist.get("tag", "")
|
||||
for cv in cardlist.get("cardviews", [])[:10]:
|
||||
rec_name = cv.get("name", "")
|
||||
if not rec_name:
|
||||
continue
|
||||
synergy = cv.get("synergy", 0.0)
|
||||
recommendations.append({
|
||||
"name": rec_name,
|
||||
"synergy": synergy,
|
||||
"role": tag or "Also Played With",
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_deckgen.error(f" Error fetching data for '{name}': {e}")
|
||||
# Graceful degradation — return empty results
|
||||
|
||||
result = {
|
||||
"recommendations": recommendations[:30],
|
||||
"links": links,
|
||||
}
|
||||
_edhrec_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
|
||||
Loaded 100 of 301 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user