Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
|
||||
# Get yours at: https://console.anthropic.com/settings/keys
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
# Anthropic model for deck generation and coaching (Claude Opus recommended)
|
||||
ANTHROPIC_MODEL=claude-opus-4-20250514
|
||||
# Coach chat provider: "local" (Ollama) or "anthropic" (Claude API)
|
||||
COACH_PROVIDER=anthropic
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1 +1 @@
|
||||
# Coach clients — Anthropic Claude, LM Studio
|
||||
# Coach clients — Perplexity, LM Studio
|
||||
@@ -1,404 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab — Anthropic Claude Structured Output Client
|
||||
════════════════════════════════════════════════════════════
|
||||
Uses the anthropic SDK to call Claude's messages endpoint
|
||||
with structured JSON output enforced via system prompt.
|
||||
Supports both sync and native async operation.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
logger = logging.getLogger("coach.anthropic")
|
||||
|
||||
# ── Anthropic Models ───────────────────────────────────────────
|
||||
CLAUDE_OPUS = "claude-opus-4-0-20250514"
|
||||
CLAUDE_SONNET = "claude-sonnet-4-5"
|
||||
CLAUDE_HAIKU = "claude-haiku-4-5"
|
||||
|
||||
|
||||
class AnthropicResponse:
|
||||
"""Parsed response container from Anthropic API.
|
||||
|
||||
Drop-in replacement for PerplexityResponse — same attribute shape
|
||||
so all existing call-sites work without changes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
parsed_json: Optional[dict],
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
model: str,
|
||||
citations: list[str] = None,
|
||||
):
|
||||
self.content = content
|
||||
self.parsed_json = parsed_json
|
||||
self.prompt_tokens = prompt_tokens
|
||||
self.completion_tokens = completion_tokens
|
||||
self.model = model
|
||||
self.citations = citations or [] # Anthropic has no citations; kept for compat
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.parsed_json is not None
|
||||
|
||||
|
||||
class AnthropicClient:
|
||||
"""
|
||||
Anthropic Claude API client.
|
||||
|
||||
Drop-in replacement for PerplexityClient.
|
||||
|
||||
Usage:
|
||||
client = AnthropicClient(api_key="sk-ant-...")
|
||||
resp = client.chat_structured(
|
||||
system_prompt="You are a deck builder.",
|
||||
user_prompt="Build a deck for...",
|
||||
json_schema=deck_schema,
|
||||
schema_name="DeckList",
|
||||
)
|
||||
if resp.ok:
|
||||
deck = resp.parsed_json
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
model: str = CLAUDE_OPUS,
|
||||
timeout: int = 120,
|
||||
base_url: str = None,
|
||||
):
|
||||
if not api_key:
|
||||
raise ValueError("Anthropic API key is required")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self._client = anthropic.Anthropic(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=float(timeout),
|
||||
)
|
||||
self._async_client = anthropic.AsyncAnthropic(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=float(timeout),
|
||||
)
|
||||
|
||||
def chat_structured(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
json_schema: dict = None,
|
||||
schema_name: str = "response",
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 8192,
|
||||
) -> AnthropicResponse:
|
||||
"""
|
||||
Send a chat request expecting JSON output.
|
||||
|
||||
The system_prompt should instruct the model to return JSON matching
|
||||
the expected schema. Claude Opus follows JSON instructions reliably.
|
||||
|
||||
Args:
|
||||
system_prompt: System message (should instruct JSON output)
|
||||
user_prompt: User message
|
||||
json_schema: (optional) Kept for call-site compatibility; appended
|
||||
as a schema hint in the system prompt when provided
|
||||
schema_name: (optional) Label used in the schema hint
|
||||
model: Override model
|
||||
temperature: Sampling temperature (0.0-1.0)
|
||||
max_tokens: Max output tokens
|
||||
|
||||
Returns:
|
||||
AnthropicResponse with parsed_json extracted from model output
|
||||
"""
|
||||
use_model = model or self.model
|
||||
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
||||
|
||||
try:
|
||||
response = self._client.messages.create(
|
||||
model=use_model,
|
||||
system=effective_system,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
return self._parse_response(response, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] API call failed: %s", e)
|
||||
raise
|
||||
|
||||
def chat_plain(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 4096,
|
||||
) -> AnthropicResponse:
|
||||
"""
|
||||
Send a plain chat request without strict structured output.
|
||||
Used for substitution fallback queries.
|
||||
"""
|
||||
use_model = model or self.model
|
||||
|
||||
try:
|
||||
response = self._client.messages.create(
|
||||
model=use_model,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
return self._parse_response(response, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] Plain chat failed: %s", e)
|
||||
raise
|
||||
|
||||
async def achat_structured(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
json_schema: dict = None,
|
||||
schema_name: str = "response",
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 8192,
|
||||
) -> AnthropicResponse:
|
||||
"""Native async structured chat — no run_in_executor needed."""
|
||||
use_model = model or self.model
|
||||
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
||||
|
||||
try:
|
||||
response = await self._async_client.messages.create(
|
||||
model=use_model,
|
||||
system=effective_system,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
return self._parse_response(response, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] Async structured chat failed: %s", e)
|
||||
raise
|
||||
|
||||
async def achat_plain(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 4096,
|
||||
) -> AnthropicResponse:
|
||||
"""Native async plain chat."""
|
||||
use_model = model or self.model
|
||||
|
||||
try:
|
||||
response = await self._async_client.messages.create(
|
||||
model=use_model,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
return self._parse_response(response, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] Async plain chat failed: %s", e)
|
||||
raise
|
||||
|
||||
def check_status(self) -> dict:
|
||||
"""Quick health check — try a minimal API call."""
|
||||
try:
|
||||
response = self._client.messages.create(
|
||||
model=self.model,
|
||||
system="Reply with the word OK.",
|
||||
messages=[{"role": "user", "content": "Status check."}],
|
||||
max_tokens=10,
|
||||
)
|
||||
content = response.content[0].text if response.content else ""
|
||||
return {
|
||||
"connected": True,
|
||||
"model": self.model,
|
||||
"response": content,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"connected": False,
|
||||
"model": self.model,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
# ── Internal helpers ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _build_system(system_prompt: str, json_schema: dict, schema_name: str) -> str:
|
||||
"""Append JSON schema hint to system prompt when a schema is provided."""
|
||||
if not json_schema:
|
||||
return system_prompt
|
||||
schema_str = json.dumps(json_schema, indent=2)
|
||||
return (
|
||||
f"{system_prompt}\n\n"
|
||||
f"You MUST respond with valid JSON only — no markdown fences, no prose.\n"
|
||||
f"The response must conform to the following JSON schema ({schema_name}):\n"
|
||||
f"{schema_str}"
|
||||
)
|
||||
|
||||
def _parse_response(self, response, use_model: str) -> AnthropicResponse:
|
||||
"""Extract content and attempt JSON parsing from an Anthropic response."""
|
||||
content = ""
|
||||
if response.content:
|
||||
content = response.content[0].text or ""
|
||||
|
||||
usage = response.usage
|
||||
prompt_tokens = usage.input_tokens if usage else 0
|
||||
completion_tokens = usage.output_tokens if usage else 0
|
||||
|
||||
parsed = None
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = self._extract_json(content)
|
||||
|
||||
logger.info(
|
||||
"[anthropic] model=%s tokens=(%d in, %d out) parsed=%s",
|
||||
use_model,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
"yes" if parsed else "no",
|
||||
)
|
||||
|
||||
return AnthropicResponse(
|
||||
content=content,
|
||||
parsed_json=parsed,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
model=use_model,
|
||||
citations=[],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_json(text: str) -> Optional[dict]:
|
||||
"""Best-effort JSON extraction from possibly noisy text."""
|
||||
try:
|
||||
return json.loads(text.strip())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Markdown fences
|
||||
fence_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text)
|
||||
if fence_match:
|
||||
try:
|
||||
return json.loads(fence_match.group(1).strip())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Brace counting
|
||||
start = text.find('{')
|
||||
if start == -1:
|
||||
return None
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
if ch == '\\' and in_string:
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
try:
|
||||
return json.loads(text[start:i + 1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
# Truncated JSON — attempt repair
|
||||
return AnthropicClient._repair_truncated_json(text[start:])
|
||||
|
||||
@staticmethod
|
||||
def _repair_truncated_json(text: str) -> Optional[dict]:
|
||||
"""
|
||||
Attempt to repair truncated JSON from max_tokens cutoff.
|
||||
Strategy: find the last complete nested object, then close
|
||||
all open brackets/braces.
|
||||
"""
|
||||
logger.info("Attempting truncated JSON repair (%d chars)...", len(text))
|
||||
last_good = -1
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
for i in range(len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
if ch == '\\' and in_string:
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth >= 1:
|
||||
last_good = i
|
||||
|
||||
if last_good == -1:
|
||||
return None
|
||||
|
||||
truncated = text[:last_good + 1]
|
||||
close_depth = 0
|
||||
in_arr = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
for ch in truncated:
|
||||
if esc:
|
||||
esc = False
|
||||
continue
|
||||
if ch == '\\' and in_str:
|
||||
esc = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = not in_str
|
||||
continue
|
||||
if in_str:
|
||||
continue
|
||||
if ch == '{':
|
||||
close_depth += 1
|
||||
elif ch == '}':
|
||||
close_depth -= 1
|
||||
elif ch == '[':
|
||||
in_arr += 1
|
||||
elif ch == ']':
|
||||
in_arr -= 1
|
||||
|
||||
suffix = ']' * in_arr + '}' * close_depth
|
||||
repaired = truncated + suffix
|
||||
try:
|
||||
result = json.loads(repaired)
|
||||
card_count = len(result.get('cards', []))
|
||||
logger.info("JSON repair succeeded: recovered %d cards", card_count)
|
||||
return result
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning("JSON repair failed: %s", e)
|
||||
return None
|
||||
@@ -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 ""
|
||||
@@ -265,92 +268,7 @@ class PerplexityClient:
|
||||
return json.loads(text[start:i+1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
# If we get here, JSON was truncated (depth > 0). Try to repair.
|
||||
return PerplexityClient._repair_truncated_json(text[start:])
|
||||
|
||||
@staticmethod
|
||||
def _repair_truncated_json(text: str) -> Optional[dict]:
|
||||
"""
|
||||
Attempt to repair truncated JSON from max_tokens cutoff.
|
||||
Strategy: find the last complete array element in "cards",
|
||||
then close all open brackets/braces.
|
||||
"""
|
||||
logger.info("Attempting truncated JSON repair (%d chars)...", len(text))
|
||||
|
||||
# Find the last complete card object (ends with }) in the cards array
|
||||
# Walk backwards to find the last closing brace that ends a valid card
|
||||
last_good = -1
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
|
||||
for i in range(len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
if ch == '\\' and in_string:
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth >= 1:
|
||||
# This closes a nested object (like a card) — mark as potential cutoff
|
||||
last_good = i
|
||||
|
||||
if last_good == -1:
|
||||
return None
|
||||
|
||||
# Take text up to the last complete nested object
|
||||
truncated = text[:last_good + 1]
|
||||
|
||||
# Now figure out what we need to close.
|
||||
# Re-scan to get the closing sequence.
|
||||
close_depth = 0
|
||||
in_arr = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
for ch in truncated:
|
||||
if esc:
|
||||
esc = False
|
||||
continue
|
||||
if ch == '\\' and in_str:
|
||||
esc = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = not in_str
|
||||
continue
|
||||
if in_str:
|
||||
continue
|
||||
if ch == '{':
|
||||
close_depth += 1
|
||||
elif ch == '}':
|
||||
close_depth -= 1
|
||||
elif ch == '[':
|
||||
in_arr += 1
|
||||
elif ch == ']':
|
||||
in_arr -= 1
|
||||
|
||||
# Build closing sequence
|
||||
suffix = ']' * in_arr + '}' * close_depth
|
||||
repaired = truncated + suffix
|
||||
|
||||
try:
|
||||
result = json.loads(repaired)
|
||||
card_count = len(result.get('cards', []))
|
||||
logger.info("JSON repair succeeded: recovered %d cards", card_count)
|
||||
return result
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning("JSON repair failed: %s", e)
|
||||
return None
|
||||
return None
|
||||
|
||||
def check_status(self) -> dict:
|
||||
"""Quick health check — try a minimal API call."""
|
||||
|
||||
+21
-148
@@ -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, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD, DEFAULT_MAX_TOKENS, ensure_dirs,
|
||||
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,16 +123,14 @@ class CoachService:
|
||||
continue
|
||||
return sessions
|
||||
|
||||
|
||||
# ── Main Coaching Pipeline ─────────────────────────────────
|
||||
|
||||
async def run_coaching_session(self, deck_id: str,
|
||||
goals: Optional[CoachGoals] = None,
|
||||
fallback_report: Optional[DeckReport] = None,
|
||||
goals: Optional[CoachGoals] = None
|
||||
) -> CoachSession:
|
||||
"""
|
||||
Full coaching pipeline:
|
||||
1. Load deck report (or use fallback from DB card list)
|
||||
1. Load deck report
|
||||
2. Identify underperformers
|
||||
3. Find replacement candidates via embeddings
|
||||
4. Build prompt
|
||||
@@ -224,11 +150,8 @@ class CoachService:
|
||||
except Exception as e:
|
||||
logger.warning("Embeddings auto-load failed: %s", e)
|
||||
|
||||
# 1. Load deck report — try simulation report first, then fallback
|
||||
# 1. Load deck report
|
||||
report = self.load_deck_report(deck_id)
|
||||
if report is None and fallback_report is not None:
|
||||
report = fallback_report
|
||||
logger.info("Using DB-built fallback report for '%s' (no simulation data)", deck_id)
|
||||
if report is None:
|
||||
raise ValueError(f"Deck report not found for: {deck_id}")
|
||||
|
||||
@@ -273,47 +196,9 @@ class CoachService:
|
||||
system_prompt = build_system_prompt(report, goals)
|
||||
user_prompt = build_user_prompt(report, candidates)
|
||||
|
||||
# 5. Call LLM (Anthropic or local Ollama)
|
||||
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
import anthropic
|
||||
anthropic_key = ANTHROPIC_API_KEY
|
||||
if not anthropic_key:
|
||||
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
|
||||
aclient = anthropic.AsyncAnthropic(api_key=anthropic_key)
|
||||
resp = await aclient.messages.create(
|
||||
model=ANTHROPIC_MODEL,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
temperature=0.7,
|
||||
)
|
||||
content = resp.content[0].text if resp.content else ""
|
||||
usage = resp.usage
|
||||
|
||||
# Build an LLMResponse-compatible object
|
||||
from types import SimpleNamespace
|
||||
llm_response = SimpleNamespace(
|
||||
content=content,
|
||||
model=resp.model,
|
||||
prompt_tokens=usage.input_tokens if usage else 0,
|
||||
completion_tokens=usage.output_tokens if usage else 0,
|
||||
parsed_json=None,
|
||||
)
|
||||
try:
|
||||
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
|
||||
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
|
||||
llm_response.parsed_json = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Try to find JSON object in the text
|
||||
match = re.search(r'\{[\s\S]*\}', content)
|
||||
if match:
|
||||
try:
|
||||
llm_response.parsed_json = json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
||||
# 5. Call LLM
|
||||
logger.info("Calling LLM for deck: %s", deck_id)
|
||||
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
||||
|
||||
# 6. Parse response into CoachSession
|
||||
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||
@@ -376,25 +261,13 @@ class CoachService:
|
||||
"""Check the health of all coach subsystems."""
|
||||
status = CoachStatus()
|
||||
|
||||
# Check LLM / Anthropic provider
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
anthropic_key = ANTHROPIC_API_KEY
|
||||
if anthropic_key:
|
||||
status.llmConnected = True
|
||||
status.llmModel = f"Claude Opus ({ANTHROPIC_MODEL})"
|
||||
status.llmModels = [ANTHROPIC_MODEL]
|
||||
else:
|
||||
status.llmConnected = False
|
||||
status.llmModel = None
|
||||
status.llmModels = []
|
||||
status.error = "Anthropic API key not configured. Set ANTHROPIC_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
|
||||
|
||||
+32
-36
@@ -1,67 +1,63 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# ── Base Paths ───────────────────────────────────────────────────────
|
||||
# ── Base Paths ──────────────────────────────────────────────
|
||||
# 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 ─────────────────────────────────────────────
|
||||
EMBEDDINGS_DIR = LAB_ROOT / "data"
|
||||
EMBEDDINGS_NPZ = EMBEDDINGS_DIR / "mtg-embeddings.npz"
|
||||
EMBEDDINGS_PARQUET = EMBEDDINGS_DIR / "mtg-embeddings.parquet"
|
||||
|
||||
# ── Deck Reports ────────────────────────────────────────────────
|
||||
# ── Deck Reports ───────────────────────────────────────────
|
||||
DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
|
||||
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
|
||||
|
||||
# ── Prompt Limits ───────────────────────────────────────────────
|
||||
MAX_PROMPT_TOKENS = 2048
|
||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 8
|
||||
MAX_UNDERPERFORMERS = 8
|
||||
# ── Prompt Limits ──────────────────────────────────────────
|
||||
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 fallback) ─────────────
|
||||
# ── LLM Generation Settings (Coach — LM Studio) ──────────────
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
DEFAULT_MAX_TOKENS = 16384
|
||||
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
|
||||
# so we need a generous limit to avoid truncation
|
||||
DEFAULT_MAX_TOKENS = 8192
|
||||
|
||||
# ── Anthropic Claude Deck Generation Settings ──────────────────────
|
||||
# Provider for deck generation: "anthropic" (Claude) or "local" (Ollama)
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "anthropic")
|
||||
# Model used when DECK_GEN_PROVIDER="anthropic"
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "claude-opus-4-20250514")
|
||||
# Anthropic API key
|
||||
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
# Anthropic model for deck generation and coaching
|
||||
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-20250514")
|
||||
# Coach chat provider: "anthropic" (Claude API) or "local" (Ollama)
|
||||
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "anthropic")
|
||||
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://localhost:11434/v1")
|
||||
# ── Perplexity Deck Generation Settings ───────────────────────
|
||||
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
|
||||
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", "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"))
|
||||
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "8192"))
|
||||
|
||||
# ── Smart Substitution Settings ───────────────────────────────────
|
||||
# Minimum embedding similarity to accept a substitute without Anthropic fallback
|
||||
# ── Smart Substitution Settings ───────────────────────────────
|
||||
# Minimum embedding similarity to accept a substitute without Perplexity fallback
|
||||
SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY", "0.75"))
|
||||
# Maximum number of alternatives to suggest per missing card
|
||||
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
|
||||
# Model for substitution fallback (claude-haiku-4-5 is fast + cheap for focused queries)
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "claude-haiku-4-5")
|
||||
# Enable Anthropic fallback for low-confidence embedding matches
|
||||
SUBSTITUTION_USE_ANTHROPIC_FALLBACK = os.environ.get("SUBSTITUTION_USE_ANTHROPIC_FALLBACK", "true").lower() == "true"
|
||||
# Model for substitution fallback (sonar is fine — small focused queries)
|
||||
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"
|
||||
|
||||
# ── Ensure directories exist ─────────────────────────────────────
|
||||
# ── Ensure directories exist ───────────────────────────────
|
||||
def ensure_dirs():
|
||||
"""Create required directories if they don't exist."""
|
||||
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
+41
-235
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -24,7 +19,6 @@ import os
|
||||
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
|
||||
|
||||
@@ -38,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"
|
||||
}
|
||||
@@ -60,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)
|
||||
|
||||
@@ -74,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
|
||||
|
||||
@@ -87,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:
|
||||
@@ -161,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:
|
||||
@@ -201,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:
|
||||
@@ -215,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
|
||||
@@ -241,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."""
|
||||
@@ -275,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)
|
||||
}
|
||||
@@ -285,168 +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())
|
||||
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,
|
||||
@@ -474,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]:
|
||||
|
||||
+140
-137
@@ -1,25 +1,22 @@
|
||||
"""
|
||||
Commander AI Lab — Ollama LLM Client
|
||||
Commander AI Lab — LM Studio Client
|
||||
════════════════════════════════════
|
||||
Fully async HTTP client targeting Ollama's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Every network call uses
|
||||
httpx.AsyncClient so it NEVER blocks the FastAPI event loop.
|
||||
Async HTTP client targeting LM Studio's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Includes retry logic for
|
||||
non-JSON responses and configurable timeout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
from .config import (
|
||||
LLM_URL,
|
||||
LLM_MODEL,
|
||||
LLM_TIMEOUT,
|
||||
LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
LM_STUDIO_URL, LM_STUDIO_MODEL,
|
||||
LM_STUDIO_TIMEOUT, LM_STUDIO_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("coach.llm")
|
||||
@@ -27,15 +24,9 @@ logger = logging.getLogger("coach.llm")
|
||||
|
||||
class LLMResponse:
|
||||
"""Parsed LLM response container."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
parsed_json: Optional[dict],
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
model: str,
|
||||
):
|
||||
def __init__(self, content: str, parsed_json: Optional[dict],
|
||||
prompt_tokens: int, completion_tokens: int,
|
||||
model: str):
|
||||
self.content = content
|
||||
self.parsed_json = parsed_json
|
||||
self.prompt_tokens = prompt_tokens
|
||||
@@ -43,56 +34,43 @@ class LLMResponse:
|
||||
self.model = model
|
||||
|
||||
|
||||
class LLMClient:
|
||||
class LMStudioClient:
|
||||
"""
|
||||
Fully async 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
|
||||
"""
|
||||
|
||||
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._resolved_model: Optional[str] = None
|
||||
def __init__(self, base_url: str = None, model: str = None,
|
||||
timeout: int = None):
|
||||
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
|
||||
|
||||
# ── Model resolution ───────────────────────────────────────
|
||||
|
||||
async def _resolve_model(self) -> str:
|
||||
"""Get the actual loaded model name from Ollama /models endpoint."""
|
||||
def _resolve_model(self) -> str:
|
||||
"""Get the actual loaded model name from LM Studio /models endpoint."""
|
||||
if self._resolved_model:
|
||||
return self._resolved_model
|
||||
try:
|
||||
status = await self.check_connection()
|
||||
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
|
||||
return self.model
|
||||
|
||||
# ── Request building ──────────────────────────────────────
|
||||
|
||||
async def _build_request_body(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
force_json: bool,
|
||||
) -> dict:
|
||||
model_name = await self._resolve_model()
|
||||
body: dict = {
|
||||
def _build_request_body(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float, max_tokens: int,
|
||||
force_json: bool) -> dict:
|
||||
model_name = self._resolve_model()
|
||||
body = {
|
||||
"model": model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
@@ -101,27 +79,32 @@ class LLMClient:
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
# Only request JSON format if using a model that supports it
|
||||
# Some models (esp. smaller ones) don't support response_format
|
||||
if force_json:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
return body
|
||||
|
||||
# ── Response parsing ──────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _strip_think_tags(text: str) -> str:
|
||||
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks."""
|
||||
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks from LLM output."""
|
||||
import re
|
||||
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
||||
# Remove all <think>...</think> blocks (greedy, handles multiline)
|
||||
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
||||
return cleaned.strip()
|
||||
|
||||
@staticmethod
|
||||
def _extract_json_object(text: str) -> Optional[dict]:
|
||||
"""Find and parse the first complete JSON object via brace-counting."""
|
||||
"""Find and parse the first complete JSON object in text.
|
||||
Uses brace-counting to handle nested objects reliably."""
|
||||
start = text.find('{')
|
||||
if start == -1:
|
||||
return None
|
||||
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
@@ -141,56 +124,66 @@ class LLMClient:
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start : i + 1]
|
||||
candidate = text[start:i+1]
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Malformed — try next opening brace
|
||||
next_start = text.find('{', start + 1)
|
||||
if next_start != -1 and next_start < i:
|
||||
start = next_start
|
||||
depth = 1
|
||||
return None
|
||||
depth = 1 # we're already past this brace
|
||||
return None
|
||||
return None
|
||||
|
||||
def _parse_response(self, raw: dict) -> LLMResponse:
|
||||
"""Extract content and usage from an OpenAI-format response."""
|
||||
"""Extract content and usage from OpenAI-format response.
|
||||
Handles DeepSeek-R1 <think> tags, markdown fences, and raw JSON."""
|
||||
choice = raw.get("choices", [{}])[0]
|
||||
content = choice.get("message", {}).get("content", "")
|
||||
usage = raw.get("usage", {})
|
||||
|
||||
# Step 1: Strip <think>...</think> blocks (DeepSeek-R1)
|
||||
cleaned = self._strip_think_tags(content)
|
||||
parsed: Optional[dict] = None
|
||||
# 1. Direct JSON parse
|
||||
|
||||
# Step 2: Try direct JSON parse on cleaned content
|
||||
parsed = None
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
# 2. Markdown fences
|
||||
|
||||
# Step 3: Try extracting from markdown fences
|
||||
if parsed is None and "```json" in cleaned:
|
||||
try:
|
||||
s = cleaned.index("```json") + 7
|
||||
e = cleaned.index("```", s)
|
||||
parsed = json.loads(cleaned[s:e].strip())
|
||||
start = cleaned.index("```json") + 7
|
||||
end = cleaned.index("```", start)
|
||||
parsed = json.loads(cleaned[start:end].strip())
|
||||
except (json.JSONDecodeError, ValueError, IndexError):
|
||||
pass
|
||||
if parsed is None and "```" in cleaned:
|
||||
try:
|
||||
s = cleaned.index("```") + 3
|
||||
e = cleaned.index("```", s)
|
||||
parsed = json.loads(cleaned[s:e].strip())
|
||||
start = cleaned.index("```") + 3
|
||||
end = cleaned.index("```", start)
|
||||
parsed = json.loads(cleaned[start:end].strip())
|
||||
except (json.JSONDecodeError, ValueError, IndexError):
|
||||
pass
|
||||
# 3. Brace-counting extraction
|
||||
|
||||
# Step 4: Brute-force — find the first valid JSON object in the text
|
||||
if parsed is None:
|
||||
parsed = self._extract_json_object(cleaned)
|
||||
# 4. Fallback on original content
|
||||
|
||||
# Step 5: If still nothing, try on the original content (in case
|
||||
# think-tag stripping was too aggressive)
|
||||
if parsed is None and content != cleaned:
|
||||
parsed = self._extract_json_object(content)
|
||||
|
||||
if parsed is not None:
|
||||
logger.info("Successfully parsed JSON from LLM response")
|
||||
else:
|
||||
logger.warning(
|
||||
"Could not extract JSON from LLM response (%d chars)", len(content)
|
||||
)
|
||||
logger.warning("Could not extract JSON from LLM response (%d chars)",
|
||||
len(content))
|
||||
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
parsed_json=parsed,
|
||||
@@ -199,84 +192,94 @@ class LLMClient:
|
||||
model=raw.get("model", self.model),
|
||||
)
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float = None,
|
||||
max_tokens: int = None,
|
||||
force_json: bool = True,
|
||||
) -> LLMResponse:
|
||||
def chat_sync(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
"""
|
||||
Async chat call to Ollama. Retries up to LLM_MAX_RETRIES.
|
||||
Never blocks the event loop — uses httpx.AsyncClient throughout.
|
||||
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
|
||||
body = await self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, force_json
|
||||
)
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||
body = self._build_request_body(system_prompt, user_prompt,
|
||||
temp, tokens, force_json)
|
||||
|
||||
last_error = None
|
||||
for attempt in range(1, LM_STUDIO_MAX_RETRIES + 1):
|
||||
try:
|
||||
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(float(self.timeout))
|
||||
) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
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"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
result = self._parse_response(raw)
|
||||
|
||||
# If we need JSON and didn't get it, retry
|
||||
if force_json and result.parsed_json is None:
|
||||
logger.warning(
|
||||
"Attempt %d: LLM returned non-JSON, retrying", attempt
|
||||
)
|
||||
logger.warning("Attempt %d: LLM returned non-JSON, retrying", attempt)
|
||||
last_error = ValueError("Non-JSON response from LLM")
|
||||
continue
|
||||
|
||||
return result
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("Attempt %d HTTP error %s", attempt, e.response.status_code)
|
||||
last_error = e
|
||||
# 400 with json format → retry without response_format constraint
|
||||
if e.response.status_code == 400 and force_json:
|
||||
logger.warning("Got 400 — retrying without response_format")
|
||||
force_json = False
|
||||
body = await self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Attempt %d failed: %s", attempt, e)
|
||||
last_error = e
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import asyncio
|
||||
await asyncio.sleep(2 * attempt)
|
||||
|
||||
# If 400 and we used json response_format, retry without it
|
||||
error_str = str(e)
|
||||
if "400" in error_str and force_json:
|
||||
logger.warning("Got 400 — retrying without response_format constraint")
|
||||
force_json = False
|
||||
body = self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False)
|
||||
|
||||
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 check_connection(self) -> dict:
|
||||
"""Check if Ollama is reachable. Returns status dict."""
|
||||
async def chat(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
"""
|
||||
Async chat call — runs the sync call in a thread executor
|
||||
so it doesn't block the FastAPI event loop.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.chat_sync(system_prompt, user_prompt,
|
||||
temperature, max_tokens, force_json)
|
||||
)
|
||||
|
||||
def check_connection(self) -> dict:
|
||||
"""
|
||||
Check if LM Studio is reachable. Returns status dict.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
models = data.get("data", [])
|
||||
model_ids = [m.get("id", "unknown") for m in models]
|
||||
return {
|
||||
"connected": True,
|
||||
"models": model_ids,
|
||||
"active_model": model_ids[0] if model_ids else self.model,
|
||||
}
|
||||
req = Request(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="GET",
|
||||
)
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
models = data.get("data", [])
|
||||
model_ids = [m.get("id", "unknown") for m in models]
|
||||
return {
|
||||
"connected": True,
|
||||
"models": model_ids,
|
||||
"active_model": model_ids[0] if model_ids else self.model,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"connected": False,
|
||||
|
||||
@@ -5,6 +5,7 @@ Pydantic v2 models for deck reports, coach requests/responses,
|
||||
and coaching session persistence.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
+29
-90
@@ -6,6 +6,7 @@ Injects deck report data, underperformer analysis, and
|
||||
candidate replacement cards into a structured template.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from .models import DeckReport, CoachGoals, CardPerformance
|
||||
@@ -26,61 +27,36 @@ RULES:
|
||||
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
|
||||
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
|
||||
- When suggesting replacements, prefer cards that fill the same functional role.
|
||||
- IMPORTANT: Suggest 5-8 DIFFERENT cards to cut and 5-8 DIFFERENT cards to add.
|
||||
- IMPORTANT: Suggest 3-5 DIFFERENT cards to cut and 3-5 DIFFERENT cards to add.
|
||||
- Each suggested add MUST be a different card — never suggest the same card twice.
|
||||
- Vary your suggestions across different functional roles (ramp, draw, removal, threats, utility).
|
||||
- If the deck is missing a functional role (e.g., no card draw, no removal), prioritize adding cards for that role.
|
||||
- If candidate replacements are provided from embeddings search, prefer those over generic suggestions.
|
||||
- For each suggested cut, provide a DETAILED reason citing specific stats (impact score, cast rate, dead card rate, clunkiness, synergy score).
|
||||
- For each suggested add, explain the SPECIFIC synergy with existing cards in the deck.
|
||||
- When reasoning about cuts, also consider keptInOpeningHandRate (low = bad in opening hands) and avgTurnCast (high = too slow for the curve).
|
||||
- When evaluating synergy, use the synergyScore: cards below 0.1 have weak co-occurrence with the rest of the deck.
|
||||
- Use perArchetypeWinRates to identify which matchup types this deck struggles with and tailor suggestions accordingly.
|
||||
- Provide at least 5 heuristic hints covering different strategic dimensions.
|
||||
{goals_section}
|
||||
|
||||
You MUST respond ONLY with valid JSON matching this exact structure:
|
||||
{{
|
||||
"summary": "4-6 sentence detailed overview of the deck's strengths, weaknesses, key synergies, and strategic identity. Reference specific win rates and simulation data.",
|
||||
"summary": "2-3 sentence overview of the deck's strengths and weaknesses",
|
||||
"suggestedCuts": [
|
||||
{{
|
||||
"cardName": "Card To Remove",
|
||||
"reason": "Detailed explanation citing specific simulation stats (impact score, cast rate, dead card rate, clunkiness score, synergy score, avg turn cast) and why this card underperforms in this specific deck context",
|
||||
"replacementOptions": ["Better Card 1", "Better Card 2"],
|
||||
"functionalRole": "ramp|draw|removal|threat|utility|combo|protection",
|
||||
"estimatedImpact": "How cutting this card is expected to improve the deck"
|
||||
"reason": "Why this card underperforms",
|
||||
"replacementOptions": ["Better Card 1", "Better Card 2"]
|
||||
}}
|
||||
],
|
||||
"suggestedAdds": [
|
||||
{{
|
||||
"cardName": "Card To Add",
|
||||
"role": "ramp|draw|removal|threat|utility|combo|protection",
|
||||
"reason": "Detailed explanation of why this card improves the deck, referencing specific weaknesses it addresses",
|
||||
"synergyWith": ["Card In Deck 1", "Card In Deck 2"],
|
||||
"estimatedManaCost": "The mana value of the suggested card",
|
||||
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
|
||||
"reason": "Why this card improves the deck",
|
||||
"synergyWith": ["Card In Deck 1", "Card In Deck 2"]
|
||||
}}
|
||||
],
|
||||
"manaCurveAnalysis": {{
|
||||
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
|
||||
"recommendations": "Specific suggestions for curve adjustments with reasoning",
|
||||
"idealRange": "What the curve should look like for this deck's strategy"
|
||||
}},
|
||||
"roleBreakdown": {{
|
||||
"ramp": "Assessment of ramp package - count, quality, and suggestions",
|
||||
"draw": "Assessment of card draw package",
|
||||
"removal": "Assessment of removal package",
|
||||
"threats": "Assessment of win conditions and threats",
|
||||
"protection": "Assessment of protection and interaction"
|
||||
}},
|
||||
"synergyAnalysis": [
|
||||
"Description of a key synergy chain in the deck and how well it performed in simulation"
|
||||
],
|
||||
"heuristicHints": [
|
||||
"Detailed strategic tip explaining the reasoning and data behind the suggestion"
|
||||
"Concise strategic tip based on data"
|
||||
],
|
||||
"manaBaseAdvice": "Specific mana base improvement suggestion including land count, color fixing, and utility lands, or null if mana base is solid",
|
||||
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates, and (5) prioritized list of improvements ranked by expected impact on win rate"
|
||||
"manaBaseAdvice": "Specific mana base improvement suggestion or null",
|
||||
"rawTextExplanation": "Detailed paragraph explaining the analysis and reasoning"
|
||||
}}
|
||||
|
||||
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
|
||||
@@ -106,9 +82,6 @@ Avg Game Length: {avg_game_length:.1f} turns
|
||||
### Matchup Data
|
||||
{matchup_section}
|
||||
|
||||
### Archetype Win Rates
|
||||
{archetype_section}
|
||||
|
||||
### Top Performing Cards (by Impact Score)
|
||||
{top_cards}
|
||||
|
||||
@@ -119,7 +92,6 @@ Avg Game Length: {avg_game_length:.1f} turns
|
||||
{candidate_section}
|
||||
|
||||
{combo_section}
|
||||
|
||||
Based on this simulation data, provide your coaching suggestions."""
|
||||
|
||||
|
||||
@@ -135,18 +107,14 @@ def _format_goals_section(goals: Optional[CoachGoals]) -> str:
|
||||
lines.append("- Keep suggestions casual and fun-focused.")
|
||||
elif goals.targetPowerLevel >= 8:
|
||||
lines.append("- Prioritize efficiency and competitive staples.")
|
||||
|
||||
if goals.metaFocus:
|
||||
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
|
||||
|
||||
if goals.budget:
|
||||
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
|
||||
"no-limit": "no budget restrictions"}
|
||||
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
|
||||
|
||||
if goals.focusAreas:
|
||||
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
|
||||
|
||||
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
|
||||
|
||||
|
||||
@@ -158,38 +126,28 @@ def _format_curve(buckets: List[int]) -> str:
|
||||
|
||||
|
||||
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
|
||||
"""Format the best performing cards with extended stats."""
|
||||
"""Format the best performing cards."""
|
||||
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
|
||||
lines = []
|
||||
for c in sorted_cards[:top_n]:
|
||||
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
|
||||
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
|
||||
lines.append(
|
||||
f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
|
||||
f"synergy={c.synergyScore:.3f}{turn_str}{tags}"
|
||||
)
|
||||
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}{tags}")
|
||||
return "\n".join(lines) if lines else "No card performance data available."
|
||||
|
||||
|
||||
def _format_underperformers(cards: List[CardPerformance],
|
||||
underperformer_names: List[str]) -> str:
|
||||
"""Format underperforming cards with full available stats."""
|
||||
underperformer_names: List[str]) -> str:
|
||||
"""Format underperforming cards with their stats."""
|
||||
card_map = {c.name: c for c in cards}
|
||||
lines = []
|
||||
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
|
||||
c = card_map.get(name)
|
||||
if c:
|
||||
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
|
||||
lines.append(
|
||||
f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"deadCardRate={c.deadCardRate:.1%}, "
|
||||
f"castRate={c.castRate:.1%}, "
|
||||
f"clunkiness={c.clunkinessScore:.3f}, "
|
||||
f"synergy={c.synergyScore:.3f}, "
|
||||
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
|
||||
f"{turn_str}"
|
||||
)
|
||||
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"deadCardRate={c.deadCardRate:.1%}, "
|
||||
f"castRate={c.castRate:.1%}, "
|
||||
f"clunkiness={c.clunkinessScore:.3f}")
|
||||
else:
|
||||
lines.append(f"- {name}: (no detailed stats)")
|
||||
return "\n".join(lines) if lines else "No clear underperformers identified."
|
||||
@@ -198,13 +156,11 @@ def _format_underperformers(cards: List[CardPerformance],
|
||||
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
||||
"""Format replacement candidates grouped by the card they'd replace."""
|
||||
if not candidates:
|
||||
return (
|
||||
"No embedding-based candidates available. "
|
||||
"Use your MTG knowledge to suggest cards that fit the deck's "
|
||||
"color identity, strategy, and missing functional roles. "
|
||||
"Suggest at least 3 different cards across different roles "
|
||||
"(ramp, draw, removal, threats, utility)."
|
||||
)
|
||||
return ("No embedding-based candidates available. "
|
||||
"Use your MTG knowledge to suggest cards that fit the deck's "
|
||||
"color identity, strategy, and missing functional roles. "
|
||||
"Suggest at least 3 different cards across different roles "
|
||||
"(ramp, draw, removal, threats, utility).")
|
||||
|
||||
lines = []
|
||||
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
|
||||
@@ -218,33 +174,19 @@ def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
||||
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
|
||||
if text_preview:
|
||||
lines.append(f" Oracle: {text_preview}")
|
||||
|
||||
lines.append("\nChoose from these candidates when possible, but also suggest ")
|
||||
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_matchups(report: DeckReport) -> str:
|
||||
"""Format per-opponent matchup data."""
|
||||
"""Format matchup data."""
|
||||
if not report.matchups:
|
||||
return "No matchup data available."
|
||||
lines = []
|
||||
for m in report.matchups:
|
||||
lines.append(
|
||||
f"- vs {m.opponentDeck}: {m.winRate:.1%} "
|
||||
f"({m.gamesPlayed} games)"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_archetypes(report: DeckReport) -> str:
|
||||
"""Format per-archetype win rates from DeckMeta."""
|
||||
rates = report.meta.perArchetypeWinRates
|
||||
if not rates:
|
||||
return "No archetype breakdown available."
|
||||
lines = []
|
||||
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
|
||||
lines.append(f"- vs {archetype}: {wr:.1%}")
|
||||
lines.append(f"- vs {m.opponentDeck}: {m.winRate:.1%} "
|
||||
f"({m.gamesPlayed} games)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -255,10 +197,8 @@ def _format_combos(report: DeckReport) -> str:
|
||||
lines = ["### Known Combos"]
|
||||
for combo in report.knownCombos:
|
||||
cards = " + ".join(combo.cardNames)
|
||||
lines.append(
|
||||
f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
|
||||
f"assemblyRate={combo.assemblyRate:.1%}"
|
||||
)
|
||||
lines.append(f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
|
||||
f"assemblyRate={combo.assemblyRate:.1%}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -299,7 +239,6 @@ def build_user_prompt(report: DeckReport,
|
||||
type_summary=", ".join(type_parts),
|
||||
role_summary=", ".join(role_parts),
|
||||
matchup_section=_format_matchups(report),
|
||||
archetype_section=_format_archetypes(report),
|
||||
top_cards=_format_top_cards(report.cards),
|
||||
underperformer_section=_format_underperformers(
|
||||
report.cards, report.underperformers),
|
||||
|
||||
@@ -31,10 +31,4 @@ IMPORTANT:
|
||||
- Provide realistic USD price estimates
|
||||
- The commander MUST be included in the cards list with its real category (Creature, Planeswalker, etc.)
|
||||
- Assign the correct bracket level based on the cards chosen and combos present
|
||||
- List Game Changer cards explicitly in the bracket section
|
||||
|
||||
OUTPUT EFFICIENCY (critical):
|
||||
- Keep each card's "reason" under 15 words — brief is better
|
||||
- Keep "synergy_with" to at most 2-3 card names per card
|
||||
- Do not repeat the card's own name in synergy_with
|
||||
- Omit estimated_price_usd if you are unsure — 0 is fine"""
|
||||
- List Game Changer cards explicitly in the bracket section"""
|
||||
@@ -1,11 +1,11 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation User Prompt Builder
|
||||
═══════════════════════════════════════════════════════
|
||||
Constructs the user message for Claude Opus deck generation
|
||||
Constructs the user message for Perplexity deck generation
|
||||
from a DeckGenV3Request.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
@@ -36,8 +36,8 @@ def build_user_prompt(
|
||||
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
|
||||
|
||||
lines = [
|
||||
"Build a complete 100-card Commander deck.",
|
||||
"",
|
||||
f"Build a complete 100-card Commander deck.",
|
||||
f"",
|
||||
f"COMMANDER: {commander}",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Substitution Prompt Templates
|
||||
═════════════════════════════════════════════════
|
||||
Prompts for the Claude Opus substitution pass.
|
||||
Prompts for the Perplexity fallback substitution pass.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation JSON Schemas
|
||||
═════════════════════════════════════════════════
|
||||
Pydantic models and JSON Schema dicts for Claude Opus
|
||||
Pydantic models and JSON Schema dicts for Perplexity
|
||||
structured output deck generation.
|
||||
"""
|
||||
|
||||
@@ -58,7 +58,7 @@ class GeneratedDeckList(BaseModel):
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# JSON Schema dicts (for Claude Opus response_format)
|
||||
# JSON Schema dicts (for Perplexity response_format)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
DECK_CARD_SCHEMA = {
|
||||
|
||||
@@ -18,16 +18,13 @@ class CardAlternative(BaseModel):
|
||||
name: str = Field(description="Exact card name")
|
||||
similarity_score: float = Field(default=0.0, description="Cosine similarity score (0-1)")
|
||||
reason: str = Field(default="", description="Why this card is a good substitute")
|
||||
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'anthropic'")
|
||||
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'perplexity'")
|
||||
role_overlap: List[str] = Field(default_factory=list, description="Shared functional roles")
|
||||
cmc_delta: float = Field(default=0.0, description="CMC difference from original")
|
||||
|
||||
|
||||
class DeckCardWithStatus(BaseModel):
|
||||
"""A deck card enriched with ownership and substitution data."""
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
name: str
|
||||
count: int = 1
|
||||
category: str = ""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — V3 Deck Generator Service
|
||||
═════════════════════════════════════════════
|
||||
Generates Commander decks using Anthropic Claude
|
||||
Generates Commander decks using Perplexity Sonar
|
||||
structured JSON output, then runs ownership check
|
||||
and smart substitution.
|
||||
"""
|
||||
@@ -13,7 +13,7 @@ import sqlite3
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
from ..clients.anthropic_client import AnthropicClient
|
||||
from ..clients.perplexity_client import PerplexityClient
|
||||
from ..schemas.deck_schema import DECK_LIST_SCHEMA, GeneratedDeckList
|
||||
from ..schemas.substitution_schema import (
|
||||
CardAlternative, DeckCardWithStatus, SubstitutionBatchResult,
|
||||
@@ -26,7 +26,7 @@ from ..prompts.substitution_prompt import (
|
||||
from ..config import (
|
||||
DECK_GEN_MODEL, DECK_GEN_TEMPERATURE, DECK_GEN_MAX_TOKENS,
|
||||
SUBSTITUTION_MIN_SIMILARITY, SUBSTITUTION_MAX_ALTERNATIVES,
|
||||
SUBSTITUTION_MODEL, SUBSTITUTION_USE_ANTHROPIC_FALLBACK,
|
||||
SUBSTITUTION_MODEL, SUBSTITUTION_USE_PPLX_FALLBACK,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("coach.deckgen")
|
||||
@@ -34,27 +34,27 @@ logger = logging.getLogger("coach.deckgen")
|
||||
|
||||
class DeckGeneratorV3:
|
||||
"""
|
||||
V3 Deck Generator using Anthropic Claude structured output.
|
||||
V3 Deck Generator using Perplexity structured output.
|
||||
|
||||
Pipeline:
|
||||
1. Resolve commander via Scryfall
|
||||
2. Build collection summary
|
||||
3. Call Anthropic Claude with structured JSON schema
|
||||
3. Call Perplexity with structured JSON schema
|
||||
4. Validate card names via Scryfall
|
||||
5. Check ownership against collection DB
|
||||
6. Run Smart Substitution (embedding + Anthropic Claude fallback)
|
||||
6. Run Smart Substitution (embedding + Perplexity fallback)
|
||||
7. Return finalized deck with substitution data
|
||||
"""
|
||||
|
||||
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
|
||||
def __init__(self, pplx_client: PerplexityClient, db_conn_factory,
|
||||
embedding_index=None):
|
||||
"""
|
||||
Args:
|
||||
anthropic_client: Configured AnthropicClient instance
|
||||
pplx_client: Configured PerplexityClient instance
|
||||
db_conn_factory: Callable that returns sqlite3.Connection
|
||||
embedding_index: Optional MTGEmbeddingIndex for substitution
|
||||
"""
|
||||
self.anthropic = anthropic_client
|
||||
self.pplx = pplx_client
|
||||
self.db_conn_factory = db_conn_factory
|
||||
self.embedding_index = embedding_index
|
||||
|
||||
@@ -176,7 +176,7 @@ class DeckGeneratorV3:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Step 3: Generate Deck via Anthropic Claude ─────────────────
|
||||
# ── Step 3: Generate Deck via Perplexity ─────────────────
|
||||
def generate_deck(
|
||||
self,
|
||||
commander_name: str,
|
||||
@@ -193,7 +193,7 @@ class DeckGeneratorV3:
|
||||
|
||||
Returns dict with:
|
||||
- commander: dict with Scryfall data
|
||||
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
|
||||
- raw_deck: parsed GeneratedDeckList from Perplexity
|
||||
- cards: list of DeckCardWithStatus (after ownership check)
|
||||
- stats: deck statistics
|
||||
- tokens_used: {prompt, completion}
|
||||
@@ -223,11 +223,11 @@ class DeckGeneratorV3:
|
||||
collection_summary=collection_block,
|
||||
)
|
||||
|
||||
# Step 4: Call Anthropic Claude with structured output
|
||||
# Step 4: Call Perplexity with structured output
|
||||
use_model = model or DECK_GEN_MODEL
|
||||
logger.info("Calling Anthropic Claude (%s) for deck generation...", use_model)
|
||||
logger.info("Calling Perplexity (%s) for deck generation...", use_model)
|
||||
|
||||
response = self.anthropic.chat_structured(
|
||||
response = self.pplx.chat_structured(
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
user_prompt=user_prompt,
|
||||
json_schema=DECK_LIST_SCHEMA,
|
||||
@@ -239,36 +239,17 @@ class DeckGeneratorV3:
|
||||
|
||||
if not response.ok:
|
||||
raise ValueError(
|
||||
f"Anthropic Claude returned invalid response: {response.content[:300]}"
|
||||
f"Perplexity returned invalid response: {response.content[:300]}"
|
||||
)
|
||||
|
||||
raw_deck = response.parsed_json
|
||||
card_count = len(raw_deck.get("cards", []))
|
||||
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)),
|
||||
" (TRUNCATED — response may have hit token limit)" if truncated else "")
|
||||
logger.info("Deck generated: %d cards, bracket %d",
|
||||
len(raw_deck.get("cards", [])),
|
||||
raw_deck.get("bracket", {}).get("level", 0))
|
||||
|
||||
# Step 5: Check ownership and build enriched card list
|
||||
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -289,7 +270,6 @@ class DeckGeneratorV3:
|
||||
},
|
||||
"model": response.model,
|
||||
"citations": response.citations,
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
# ── Step 5: Ownership Check ──────────────────────────────
|
||||
@@ -297,26 +277,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,62 +311,10 @@ 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
|
||||
|
||||
# ── Step 5b: Basic Land Fill ──────────────────────────────
|
||||
_COLOR_TO_BASIC = {
|
||||
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
|
||||
'R': 'Mountain', 'G': 'Forest',
|
||||
}
|
||||
_BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'}
|
||||
|
||||
def _fill_basic_lands(
|
||||
self,
|
||||
cards: List[DeckCardWithStatus],
|
||||
color_identity: List[str],
|
||||
target_total: int = 100,
|
||||
) -> List[DeckCardWithStatus]:
|
||||
"""
|
||||
Strip LLM-provided basics, then add the right number evenly
|
||||
across the commander's colors to reach exactly target_total.
|
||||
"""
|
||||
non_basics = [c for c in cards if c.name not in self._BASIC_NAMES]
|
||||
current_total = sum(c.count for c in non_basics)
|
||||
basics_needed = max(0, target_total - current_total)
|
||||
|
||||
if basics_needed == 0:
|
||||
return non_basics
|
||||
|
||||
ci = [c.upper() for c in color_identity]
|
||||
basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC]
|
||||
if not basic_names:
|
||||
basic_names = ['Wastes']
|
||||
|
||||
per_color = basics_needed // len(basic_names)
|
||||
remainder = basics_needed % len(basic_names)
|
||||
|
||||
for i, bname in enumerate(basic_names):
|
||||
qty = per_color + (1 if i < remainder else 0)
|
||||
if qty > 0:
|
||||
non_basics.append(DeckCardWithStatus(
|
||||
name=bname,
|
||||
count=qty,
|
||||
category='Land',
|
||||
role_tags=[],
|
||||
reason='Basic land for mana fixing',
|
||||
estimated_price_usd=0.10,
|
||||
owned=True,
|
||||
owned_qty=qty,
|
||||
status='owned',
|
||||
))
|
||||
|
||||
logger.info("Basic land fill: %d basics added across %d color(s)",
|
||||
basics_needed, len(basic_names))
|
||||
return non_basics
|
||||
|
||||
# ── Step 6: Smart Substitution ───────────────────────────
|
||||
def run_substitution(
|
||||
self,
|
||||
@@ -411,7 +328,7 @@ class DeckGeneratorV3:
|
||||
Pipeline:
|
||||
a) Identify missing cards
|
||||
b) For each: try embedding similarity (free, fast)
|
||||
c) For low-confidence matches: batch Anthropic Claude fallback
|
||||
c) For low-confidence matches: batch Perplexity fallback
|
||||
d) Auto-select best substitute per card
|
||||
|
||||
Returns SubstitutionBatchResult with all cards updated.
|
||||
@@ -443,7 +360,7 @@ class DeckGeneratorV3:
|
||||
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
|
||||
|
||||
# a) Embedding pass
|
||||
needs_anthropic_fallback = []
|
||||
needs_pplx_fallback = []
|
||||
for card in missing:
|
||||
embedding_alts = self._embedding_substitution(
|
||||
card, color_identity, available_pool
|
||||
@@ -459,12 +376,12 @@ class DeckGeneratorV3:
|
||||
available_pool = [n for n in available_pool
|
||||
if n.lower() != card.selected_substitute.lower()]
|
||||
else:
|
||||
needs_anthropic_fallback.append(card)
|
||||
needs_pplx_fallback.append(card)
|
||||
|
||||
# b) Anthropic Claude fallback for low-confidence matches
|
||||
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
|
||||
self._anthropic_substitution(
|
||||
needs_anthropic_fallback, available_pool,
|
||||
# b) Perplexity fallback for low-confidence matches
|
||||
if needs_pplx_fallback and SUBSTITUTION_USE_PPLX_FALLBACK:
|
||||
self._pplx_substitution(
|
||||
needs_pplx_fallback, available_pool,
|
||||
commander, strategy
|
||||
)
|
||||
|
||||
@@ -522,7 +439,7 @@ class DeckGeneratorV3:
|
||||
|
||||
return alternatives
|
||||
|
||||
def _anthropic_substitution(
|
||||
def _pplx_substitution(
|
||||
self,
|
||||
missing_cards: List[DeckCardWithStatus],
|
||||
available_pool: List[str],
|
||||
@@ -530,7 +447,7 @@ class DeckGeneratorV3:
|
||||
strategy: str,
|
||||
):
|
||||
"""
|
||||
Batch Anthropic Claude fallback for cards with low embedding confidence.
|
||||
Batch Perplexity fallback for cards with low embedding confidence.
|
||||
Mutates the cards in-place with substitution data.
|
||||
"""
|
||||
if not available_pool:
|
||||
@@ -557,7 +474,7 @@ class DeckGeneratorV3:
|
||||
|
||||
try:
|
||||
from ..schemas.substitution_schema import SUBSTITUTION_RESPONSE_SCHEMA
|
||||
response = self.anthropic.chat_structured(
|
||||
response = self.pplx.chat_structured(
|
||||
system_prompt=SUBSTITUTION_SYSTEM,
|
||||
user_prompt=user_prompt,
|
||||
json_schema=SUBSTITUTION_RESPONSE_SCHEMA,
|
||||
@@ -568,11 +485,11 @@ class DeckGeneratorV3:
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
logger.warning("Anthropic Claude substitution fallback failed — non-JSON response")
|
||||
logger.warning("Perplexity substitution fallback failed — non-JSON response")
|
||||
return
|
||||
|
||||
subs = response.parsed_json.get("substitutions", [])
|
||||
logger.info("Anthropic Claude suggested %d substitutions", len(subs))
|
||||
logger.info("Perplexity suggested %d substitutions", len(subs))
|
||||
|
||||
# Apply substitutions
|
||||
card_by_name = {c.name.lower(): c for c in missing_cards}
|
||||
@@ -584,20 +501,20 @@ class DeckGeneratorV3:
|
||||
if orig in card_by_name and substitute:
|
||||
card = card_by_name[orig]
|
||||
# Add as top alternative
|
||||
anthropic_alt = CardAlternative(
|
||||
pplx_alt = CardAlternative(
|
||||
name=substitute,
|
||||
similarity_score=0.80, # Nominal score for Anthropic Claude suggestions
|
||||
similarity_score=0.80, # Nominal score for Perplexity suggestions
|
||||
reason=reason,
|
||||
source="Anthropic Claude",
|
||||
source="perplexity",
|
||||
role_overlap=sub.get("role_overlap", []),
|
||||
)
|
||||
# Insert at front
|
||||
card.alternatives.insert(0, anthropic_alt)
|
||||
card.alternatives.insert(0, pplx_alt)
|
||||
card.selected_substitute = substitute
|
||||
card.status = "substituted"
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Anthropic Claude substitution fallback error: %s", e)
|
||||
logger.error("Perplexity substitution fallback error: %s", e)
|
||||
|
||||
# ── Stats ────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
+1940
-163
File diff suppressed because it is too large.
Load diff
+8305
-235
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,431 +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
|
||||
python -m ml.eval.elo_tracker --top-n 10
|
||||
"""
|
||||
|
||||
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,
|
||||
top_n: int = 10,
|
||||
) -> EloResult:
|
||||
"""
|
||||
Discover generation checkpoints and run an ELO tournament.
|
||||
|
||||
Looks for `gen-*/best_policy.pt` under checkpoint_dir.
|
||||
Also includes heuristic and random baselines.
|
||||
|
||||
Args:
|
||||
top_n: Only include the N most recent gen-* checkpoints.
|
||||
0 means include all (unbounded). Default is 10.
|
||||
"""
|
||||
policies: Dict[str, object] = {
|
||||
"heuristic": HeuristicPolicy(),
|
||||
"random": RandomPolicy(),
|
||||
}
|
||||
|
||||
ckpt_path = Path(checkpoint_dir)
|
||||
if ckpt_path.exists():
|
||||
gen_dirs = sorted(ckpt_path.glob("gen-*"))
|
||||
|
||||
# Apply top_n cutoff — keep only the latest N generation dirs
|
||||
if top_n > 0 and len(gen_dirs) > top_n:
|
||||
skipped = len(gen_dirs) - top_n
|
||||
logger.info(
|
||||
"top_n=%d: skipping %d older generation(s), using %s … %s",
|
||||
top_n, skipped,
|
||||
gen_dirs[-top_n].name,
|
||||
gen_dirs[-1].name,
|
||||
)
|
||||
gen_dirs = gen_dirs[-top_n:]
|
||||
|
||||
for gen_dir in gen_dirs:
|
||||
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)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top-n", type=int, default=10,
|
||||
help="Only include the N most recent gen checkpoints (0 = all, default: 10)",
|
||||
)
|
||||
|
||||
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,
|
||||
top_n=args.top_n,
|
||||
)
|
||||
|
||||
# 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,7 +531,6 @@ def collect_rollouts(
|
||||
total_wins = 0
|
||||
total_losses = 0
|
||||
total_draws = 0
|
||||
warnings.warn(_TRAINING_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
|
||||
total_steps = 0
|
||||
total_rewards = 0.0
|
||||
|
||||
@@ -574,16 +538,12 @@ def collect_rollouts(
|
||||
# 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,24 +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
|
||||
openai>=1.0.0
|
||||
aiosqlite>=0.19.0
|
||||
|
||||
# Anthropic Claude API
|
||||
anthropic>=0.40.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/.
|
||||
"""
|
||||
-535
@@ -1,535 +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()
|
||||
|
||||
Async-safety: every DB and HTTP call in this module uses
|
||||
async helpers so nothing blocks the FastAPI event loop.
|
||||
"""
|
||||
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.async_db import (
|
||||
async_get_db, async_fetchall, async_fetchone,
|
||||
async_execute, async_execute_commit, async_commit,
|
||||
)
|
||||
from services.deck_service import _compute_deck_analysis
|
||||
from services.logging import log_coach, log_deckgen
|
||||
import httpx
|
||||
|
||||
from coach.config import COACH_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL, DECK_GEN_PROVIDER, DECK_GEN_MODEL
|
||||
|
||||
LLM_TIMEOUT = 120 # seconds
|
||||
|
||||
router = APIRouter(tags=["coach"])
|
||||
|
||||
# Global coach instances (initialized at startup)
|
||||
_coach_service = None
|
||||
_coach_embeddings = None
|
||||
_coach_llm = None
|
||||
_deck_gen_v3 = None
|
||||
_deck_gen_v3_error = None
|
||||
|
||||
|
||||
|
||||
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
|
||||
from services.database import _get_db_conn
|
||||
ensure_dirs()
|
||||
_coach_llm = LLMClient()
|
||||
_coach_embeddings = MTGEmbeddingIndex()
|
||||
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")
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings load failed: {e}")
|
||||
_coach_service = CoachService(_coach_llm, _coach_embeddings)
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
log_coach.info(f" Coach LLM: Anthropic Claude ({ANTHROPIC_MODEL})")
|
||||
else:
|
||||
llm_status = _coach_llm.check_connection_sync()
|
||||
if llm_status.get("connected"):
|
||||
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||
else:
|
||||
log_coach.warning(" Coach LLM: Not connected (start Ollama on localhost:11434)")
|
||||
_deck_gen_v3_error = None
|
||||
try:
|
||||
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
|
||||
if DECK_GEN_PROVIDER == "anthropic":
|
||||
_deck_gen_v3 = DeckBuilderAdapter(db_conn_factory=_get_db_conn, model=DECK_GEN_MODEL)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (Anthropic Claude {DECK_GEN_MODEL})")
|
||||
else:
|
||||
from coach.config import LLM_MODEL
|
||||
_deck_gen_v3 = DeckBuilderAdapter(db_conn_factory=_get_db_conn, model=LLM_MODEL)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (Ollama {LLM_MODEL})")
|
||||
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")
|
||||
_coach_service = None
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/coach/status")
|
||||
async def coach_status():
|
||||
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 (async DB)."""
|
||||
rows = await async_fetchall(
|
||||
"""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"""
|
||||
)
|
||||
report_ids = set()
|
||||
if _coach_service:
|
||||
report_ids = set(_coach_service.list_deck_reports())
|
||||
decks = []
|
||||
for r in rows:
|
||||
deck_name = r["deck_name"]
|
||||
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):
|
||||
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):
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
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
|
||||
fallback_report = None
|
||||
try:
|
||||
fallback_report = await _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}")
|
||||
|
||||
|
||||
async def _build_deck_report_from_db(deck_slug: str):
|
||||
"""Build a lightweight DeckReport from DB (async). Allows coaching without simulation data."""
|
||||
from coach.models import DeckReport, CardPerformance, DeckStructure
|
||||
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
|
||||
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():
|
||||
matched_deck = await async_fetchone(
|
||||
"SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
|
||||
(int(slug_lower),)
|
||||
)
|
||||
else:
|
||||
matched_deck = await async_fetchone(
|
||||
"""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)
|
||||
)
|
||||
if matched_deck is None:
|
||||
return None
|
||||
deck_id = matched_deck["id"]
|
||||
deck_name = matched_deck["name"]
|
||||
commander = matched_deck["commander_name"] or ""
|
||||
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 = []
|
||||
card_rows = await async_fetchall(
|
||||
"""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,)
|
||||
)
|
||||
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 ""
|
||||
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")
|
||||
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
|
||||
if t in type_line:
|
||||
type_counts[t] = type_counts.get(t, 0) + qty
|
||||
bucket = min(int(cmc), 7)
|
||||
cmc_buckets[bucket] += qty
|
||||
cards.append(CardPerformance(name=card_name, drawnRate=0.0, castRate=0.0, impactScore=0.0, tags=tags))
|
||||
return DeckReport(
|
||||
deckId=deck_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):
|
||||
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):
|
||||
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():
|
||||
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):
|
||||
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. Fully async - no event loop blocking."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
report = _coach_service.load_deck_report(body.deck_id)
|
||||
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."
|
||||
)
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
for msg in body.messages:
|
||||
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
|
||||
# ── Anthropic Claude provider path (async httpx) ──
|
||||
if COACH_PROVIDER == 'anthropic':
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(400, 'Anthropic API key not configured.')
|
||||
import anthropic
|
||||
aclient = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
|
||||
if body.stream:
|
||||
async def anthropic_stream():
|
||||
async with aclient.messages.stream(
|
||||
model=ANTHROPIC_MODEL, system=system_prompt,
|
||||
messages=[{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"],
|
||||
max_tokens=4096, temperature=0.7,
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
yield f"data: {json.dumps({'content': text})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return StreamingResponse(anthropic_stream(), media_type="text/event-stream")
|
||||
else:
|
||||
try:
|
||||
resp = await aclient.messages.create(
|
||||
model=ANTHROPIC_MODEL, system=system_prompt,
|
||||
messages=[{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"],
|
||||
max_tokens=4096, temperature=0.7,
|
||||
)
|
||||
content = resp.content[0].text if resp.content else ""
|
||||
usage = resp.usage
|
||||
return {
|
||||
"content": content, "model": resp.model,
|
||||
"prompt_tokens": usage.input_tokens if usage else 0,
|
||||
"completion_tokens": usage.output_tokens if usage else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Anthropic API call failed: {e}")
|
||||
# ── Local Ollama provider path (async httpx) ──
|
||||
try:
|
||||
model_name = await _coach_llm._resolve_model()
|
||||
llm_body = {"model": model_name, "messages": messages, "temperature": 0.7, "max_tokens": 4096}
|
||||
if body.stream:
|
||||
llm_body["stream"] = True
|
||||
async def generate():
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(LLM_TIMEOUT))) as client:
|
||||
async with client.stream("POST", f"{_coach_llm.base_url}/chat/completions",
|
||||
json=llm_body, headers={"Content-Type": "application/json"}) 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(generate(), media_type="text/event-stream")
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(LLM_TIMEOUT))) as client:
|
||||
resp = await client.post(
|
||||
f"{_coach_llm.base_url}/chat/completions",
|
||||
json=llm_body, headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
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 (async DB + async HTTP)."""
|
||||
db = await async_get_db()
|
||||
deck = await async_fetchone("SELECT * FROM decks WHERE id = ?", (body.deck_id,))
|
||||
if not deck:
|
||||
raise HTTPException(404, f"Deck {body.deck_id} not found")
|
||||
results = {"cuts": [], "adds": [], "errors": []}
|
||||
for card_name in body.accepted_cuts:
|
||||
row = await async_fetchone(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
)
|
||||
if row:
|
||||
await async_execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": card_name, "status": "removed"})
|
||||
else:
|
||||
row = await async_fetchone(
|
||||
"SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
)
|
||||
if row:
|
||||
await async_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"})
|
||||
for card_name in body.accepted_adds:
|
||||
ce = await async_fetchone(
|
||||
"SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(card_name,)
|
||||
)
|
||||
scryfall_id = None
|
||||
resolved_name = card_name
|
||||
if ce:
|
||||
scryfall_id = ce["scryfall_id"]
|
||||
resolved_name = ce["name"]
|
||||
else:
|
||||
try:
|
||||
import urllib.parse
|
||||
encoded = urllib.parse.quote(card_name)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
|
||||
headers={"User-Agent": "commander-ai-lab/1.0"}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
card_data = resp.json()
|
||||
scryfall_id = card_data.get("id")
|
||||
resolved_name = card_data.get("name", card_name)
|
||||
except Exception:
|
||||
pass
|
||||
if scryfall_id:
|
||||
existing = await async_fetchone(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
|
||||
(body.deck_id, scryfall_id)
|
||||
)
|
||||
if not existing:
|
||||
await async_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"})
|
||||
await async_execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
|
||||
await async_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 similar cards using embeddings (async DB for ownership check)."""
|
||||
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)
|
||||
results = []
|
||||
for m in matches:
|
||||
owned = await async_fetchone(
|
||||
"SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)",
|
||||
(m.name,)
|
||||
)
|
||||
owned_qty = (owned["qty"] or 0) if owned else 0
|
||||
ce = await async_fetchone(
|
||||
"SELECT scryfall_id, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(m.name,)
|
||||
)
|
||||
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():
|
||||
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,597 +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 "quantity" in body:
|
||||
qty_val = int(body["quantity"])
|
||||
if qty_val < 0:
|
||||
raise HTTPException(400, "quantity must be >= 0")
|
||||
updates["quantity"] = qty_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
Loaded 100 of 318 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user