Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd94435993 | ||
|
|
dff7ae2860 |
No files matched your search
+3
-7
@@ -6,13 +6,9 @@
|
|||||||
# Get yours at: https://app.ximilar.com/
|
# Get yours at: https://app.ximilar.com/
|
||||||
XIMILAR_API_KEY=your-ximilar-api-key-here
|
XIMILAR_API_KEY=your-ximilar-api-key-here
|
||||||
|
|
||||||
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
|
# Perplexity API Key (AI deck research & generation)
|
||||||
# Get yours at: https://console.anthropic.com/settings/keys
|
# Get yours at: https://www.perplexity.ai/settings/api
|
||||||
ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
PPLX_API_KEY=your-perplexity-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)
|
# CORS allowed origins (comma-separated)
|
||||||
# Default: http://localhost:5173,http://localhost:8080
|
# Default: http://localhost:5173,http://localhost:8080
|
||||||
|
|||||||
@@ -1,180 +1,381 @@
|
|||||||
# Commander AI Lab
|
# Commander AI Lab
|
||||||
|
|
||||||
A full-stack MTG Commander AI research platform. Simulate hundreds of games overnight, train ML models on the results, and let simulation data automatically shape the AI deck builder — all from a web UI or CLI.
|
Headless batch simulation environment for 3-AI MTG Commander pods.
|
||||||
|
Run 100+ games, analyze win rates, and stress-test your decks — all from a web UI or CLI.
|
||||||
## What It Does
|
|
||||||
|
|
||||||
| Layer | What it is |
|
|
||||||
|---|---|
|
|
||||||
| **Monte Carlo Engine** | Pure-Python headless sim — runs thousands of games in minutes, no JVM needed |
|
|
||||||
| **Forge Engine** | Java/Forge rules-complete sim for ground-truth validation |
|
|
||||||
| **Overnight Runner** | Unattended batch runner — runs sims, updates sim weights, trains neural net |
|
|
||||||
| **ML Pipeline** | Outcome-weighted RL nudges card scoring weights; neural net trains on decision logs |
|
|
||||||
| **Deck Builder** | 7-step LLM pipeline (EDHrec → Scryfall → Ollama) now injected with sim-learned insights |
|
|
||||||
| **AI Coach** | LLM-powered coaching sessions (Anthropic/Ollama) with streaming support |
|
|
||||||
| **Web UI** | React + FastAPI dashboard for decks, sims, training, and deck building |
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Web UI (React/TypeScript — frontend/) │
|
│ Web Browser (ai-lab.js + ai-lab.css) │
|
||||||
│ Deck Builder · Sim Dashboard · Training · Coach │
|
│ Select decks → Start batch → Poll progress → View stats │
|
||||||
└────────────────────────┬────────────────────────────────────────┘
|
└─────────────────────┬───────────────────────────────────────┘
|
||||||
│ HTTP/REST + WebSocket
|
│ HTTP (REST)
|
||||||
┌────────────────────────┴────────────────────────────────────────┐
|
┌─────────────────────┴───────────────────────────────────────┐
|
||||||
│ FastAPI Server (lab_api.py / routes/ — port 8080) │
|
│ Python FastAPI Server (lab_api.py, port 8080) │
|
||||||
│ /api/lab/* /api/ml/* /api/coach/* /api/deck-builder/* │
|
│ /api/lab/start — spawn Java batch runner subprocess │
|
||||||
└────────┬──────────────────┬──────────────────┬──────────────────┘
|
│ /api/lab/status — poll progress (parses stdout) │
|
||||||
│ │ │
|
│ /api/lab/result — return completed JSON results │
|
||||||
Java subprocess Python sim engine Ollama (local LLM)
|
│ /api/lab/decks — list decks from Forge user data │
|
||||||
(Forge rules) (Monte Carlo) qwen2.5:7b + others
|
│ /api/lab/history— browse past batch runs │
|
||||||
│ │
|
│ /api/lab/log — stream live console output │
|
||||||
┌────────┴───────┐ ┌───────┴────────────────────────────────────┐
|
└─────────────────────┬───────────────────────────────────────┘
|
||||||
│ Forge Engine │ │ src/commander_ai_lab/ │
|
│ subprocess (java -jar)
|
||||||
│ (rules- │ │ sim/ — engine, models, rules │
|
┌─────────────────────┴───────────────────────────────────────┐
|
||||||
│ complete) │ │ deck_builder/ — 7-step LLM pipeline │
|
│ Java CLI (LabCli.java + BatchRunner + MultiThreadRunner) │
|
||||||
└────────────────┘ │ coach/ — streaming LLM coach │
|
│ Parses Forge sim output → GameResult objects → JSON │
|
||||||
└────────────────────────────────────────────┘
|
└─────────────────────┬───────────────────────────────────────┘
|
||||||
|
│ subprocess per game
|
||||||
|
┌─────────────────────┴───────────────────────────────────────┐
|
||||||
|
│ Card Forge Engine (forge-gui-desktop sim mode) │
|
||||||
|
│ java -jar forge.jar sim -d "deck1" "deck2" "deck3" │
|
||||||
|
│ -f commander -n 1 -q │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start (Windows)
|
## Quick Start (Windows)
|
||||||
|
|
||||||
**Prerequisites:** Java 17+, Python 3.10+, Maven 3.8+, [Forge](https://github.com/Card-Forge/forge) built at `D:\ForgeCommander\forge-repo`
|
### Prerequisites
|
||||||
|
- **Java 17+** (`java -version`)
|
||||||
|
- **Python 3.10+** (`python --version`)
|
||||||
|
- **Maven 3.8+** (`mvn --version`)
|
||||||
|
- **Forge repo** cloned and built at `D:\ForgeCommander\forge-repo`
|
||||||
|
|
||||||
|
### 1. Build Forge (one-time)
|
||||||
```powershell
|
```powershell
|
||||||
# 1. Build Forge (one-time)
|
|
||||||
cd D:\ForgeCommander\forge-repo
|
cd D:\ForgeCommander\forge-repo
|
||||||
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
||||||
|
```
|
||||||
|
|
||||||
# 2. Build Commander AI Lab
|
### 2. Build Commander AI Lab
|
||||||
|
```powershell
|
||||||
cd D:\ForgeCommander\commander-ai-lab
|
cd D:\ForgeCommander\commander-ai-lab
|
||||||
mvn package "-DskipTests"
|
mvn package "-DskipTests"
|
||||||
|
```
|
||||||
|
This produces `target/commander-ai-lab-1.0.0-SNAPSHOT.jar` (shaded fat JAR).
|
||||||
|
|
||||||
# 3. Install Python deps
|
### 3. Install Python dependencies
|
||||||
pip install -e .
|
```powershell
|
||||||
|
pip install fastapi uvicorn
|
||||||
|
```
|
||||||
|
|
||||||
# 4. Launch
|
### 4. Launch the Web UI
|
||||||
|
**Option A — One-click launcher:**
|
||||||
|
```powershell
|
||||||
.\start-lab.bat
|
.\start-lab.bat
|
||||||
# → open http://localhost:8080
|
|
||||||
```
|
```
|
||||||
|
Edit the paths at the top of `start-lab.bat` if needed.
|
||||||
|
|
||||||
## Overnight AFK Runner
|
**Option B — Manual launch:**
|
||||||
|
|
||||||
Runs batch sims unattended, then updates card weights and trains the neural net:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python overnight-run.py # default: 11.5h DeepSeek sims
|
python lab_api.py `
|
||||||
python overnight-run.py --batches 20 --games 50 # fixed batch count
|
--forge-jar "D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
|
||||||
python overnight-run.py --no-update-weights # skip weight update
|
--forge-dir "D:\ForgeCommander\forge-repo\forge-gui" `
|
||||||
python overnight-run.py --no-train # skip neural net training
|
--port 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
**What happens after sims complete:**
|
Open `http://localhost:8080` — select 3 decks, set game count, click **Run AI Sims**.
|
||||||
1. `update_weights.py` — nudges `learned_weights.json` from decision logs (feeds deck builder)
|
|
||||||
2. `/api/ml/train` — trains the neural net on the full decision dataset
|
|
||||||
|
|
||||||
## Sim → Deck Builder Feedback Loop
|
|
||||||
|
|
||||||
Simulation outcomes automatically improve deck recommendations:
|
|
||||||
|
|
||||||
```
|
|
||||||
Overnight sims → decisions_*.jsonl
|
|
||||||
↓
|
|
||||||
update_weights.py → learned_weights.json
|
|
||||||
↓
|
|
||||||
sim_insights.py → natural-language insight block
|
|
||||||
↓
|
|
||||||
Ollama prompts (suggest_cards + enforce_deck_ratios)
|
|
||||||
↓
|
|
||||||
Better card recommendations
|
|
||||||
```
|
|
||||||
|
|
||||||
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. No-op until real sim data exists.
|
|
||||||
|
|
||||||
## Deck Builder Pipeline
|
|
||||||
|
|
||||||
The `POST /api/deck-builder/build` endpoint runs 7 steps:
|
|
||||||
|
|
||||||
1. Resolve commander via Scryfall
|
|
||||||
2. Fetch EDHrec recommendations
|
|
||||||
3. Fetch Scryfall candidates by category (ramp, removal, draw, lands)
|
|
||||||
4. Ollama suggests synergy/protection/wincon cards ← **sim insights injected here**
|
|
||||||
5. Filter by color identity, collection, ban list
|
|
||||||
6. Ollama enforces ratios (trim/expand to 99) ← **sim insights injected here**
|
|
||||||
7. Assemble final deck
|
|
||||||
|
|
||||||
## ML Pipeline
|
|
||||||
|
|
||||||
|
### 5. CLI-only usage (no web server)
|
||||||
```powershell
|
```powershell
|
||||||
# Update sim card weights from decision logs (runs automatically in overnight-run.py)
|
cd D:\ForgeCommander\forge-repo\forge-gui
|
||||||
python -m ml.scripts.update_weights --min-games 10 --lr 0.05 --dry-run
|
|
||||||
|
|
||||||
# Reset weights to defaults
|
java -jar "D:\ForgeCommander\commander-ai-lab\target\commander-ai-lab-1.0.0-SNAPSHOT.jar" `
|
||||||
python -m ml.scripts.update_weights --reset
|
--forge-jar "..\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
|
||||||
|
--forge-dir "." `
|
||||||
# Generate synthetic training data
|
--deck1 "Edgar Markov" `
|
||||||
python ml/scripts/generate_synthetic_data.py
|
--deck2 "Grimgrin" `
|
||||||
|
--deck3 "Xyris" `
|
||||||
|
--games 100 `
|
||||||
|
--threads 4 `
|
||||||
|
--output results/batch-100.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Learned weights are clamped to `[-20.0, 20.0]` and stored in `src/commander_ai_lab/sim/learned_weights.json`.
|
## Scaling Tests
|
||||||
|
|
||||||
## Python Simulator API
|
### Single-threaded baseline (10 games)
|
||||||
|
```powershell
|
||||||
```python
|
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||||
from commander_ai_lab.lab.experiments import build_deck, run_simulation
|
--forge-jar "..." --forge-dir "..." `
|
||||||
|
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||||
deck = build_deck(["Sol Ring", "Cultivate", "Lightning Bolt"] + ["Forest"] * 36)
|
--games 10 --threads 1 --output results/baseline-10.json
|
||||||
stats = run_simulation(deck, num_games=100, name_a="My Deck")
|
|
||||||
print(f"Win rate: {stats['win_rate']:.1%}, Avg turns: {stats['avg_turns']:.1f}")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The Monte Carlo engine models: combat keywords (flying, trample, deathtouch, lifelink, menace, reach, haste), summoning sickness, tapped/untap state, commander zone, multiplayer attack targeting, and board wipes. It does **not** model the stack, instants, or continuous type-changing effects.
|
### Multi-threaded (10 games, 2 threads)
|
||||||
|
```powershell
|
||||||
|
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||||
|
--forge-jar "..." --forge-dir "..." `
|
||||||
|
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||||
|
--games 10 --threads 2 --output results/mt-10-t2.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scale to 100 games (4 threads)
|
||||||
|
```powershell
|
||||||
|
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||||
|
--forge-jar "..." --forge-dir "..." `
|
||||||
|
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||||
|
--games 100 --threads 4 --output results/scale-100-t4.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Maximum throughput test (100 games, 8 threads)
|
||||||
|
```powershell
|
||||||
|
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||||
|
--forge-jar "..." --forge-dir "..." `
|
||||||
|
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||||
|
--games 100 --threads 8 --seed 42 --output results/scale-100-t8-seed42.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance comparison script
|
||||||
|
Run different thread counts and compare sims/sec:
|
||||||
|
```powershell
|
||||||
|
# Baseline: 1 thread
|
||||||
|
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 1 --seed 42 --output results/perf-t1.json
|
||||||
|
|
||||||
|
# 2 threads
|
||||||
|
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 2 --seed 42 --output results/perf-t2.json
|
||||||
|
|
||||||
|
# 4 threads
|
||||||
|
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 4 --seed 42 --output results/perf-t4.json
|
||||||
|
|
||||||
|
# 8 threads
|
||||||
|
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 8 --seed 42 --output results/perf-t8.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Then compare the `simsPerSecond` values in each output JSON to see scaling behavior.
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/api/lab/decks` | GET | List available Commander decks |
|
| `/api/lab/decks` | GET | List available Commander decks |
|
||||||
| `/api/lab/start` | POST | Start Forge batch (returns batchId) |
|
| `/api/lab/start` | POST | Start a batch run (returns batchId) |
|
||||||
| `/api/lab/start-deepseek` | POST | Start Python/DeepSeek batch |
|
| `/api/lab/status?batchId=X` | GET | Poll progress for a running batch |
|
||||||
| `/api/lab/status?batchId=X` | GET | Poll batch progress |
|
| `/api/lab/result?batchId=X` | GET | Get completed batch result JSON |
|
||||||
| `/api/lab/result?batchId=X` | GET | Get completed batch JSON |
|
| `/api/lab/log?batchId=X` | GET | Get live log lines from running batch |
|
||||||
| `/api/lab/history` | GET | List past batch runs |
|
| `/api/lab/history` | GET | List past batch result files |
|
||||||
| `/api/ml/train` | POST | Start neural net training |
|
|
||||||
| `/api/ml/train/status` | GET | Poll training progress |
|
### POST /api/lab/start
|
||||||
| `/api/coach/chat` | POST | LLM coaching session (streaming) |
|
```json
|
||||||
| `/api/deck-builder/build` | POST | Build a 99-card Commander deck |
|
{
|
||||||
|
"decks": ["Edgar Markov", "Grimgrin", "Xyris"],
|
||||||
|
"numGames": 100,
|
||||||
|
"threads": 4,
|
||||||
|
"seed": null,
|
||||||
|
"clock": 120
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/lab/status response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"batchId": "abc123",
|
||||||
|
"running": true,
|
||||||
|
"completed": 42,
|
||||||
|
"total": 100,
|
||||||
|
"threads": 4,
|
||||||
|
"elapsedMs": 156000,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Simulator (Monte Carlo Engine)
|
||||||
|
|
||||||
|
A pure-Python deck simulator for rapid Monte Carlo analysis — no Forge JVM required.
|
||||||
|
Ported from the [mtg-commander-lan](https://github.com/) Node.js simulator.
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
```
|
||||||
|
src/commander_ai_lab/
|
||||||
|
├── __init__.py
|
||||||
|
├── sim/ # Core simulation engine
|
||||||
|
│ ├── __init__.py # Public exports
|
||||||
|
│ ├── models.py # Card, Player, SimState, GameResult, PlayerStats
|
||||||
|
│ ├── engine.py # GameEngine — turn loop, combat (flying/trample/deathtouch/lifelink/menace/reach)
|
||||||
|
│ └── rules.py # AI_DEFAULT_WEIGHTS, enrich_card(), score_card(), parse_decklist()
|
||||||
|
└── lab/ # High-level experiment helpers
|
||||||
|
├── __init__.py
|
||||||
|
└── experiments.py # build_deck(), run_single_game(), run_simulation(), _cli_main()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install as editable package
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\ForgeCommander\commander-ai-lab
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs `commander_ai_lab` as an importable package and registers the `commander-sim` CLI command.
|
||||||
|
|
||||||
|
### Quick smoke test
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m commander_ai_lab.lab.experiments
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output — a 10-game Monte Carlo run of a sample "Korvold" deck:
|
||||||
|
|
||||||
|
```
|
||||||
|
══════════════════════════════════════════
|
||||||
|
Commander AI Lab — Experiment Runner
|
||||||
|
══════════════════════════════════════════
|
||||||
|
|
||||||
|
--- Demo: Korvold Aristocrats (10 games) ---
|
||||||
|
|
||||||
|
Win rate: XX.X%
|
||||||
|
Avg turns: XX.X
|
||||||
|
Avg damage dealt: XX.X
|
||||||
|
Avg spells cast: XX.X
|
||||||
|
Avg creatures: XX.X
|
||||||
|
Elapsed: 0.02s
|
||||||
|
|
||||||
|
Done.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python API
|
||||||
|
|
||||||
|
```python
|
||||||
|
from commander_ai_lab.sim.models import Card
|
||||||
|
from commander_ai_lab.sim.engine import GameEngine
|
||||||
|
from commander_ai_lab.lab.experiments import build_deck, run_simulation
|
||||||
|
|
||||||
|
# Build a deck from card names
|
||||||
|
deck = build_deck(["Sol Ring", "Cultivate", "Lightning Bolt", "Forest"] + ["Forest"] * 36)
|
||||||
|
|
||||||
|
# Monte Carlo simulation (100 games)
|
||||||
|
stats = run_simulation(deck, num_games=100, name_a="My Deck")
|
||||||
|
print(f"Win rate: {stats['win_rate']:.1%}")
|
||||||
|
print(f"Avg turns: {stats['avg_turns']:.1f}")
|
||||||
|
```
|
||||||
|
|
||||||
|
Key functions:
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|---|---|
|
||||||
|
| `build_deck(card_names)` | Create enriched `Card` list from names |
|
||||||
|
| `build_deck_from_text(decklist)` | Parse `"1 Sol Ring\n37 Forest"` format |
|
||||||
|
| `run_single_game(deck_a, deck_b)` | One game, returns winner/turns/stats |
|
||||||
|
| `run_simulation(deck_a, deck_b, n)` | Monte Carlo over `n` games, aggregated stats |
|
||||||
|
|
||||||
|
### CLI entry point
|
||||||
|
|
||||||
|
After `pip install -e .`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
commander-sim
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs the same demo as `python -m commander_ai_lab.lab.experiments`.
|
||||||
|
|
||||||
|
### How the engine works
|
||||||
|
|
||||||
|
1. **Deck enrichment** — `enrich_card()` infers type, CMC, keywords, and power/toughness from card name heuristics when Scryfall data is unavailable.
|
||||||
|
2. **AI scoring** — `score_card()` uses weighted categories (removal, ramp, draw, aggro, etc.) to pick the best play each turn.
|
||||||
|
3. **Game loop** — Each turn: untap → draw → play lands → cast spells (AI scored) → combat (attack/block decisions) → end step. Full keyword support for flying, trample, deathtouch, lifelink, menace, and reach.
|
||||||
|
4. **Monte Carlo** — `run_simulation()` runs N independent games and aggregates win rate, avg turns, damage dealt/received, spells cast, creatures played, removal used, ramp played, cards drawn, and max board size.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
commander-ai-lab/
|
commander-ai-lab/
|
||||||
├── overnight-run.py # AFK batch runner
|
├── lab_api.py # Python FastAPI server (web UI backend)
|
||||||
├── start-lab.bat # Windows one-click launcher
|
├── start-lab.bat # Windows one-click launcher
|
||||||
├── lab_api.py # FastAPI server entry point
|
├── pom.xml # Maven build (Java 17, shaded JAR)
|
||||||
├── frontend/ # React + TypeScript web UI
|
├── pyproject.toml # Python package config (src/ layout)
|
||||||
├── ui/ # Legacy static HTML UI
|
├── ui/
|
||||||
├── src/commander_ai_lab/
|
│ ├── index.html # Web UI entry point
|
||||||
│ ├── sim/ # Monte Carlo engine (engine, models, rules)
|
│ ├── ai-lab.js # UI controller (deck selection, progress, results)
|
||||||
│ ├── deck_builder/ # LLM deck builder pipeline
|
│ ├── ai-lab.css # Styles (dark theme, responsive)
|
||||||
│ │ ├── api/ # Scryfall, EDHrec, Ollama, sim_insights
|
│ ├── collection.html # Card collection manager
|
||||||
│ │ ├── core/ # Models, rules engine, assembler
|
│ ├── collection.js # Collection controller
|
||||||
│ │ └── pipeline/ # build_deck.py (7-step orchestrator)
|
│ ├── collection.css # Collection styles
|
||||||
│ └── coach/ # LLM coaching (Anthropic + Ollama)
|
│ ├── deckbuilder.html # Deck builder + EDH advisor
|
||||||
├── ml/
|
│ ├── deckbuilder.js # Deck builder controller
|
||||||
│ ├── scripts/ # update_weights.py, ml_cli.py
|
│ └── deckbuilder.css # Deck builder styles
|
||||||
│ ├── training/ # Neural net trainer
|
├── src/commander_ai_lab/ # Python simulator package
|
||||||
│ ├── encoder/ # Feature encoding
|
│ ├── __init__.py
|
||||||
│ └── models/ # Saved model checkpoints
|
│ ├── sim/ # Core engine (models, engine, rules)
|
||||||
├── scanner/ # Card OCR pipeline
|
│ └── lab/ # Experiment helpers
|
||||||
├── logs/decisions/ # ML decision logs (gitignored)
|
├── src/main/java/commanderailab/ # Java batch runner (Forge integration)
|
||||||
└── src/main/java/commanderailab/ # Java/Forge batch runner
|
│ ├── cli/LabCli.java # CLI entry point (picocli)
|
||||||
|
│ ├── batch/
|
||||||
|
│ │ ├── BatchRunner.java # Single-thread runner (parses Forge output)
|
||||||
|
│ │ └── MultiThreadBatchRunner.java # Multi-thread runner (ExecutorService)
|
||||||
|
│ ├── bridge/WebApiBridge.java # Java-side web API bridge
|
||||||
|
│ ├── schema/
|
||||||
|
│ │ ├── BatchResult.java # Data model POJOs
|
||||||
|
│ │ └── JsonExporter.java # JSON serialization + validation
|
||||||
|
│ ├── stats/StatsAggregator.java # Win rates, turn counts, breakdowns
|
||||||
|
│ └── ai/
|
||||||
|
│ ├── AiPolicy.java # Policy interface
|
||||||
|
│ └── ForgeBuiltinPolicy.java # v1 Forge AI wrapper
|
||||||
|
├── scanner/ # Card scanner (OCR pipeline)
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── ocr.py # Tesseract OCR wrapper
|
||||||
|
│ ├── pipeline.py # Image preprocessing pipeline
|
||||||
|
│ └── preprocess.py # Image preprocessing helpers
|
||||||
|
├── docs/
|
||||||
|
│ ├── PROJECT_VISION.md
|
||||||
|
│ ├── batch-result-schema.json
|
||||||
|
│ └── game-log-schema.json
|
||||||
|
├── sample-decks/ # 3 sample .dck files
|
||||||
|
├── precon-decks/ # Preconstructed decks + index
|
||||||
|
├── results/ # Batch output JSON files (gitignored)
|
||||||
|
└── src/test/java/ # Unit tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Known Limitations
|
## Output Format
|
||||||
|
|
||||||
- **Monte Carlo engine** — no stack, no instants, no continuous type changes (animated lands don't attack)
|
Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
|
||||||
- **Forge engine** — each game spawns a new JVM (~2-3s overhead), ~0.04 sims/sec single-threaded
|
```json
|
||||||
- **Forge winner life total** not captured (quiet mode only reports losers)
|
{
|
||||||
- **Deck builder** — Ollama suggestions are only as good as the local model; `qwen2.5:7b` works well
|
"metadata": {
|
||||||
|
"schemaVersion": "1.0.0",
|
||||||
|
"batchId": "uuid",
|
||||||
|
"timestamp": "2026-03-06T...",
|
||||||
|
"totalGames": 100,
|
||||||
|
"completedGames": 100,
|
||||||
|
"format": "commander",
|
||||||
|
"podSize": 3,
|
||||||
|
"engineVersion": "forge-2.0.12-SNAPSHOT",
|
||||||
|
"masterSeed": 42,
|
||||||
|
"threads": 4,
|
||||||
|
"elapsedMs": 260000
|
||||||
|
},
|
||||||
|
"decks": [...],
|
||||||
|
"games": [...],
|
||||||
|
"summary": {
|
||||||
|
"perDeck": [
|
||||||
|
{ "deckName": "Edgar Markov", "winRate": 0.60, "wins": 60, ... }
|
||||||
|
],
|
||||||
|
"avgGameTurns": 12.3,
|
||||||
|
"simsPerSecond": 0.38
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- Each game spawns a new JVM process (~2-3s overhead per game)
|
||||||
|
- Max throughput ~0.04 sims/sec single-threaded (Forge sim is CPU-heavy)
|
||||||
|
- Multithreading spawns parallel subprocesses — throughput scales linearly with threads up to CPU core count
|
||||||
@@ -1 +1 @@
|
|||||||
# Coach clients — Anthropic Claude, LM Studio
|
# Coach clients — Perplexity, LM Studio
|
||||||
@@ -1,401 +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.
|
|
||||||
|
|
||||||
All substantive calls use .messages.stream() to avoid the SDK's
|
|
||||||
10-minute non-streaming timeout restriction on large max_tokens.
|
|
||||||
"""
|
|
||||||
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.
|
|
||||||
All substantive calls use .messages.stream() so the SDK never
|
|
||||||
enforces the 10-minute non-streaming timeout.
|
|
||||||
|
|
||||||
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.
|
|
||||||
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
|
|
||||||
"""
|
|
||||||
use_model = model or self.model
|
|
||||||
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self._client.messages.stream(
|
|
||||||
model=use_model,
|
|
||||||
system=effective_system,
|
|
||||||
messages=[{"role": "user", "content": user_prompt}],
|
|
||||||
temperature=temperature,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
) as stream:
|
|
||||||
content = stream.get_final_text()
|
|
||||||
final_msg = stream.get_final_message()
|
|
||||||
return self._parse_streamed(content, final_msg, use_model)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("[anthropic] chat_structured 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.
|
|
||||||
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
|
|
||||||
"""
|
|
||||||
use_model = model or self.model
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self._client.messages.stream(
|
|
||||||
model=use_model,
|
|
||||||
system=system_prompt,
|
|
||||||
messages=[{"role": "user", "content": user_prompt}],
|
|
||||||
temperature=temperature,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
) as stream:
|
|
||||||
content = stream.get_final_text()
|
|
||||||
final_msg = stream.get_final_message()
|
|
||||||
return self._parse_streamed(content, final_msg, use_model)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("[anthropic] chat_plain 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 using streaming."""
|
|
||||||
use_model = model or self.model
|
|
||||||
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with self._async_client.messages.stream(
|
|
||||||
model=use_model,
|
|
||||||
system=effective_system,
|
|
||||||
messages=[{"role": "user", "content": user_prompt}],
|
|
||||||
temperature=temperature,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
) as stream:
|
|
||||||
content = await stream.get_final_text()
|
|
||||||
final_msg = await stream.get_final_message()
|
|
||||||
return self._parse_streamed(content, final_msg, use_model)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("[anthropic] achat_structured 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 using streaming."""
|
|
||||||
use_model = model or self.model
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with self._async_client.messages.stream(
|
|
||||||
model=use_model,
|
|
||||||
system=system_prompt,
|
|
||||||
messages=[{"role": "user", "content": user_prompt}],
|
|
||||||
temperature=temperature,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
) as stream:
|
|
||||||
content = await stream.get_final_text()
|
|
||||||
final_msg = await stream.get_final_message()
|
|
||||||
return self._parse_streamed(content, final_msg, use_model)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("[anthropic] achat_plain failed: %s", e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def check_status(self) -> dict:
|
|
||||||
"""
|
|
||||||
Quick health check — tiny max_tokens=10 call, safe to use non-streaming
|
|
||||||
since it will never approach the 10-minute threshold.
|
|
||||||
"""
|
|
||||||
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_streamed(self, content: str, final_msg, use_model: str) -> AnthropicResponse:
|
|
||||||
"""Build an AnthropicResponse from streamed text + final message metadata."""
|
|
||||||
usage = getattr(final_msg, "usage", None)
|
|
||||||
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
|
|
||||||
+36
-138
@@ -9,8 +9,6 @@ calls the LLM, parses responses, and persists coaching sessions.
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
@@ -18,8 +16,7 @@ from typing import Optional, List, Dict
|
|||||||
from .config import (
|
from .config import (
|
||||||
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
|
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
|
||||||
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
|
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
|
||||||
COACH_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
|
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
|
||||||
UNDERPERFORMER_IMPACT_THRESHOLD, DEFAULT_MAX_TOKENS, ensure_dirs,
|
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
DeckReport, CoachGoals, CoachSession,
|
DeckReport, CoachGoals, CoachSession,
|
||||||
@@ -31,18 +28,6 @@ from .prompt_template import build_system_prompt, build_user_prompt
|
|||||||
|
|
||||||
logger = logging.getLogger("coach.service")
|
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:
|
class CoachService:
|
||||||
"""
|
"""
|
||||||
@@ -65,10 +50,12 @@ class CoachService:
|
|||||||
|
|
||||||
def load_deck_report(self, deck_id: str) -> Optional[DeckReport]:
|
def load_deck_report(self, deck_id: str) -> Optional[DeckReport]:
|
||||||
"""Load a deck report from disk."""
|
"""Load a deck report from disk."""
|
||||||
|
# Try exact filename first, then case-insensitive search
|
||||||
report_path = DECK_REPORTS_DIR / f"{deck_id}.json"
|
report_path = DECK_REPORTS_DIR / f"{deck_id}.json"
|
||||||
if not report_path.exists():
|
if not report_path.exists():
|
||||||
|
# Try case-insensitive match
|
||||||
for f in DECK_REPORTS_DIR.glob("*.json"):
|
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
|
report_path = f
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -93,57 +80,15 @@ class CoachService:
|
|||||||
# ── Coach Session I/O ──────────────────────────────────────
|
# ── Coach Session I/O ──────────────────────────────────────
|
||||||
|
|
||||||
def save_session(self, session: CoachSession) -> Path:
|
def save_session(self, session: CoachSession) -> Path:
|
||||||
"""Persist a coaching session to disk and SQLite."""
|
"""Persist a coaching session to disk."""
|
||||||
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
|
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
|
||||||
with open(path, "w") as f:
|
with open(path, "w") as f:
|
||||||
f.write(session.model_dump_json(indent=2))
|
f.write(session.model_dump_json(indent=2))
|
||||||
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
|
return path
|
||||||
|
|
||||||
def load_session(self, session_id: str) -> Optional[CoachSession]:
|
def load_session(self, session_id: str) -> Optional[CoachSession]:
|
||||||
"""Load a coaching session from SQLite, falling back to disk."""
|
"""Load a coaching session from disk."""
|
||||||
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)
|
|
||||||
path = COACH_SESSIONS_DIR / f"{session_id}.json"
|
path = COACH_SESSIONS_DIR / f"{session_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -156,14 +101,7 @@ class CoachService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def list_sessions(self, deck_id: str = None) -> List[dict]:
|
def list_sessions(self, deck_id: str = None) -> List[dict]:
|
||||||
"""List coaching sessions from SQLite, falling back to disk."""
|
"""List all coaching sessions, optionally filtered by deck."""
|
||||||
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)
|
|
||||||
if not COACH_SESSIONS_DIR.exists():
|
if not COACH_SESSIONS_DIR.exists():
|
||||||
return []
|
return []
|
||||||
sessions = []
|
sessions = []
|
||||||
@@ -187,19 +125,17 @@ class CoachService:
|
|||||||
|
|
||||||
# ── Main Coaching Pipeline ─────────────────────────────────
|
# ── Main Coaching Pipeline ─────────────────────────────────
|
||||||
|
|
||||||
async def run_coaching_session(
|
async def run_coaching_session(self, deck_id: str,
|
||||||
self,
|
goals: Optional[CoachGoals] = None,
|
||||||
deck_id: str,
|
fallback_report: Optional[DeckReport] = None,
|
||||||
goals: Optional[CoachGoals] = None,
|
) -> CoachSession:
|
||||||
fallback_report: Optional[DeckReport] = None,
|
|
||||||
) -> CoachSession:
|
|
||||||
"""
|
"""
|
||||||
Full coaching pipeline:
|
Full coaching pipeline:
|
||||||
1. Load deck report (or use fallback from DB card list)
|
1. Load deck report (or use fallback from DB card list)
|
||||||
2. Identify underperformers
|
2. Identify underperformers
|
||||||
3. Find replacement candidates via embeddings
|
3. Find replacement candidates via embeddings
|
||||||
4. Build prompt
|
4. Build prompt
|
||||||
5. Call LLM (streaming for Anthropic to avoid 10-min SDK timeout)
|
5. Call LLM
|
||||||
6. Parse response
|
6. Parse response
|
||||||
7. Persist session
|
7. Persist session
|
||||||
"""
|
"""
|
||||||
@@ -215,7 +151,7 @@ class CoachService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Embeddings auto-load failed: %s", e)
|
logger.warning("Embeddings auto-load failed: %s", e)
|
||||||
|
|
||||||
# 1. Load deck report
|
# 1. Load deck report — try simulation report first, then fallback
|
||||||
report = self.load_deck_report(deck_id)
|
report = self.load_deck_report(deck_id)
|
||||||
if report is None and fallback_report is not None:
|
if report is None and fallback_report is not None:
|
||||||
report = fallback_report
|
report = fallback_report
|
||||||
@@ -228,15 +164,20 @@ class CoachService:
|
|||||||
|
|
||||||
# 3. Find replacement candidates for underperformers
|
# 3. Find replacement candidates for underperformers
|
||||||
candidates: Dict[str, List[dict]] = {}
|
candidates: Dict[str, List[dict]] = {}
|
||||||
|
|
||||||
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
|
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
|
||||||
if not underperformers:
|
if not underperformers:
|
||||||
|
# If no explicit underperformers, pick lowest impact cards
|
||||||
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
|
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
|
||||||
underperformers = [
|
underperformers = [
|
||||||
c.name for c in sorted_cards[:MAX_UNDERPERFORMERS]
|
c.name for c in sorted_cards[:MAX_UNDERPERFORMERS]
|
||||||
if c.impactScore < UNDERPERFORMER_IMPACT_THRESHOLD
|
if c.impactScore < UNDERPERFORMER_IMPACT_THRESHOLD
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# If still no underperformers and we have cards, pick bottom N by impact
|
||||||
if not underperformers and report.cards:
|
if not underperformers and report.cards:
|
||||||
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
|
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
|
||||||
|
# Take the bottom 5 non-land cards as candidates for cuts
|
||||||
underperformers = [
|
underperformers = [
|
||||||
c.name for c in sorted_cards[:5]
|
c.name for c in sorted_cards[:5]
|
||||||
if not any(t in (c.tags or []) for t in [])
|
if not any(t in (c.tags or []) for t in [])
|
||||||
@@ -260,52 +201,12 @@ class CoachService:
|
|||||||
user_prompt = build_user_prompt(report, candidates)
|
user_prompt = build_user_prompt(report, candidates)
|
||||||
|
|
||||||
# 5. Call LLM
|
# 5. Call LLM
|
||||||
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
|
logger.info("Calling LLM for deck: %s", deck_id)
|
||||||
if COACH_PROVIDER == "anthropic":
|
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
||||||
import anthropic as _anthropic
|
|
||||||
anthropic_key = ANTHROPIC_API_KEY
|
|
||||||
if not anthropic_key:
|
|
||||||
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
|
|
||||||
|
|
||||||
# Use streaming to avoid the Anthropic SDK 10-minute non-streaming timeout.
|
|
||||||
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
|
|
||||||
async with aclient.messages.stream(
|
|
||||||
model=ANTHROPIC_MODEL,
|
|
||||||
system=system_prompt,
|
|
||||||
messages=[{"role": "user", "content": user_prompt}],
|
|
||||||
max_tokens=DEFAULT_MAX_TOKENS,
|
|
||||||
temperature=0.7,
|
|
||||||
) as stream:
|
|
||||||
content = await stream.get_final_text()
|
|
||||||
final_msg = await stream.get_final_message()
|
|
||||||
|
|
||||||
usage = getattr(final_msg, "usage", None)
|
|
||||||
|
|
||||||
# Build an LLMResponse-compatible object
|
|
||||||
from types import SimpleNamespace
|
|
||||||
llm_response = SimpleNamespace(
|
|
||||||
content=content,
|
|
||||||
model=final_msg.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):
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 6. Parse response into CoachSession
|
# 6. Parse response into CoachSession
|
||||||
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||||
|
|
||||||
session = CoachSession(
|
session = CoachSession(
|
||||||
sessionId=session_id,
|
sessionId=session_id,
|
||||||
deckId=deck_id,
|
deckId=deck_id,
|
||||||
@@ -319,12 +220,14 @@ class CoachService:
|
|||||||
if llm_response.parsed_json:
|
if llm_response.parsed_json:
|
||||||
self._populate_session_from_json(session, llm_response.parsed_json)
|
self._populate_session_from_json(session, llm_response.parsed_json)
|
||||||
else:
|
else:
|
||||||
|
# Fallback: store raw text
|
||||||
session.rawTextExplanation = llm_response.content
|
session.rawTextExplanation = llm_response.content
|
||||||
session.summary = "LLM returned non-JSON response. See raw explanation."
|
session.summary = "LLM returned non-JSON response. See raw explanation."
|
||||||
|
|
||||||
# 7. Persist
|
# 7. Persist
|
||||||
self.save_session(session)
|
self.save_session(session)
|
||||||
logger.info("Coaching session saved: %s", session_id)
|
logger.info("Coaching session saved: %s", session_id)
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def _populate_session_from_json(self, session: CoachSession, data: dict):
|
def _populate_session_from_json(self, session: CoachSession, data: dict):
|
||||||
@@ -333,9 +236,11 @@ class CoachService:
|
|||||||
session.rawTextExplanation = data.get("rawTextExplanation", "")
|
session.rawTextExplanation = data.get("rawTextExplanation", "")
|
||||||
session.manaBaseAdvice = data.get("manaBaseAdvice")
|
session.manaBaseAdvice = data.get("manaBaseAdvice")
|
||||||
|
|
||||||
|
# Heuristic hints
|
||||||
hints = data.get("heuristicHints", [])
|
hints = data.get("heuristicHints", [])
|
||||||
session.heuristicHints = hints if isinstance(hints, list) else [str(hints)]
|
session.heuristicHints = hints if isinstance(hints, list) else [str(hints)]
|
||||||
|
|
||||||
|
# Suggested cuts
|
||||||
for cut_data in data.get("suggestedCuts", []):
|
for cut_data in data.get("suggestedCuts", []):
|
||||||
if isinstance(cut_data, dict):
|
if isinstance(cut_data, dict):
|
||||||
session.suggestedCuts.append(SuggestedCut(
|
session.suggestedCuts.append(SuggestedCut(
|
||||||
@@ -344,6 +249,7 @@ class CoachService:
|
|||||||
replacementOptions=cut_data.get("replacementOptions", []),
|
replacementOptions=cut_data.get("replacementOptions", []),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Suggested adds
|
||||||
for add_data in data.get("suggestedAdds", []):
|
for add_data in data.get("suggestedAdds", []):
|
||||||
if isinstance(add_data, dict):
|
if isinstance(add_data, dict):
|
||||||
session.suggestedAdds.append(SuggestedAdd(
|
session.suggestedAdds.append(SuggestedAdd(
|
||||||
@@ -359,27 +265,19 @@ class CoachService:
|
|||||||
"""Check the health of all coach subsystems."""
|
"""Check the health of all coach subsystems."""
|
||||||
status = CoachStatus()
|
status = CoachStatus()
|
||||||
|
|
||||||
if COACH_PROVIDER == "anthropic":
|
# Check LLM
|
||||||
anthropic_key = ANTHROPIC_API_KEY
|
llm_status = self.llm.check_connection()
|
||||||
if anthropic_key:
|
status.llmConnected = llm_status.get("connected", False)
|
||||||
status.llmConnected = True
|
status.llmModel = llm_status.get("active_model")
|
||||||
status.llmModel = f"Claude Opus ({ANTHROPIC_MODEL})"
|
status.llmModels = llm_status.get("models", [])
|
||||||
status.llmModels = [ANTHROPIC_MODEL]
|
if not status.llmConnected:
|
||||||
else:
|
status.error = llm_status.get("error", "LM Studio not reachable")
|
||||||
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 embeddings
|
||||||
status.embeddingsLoaded = self.embeddings.loaded
|
status.embeddingsLoaded = self.embeddings.loaded
|
||||||
status.embeddingCards = self.embeddings.card_count
|
status.embeddingCards = self.embeddings.card_count
|
||||||
|
|
||||||
|
# Check deck reports
|
||||||
status.deckReportsAvailable = len(self.list_deck_reports())
|
status.deckReportsAvailable = len(self.list_deck_reports())
|
||||||
|
|
||||||
return status
|
return status
|
||||||
+27
-30
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Commander AI Lab — Coach Configuration
|
Commander AI Lab — Coach Configuration
|
||||||
────────────────────────────────────────
|
═══════════════════════════════════════
|
||||||
Central config for Ollama, embeddings, and coach service paths.
|
Central config for Ollama, embeddings, and coach service paths.
|
||||||
All paths are relative to the commander-ai-lab root directory.
|
All paths are relative to the commander-ai-lab root directory.
|
||||||
"""
|
"""
|
||||||
@@ -8,60 +8,57 @@ All paths are relative to the commander-ai-lab root directory.
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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))
|
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
|
||||||
|
|
||||||
# ── Ollama (OpenAI-compatible API) ────────────────────────────
|
# ── Ollama (OpenAI-compatible API) ──────────────────────
|
||||||
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
|
LLM_URL = os.environ.get("LLM_URL", "http://192.168.0.240:11434/v1")
|
||||||
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
||||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
||||||
LLM_MAX_RETRIES = 3
|
LLM_MAX_RETRIES = 3
|
||||||
|
|
||||||
# ── Embeddings ───────────────────────────────────────────────────
|
# ── Embeddings ─────────────────────────────────────────────
|
||||||
EMBEDDINGS_DIR = LAB_ROOT / "data"
|
EMBEDDINGS_DIR = LAB_ROOT / "data"
|
||||||
EMBEDDINGS_NPZ = EMBEDDINGS_DIR / "mtg-embeddings.npz"
|
EMBEDDINGS_NPZ = EMBEDDINGS_DIR / "mtg-embeddings.npz"
|
||||||
EMBEDDINGS_PARQUET = EMBEDDINGS_DIR / "mtg-embeddings.parquet"
|
EMBEDDINGS_PARQUET = EMBEDDINGS_DIR / "mtg-embeddings.parquet"
|
||||||
|
|
||||||
# ── Deck Reports ────────────────────────────────────────────────
|
# ── Deck Reports ───────────────────────────────────────────
|
||||||
DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
|
DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
|
||||||
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
|
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
|
||||||
|
|
||||||
# ── Prompt Limits ───────────────────────────────────────────────
|
# ── Prompt Limits ──────────────────────────────────────────
|
||||||
MAX_PROMPT_TOKENS = 2048
|
MAX_PROMPT_TOKENS = 2048
|
||||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 8
|
MAX_CANDIDATES_PER_UNDERPERFORMER = 5
|
||||||
MAX_UNDERPERFORMERS = 8
|
MAX_UNDERPERFORMERS = 4
|
||||||
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
||||||
|
|
||||||
# ── LLM Generation Settings (Coach — Ollama fallback) ─────────────
|
# ── LLM Generation Settings (Coach — Ollama) ──────────────
|
||||||
DEFAULT_TEMPERATURE = 0.7
|
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 ──────────────────────
|
# ── Perplexity Deck Generation Settings ───────────────────────
|
||||||
# Provider for deck generation: "anthropic" (Claude) or "local" (Ollama)
|
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
|
||||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "anthropic")
|
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
|
||||||
# Model used when DECK_GEN_PROVIDER="anthropic"
|
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
|
||||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "claude-opus-4-20250514")
|
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
|
||||||
# Anthropic API key
|
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://192.168.0.240:11434/v1")
|
||||||
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")
|
|
||||||
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
|
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", "16384"))
|
||||||
|
|
||||||
# ── Smart Substitution Settings ───────────────────────────────────
|
# ── Smart Substitution Settings ───────────────────────────────
|
||||||
# Minimum embedding similarity to accept a substitute without Anthropic fallback
|
# Minimum embedding similarity to accept a substitute without Perplexity fallback
|
||||||
SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY", "0.75"))
|
SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY", "0.75"))
|
||||||
# Maximum number of alternatives to suggest per missing card
|
# Maximum number of alternatives to suggest per missing card
|
||||||
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
|
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)
|
# Model for substitution fallback (sonar is fine — small focused queries)
|
||||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "claude-haiku-4-5")
|
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "gpt-oss:20b")
|
||||||
# Enable Anthropic fallback for low-confidence embedding matches
|
# Enable Perplexity fallback for low-confidence embedding matches
|
||||||
SUBSTITUTION_USE_ANTHROPIC_FALLBACK = os.environ.get("SUBSTITUTION_USE_ANTHROPIC_FALLBACK", "true").lower() == "true"
|
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
|
||||||
|
|
||||||
# ── Ensure directories exist ─────────────────────────────────────
|
# ── Ensure directories exist ───────────────────────────────
|
||||||
def ensure_dirs():
|
def ensure_dirs():
|
||||||
"""Create required directories if they don't exist."""
|
"""Create required directories if they don't exist."""
|
||||||
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
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)
|
Format: Parquet with 768-dim float32 embeddings per card (~32K cards)
|
||||||
Model: Alibaba-NLP/gte-modernbert-base
|
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).
|
Uses NumPy brute-force cosine similarity — fast enough for ~32K cards (<100ms).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -24,7 +19,6 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
from urllib.request import urlopen, Request
|
from urllib.request import urlopen, Request
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -38,16 +32,8 @@ HF_PARQUET_URL = (
|
|||||||
"resolve/main/mtg_embeddings.parquet"
|
"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 ─────────────────────────────────
|
# ── Color identity parsing ─────────────────────────────────
|
||||||
|
# Maps mana symbols like {W}, {U}, {B}, {R}, {G} to color letters
|
||||||
MANA_SYMBOL_COLORS = {
|
MANA_SYMBOL_COLORS = {
|
||||||
"W": "W", "U": "U", "B": "B", "R": "R", "G": "G"
|
"W": "W", "U": "U", "B": "B", "R": "R", "G": "G"
|
||||||
}
|
}
|
||||||
@@ -60,6 +46,7 @@ def _parse_color_identity(mana_cost: str) -> str:
|
|||||||
colors = set()
|
colors = set()
|
||||||
for match in re.finditer(r'\{([WUBRG])\}', mana_cost or ""):
|
for match in re.finditer(r'\{([WUBRG])\}', mana_cost or ""):
|
||||||
colors.add(match.group(1))
|
colors.add(match.group(1))
|
||||||
|
# Sort in WUBRG order
|
||||||
order = "WUBRG"
|
order = "WUBRG"
|
||||||
return "".join(c for c in order if c in colors)
|
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:
|
if symbol in MANA_SYMBOL_COLORS:
|
||||||
total += 1.0
|
total += 1.0
|
||||||
elif symbol == "X":
|
elif symbol == "X":
|
||||||
pass
|
pass # X = 0 for CMC purposes
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
total += float(symbol)
|
total += float(symbol)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
# Hybrid/phyrexian symbols count as 1
|
||||||
total += 1.0
|
total += 1.0
|
||||||
return total
|
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."""
|
"""Check if card's color identity is within deck's color identity."""
|
||||||
deck_set = set(deck_colors)
|
deck_set = set(deck_colors)
|
||||||
card_set = set(card_colors)
|
card_set = set(card_colors)
|
||||||
return card_set.issubset(deck_set) or len(card_set) == 0
|
return card_set.issubset(deck_set) or len(card_set) == 0 # colorless always allowed
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class CardMatch:
|
class CardMatch:
|
||||||
@@ -161,17 +114,15 @@ class MTGEmbeddingIndex:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.names: np.ndarray = None
|
self.names: np.ndarray = None # (N,) string array
|
||||||
self.colors: np.ndarray = None
|
self.colors: np.ndarray = None # (N,) string array (e.g., "WU", "BRG")
|
||||||
self.mana_values: np.ndarray = None
|
self.mana_values: np.ndarray = None # (N,) float array
|
||||||
self.types: np.ndarray = None
|
self.types: np.ndarray = None # (N,) string array
|
||||||
self.mana_costs: np.ndarray = None
|
self.mana_costs: np.ndarray = None # (N,) string array
|
||||||
self.texts: np.ndarray = None
|
self.texts: np.ndarray = None # (N,) string array
|
||||||
self.vectors: np.ndarray = None
|
self.vectors: np.ndarray = None # (N, 768) float32, L2-normalized
|
||||||
self._name_to_idx: dict = {}
|
self._name_to_idx: dict = {}
|
||||||
self._loaded = False
|
self._loaded = False
|
||||||
self._sentence_model = None # lazy-loaded only when needed
|
|
||||||
self._custom_dirty = False # tracks whether custom cache needs saving
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def loaded(self) -> bool:
|
def loaded(self) -> bool:
|
||||||
@@ -201,7 +152,10 @@ class MTGEmbeddingIndex:
|
|||||||
return EMBEDDINGS_PARQUET
|
return EMBEDDINGS_PARQUET
|
||||||
|
|
||||||
def convert_parquet_to_npz(self) -> Path:
|
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:
|
try:
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -215,11 +169,14 @@ class MTGEmbeddingIndex:
|
|||||||
types = df["type"].fillna("").values.astype(str)
|
types = df["type"].fillna("").values.astype(str)
|
||||||
texts = df["text"].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])
|
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)
|
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)
|
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 = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
||||||
norms[norms == 0] = 1.0
|
norms[norms == 0] = 1.0
|
||||||
embeddings = embeddings / norms
|
embeddings = embeddings / norms
|
||||||
@@ -241,27 +198,25 @@ class MTGEmbeddingIndex:
|
|||||||
return EMBEDDINGS_NPZ
|
return EMBEDDINGS_NPZ
|
||||||
|
|
||||||
def load(self, force_download: bool = False) -> bool:
|
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.
|
and converting from Parquet.
|
||||||
"""
|
"""
|
||||||
if self._loaded and not force_download:
|
if self._loaded and not force_download:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Try NPZ first (fast path)
|
||||||
if EMBEDDINGS_NPZ.exists() and not force_download:
|
if EMBEDDINGS_NPZ.exists() and not force_download:
|
||||||
ok = self._load_npz()
|
return 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
|
|
||||||
|
|
||||||
if ok:
|
# Download and convert
|
||||||
self._load_custom_cache()
|
try:
|
||||||
|
self.download_parquet(force=force_download)
|
||||||
return ok
|
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:
|
def _load_npz(self) -> bool:
|
||||||
"""Load from pre-built NPZ file."""
|
"""Load from pre-built NPZ file."""
|
||||||
@@ -275,6 +230,7 @@ class MTGEmbeddingIndex:
|
|||||||
self.texts = data.get("texts", np.array([""] * len(self.names)))
|
self.texts = data.get("texts", np.array([""] * len(self.names)))
|
||||||
self.vectors = data["vectors"]
|
self.vectors = data["vectors"]
|
||||||
|
|
||||||
|
# Build name lookup
|
||||||
self._name_to_idx = {
|
self._name_to_idx = {
|
||||||
name.lower(): i for i, name in enumerate(self.names)
|
name.lower(): i for i, name in enumerate(self.names)
|
||||||
}
|
}
|
||||||
@@ -285,168 +241,12 @@ class MTGEmbeddingIndex:
|
|||||||
logger.error("Failed to load NPZ: %s", e)
|
logger.error("Failed to load NPZ: %s", e)
|
||||||
return False
|
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]:
|
def get_card_vector(self, card_name: str) -> Optional[np.ndarray]:
|
||||||
"""Get the embedding vector for a card by name (case-insensitive).
|
"""Get the embedding vector for a card by name (case-insensitive)."""
|
||||||
|
|
||||||
On a cache miss, fetches from Scryfall and embeds on the fly.
|
|
||||||
"""
|
|
||||||
idx = self._name_to_idx.get(card_name.lower())
|
idx = self._name_to_idx.get(card_name.lower())
|
||||||
if idx is not None:
|
if idx is not None:
|
||||||
return self.vectors[idx]
|
return self.vectors[idx]
|
||||||
|
return None
|
||||||
# Cache miss — try Scryfall fallback
|
|
||||||
return self._fetch_and_embed_card(card_name)
|
|
||||||
|
|
||||||
def search_similar(self, query_card: str,
|
def search_similar(self, query_card: str,
|
||||||
color_filter: List[str] = None,
|
color_filter: List[str] = None,
|
||||||
@@ -474,32 +274,38 @@ class MTGEmbeddingIndex:
|
|||||||
|
|
||||||
query_vec = self.get_card_vector(query_card)
|
query_vec = self.get_card_vector(query_card)
|
||||||
if query_vec is None:
|
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 []
|
return []
|
||||||
|
|
||||||
|
# Compute similarities (dot product on normalized vectors = cosine similarity)
|
||||||
similarities = self.vectors @ query_vec
|
similarities = self.vectors @ query_vec
|
||||||
|
|
||||||
|
# Build exclusion set
|
||||||
exclude_set = set()
|
exclude_set = set()
|
||||||
exclude_set.add(query_card.lower())
|
exclude_set.add(query_card.lower())
|
||||||
if exclude_cards:
|
if exclude_cards:
|
||||||
exclude_set.update(name.lower() for name in exclude_cards)
|
exclude_set.update(name.lower() for name in exclude_cards)
|
||||||
|
|
||||||
|
# Filter and collect results
|
||||||
results = []
|
results = []
|
||||||
for idx in np.argsort(similarities)[::-1]:
|
for idx in np.argsort(similarities)[::-1]:
|
||||||
name = str(self.names[idx])
|
name = str(self.names[idx])
|
||||||
if name.lower() in exclude_set:
|
if name.lower() in exclude_set:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Color filter
|
||||||
if color_filter:
|
if color_filter:
|
||||||
card_colors = str(self.colors[idx])
|
card_colors = str(self.colors[idx])
|
||||||
if not _is_color_subset(card_colors, color_filter):
|
if not _is_color_subset(card_colors, color_filter):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Type filter
|
||||||
if type_filter:
|
if type_filter:
|
||||||
card_type = str(self.types[idx])
|
card_type = str(self.types[idx])
|
||||||
if type_filter.lower() not in card_type.lower():
|
if type_filter.lower() not in card_type.lower():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Mana value range filter
|
||||||
if mana_range:
|
if mana_range:
|
||||||
mv = float(self.mana_values[idx])
|
mv = float(self.mana_values[idx])
|
||||||
if mv < mana_range[0] or mv > mana_range[1]:
|
if mv < mana_range[0] or mv > mana_range[1]:
|
||||||
|
|||||||
+130
-127
@@ -1,25 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
Commander AI Lab — Ollama LLM Client
|
Commander AI Lab — Ollama Client
|
||||||
════════════════════════════════════
|
════════════════════════════════════
|
||||||
Fully async HTTP client targeting Ollama's OpenAI-compatible
|
Async HTTP client targeting Ollama's OpenAI-compatible
|
||||||
/v1/chat/completions endpoint. Every network call uses
|
/v1/chat/completions endpoint. Includes retry logic for
|
||||||
httpx.AsyncClient so it NEVER blocks the FastAPI event loop.
|
non-JSON responses and configurable timeout.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.request import urlopen, Request
|
||||||
import httpx
|
from urllib.error import URLError
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
LLM_URL,
|
LLM_URL, LLM_MODEL,
|
||||||
LLM_MODEL,
|
LLM_TIMEOUT, LLM_MAX_RETRIES,
|
||||||
LLM_TIMEOUT,
|
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
||||||
LLM_MAX_RETRIES,
|
|
||||||
DEFAULT_TEMPERATURE,
|
|
||||||
DEFAULT_MAX_TOKENS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("coach.llm")
|
logger = logging.getLogger("coach.llm")
|
||||||
@@ -27,15 +24,9 @@ logger = logging.getLogger("coach.llm")
|
|||||||
|
|
||||||
class LLMResponse:
|
class LLMResponse:
|
||||||
"""Parsed LLM response container."""
|
"""Parsed LLM response container."""
|
||||||
|
def __init__(self, content: str, parsed_json: Optional[dict],
|
||||||
def __init__(
|
prompt_tokens: int, completion_tokens: int,
|
||||||
self,
|
model: str):
|
||||||
content: str,
|
|
||||||
parsed_json: Optional[dict],
|
|
||||||
prompt_tokens: int,
|
|
||||||
completion_tokens: int,
|
|
||||||
model: str,
|
|
||||||
):
|
|
||||||
self.content = content
|
self.content = content
|
||||||
self.parsed_json = parsed_json
|
self.parsed_json = parsed_json
|
||||||
self.prompt_tokens = prompt_tokens
|
self.prompt_tokens = prompt_tokens
|
||||||
@@ -45,7 +36,7 @@ class LLMResponse:
|
|||||||
|
|
||||||
class LLMClient:
|
class LLMClient:
|
||||||
"""
|
"""
|
||||||
Fully async client for Ollama's OpenAI-compatible API.
|
Client for Ollama's OpenAI-compatible API.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
client = LLMClient()
|
client = LLMClient()
|
||||||
@@ -54,25 +45,19 @@ class LLMClient:
|
|||||||
suggestions = response.parsed_json
|
suggestions = response.parsed_json
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, base_url: str = None, model: str = None,
|
||||||
self,
|
timeout: int = None):
|
||||||
base_url: str = None,
|
|
||||||
model: str = None,
|
|
||||||
timeout: int = None,
|
|
||||||
):
|
|
||||||
self.base_url = (base_url or LLM_URL).rstrip("/")
|
self.base_url = (base_url or LLM_URL).rstrip("/")
|
||||||
self.model = model or LLM_MODEL
|
self.model = model or LLM_MODEL
|
||||||
self.timeout = timeout or LLM_TIMEOUT
|
self.timeout = timeout or LLM_TIMEOUT
|
||||||
self._resolved_model: Optional[str] = None
|
self._resolved_model = None # actual model name from /models endpoint
|
||||||
|
|
||||||
# ── Model resolution ───────────────────────────────────────
|
def _resolve_model(self) -> str:
|
||||||
|
|
||||||
async def _resolve_model(self) -> str:
|
|
||||||
"""Get the actual loaded model name from Ollama /models endpoint."""
|
"""Get the actual loaded model name from Ollama /models endpoint."""
|
||||||
if self._resolved_model:
|
if self._resolved_model:
|
||||||
return self._resolved_model
|
return self._resolved_model
|
||||||
try:
|
try:
|
||||||
status = await self.check_connection()
|
status = self.check_connection()
|
||||||
if status.get("connected") and status.get("active_model"):
|
if status.get("connected") and status.get("active_model"):
|
||||||
self._resolved_model = status["active_model"]
|
self._resolved_model = status["active_model"]
|
||||||
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
||||||
@@ -81,18 +66,11 @@ class LLMClient:
|
|||||||
pass
|
pass
|
||||||
return self.model
|
return self.model
|
||||||
|
|
||||||
# ── Request building ──────────────────────────────────────
|
def _build_request_body(self, system_prompt: str, user_prompt: str,
|
||||||
|
temperature: float, max_tokens: int,
|
||||||
async def _build_request_body(
|
force_json: bool) -> dict:
|
||||||
self,
|
model_name = self._resolve_model()
|
||||||
system_prompt: str,
|
body = {
|
||||||
user_prompt: str,
|
|
||||||
temperature: float,
|
|
||||||
max_tokens: int,
|
|
||||||
force_json: bool,
|
|
||||||
) -> dict:
|
|
||||||
model_name = await self._resolve_model()
|
|
||||||
body: dict = {
|
|
||||||
"model": model_name,
|
"model": model_name,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
@@ -101,27 +79,32 @@ class LLMClient:
|
|||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"max_tokens": max_tokens,
|
"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:
|
if force_json:
|
||||||
body["response_format"] = {"type": "json_object"}
|
body["response_format"] = {"type": "json_object"}
|
||||||
return body
|
return body
|
||||||
|
|
||||||
# ── Response parsing ──────────────────────────────────────
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _strip_think_tags(text: str) -> str:
|
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
|
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
|
@staticmethod
|
||||||
def _extract_json_object(text: str) -> Optional[dict]:
|
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('{')
|
start = text.find('{')
|
||||||
if start == -1:
|
if start == -1:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
depth = 0
|
depth = 0
|
||||||
in_string = False
|
in_string = False
|
||||||
escape_next = False
|
escape_next = False
|
||||||
|
|
||||||
for i in range(start, len(text)):
|
for i in range(start, len(text)):
|
||||||
ch = text[i]
|
ch = text[i]
|
||||||
if escape_next:
|
if escape_next:
|
||||||
@@ -141,56 +124,66 @@ class LLMClient:
|
|||||||
elif ch == '}':
|
elif ch == '}':
|
||||||
depth -= 1
|
depth -= 1
|
||||||
if depth == 0:
|
if depth == 0:
|
||||||
candidate = text[start : i + 1]
|
candidate = text[start:i+1]
|
||||||
try:
|
try:
|
||||||
return json.loads(candidate)
|
return json.loads(candidate)
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
# Malformed — try next opening brace
|
||||||
next_start = text.find('{', start + 1)
|
next_start = text.find('{', start + 1)
|
||||||
if next_start != -1 and next_start < i:
|
if next_start != -1 and next_start < i:
|
||||||
start = next_start
|
start = next_start
|
||||||
depth = 1
|
depth = 1 # we're already past this brace
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _parse_response(self, raw: dict) -> LLMResponse:
|
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]
|
choice = raw.get("choices", [{}])[0]
|
||||||
content = choice.get("message", {}).get("content", "")
|
content = choice.get("message", {}).get("content", "")
|
||||||
usage = raw.get("usage", {})
|
usage = raw.get("usage", {})
|
||||||
|
|
||||||
|
# Step 1: Strip <think>...</think> blocks (DeepSeek-R1)
|
||||||
cleaned = self._strip_think_tags(content)
|
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:
|
try:
|
||||||
parsed = json.loads(cleaned)
|
parsed = json.loads(cleaned)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
# 2. Markdown fences
|
|
||||||
|
# Step 3: Try extracting from markdown fences
|
||||||
if parsed is None and "```json" in cleaned:
|
if parsed is None and "```json" in cleaned:
|
||||||
try:
|
try:
|
||||||
s = cleaned.index("```json") + 7
|
start = cleaned.index("```json") + 7
|
||||||
e = cleaned.index("```", s)
|
end = cleaned.index("```", start)
|
||||||
parsed = json.loads(cleaned[s:e].strip())
|
parsed = json.loads(cleaned[start:end].strip())
|
||||||
except (json.JSONDecodeError, ValueError, IndexError):
|
except (json.JSONDecodeError, ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
if parsed is None and "```" in cleaned:
|
if parsed is None and "```" in cleaned:
|
||||||
try:
|
try:
|
||||||
s = cleaned.index("```") + 3
|
start = cleaned.index("```") + 3
|
||||||
e = cleaned.index("```", s)
|
end = cleaned.index("```", start)
|
||||||
parsed = json.loads(cleaned[s:e].strip())
|
parsed = json.loads(cleaned[start:end].strip())
|
||||||
except (json.JSONDecodeError, ValueError, IndexError):
|
except (json.JSONDecodeError, ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
# 3. Brace-counting extraction
|
|
||||||
|
# Step 4: Brute-force — find the first valid JSON object in the text
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
parsed = self._extract_json_object(cleaned)
|
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:
|
if parsed is None and content != cleaned:
|
||||||
parsed = self._extract_json_object(content)
|
parsed = self._extract_json_object(content)
|
||||||
|
|
||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
logger.info("Successfully parsed JSON from LLM response")
|
logger.info("Successfully parsed JSON from LLM response")
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning("Could not extract JSON from LLM response (%d chars)",
|
||||||
"Could not extract JSON from LLM response (%d chars)", len(content)
|
len(content))
|
||||||
)
|
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=content,
|
content=content,
|
||||||
parsed_json=parsed,
|
parsed_json=parsed,
|
||||||
@@ -199,84 +192,94 @@ class LLMClient:
|
|||||||
model=raw.get("model", self.model),
|
model=raw.get("model", self.model),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Public API ─────────────────────────────────────────────
|
def chat_sync(self, system_prompt: str, user_prompt: str,
|
||||||
|
temperature: float = None, max_tokens: int = None,
|
||||||
async def chat(
|
force_json: bool = True) -> LLMResponse:
|
||||||
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.
|
Synchronous chat call to Ollama.
|
||||||
Never blocks the event loop — uses httpx.AsyncClient throughout.
|
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
|
||||||
"""
|
"""
|
||||||
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
||||||
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
||||||
body = await self._build_request_body(
|
body = self._build_request_body(system_prompt, user_prompt,
|
||||||
system_prompt, user_prompt, temp, tokens, force_json
|
temp, tokens, force_json)
|
||||||
)
|
|
||||||
last_error: Optional[Exception] = None
|
last_error = None
|
||||||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||||
try:
|
try:
|
||||||
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
|
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
|
||||||
async with httpx.AsyncClient(
|
req = Request(
|
||||||
timeout=httpx.Timeout(float(self.timeout))
|
f"{self.base_url}/chat/completions",
|
||||||
) as client:
|
data=json.dumps(body).encode("utf-8"),
|
||||||
resp = await client.post(
|
headers={"Content-Type": "application/json"},
|
||||||
f"{self.base_url}/chat/completions",
|
method="POST",
|
||||||
json=body,
|
)
|
||||||
headers={"Content-Type": "application/json"},
|
with urlopen(req, timeout=self.timeout) as resp:
|
||||||
)
|
raw = json.loads(resp.read().decode("utf-8"))
|
||||||
resp.raise_for_status()
|
|
||||||
raw = resp.json()
|
|
||||||
result = self._parse_response(raw)
|
result = self._parse_response(raw)
|
||||||
|
|
||||||
|
# If we need JSON and didn't get it, retry
|
||||||
if force_json and result.parsed_json is None:
|
if force_json and result.parsed_json is None:
|
||||||
logger.warning(
|
logger.warning("Attempt %d: LLM returned non-JSON, retrying", attempt)
|
||||||
"Attempt %d: LLM returned non-JSON, retrying", attempt
|
|
||||||
)
|
|
||||||
last_error = ValueError("Non-JSON response from LLM")
|
last_error = ValueError("Non-JSON response from LLM")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return result
|
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:
|
except Exception as e:
|
||||||
logger.warning("Attempt %d failed: %s", attempt, e)
|
logger.warning("Attempt %d failed: %s", attempt, e)
|
||||||
last_error = e
|
last_error = e
|
||||||
if attempt < LLM_MAX_RETRIES:
|
|
||||||
import asyncio
|
# If 400 and we used json response_format, retry without it
|
||||||
await asyncio.sleep(2 * attempt)
|
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 < LLM_MAX_RETRIES:
|
||||||
|
import time
|
||||||
|
time.sleep(2 * attempt) # Simple backoff
|
||||||
|
|
||||||
raise ConnectionError(
|
raise ConnectionError(
|
||||||
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
|
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def check_connection(self) -> dict:
|
async def chat(self, system_prompt: str, user_prompt: str,
|
||||||
"""Check if Ollama is reachable. Returns status dict."""
|
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 Ollama is reachable. Returns status dict.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
req = Request(
|
||||||
resp = await client.get(
|
f"{self.base_url}/models",
|
||||||
f"{self.base_url}/models",
|
headers={"Content-Type": "application/json"},
|
||||||
headers={"Content-Type": "application/json"},
|
method="GET",
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
with urlopen(req, timeout=5) as resp:
|
||||||
data = resp.json()
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
models = data.get("data", [])
|
models = data.get("data", [])
|
||||||
model_ids = [m.get("id", "unknown") for m in models]
|
model_ids = [m.get("id", "unknown") for m in models]
|
||||||
return {
|
return {
|
||||||
"connected": True,
|
"connected": True,
|
||||||
"models": model_ids,
|
"models": model_ids,
|
||||||
"active_model": model_ids[0] if model_ids else self.model,
|
"active_model": model_ids[0] if model_ids else self.model,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
"connected": False,
|
"connected": False,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Pydantic v2 models for deck reports, coach requests/responses,
|
|||||||
and coaching session persistence.
|
and coaching session persistence.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from pydantic import BaseModel, Field
|
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.
|
candidate replacement cards into a structured template.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
from .models import DeckReport, CoachGoals, CardPerformance
|
from .models import DeckReport, CoachGoals, CardPerformance
|
||||||
@@ -26,61 +27,36 @@ RULES:
|
|||||||
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
|
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
|
||||||
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
|
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
|
||||||
- When suggesting replacements, prefer cards that fill the same functional role.
|
- 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.
|
- 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).
|
- 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 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.
|
- 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}
|
{goals_section}
|
||||||
|
|
||||||
You MUST respond ONLY with valid JSON matching this exact structure:
|
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": [
|
"suggestedCuts": [
|
||||||
{{
|
{{
|
||||||
"cardName": "Card To Remove",
|
"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",
|
"reason": "Why this card underperforms",
|
||||||
"replacementOptions": ["Better Card 1", "Better Card 2"],
|
"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"
|
|
||||||
}}
|
}}
|
||||||
],
|
],
|
||||||
"suggestedAdds": [
|
"suggestedAdds": [
|
||||||
{{
|
{{
|
||||||
"cardName": "Card To Add",
|
"cardName": "Card To Add",
|
||||||
"role": "ramp|draw|removal|threat|utility|combo|protection",
|
"role": "ramp|draw|removal|threat|utility|combo|protection",
|
||||||
"reason": "Detailed explanation of why this card improves the deck, referencing specific weaknesses it addresses",
|
"reason": "Why this card improves the deck",
|
||||||
"synergyWith": ["Card In Deck 1", "Card In Deck 2"],
|
"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"
|
|
||||||
}}
|
}}
|
||||||
],
|
],
|
||||||
"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": [
|
"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",
|
"manaBaseAdvice": "Specific mana base improvement suggestion or null",
|
||||||
"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"
|
"rawTextExplanation": "Detailed paragraph explaining the analysis and reasoning"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
|
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 Data
|
||||||
{matchup_section}
|
{matchup_section}
|
||||||
|
|
||||||
### Archetype Win Rates
|
|
||||||
{archetype_section}
|
|
||||||
|
|
||||||
### Top Performing Cards (by Impact Score)
|
### Top Performing Cards (by Impact Score)
|
||||||
{top_cards}
|
{top_cards}
|
||||||
|
|
||||||
@@ -119,7 +92,6 @@ Avg Game Length: {avg_game_length:.1f} turns
|
|||||||
{candidate_section}
|
{candidate_section}
|
||||||
|
|
||||||
{combo_section}
|
{combo_section}
|
||||||
|
|
||||||
Based on this simulation data, provide your coaching suggestions."""
|
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.")
|
lines.append("- Keep suggestions casual and fun-focused.")
|
||||||
elif goals.targetPowerLevel >= 8:
|
elif goals.targetPowerLevel >= 8:
|
||||||
lines.append("- Prioritize efficiency and competitive staples.")
|
lines.append("- Prioritize efficiency and competitive staples.")
|
||||||
|
|
||||||
if goals.metaFocus:
|
if goals.metaFocus:
|
||||||
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
|
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
|
||||||
|
|
||||||
if goals.budget:
|
if goals.budget:
|
||||||
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
|
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
|
||||||
"no-limit": "no budget restrictions"}
|
"no-limit": "no budget restrictions"}
|
||||||
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
|
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
|
||||||
|
|
||||||
if goals.focusAreas:
|
if goals.focusAreas:
|
||||||
lines.append(f"- Focus improvement on: {', '.join(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."
|
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:
|
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)
|
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
|
||||||
lines = []
|
lines = []
|
||||||
for c in sorted_cards[:top_n]:
|
for c in sorted_cards[:top_n]:
|
||||||
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
|
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}, "
|
||||||
lines.append(
|
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}{tags}")
|
||||||
f"- {c.name}: impact={c.impactScore:.3f}, "
|
|
||||||
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
|
|
||||||
f"synergy={c.synergyScore:.3f}{turn_str}{tags}"
|
|
||||||
)
|
|
||||||
return "\n".join(lines) if lines else "No card performance data available."
|
return "\n".join(lines) if lines else "No card performance data available."
|
||||||
|
|
||||||
|
|
||||||
def _format_underperformers(cards: List[CardPerformance],
|
def _format_underperformers(cards: List[CardPerformance],
|
||||||
underperformer_names: List[str]) -> str:
|
underperformer_names: List[str]) -> str:
|
||||||
"""Format underperforming cards with full available stats."""
|
"""Format underperforming cards with their stats."""
|
||||||
card_map = {c.name: c for c in cards}
|
card_map = {c.name: c for c in cards}
|
||||||
lines = []
|
lines = []
|
||||||
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
|
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
|
||||||
c = card_map.get(name)
|
c = card_map.get(name)
|
||||||
if c:
|
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}, "
|
||||||
lines.append(
|
f"deadCardRate={c.deadCardRate:.1%}, "
|
||||||
f"- {c.name}: impact={c.impactScore:.3f}, "
|
f"castRate={c.castRate:.1%}, "
|
||||||
f"deadCardRate={c.deadCardRate:.1%}, "
|
f"clunkiness={c.clunkinessScore:.3f}")
|
||||||
f"castRate={c.castRate:.1%}, "
|
|
||||||
f"clunkiness={c.clunkinessScore:.3f}, "
|
|
||||||
f"synergy={c.synergyScore:.3f}, "
|
|
||||||
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
|
|
||||||
f"{turn_str}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
lines.append(f"- {name}: (no detailed stats)")
|
lines.append(f"- {name}: (no detailed stats)")
|
||||||
return "\n".join(lines) if lines else "No clear underperformers identified."
|
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:
|
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
||||||
"""Format replacement candidates grouped by the card they'd replace."""
|
"""Format replacement candidates grouped by the card they'd replace."""
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return (
|
return ("No embedding-based candidates available. "
|
||||||
"No embedding-based candidates available. "
|
"Use your MTG knowledge to suggest cards that fit the deck's "
|
||||||
"Use your MTG knowledge to suggest cards that fit the deck's "
|
"color identity, strategy, and missing functional roles. "
|
||||||
"color identity, strategy, and missing functional roles. "
|
"Suggest at least 3 different cards across different roles "
|
||||||
"Suggest at least 3 different cards across different roles "
|
"(ramp, draw, removal, threats, utility).")
|
||||||
"(ramp, draw, removal, threats, utility)."
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
|
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}]")
|
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
|
||||||
if text_preview:
|
if text_preview:
|
||||||
lines.append(f" Oracle: {text_preview}")
|
lines.append(f" Oracle: {text_preview}")
|
||||||
|
|
||||||
lines.append("\nChoose from these candidates when possible, but also suggest ")
|
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.")
|
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _format_matchups(report: DeckReport) -> str:
|
def _format_matchups(report: DeckReport) -> str:
|
||||||
"""Format per-opponent matchup data."""
|
"""Format matchup data."""
|
||||||
if not report.matchups:
|
if not report.matchups:
|
||||||
return "No matchup data available."
|
return "No matchup data available."
|
||||||
lines = []
|
lines = []
|
||||||
for m in report.matchups:
|
for m in report.matchups:
|
||||||
lines.append(
|
lines.append(f"- vs {m.opponentDeck}: {m.winRate:.1%} "
|
||||||
f"- vs {m.opponentDeck}: {m.winRate:.1%} "
|
f"({m.gamesPlayed} games)")
|
||||||
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%}")
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -255,10 +197,8 @@ def _format_combos(report: DeckReport) -> str:
|
|||||||
lines = ["### Known Combos"]
|
lines = ["### Known Combos"]
|
||||||
for combo in report.knownCombos:
|
for combo in report.knownCombos:
|
||||||
cards = " + ".join(combo.cardNames)
|
cards = " + ".join(combo.cardNames)
|
||||||
lines.append(
|
lines.append(f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
|
||||||
f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
|
f"assemblyRate={combo.assemblyRate:.1%}")
|
||||||
f"assemblyRate={combo.assemblyRate:.1%}"
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -299,7 +239,6 @@ def build_user_prompt(report: DeckReport,
|
|||||||
type_summary=", ".join(type_parts),
|
type_summary=", ".join(type_parts),
|
||||||
role_summary=", ".join(role_parts),
|
role_summary=", ".join(role_parts),
|
||||||
matchup_section=_format_matchups(report),
|
matchup_section=_format_matchups(report),
|
||||||
archetype_section=_format_archetypes(report),
|
|
||||||
top_cards=_format_top_cards(report.cards),
|
top_cards=_format_top_cards(report.cards),
|
||||||
underperformer_section=_format_underperformers(
|
underperformer_section=_format_underperformers(
|
||||||
report.cards, report.underperformers),
|
report.cards, report.underperformers),
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Commander AI Lab — Deck Generation User Prompt Builder
|
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 a DeckGenV3Request.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
def build_user_prompt(
|
def build_user_prompt(
|
||||||
@@ -36,8 +36,8 @@ def build_user_prompt(
|
|||||||
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
|
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"Build a complete 100-card Commander deck.",
|
f"Build a complete 100-card Commander deck.",
|
||||||
"",
|
f"",
|
||||||
f"COMMANDER: {commander}",
|
f"COMMANDER: {commander}",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Commander AI Lab — Substitution Prompt Templates
|
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)
|
output_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Load all batch results
|
# 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:
|
if not batch_files:
|
||||||
logger.info("No batch result files found in %s", results_dir)
|
logger.info("No batch result files found in %s", results_dir)
|
||||||
return []
|
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
|
# Also load existing batch results for comprehensive reports
|
||||||
results_dir = Path(batch_json_path).parent
|
results_dir = Path(batch_json_path).parent
|
||||||
all_batches = []
|
all_batches = []
|
||||||
for bf in sorted(results_dir.glob("ml-decision-*.json")):
|
for bf in sorted(results_dir.glob("batch-*.json")):
|
||||||
try:
|
try:
|
||||||
with open(bf, "r", encoding="utf-8") as f:
|
with open(bf, "r", encoding="utf-8") as f:
|
||||||
all_batches.append(json.load(f))
|
all_batches.append(json.load(f))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Commander AI Lab — Deck Generation JSON Schemas
|
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.
|
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 = {
|
DECK_CARD_SCHEMA = {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class CardAlternative(BaseModel):
|
|||||||
name: str = Field(description="Exact card name")
|
name: str = Field(description="Exact card name")
|
||||||
similarity_score: float = Field(default=0.0, description="Cosine similarity score (0-1)")
|
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")
|
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")
|
role_overlap: List[str] = Field(default_factory=list, description="Shared functional roles")
|
||||||
cmc_delta: float = Field(default=0.0, description="CMC difference from original")
|
cmc_delta: float = Field(default=0.0, description="CMC difference from original")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Commander AI Lab — V3 Deck Generator Service
|
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
|
structured JSON output, then runs ownership check
|
||||||
and smart substitution.
|
and smart substitution.
|
||||||
"""
|
"""
|
||||||
@@ -13,7 +13,7 @@ import sqlite3
|
|||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
from urllib.request import urlopen, Request
|
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.deck_schema import DECK_LIST_SCHEMA, GeneratedDeckList
|
||||||
from ..schemas.substitution_schema import (
|
from ..schemas.substitution_schema import (
|
||||||
CardAlternative, DeckCardWithStatus, SubstitutionBatchResult,
|
CardAlternative, DeckCardWithStatus, SubstitutionBatchResult,
|
||||||
@@ -26,7 +26,7 @@ from ..prompts.substitution_prompt import (
|
|||||||
from ..config import (
|
from ..config import (
|
||||||
DECK_GEN_MODEL, DECK_GEN_TEMPERATURE, DECK_GEN_MAX_TOKENS,
|
DECK_GEN_MODEL, DECK_GEN_TEMPERATURE, DECK_GEN_MAX_TOKENS,
|
||||||
SUBSTITUTION_MIN_SIMILARITY, SUBSTITUTION_MAX_ALTERNATIVES,
|
SUBSTITUTION_MIN_SIMILARITY, SUBSTITUTION_MAX_ALTERNATIVES,
|
||||||
SUBSTITUTION_MODEL, SUBSTITUTION_USE_ANTHROPIC_FALLBACK,
|
SUBSTITUTION_MODEL, SUBSTITUTION_USE_PPLX_FALLBACK,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("coach.deckgen")
|
logger = logging.getLogger("coach.deckgen")
|
||||||
@@ -34,27 +34,27 @@ logger = logging.getLogger("coach.deckgen")
|
|||||||
|
|
||||||
class DeckGeneratorV3:
|
class DeckGeneratorV3:
|
||||||
"""
|
"""
|
||||||
V3 Deck Generator using Anthropic Claude structured output.
|
V3 Deck Generator using Perplexity structured output.
|
||||||
|
|
||||||
Pipeline:
|
Pipeline:
|
||||||
1. Resolve commander via Scryfall
|
1. Resolve commander via Scryfall
|
||||||
2. Build collection summary
|
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
|
4. Validate card names via Scryfall
|
||||||
5. Check ownership against collection DB
|
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
|
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):
|
embedding_index=None):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
anthropic_client: Configured AnthropicClient instance
|
pplx_client: Configured PerplexityClient instance
|
||||||
db_conn_factory: Callable that returns sqlite3.Connection
|
db_conn_factory: Callable that returns sqlite3.Connection
|
||||||
embedding_index: Optional MTGEmbeddingIndex for substitution
|
embedding_index: Optional MTGEmbeddingIndex for substitution
|
||||||
"""
|
"""
|
||||||
self.anthropic = anthropic_client
|
self.pplx = pplx_client
|
||||||
self.db_conn_factory = db_conn_factory
|
self.db_conn_factory = db_conn_factory
|
||||||
self.embedding_index = embedding_index
|
self.embedding_index = embedding_index
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ class DeckGeneratorV3:
|
|||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
# ── Step 3: Generate Deck via Anthropic Claude ─────────────────
|
# ── Step 3: Generate Deck via Perplexity ─────────────────
|
||||||
def generate_deck(
|
def generate_deck(
|
||||||
self,
|
self,
|
||||||
commander_name: str,
|
commander_name: str,
|
||||||
@@ -193,7 +193,7 @@ class DeckGeneratorV3:
|
|||||||
|
|
||||||
Returns dict with:
|
Returns dict with:
|
||||||
- commander: dict with Scryfall data
|
- 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)
|
- cards: list of DeckCardWithStatus (after ownership check)
|
||||||
- stats: deck statistics
|
- stats: deck statistics
|
||||||
- tokens_used: {prompt, completion}
|
- tokens_used: {prompt, completion}
|
||||||
@@ -223,11 +223,11 @@ class DeckGeneratorV3:
|
|||||||
collection_summary=collection_block,
|
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
|
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,
|
system_prompt=SYSTEM_PROMPT,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
json_schema=DECK_LIST_SCHEMA,
|
json_schema=DECK_LIST_SCHEMA,
|
||||||
@@ -239,7 +239,7 @@ class DeckGeneratorV3:
|
|||||||
|
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Anthropic Claude returned invalid response: {response.content[:300]}"
|
f"Perplexity returned invalid response: {response.content[:300]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_deck = response.parsed_json
|
raw_deck = response.parsed_json
|
||||||
@@ -411,7 +411,7 @@ class DeckGeneratorV3:
|
|||||||
Pipeline:
|
Pipeline:
|
||||||
a) Identify missing cards
|
a) Identify missing cards
|
||||||
b) For each: try embedding similarity (free, fast)
|
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
|
d) Auto-select best substitute per card
|
||||||
|
|
||||||
Returns SubstitutionBatchResult with all cards updated.
|
Returns SubstitutionBatchResult with all cards updated.
|
||||||
@@ -443,7 +443,7 @@ class DeckGeneratorV3:
|
|||||||
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
|
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
|
||||||
|
|
||||||
# a) Embedding pass
|
# a) Embedding pass
|
||||||
needs_anthropic_fallback = []
|
needs_pplx_fallback = []
|
||||||
for card in missing:
|
for card in missing:
|
||||||
embedding_alts = self._embedding_substitution(
|
embedding_alts = self._embedding_substitution(
|
||||||
card, color_identity, available_pool
|
card, color_identity, available_pool
|
||||||
@@ -459,12 +459,12 @@ class DeckGeneratorV3:
|
|||||||
available_pool = [n for n in available_pool
|
available_pool = [n for n in available_pool
|
||||||
if n.lower() != card.selected_substitute.lower()]
|
if n.lower() != card.selected_substitute.lower()]
|
||||||
else:
|
else:
|
||||||
needs_anthropic_fallback.append(card)
|
needs_pplx_fallback.append(card)
|
||||||
|
|
||||||
# b) Anthropic Claude fallback for low-confidence matches
|
# b) Perplexity fallback for low-confidence matches
|
||||||
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
|
if needs_pplx_fallback and SUBSTITUTION_USE_PPLX_FALLBACK:
|
||||||
self._anthropic_substitution(
|
self._pplx_substitution(
|
||||||
needs_anthropic_fallback, available_pool,
|
needs_pplx_fallback, available_pool,
|
||||||
commander, strategy
|
commander, strategy
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -522,7 +522,7 @@ class DeckGeneratorV3:
|
|||||||
|
|
||||||
return alternatives
|
return alternatives
|
||||||
|
|
||||||
def _anthropic_substitution(
|
def _pplx_substitution(
|
||||||
self,
|
self,
|
||||||
missing_cards: List[DeckCardWithStatus],
|
missing_cards: List[DeckCardWithStatus],
|
||||||
available_pool: List[str],
|
available_pool: List[str],
|
||||||
@@ -530,7 +530,7 @@ class DeckGeneratorV3:
|
|||||||
strategy: str,
|
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.
|
Mutates the cards in-place with substitution data.
|
||||||
"""
|
"""
|
||||||
if not available_pool:
|
if not available_pool:
|
||||||
@@ -557,7 +557,7 @@ class DeckGeneratorV3:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from ..schemas.substitution_schema import SUBSTITUTION_RESPONSE_SCHEMA
|
from ..schemas.substitution_schema import SUBSTITUTION_RESPONSE_SCHEMA
|
||||||
response = self.anthropic.chat_structured(
|
response = self.pplx.chat_structured(
|
||||||
system_prompt=SUBSTITUTION_SYSTEM,
|
system_prompt=SUBSTITUTION_SYSTEM,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
json_schema=SUBSTITUTION_RESPONSE_SCHEMA,
|
json_schema=SUBSTITUTION_RESPONSE_SCHEMA,
|
||||||
@@ -568,11 +568,11 @@ class DeckGeneratorV3:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
logger.warning("Anthropic Claude substitution fallback failed — non-JSON response")
|
logger.warning("Perplexity substitution fallback failed — non-JSON response")
|
||||||
return
|
return
|
||||||
|
|
||||||
subs = response.parsed_json.get("substitutions", [])
|
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
|
# Apply substitutions
|
||||||
card_by_name = {c.name.lower(): c for c in missing_cards}
|
card_by_name = {c.name.lower(): c for c in missing_cards}
|
||||||
@@ -584,20 +584,20 @@ class DeckGeneratorV3:
|
|||||||
if orig in card_by_name and substitute:
|
if orig in card_by_name and substitute:
|
||||||
card = card_by_name[orig]
|
card = card_by_name[orig]
|
||||||
# Add as top alternative
|
# Add as top alternative
|
||||||
anthropic_alt = CardAlternative(
|
pplx_alt = CardAlternative(
|
||||||
name=substitute,
|
name=substitute,
|
||||||
similarity_score=0.80, # Nominal score for Anthropic Claude suggestions
|
similarity_score=0.80, # Nominal score for Perplexity suggestions
|
||||||
reason=reason,
|
reason=reason,
|
||||||
source="Anthropic Claude",
|
source="perplexity",
|
||||||
role_overlap=sub.get("role_overlap", []),
|
role_overlap=sub.get("role_overlap", []),
|
||||||
)
|
)
|
||||||
# Insert at front
|
# Insert at front
|
||||||
card.alternatives.insert(0, anthropic_alt)
|
card.alternatives.insert(0, pplx_alt)
|
||||||
card.selected_substitute = substitute
|
card.selected_substitute = substitute
|
||||||
card.status = "substituted"
|
card.status = "substituted"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Anthropic Claude substitution fallback error: %s", e)
|
logger.error("Perplexity substitution fallback error: %s", e)
|
||||||
|
|
||||||
# ── Stats ────────────────────────────────────────────────
|
# ── Stats ────────────────────────────────────────────────
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ if (-not (Test-Path $envFile)) {
|
|||||||
# Uncomment and fill in the values you need:
|
# Uncomment and fill in the values you need:
|
||||||
|
|
||||||
# XIMILAR_API_KEY=your_ximilar_key_here
|
# XIMILAR_API_KEY=your_ximilar_key_here
|
||||||
# ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
# PPLX_API_KEY=your_perplexity_key_here
|
||||||
# FORGE_JAR=C:\Path\To\forge-gui-desktop.jar
|
# FORGE_JAR=C:\Path\To\forge-gui-desktop.jar
|
||||||
# FORGE_DIR=C:\Path\To\Forge
|
# FORGE_DIR=C:\Path\To\Forge
|
||||||
# LAB_PORT=8080
|
# LAB_PORT=8080
|
||||||
|
|||||||
+4
-13
@@ -36,12 +36,11 @@ from routes.deepseek import router as deepseek_router
|
|||||||
from routes.deckgen import router as deckgen_router
|
from routes.deckgen import router as deckgen_router
|
||||||
from routes.coach import router as coach_router, init_coach_service
|
from routes.coach import router as coach_router, init_coach_service
|
||||||
from routes.ml import router as ml_router
|
from routes.ml import router as ml_router
|
||||||
from routes.ws_game import router as ws_game_router
|
|
||||||
|
|
||||||
log = logging.getLogger("commander_ai_lab.api")
|
log = logging.getLogger("commander_ai_lab.api")
|
||||||
|
|
||||||
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
|
|
||||||
# ── CORS origins (env-configurable) ────────────────────────────
|
# ── CORS origins (env-configurable) ────────────────────────────
|
||||||
|
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080,http://192.168.0.240:5173,http://192.168.0.240:8080"
|
||||||
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
||||||
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
||||||
|
|
||||||
@@ -64,7 +63,6 @@ app.include_router(scanner_router)
|
|||||||
app.include_router(deepseek_router)
|
app.include_router(deepseek_router)
|
||||||
app.include_router(deckgen_router)
|
app.include_router(deckgen_router)
|
||||||
app.include_router(coach_router)
|
app.include_router(coach_router)
|
||||||
app.include_router(ws_game_router)
|
|
||||||
app.include_router(ml_router)
|
app.include_router(ml_router)
|
||||||
|
|
||||||
|
|
||||||
@@ -86,13 +84,6 @@ async def _on_startup():
|
|||||||
if not COMMANDER_META:
|
if not COMMANDER_META:
|
||||||
load_commander_meta()
|
load_commander_meta()
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
|
||||||
async def _on_shutdown():
|
|
||||||
"""Close the async SQLite connection pool."""
|
|
||||||
from services.async_db import async_close_db
|
|
||||||
await async_close_db()
|
|
||||||
|
|
||||||
# ── Static UI ──────────────────────────────────────────────────
|
# ── Static UI ──────────────────────────────────────────────────
|
||||||
_legacy_ui_dir = Path(__file__).parent / "ui"
|
_legacy_ui_dir = Path(__file__).parent / "ui"
|
||||||
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||||
@@ -121,7 +112,7 @@ def _parse_args():
|
|||||||
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
|
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
|
||||||
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
|
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
|
||||||
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
|
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
|
||||||
p.add_argument("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
|
p.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", ""))
|
||||||
p.add_argument("--verbose", "-v", action="store_true")
|
p.add_argument("--verbose", "-v", action="store_true")
|
||||||
return p.parse_args()
|
return p.parse_args()
|
||||||
|
|
||||||
@@ -289,7 +280,7 @@ def main():
|
|||||||
CFG.precon_dir = args.precon_dir or _resolve_precon_dir()
|
CFG.precon_dir = args.precon_dir or _resolve_precon_dir()
|
||||||
CFG.port = args.port
|
CFG.port = args.port
|
||||||
CFG.ximilar_api_key = args.ximilar_key
|
CFG.ximilar_api_key = args.ximilar_key
|
||||||
CFG.anthropic_api_key = args.anthropic_key
|
CFG.pplx_api_key = args.pplx_key
|
||||||
|
|
||||||
from services.forge_runner import get_java17
|
from services.forge_runner import get_java17
|
||||||
j17 = get_java17()
|
j17 = get_java17()
|
||||||
@@ -303,7 +294,7 @@ def main():
|
|||||||
log.info(f" Lab JAR: {CFG.lab_jar or 'NOT SET'}")
|
log.info(f" Lab JAR: {CFG.lab_jar or 'NOT SET'}")
|
||||||
log.info(f" Results: {CFG.results_dir}")
|
log.info(f" Results: {CFG.results_dir}")
|
||||||
log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}")
|
log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}")
|
||||||
log.info(f" Anthropic: {'configured' if CFG.anthropic_api_key else 'NOT SET'}")
|
log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET'}")
|
||||||
log.info(f" Java 17: {j17}")
|
log.info(f" Java 17: {j17}")
|
||||||
|
|
||||||
load_commander_meta()
|
load_commander_meta()
|
||||||
|
|||||||
+1
-25
@@ -16,7 +16,6 @@ Reuses `run_match`, `LearnedPolicy`, `HeuristicPolicy`,
|
|||||||
Usage:
|
Usage:
|
||||||
python -m ml.eval.elo_tracker
|
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 --episodes 50 --output data/elo_history.json
|
||||||
python -m ml.eval.elo_tracker --top-n 10
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -286,17 +285,12 @@ def run_generation_tournament(
|
|||||||
episodes_per_matchup: int = 50,
|
episodes_per_matchup: int = 50,
|
||||||
playstyle: str = "midrange",
|
playstyle: str = "midrange",
|
||||||
k_factor: float = DEFAULT_K,
|
k_factor: float = DEFAULT_K,
|
||||||
top_n: int = 10,
|
|
||||||
) -> EloResult:
|
) -> EloResult:
|
||||||
"""
|
"""
|
||||||
Discover generation checkpoints and run an ELO tournament.
|
Discover generation checkpoints and run an ELO tournament.
|
||||||
|
|
||||||
Looks for `gen-*/best_policy.pt` under checkpoint_dir.
|
Looks for `gen-*/best_policy.pt` under checkpoint_dir.
|
||||||
Also includes heuristic and random baselines.
|
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] = {
|
policies: Dict[str, object] = {
|
||||||
"heuristic": HeuristicPolicy(),
|
"heuristic": HeuristicPolicy(),
|
||||||
@@ -305,20 +299,7 @@ def run_generation_tournament(
|
|||||||
|
|
||||||
ckpt_path = Path(checkpoint_dir)
|
ckpt_path = Path(checkpoint_dir)
|
||||||
if ckpt_path.exists():
|
if ckpt_path.exists():
|
||||||
gen_dirs = sorted(ckpt_path.glob("gen-*"))
|
for gen_dir in 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"
|
model_path = gen_dir / "best_policy.pt"
|
||||||
if model_path.exists():
|
if model_path.exists():
|
||||||
name = gen_dir.name # e.g., "gen-001"
|
name = gen_dir.name # e.g., "gen-001"
|
||||||
@@ -382,10 +363,6 @@ def main():
|
|||||||
"--k-factor", type=float, default=32,
|
"--k-factor", type=float, default=32,
|
||||||
help="ELO K-factor (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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -396,7 +373,6 @@ def main():
|
|||||||
episodes_per_matchup=args.episodes,
|
episodes_per_matchup=args.episodes,
|
||||||
playstyle=args.playstyle,
|
playstyle=args.playstyle,
|
||||||
k_factor=args.k_factor,
|
k_factor=args.k_factor,
|
||||||
top_n=args.top_n,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Print results
|
# Print results
|
||||||
|
|||||||
@@ -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,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
|
|
||||||
@@ -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
|
For real game integration (Phase 9+), the Java sim would call the
|
||||||
policy server and feed back actual game states — this synthetic
|
policy server and feed back actual game states — this synthetic
|
||||||
version allows training to begin immediately.
|
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 logging
|
||||||
import warnings
|
|
||||||
import random
|
import random
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
@@ -48,12 +42,6 @@ from ml.training.decision_exporter import DecisionExporter
|
|||||||
|
|
||||||
logger = logging.getLogger("ml.self_play")
|
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
|
@dataclass
|
||||||
class PlayerState:
|
class PlayerState:
|
||||||
@@ -566,7 +554,6 @@ def collect_rollouts(
|
|||||||
total_wins = 0
|
total_wins = 0
|
||||||
total_losses = 0
|
total_losses = 0
|
||||||
total_draws = 0
|
total_draws = 0
|
||||||
warnings.warn(_TRAINING_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
|
|
||||||
total_steps = 0
|
total_steps = 0
|
||||||
total_rewards = 0.0
|
total_rewards = 0.0
|
||||||
|
|
||||||
|
|||||||
+4
-75
@@ -25,7 +25,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -43,9 +42,6 @@ DEFAULT_CONFIG = {
|
|||||||
"time_limit_hours": 11.5, # stop after this many hours (leave buffer for training)
|
"time_limit_hours": 11.5, # stop after this many hours (leave buffer for training)
|
||||||
"pause_between_batches": 10, # seconds to pause between batches
|
"pause_between_batches": 10, # seconds to pause between batches
|
||||||
"auto_train": True, # trigger ML training after all batches
|
"auto_train": True, # trigger ML training after all batches
|
||||||
"auto_update_weights": True, # run update_weights.py after sims
|
|
||||||
"weights_min_games": 10, # minimum games required for weight update
|
|
||||||
"weights_lr": 0.05, # learning rate for weight update
|
|
||||||
"train_epochs": 100, # training epochs
|
"train_epochs": 100, # training epochs
|
||||||
"train_lr": 0.001, # learning rate
|
"train_lr": 0.001, # learning rate
|
||||||
"train_batch_size": 256, # training batch size
|
"train_batch_size": 256, # training batch size
|
||||||
@@ -233,52 +229,6 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
|||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
|
||||||
def run_update_weights(cfg):
|
|
||||||
"""
|
|
||||||
Run ml.scripts.update_weights to nudge learned_weights.json from sim
|
|
||||||
decision logs. This feeds the deck builder's sim_insights injection.
|
|
||||||
|
|
||||||
Returns True on success, False on failure (non-fatal — overnight run
|
|
||||||
continues regardless).
|
|
||||||
"""
|
|
||||||
log.info("")
|
|
||||||
log.info("── Auto-update sim weights ─────────────────────────────────")
|
|
||||||
log.info("Running update_weights.py to refresh learned_weights.json...")
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
sys.executable, "-m", "ml.scripts.update_weights",
|
|
||||||
"--min-games", str(cfg["weights_min_games"]),
|
|
||||||
"--lr", str(cfg["weights_lr"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=120, # 2 min ceiling — this should complete in seconds
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
# Print the last ~800 chars of the weight report to the log
|
|
||||||
output = (result.stdout or "").strip()
|
|
||||||
if output:
|
|
||||||
for line in output.splitlines()[-20:]:
|
|
||||||
log.info(f" {line}")
|
|
||||||
log.info("Sim weights updated — deck builder will use new insights on next build.")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
log.warning(f"update_weights.py exited with code {result.returncode}")
|
|
||||||
if result.stderr:
|
|
||||||
log.warning(result.stderr.strip()[-300:])
|
|
||||||
return False
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
log.warning("update_weights.py timed out after 120s — skipping")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f"update_weights.py failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def start_training(base_url, epochs, lr, batch_size, patience):
|
def start_training(base_url, epochs, lr, batch_size, patience):
|
||||||
"""Trigger ML training and return immediately."""
|
"""Trigger ML training and return immediately."""
|
||||||
resp = api_post(base_url, "/api/ml/train", {
|
resp = api_post(base_url, "/api/ml/train", {
|
||||||
@@ -414,7 +364,6 @@ def run_overnight(cfg):
|
|||||||
else:
|
else:
|
||||||
log.info(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
|
log.info(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
|
||||||
log.info(f" Engine: {engine}")
|
log.info(f" Engine: {engine}")
|
||||||
log.info(f" Auto-update weights: {'Yes' if cfg['auto_update_weights'] else 'No'}")
|
|
||||||
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
|
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
|
||||||
log.info("")
|
log.info("")
|
||||||
|
|
||||||
@@ -491,17 +440,6 @@ def run_overnight(cfg):
|
|||||||
log.info(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
|
log.info(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
|
||||||
log.info("")
|
log.info("")
|
||||||
|
|
||||||
# ── Auto-update sim weights ───────────────────────────────
|
|
||||||
# Runs update_weights.py to nudge learned_weights.json from the
|
|
||||||
# decision logs produced during this run. This feeds sim_insights
|
|
||||||
# into the deck builder's Ollama prompts on the next deck build.
|
|
||||||
# Runs before neural net training so both pipelines benefit from
|
|
||||||
# the freshest possible data.
|
|
||||||
if cfg["auto_update_weights"]:
|
|
||||||
run_update_weights(cfg)
|
|
||||||
else:
|
|
||||||
log.info("Skipping sim weight update (--no-update-weights flag set)")
|
|
||||||
|
|
||||||
# ── Auto-train ────────────────────────────────────────────
|
# ── Auto-train ────────────────────────────────────────────
|
||||||
if cfg["auto_train"]:
|
if cfg["auto_train"]:
|
||||||
if decisions_after < 100:
|
if decisions_after < 100:
|
||||||
@@ -544,17 +482,17 @@ def main():
|
|||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog="""
|
epilog="""
|
||||||
Examples:
|
Examples:
|
||||||
# Run DeepSeek sims for 11.5 hours, then update weights + train:
|
# Run DeepSeek sims for 11.5 hours, then train:
|
||||||
python overnight-run.py
|
python overnight-run.py
|
||||||
|
|
||||||
# Run 20 batches of 50 games each, then update weights + train:
|
# Run 20 batches of 50 games each, then train:
|
||||||
python overnight-run.py --batches 20 --games 50
|
python overnight-run.py --batches 20 --games 50
|
||||||
|
|
||||||
# Run for 6 hours with Java engine:
|
# Run for 6 hours with Java engine:
|
||||||
python overnight-run.py --engine java --hours 6 --decks "Deck A" "Deck B" "Deck C"
|
python overnight-run.py --engine java --hours 6 --decks "Deck A" "Deck B" "Deck C"
|
||||||
|
|
||||||
# Just run sims, skip both weight update and training:
|
# Just run sims, no training:
|
||||||
python overnight-run.py --no-train --no-update-weights
|
python overnight-run.py --no-train
|
||||||
|
|
||||||
# Custom training settings:
|
# Custom training settings:
|
||||||
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
|
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
|
||||||
@@ -576,12 +514,6 @@ Examples:
|
|||||||
help="Deck names (auto-detected if not specified)")
|
help="Deck names (auto-detected if not specified)")
|
||||||
parser.add_argument("--no-train", action="store_true",
|
parser.add_argument("--no-train", action="store_true",
|
||||||
help="Skip auto-training after sims")
|
help="Skip auto-training after sims")
|
||||||
parser.add_argument("--no-update-weights", action="store_true",
|
|
||||||
help="Skip updating learned_weights.json after sims")
|
|
||||||
parser.add_argument("--weights-min-games", type=int, default=10,
|
|
||||||
help="Minimum games required for weight update (default: 10)")
|
|
||||||
parser.add_argument("--weights-lr", type=float, default=0.05,
|
|
||||||
help="Learning rate for sim weight update (default: 0.05)")
|
|
||||||
parser.add_argument("--epochs", type=int, default=100,
|
parser.add_argument("--epochs", type=int, default=100,
|
||||||
help="Training epochs (default: 100)")
|
help="Training epochs (default: 100)")
|
||||||
parser.add_argument("--lr", type=float, default=0.001,
|
parser.add_argument("--lr", type=float, default=0.001,
|
||||||
@@ -604,9 +536,6 @@ Examples:
|
|||||||
cfg["time_limit_hours"] = args.hours
|
cfg["time_limit_hours"] = args.hours
|
||||||
cfg["pause_between_batches"] = args.pause
|
cfg["pause_between_batches"] = args.pause
|
||||||
cfg["auto_train"] = not args.no_train
|
cfg["auto_train"] = not args.no_train
|
||||||
cfg["auto_update_weights"] = not args.no_update_weights
|
|
||||||
cfg["weights_min_games"] = args.weights_min_games
|
|
||||||
cfg["weights_lr"] = args.weights_lr
|
|
||||||
cfg["train_epochs"] = args.epochs
|
cfg["train_epochs"] = args.epochs
|
||||||
cfg["train_lr"] = args.lr
|
cfg["train_lr"] = args.lr
|
||||||
cfg["train_batch_size"] = args.batch_size
|
cfg["train_batch_size"] = args.batch_size
|
||||||
|
|||||||
@@ -7,18 +7,9 @@ uvicorn[standard]>=0.23.0
|
|||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
httpx>=0.24.0
|
httpx>=0.24.0
|
||||||
websockets>=11.0
|
websockets>=11.0
|
||||||
openai>=1.0.0
|
|
||||||
aiosqlite>=0.19.0
|
|
||||||
|
|
||||||
# Anthropic Claude API
|
|
||||||
anthropic>=0.40.0
|
|
||||||
|
|
||||||
# Deck Builder dependencies
|
# Deck Builder dependencies
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
beautifulsoup4>=4.12.0
|
beautifulsoup4>=4.12.0
|
||||||
ollama>=0.2.0
|
ollama>=0.2.0
|
||||||
scrython>=2.0.0
|
scrython>=2.0.0
|
||||||
|
|
||||||
# Embeddings
|
|
||||||
sentence-transformers>=3.0.0
|
|
||||||
torch>=2.0.0
|
|
||||||
+562
-428
File diff suppressed because it is too large.
Load diff
@@ -45,103 +45,6 @@ from services.scryfall import (
|
|||||||
router = APIRouter(tags=["collection"])
|
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")
|
@router.get("/api/collection/export")
|
||||||
async def export_collection(
|
async def export_collection(
|
||||||
format: str = "INTERNAL_CSV",
|
format: str = "INTERNAL_CSV",
|
||||||
@@ -158,17 +61,6 @@ async def export_collection(
|
|||||||
priceMin: Optional[float] = None,
|
priceMin: Optional[float] = None,
|
||||||
priceMax: Optional[float] = None,
|
priceMax: Optional[float] = None,
|
||||||
category: Optional[str] = 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."""
|
"""Export collection in various formats with optional filters."""
|
||||||
where_str, params = _build_collection_filters(
|
where_str, params = _build_collection_filters(
|
||||||
@@ -176,12 +68,6 @@ async def export_collection(
|
|||||||
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
|
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
|
||||||
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
|
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
|
||||||
priceMin=priceMin, priceMax=priceMax, category=category,
|
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"
|
sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC"
|
||||||
@@ -406,11 +292,6 @@ async def update_collection_card(cardId: int, body: dict):
|
|||||||
if finish_val not in ("NORMAL", "FOIL", "ETCHED"):
|
if finish_val not in ("NORMAL", "FOIL", "ETCHED"):
|
||||||
raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED")
|
raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED")
|
||||||
updates["finish"] = finish_val
|
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:
|
if not updates:
|
||||||
return _add_image_url(_row_to_dict(row))
|
return _add_image_url(_row_to_dict(row))
|
||||||
|
|||||||
+88
-194
@@ -1,14 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
routes/deckgen.py
|
routes/deckgen.py
|
||||||
=================
|
=================
|
||||||
Deck generation endpoints (classic, Anthropic Claude V1, V3):
|
Deck generation endpoints (classic, Perplexity V1, V3):
|
||||||
GET /api/deck-generator/config
|
GET /api/deck-generator/config
|
||||||
POST /api/deck-generator/preview
|
POST /api/deck-generator/preview
|
||||||
POST /api/deck-generator/commit
|
POST /api/deck-generator/commit
|
||||||
GET /api/deck-generator/commander-search
|
GET /api/deck-generator/commander-search
|
||||||
POST /api/deck-research
|
POST /api/deck-research
|
||||||
POST /api/deck-generate
|
POST /api/deck-generate
|
||||||
GET /api/anthropic/status
|
GET /api/pplx/status
|
||||||
GET /api/deck/v3/status
|
GET /api/deck/v3/status
|
||||||
POST /api/deck/v3/generate
|
POST /api/deck/v3/generate
|
||||||
POST /api/deck/v3/commit
|
POST /api/deck/v3/commit
|
||||||
@@ -33,13 +33,11 @@ import httpx
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from models.requests import DeckResearchRequest, DeckGenerateAIRequest
|
|
||||||
|
|
||||||
import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access
|
import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access
|
||||||
from models.state import CFG
|
from models.state import CFG
|
||||||
from models.requests import (
|
from models.requests import (
|
||||||
DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest,
|
DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest,
|
||||||
DeckGenerationSourceConfig,
|
DeckGenerationSourceConfig, DeckResearchRequest, DeckGenerateAIRequest,
|
||||||
)
|
)
|
||||||
from models.responses import GeneratedDeckCard
|
from models.responses import GeneratedDeckCard
|
||||||
from services.database import _get_db_conn
|
from services.database import _get_db_conn
|
||||||
@@ -49,44 +47,6 @@ from services.deck_service import _write_dck_file, _build_dck_lines
|
|||||||
|
|
||||||
router = APIRouter(tags=["deckgen"])
|
router = APIRouter(tags=["deckgen"])
|
||||||
|
|
||||||
# ── V3 Result Cache (avoids re-generating on export) ─────────────
|
|
||||||
import hashlib
|
|
||||||
_v3_result_cache: dict[str, tuple[float, dict]] = {} # key -> (timestamp, result)
|
|
||||||
_V3_CACHE_TTL = 600 # 10 minutes
|
|
||||||
|
|
||||||
def _v3_cache_key(req) -> str:
|
|
||||||
"""Deterministic cache key from a DeckGenV3Request."""
|
|
||||||
blob = json.dumps({
|
|
||||||
'commander': req.commander_name.strip().lower(),
|
|
||||||
'strategy': req.strategy or '',
|
|
||||||
'bracket': req.target_bracket,
|
|
||||||
'budget': req.budget_usd,
|
|
||||||
'budget_mode': req.budget_mode,
|
|
||||||
'omit': sorted(req.omit_cards or []),
|
|
||||||
'collection': req.use_collection,
|
|
||||||
'model': req.model or '',
|
|
||||||
}, sort_keys=True)
|
|
||||||
return hashlib.sha256(blob.encode()).hexdigest()[:16]
|
|
||||||
|
|
||||||
def _v3_cache_get(key: str) -> dict | None:
|
|
||||||
"""Return cached result if within TTL, else None."""
|
|
||||||
entry = _v3_result_cache.get(key)
|
|
||||||
if entry and (time.time() - entry[0]) < _V3_CACHE_TTL:
|
|
||||||
return entry[1]
|
|
||||||
if entry:
|
|
||||||
del _v3_result_cache[key]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _v3_cache_put(key: str, result: dict):
|
|
||||||
"""Store result in cache with current timestamp."""
|
|
||||||
# Evict stale entries if cache grows large
|
|
||||||
if len(_v3_result_cache) > 50:
|
|
||||||
cutoff = time.time() - _V3_CACHE_TTL
|
|
||||||
stale = [k for k, (ts, _) in _v3_result_cache.items() if ts < cutoff]
|
|
||||||
for k in stale:
|
|
||||||
del _v3_result_cache[k]
|
|
||||||
_v3_result_cache[key] = (time.time(), result)
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
# Auto Deck Generator
|
# Auto Deck Generator
|
||||||
@@ -103,7 +63,7 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
|
|||||||
# Try scryfall_id first
|
# Try scryfall_id first
|
||||||
if req.commander_scryfall_id:
|
if req.commander_scryfall_id:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT name, type_line, color_identity, mana_cost, scryfall_id, oracle_text FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
|
"SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
|
||||||
(req.commander_scryfall_id,)
|
(req.commander_scryfall_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
@@ -119,7 +79,6 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
|
|||||||
"color_identity": ci,
|
"color_identity": ci,
|
||||||
"type_line": row["type_line"],
|
"type_line": row["type_line"],
|
||||||
"mana_cost": row["mana_cost"] or "",
|
"mana_cost": row["mana_cost"] or "",
|
||||||
"oracle_text": row["oracle_text"] or "",
|
|
||||||
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
|
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
|
||||||
}
|
}
|
||||||
# Fallback to Scryfall API
|
# Fallback to Scryfall API
|
||||||
@@ -137,7 +96,7 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
|
|||||||
if req.commander_name:
|
if req.commander_name:
|
||||||
name_lower = req.commander_name.strip().lower()
|
name_lower = req.commander_name.strip().lower()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT name, type_line, color_identity, mana_cost, scryfall_id, oracle_text FROM collection_entries WHERE LOWER(name) = ? LIMIT 1",
|
"SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE LOWER(name) = ? LIMIT 1",
|
||||||
(name_lower,)
|
(name_lower,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
@@ -153,7 +112,6 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
|
|||||||
"color_identity": ci,
|
"color_identity": ci,
|
||||||
"type_line": row["type_line"],
|
"type_line": row["type_line"],
|
||||||
"mana_cost": row["mana_cost"] or "",
|
"mana_cost": row["mana_cost"] or "",
|
||||||
"oracle_text": row["oracle_text"] or "",
|
|
||||||
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
|
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +143,6 @@ def _scryfall_to_commander(data: dict) -> dict:
|
|||||||
"color_identity": ci,
|
"color_identity": ci,
|
||||||
"type_line": data.get("type_line", ""),
|
"type_line": data.get("type_line", ""),
|
||||||
"mana_cost": data.get("mana_cost", ""),
|
"mana_cost": data.get("mana_cost", ""),
|
||||||
"oracle_text": data.get("oracle_text", ""),
|
|
||||||
"image_url": image_uris.get("normal", ""),
|
"image_url": image_uris.get("normal", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,31 +193,6 @@ def _get_collection_for_colors(color_identity: list) -> list:
|
|||||||
return valid
|
return valid
|
||||||
|
|
||||||
|
|
||||||
# ── Oracle-text keyword extraction for scoring ────────────────────
|
|
||||||
_ORACLE_STOP_WORDS = frozenset({
|
|
||||||
'a', 'an', 'the', 'of', 'to', 'and', 'or', 'is', 'it', 'its',
|
|
||||||
'in', 'on', 'at', 'for', 'by', 'with', 'from', 'as', 'if',
|
|
||||||
'that', 'this', 'you', 'your', 'up', 'all', 'each', 'may',
|
|
||||||
'can', 'one', 'two', 'three', 'four', 'five', 'card', 'cards',
|
|
||||||
'target', 'player', 'creature', 'creatures', 'spell', 'spells',
|
|
||||||
'permanent', 'permanents', 'turn', 'end', 'step', 'phase',
|
|
||||||
'mana', 'color', 'pay', 'cost', 'tap', 'untap', 'put', 'get',
|
|
||||||
'has', 'have', 'are', 'be', 'been', 'was', 'were', 'do', 'does',
|
|
||||||
'any', 'no', 'not', 'only', 'also', 'then', 'when', 'whenever',
|
|
||||||
'where', 'which', 'who', 'them', 'they', 'their', 'other',
|
|
||||||
'more', 'less', 'than', 'into', 'under', 'over', 'until',
|
|
||||||
'would', 'could', 'instead', 'about', 'number', 'equal',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_oracle_keywords(oracle_text: str) -> set[str]:
|
|
||||||
"""Extract meaningful keywords from oracle text, filtering stop words."""
|
|
||||||
if not oracle_text:
|
|
||||||
return set()
|
|
||||||
cleaned = re.sub(r'\([^)]*\)', '', oracle_text)
|
|
||||||
tokens = re.findall(r'[a-z]{3,}', cleaned.lower())
|
|
||||||
return {t for t in tokens if t not in _ORACLE_STOP_WORDS}
|
|
||||||
|
|
||||||
def _generate_deck(req: DeckGenerationRequest) -> dict:
|
def _generate_deck(req: DeckGenerationRequest) -> dict:
|
||||||
"""
|
"""
|
||||||
Core deck generation algorithm.
|
Core deck generation algorithm.
|
||||||
@@ -279,8 +211,6 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
|||||||
|
|
||||||
color_identity = req.color_identity or commander.get("color_identity", [])
|
color_identity = req.color_identity or commander.get("color_identity", [])
|
||||||
log_deckgen.info(f" Commander: {commander['name']}, CI: {color_identity}")
|
log_deckgen.info(f" Commander: {commander['name']}, CI: {color_identity}")
|
||||||
commander_keywords = _extract_oracle_keywords(commander.get("oracle_text", ""))
|
|
||||||
log_deckgen.info(f" Commander keywords ({len(commander_keywords)}): {sorted(commander_keywords)[:15]}")
|
|
||||||
|
|
||||||
# 2. Load collection
|
# 2. Load collection
|
||||||
collection = _get_collection_for_colors(color_identity)
|
collection = _get_collection_for_colors(color_identity)
|
||||||
@@ -347,7 +277,6 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
|||||||
)
|
)
|
||||||
candidate_map[key] = {
|
candidate_map[key] = {
|
||||||
"scryfall_id": card.get("scryfall_id", ""),
|
"scryfall_id": card.get("scryfall_id", ""),
|
||||||
"oracle_text": card.get("oracle_text", ""),
|
|
||||||
"name": name,
|
"name": name,
|
||||||
"type_line": type_line,
|
"type_line": type_line,
|
||||||
"mana_cost": card.get("mana_cost", ""),
|
"mana_cost": card.get("mana_cost", ""),
|
||||||
@@ -440,17 +369,6 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
|||||||
score += 5
|
score += 5
|
||||||
elif cmc <= 4:
|
elif cmc <= 4:
|
||||||
score += 3
|
score += 3
|
||||||
# Oracle-text keyword overlap with commander
|
|
||||||
card_oracle = card.get("oracle_text") or ""
|
|
||||||
if commander_keywords and card_oracle:
|
|
||||||
card_kw = _extract_oracle_keywords(card_oracle)
|
|
||||||
overlap = len(commander_keywords & card_kw)
|
|
||||||
if overlap >= 4:
|
|
||||||
score += 12
|
|
||||||
elif overlap >= 2:
|
|
||||||
score += 7
|
|
||||||
elif overlap >= 1:
|
|
||||||
score += 3
|
|
||||||
card["_score"] = score
|
card["_score"] = score
|
||||||
cards.sort(key=lambda x: x["_score"], reverse=True)
|
cards.sort(key=lambda x: x["_score"], reverse=True)
|
||||||
|
|
||||||
@@ -724,7 +642,7 @@ async def deck_generator_commander_search(q: str = ""):
|
|||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
# Anthropic Claude API — AI Deck Research & Generation
|
# Perplexity API — AI Deck Research & Generation
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
@@ -847,62 +765,54 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _call_anthropic_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
||||||
"""Call Anthropic Claude API messages endpoint (non-blocking).
|
"""Call Perplexity API chat/completions endpoint (non-blocking).
|
||||||
Uses httpx.AsyncClient so the uvicorn event loop is never stalled.
|
Uses httpx.AsyncClient so the uvicorn event loop is never stalled.
|
||||||
Returns the assistant message content.
|
Returns the assistant message content.
|
||||||
"""
|
"""
|
||||||
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL, ANTHROPIC_MODEL
|
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL
|
||||||
|
|
||||||
if DECK_GEN_PROVIDER == 'local':
|
if DECK_GEN_PROVIDER == 'local':
|
||||||
base_url = DECK_GEN_BASE_URL
|
base_url = DECK_GEN_BASE_URL
|
||||||
model = DECK_GEN_MODEL
|
model = DECK_GEN_MODEL
|
||||||
headers = {'Content-Type': 'application/json'}
|
headers = {'Content-Type': 'application/json'}
|
||||||
else:
|
else:
|
||||||
if not CFG.anthropic_api_key:
|
if not CFG.pplx_api_key:
|
||||||
raise HTTPException(400, 'Anthropic Claude API key not configured. Set anthropic_api_key env var or --anthropic-key.')
|
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
|
||||||
base_url = 'https://api.anthropic.com'
|
base_url = 'https://api.perplexity.ai'
|
||||||
model = ANTHROPIC_MODEL
|
model = 'sonar'
|
||||||
headers = {
|
headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-api-key': CFG.anthropic_api_key, 'anthropic-version': '2023-06-01',
|
'Authorization': f'Bearer {CFG.pplx_api_key}',
|
||||||
}
|
}
|
||||||
# Separate system messages for Anthropic API format
|
|
||||||
system_msgs = [m['content'] for m in messages if m.get('role') == 'system']
|
|
||||||
user_msgs = [m for m in messages if m.get('role') != 'system']
|
|
||||||
system_text = '\n'.join(system_msgs) if system_msgs else None
|
|
||||||
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'model': model,
|
'model': model,
|
||||||
'messages': user_msgs,
|
'messages': messages,
|
||||||
'max_tokens': max_tokens,
|
'max_tokens': max_tokens,
|
||||||
'temperature': temperature,
|
'temperature': temperature,
|
||||||
}
|
}
|
||||||
if system_text:
|
|
||||||
payload['system'] = system_text
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f'{base_url}/v1/messages',
|
f'{base_url}/chat/completions',
|
||||||
json=payload,
|
json=payload,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
raise HTTPException(502, f'Anthropic Claude API returned {e.response.status_code}: {e.response.text[:200]}')
|
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}: {e.response.text[:200]}')
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
raise HTTPException(502, f'Anthropic Claude API call failed: {e}')
|
raise HTTPException(502, f'Perplexity API call failed: {e}')
|
||||||
|
|
||||||
content_blocks = data.get('content', [])
|
choices = data.get('choices', [])
|
||||||
if not content_blocks:
|
if not choices:
|
||||||
raise HTTPException(502, 'Empty response from Anthropic Claude API')
|
raise HTTPException(502, 'Empty response from Perplexity API')
|
||||||
content = content_blocks[0].get('text', '')
|
content = choices[0].get('message', {}).get('content', '')
|
||||||
usage = data.get('usage', {})
|
usage = data.get('usage', {})
|
||||||
log_pplx.debug(
|
log_pplx.debug(
|
||||||
f'tokens: prompt={usage.get("input_tokens", "?")}, '
|
f'tokens: prompt={usage.get("prompt_tokens", "?")}, '
|
||||||
f'completion={usage.get("output_tokens", "?")}, '
|
f'completion={usage.get("completion_tokens", "?")}, '
|
||||||
f'model={data.get("model", "?")}'
|
f'model={data.get("model", "?")}'
|
||||||
)
|
)
|
||||||
return content
|
return content
|
||||||
@@ -1036,11 +946,11 @@ def _fill_basic_lands(cards: list[dict], color_identity: list[str] | None = None
|
|||||||
return non_basics
|
return non_basics
|
||||||
|
|
||||||
|
|
||||||
# ── Research Endpoint ───────────────────────────────────────────────────
|
# ── Research Endpoint ─────────────────────────────────────────
|
||||||
|
|
||||||
@router.post('/api/deck-research')
|
@router.post('/api/deck-research')
|
||||||
async def deck_research(req: DeckResearchRequest):
|
async def deck_research(req: DeckResearchRequest):
|
||||||
"""Analyze an existing deck using Anthropic Claude and suggest improvements."""
|
"""Analyze an existing deck using Perplexity AI and suggest improvements."""
|
||||||
conn = _get_db_conn()
|
conn = _get_db_conn()
|
||||||
|
|
||||||
# Load deck info
|
# Load deck info
|
||||||
@@ -1165,7 +1075,7 @@ DECKLIST ({len(card_rows)} cards):
|
|||||||
|
|
||||||
Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available."""
|
Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available."""
|
||||||
|
|
||||||
content = await _call_anthropic_api([
|
content = await _call_pplx_api([
|
||||||
{'role': 'system', 'content': system_msg},
|
{'role': 'system', 'content': system_msg},
|
||||||
{'role': 'user', 'content': user_msg},
|
{'role': 'user', 'content': user_msg},
|
||||||
], max_tokens=8192)
|
], max_tokens=8192)
|
||||||
@@ -1191,11 +1101,11 @@ Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4
|
|||||||
return analysis
|
return analysis
|
||||||
|
|
||||||
|
|
||||||
# ── Generate Endpoint ───────────────────────────────────────────────
|
# ── Generate Endpoint ─────────────────────────────────────────
|
||||||
|
|
||||||
@router.post('/api/deck-generate')
|
@router.post('/api/deck-generate')
|
||||||
async def deck_generate_ai(req: DeckGenerateAIRequest):
|
async def deck_generate_ai(req: DeckGenerateAIRequest):
|
||||||
"""Generate a full 100-card Commander deck using Anthropic Claude."""
|
"""Generate a full 100-card Commander deck using Perplexity AI."""
|
||||||
commander = req.commander.strip()
|
commander = req.commander.strip()
|
||||||
if not commander:
|
if not commander:
|
||||||
raise HTTPException(400, 'Commander name is required')
|
raise HTTPException(400, 'Commander name is required')
|
||||||
@@ -1275,7 +1185,7 @@ COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'}
|
|||||||
|
|
||||||
Build the deck as JSON. Prioritize collection cards when they fit the strategy."""
|
Build the deck as JSON. Prioritize collection cards when they fit the strategy."""
|
||||||
|
|
||||||
content = await _call_anthropic_api([
|
content = await _call_pplx_api([
|
||||||
{'role': 'system', 'content': system_msg},
|
{'role': 'system', 'content': system_msg},
|
||||||
{'role': 'user', 'content': user_msg},
|
{'role': 'user', 'content': user_msg},
|
||||||
], max_tokens=8192, temperature=0.3)
|
], max_tokens=8192, temperature=0.3)
|
||||||
@@ -1302,16 +1212,16 @@ Build the deck as JSON. Prioritize collection cards when they fit the strategy."
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get('/api/anthropic/status')
|
@router.get('/api/pplx/status')
|
||||||
async def anthropic_status():
|
async def pplx_status():
|
||||||
"""Check if Anthropic Claude API is configured."""
|
"""Check if Perplexity API is configured."""
|
||||||
return {
|
return {
|
||||||
'configured': bool(CFG.anthropic_api_key),
|
'configured': bool(CFG.pplx_api_key),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
# V3 Deck Generator (Anthropic Claude Structured Output)
|
# V3 Deck Generator (Perplexity Structured Output)
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@router.get('/api/deck/v3/status')
|
@router.get('/api/deck/v3/status')
|
||||||
@@ -1319,9 +1229,9 @@ async def deck_gen_v3_status():
|
|||||||
"""Check V3 deck generator status."""
|
"""Check V3 deck generator status."""
|
||||||
return {
|
return {
|
||||||
'initialized': _coach._deck_gen_v3 is not None,
|
'initialized': _coach._deck_gen_v3 is not None,
|
||||||
'anthropic_configured': bool(CFG.anthropic_api_key),
|
'pplx_configured': bool(CFG.pplx_api_key),
|
||||||
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'anthropic_client', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
|
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'pplx', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
|
||||||
'embeddings_loaded': False,
|
'embeddings_loaded': False,
|
||||||
'embedding_cards': 0,
|
'embedding_cards': 0,
|
||||||
'error': _coach._deck_gen_v3_error,
|
'error': _coach._deck_gen_v3_error,
|
||||||
}
|
}
|
||||||
@@ -1330,14 +1240,14 @@ async def deck_gen_v3_status():
|
|||||||
@router.post('/api/deck/v3/generate')
|
@router.post('/api/deck/v3/generate')
|
||||||
async def deck_gen_v3_generate(req: DeckGenV3Request):
|
async def deck_gen_v3_generate(req: DeckGenV3Request):
|
||||||
"""
|
"""
|
||||||
V3 Deck Generation — Anthropic Claude Structured Output with Smart Substitution.
|
V3 Deck Generation — Perplexity structured output with Smart Substitution.
|
||||||
|
|
||||||
Pipeline:
|
Pipeline:
|
||||||
1. Resolve commander via Scryfall
|
1. Resolve commander via Scryfall
|
||||||
2. Build collection summary from DB
|
2. Build collection summary from DB
|
||||||
3. Call Anthropic Claude with JSON schema enforcement
|
3. Call Perplexity Sonar with JSON schema enforcement
|
||||||
4. Cross-reference cards with collection for ownership
|
4. Cross-reference cards with collection for ownership
|
||||||
5. Run Smart Substitution (embedding + Anthropic fallback)
|
5. Run Smart Substitution (embedding + Perplexity fallback)
|
||||||
6. Return complete deck with substitution data
|
6. Return complete deck with substitution data
|
||||||
"""
|
"""
|
||||||
if _coach._deck_gen_v3 is None:
|
if _coach._deck_gen_v3 is None:
|
||||||
@@ -1378,9 +1288,6 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
|
|||||||
from coach.services.deck_generator import DeckGeneratorV3
|
from coach.services.deck_generator import DeckGeneratorV3
|
||||||
result['stats'] = DeckGeneratorV3._compute_stats(sub_result.cards)
|
result['stats'] = DeckGeneratorV3._compute_stats(sub_result.cards)
|
||||||
|
|
||||||
# Cache result for export endpoints
|
|
||||||
_v3_cache_put(_v3_cache_key(req), result)
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -1498,20 +1405,17 @@ async def deck_gen_v3_export_csv(req: DeckGenV3Request):
|
|||||||
if _coach._deck_gen_v3 is None:
|
if _coach._deck_gen_v3 is None:
|
||||||
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
||||||
|
|
||||||
_ck = _v3_cache_key(req)
|
result = _coach._deck_gen_v3.generate_deck(
|
||||||
result = _v3_cache_get(_ck)
|
commander_name=req.commander_name.strip(),
|
||||||
if result is None:
|
strategy=req.strategy,
|
||||||
result = _coach._deck_gen_v3.generate_deck(
|
target_bracket=req.target_bracket,
|
||||||
commander_name=req.commander_name.strip(),
|
budget_usd=req.budget_usd,
|
||||||
strategy=req.strategy,
|
budget_mode=req.budget_mode,
|
||||||
target_bracket=req.target_bracket,
|
omit_cards=req.omit_cards,
|
||||||
budget_usd=req.budget_usd,
|
use_collection=req.use_collection,
|
||||||
budget_mode=req.budget_mode,
|
model=req.model,
|
||||||
omit_cards=req.omit_cards,
|
)
|
||||||
use_collection=req.use_collection,
|
|
||||||
model=req.model,
|
|
||||||
)
|
|
||||||
_v3_cache_put(_ck, result)
|
|
||||||
cards = result.get('cards', [])
|
cards = result.get('cards', [])
|
||||||
commander = result.get('commander', {})
|
commander = result.get('commander', {})
|
||||||
|
|
||||||
@@ -1548,20 +1452,16 @@ async def deck_gen_v3_export_dck(req: DeckGenV3Request):
|
|||||||
if _coach._deck_gen_v3 is None:
|
if _coach._deck_gen_v3 is None:
|
||||||
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
||||||
|
|
||||||
_ck = _v3_cache_key(req)
|
result = _coach._deck_gen_v3.generate_deck(
|
||||||
result = _v3_cache_get(_ck)
|
commander_name=req.commander_name.strip(),
|
||||||
if result is None:
|
strategy=req.strategy,
|
||||||
result = _coach._deck_gen_v3.generate_deck(
|
target_bracket=req.target_bracket,
|
||||||
commander_name=req.commander_name.strip(),
|
budget_usd=req.budget_usd,
|
||||||
strategy=req.strategy,
|
budget_mode=req.budget_mode,
|
||||||
target_bracket=req.target_bracket,
|
omit_cards=req.omit_cards,
|
||||||
budget_usd=req.budget_usd,
|
use_collection=req.use_collection,
|
||||||
budget_mode=req.budget_mode,
|
model=req.model,
|
||||||
omit_cards=req.omit_cards,
|
)
|
||||||
use_collection=req.use_collection,
|
|
||||||
model=req.model,
|
|
||||||
)
|
|
||||||
_v3_cache_put(_ck, result)
|
|
||||||
|
|
||||||
cards = result.get('cards', [])
|
cards = result.get('cards', [])
|
||||||
commander = result.get('commander', {})
|
commander = result.get('commander', {})
|
||||||
@@ -1587,20 +1487,16 @@ async def deck_gen_v3_export_moxfield(req: DeckGenV3Request):
|
|||||||
if _coach._deck_gen_v3 is None:
|
if _coach._deck_gen_v3 is None:
|
||||||
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
||||||
|
|
||||||
_ck = _v3_cache_key(req)
|
result = _coach._deck_gen_v3.generate_deck(
|
||||||
result = _v3_cache_get(_ck)
|
commander_name=req.commander_name.strip(),
|
||||||
if result is None:
|
strategy=req.strategy,
|
||||||
result = _coach._deck_gen_v3.generate_deck(
|
target_bracket=req.target_bracket,
|
||||||
commander_name=req.commander_name.strip(),
|
budget_usd=req.budget_usd,
|
||||||
strategy=req.strategy,
|
budget_mode=req.budget_mode,
|
||||||
target_bracket=req.target_bracket,
|
omit_cards=req.omit_cards,
|
||||||
budget_usd=req.budget_usd,
|
use_collection=req.use_collection,
|
||||||
budget_mode=req.budget_mode,
|
model=req.model,
|
||||||
omit_cards=req.omit_cards,
|
)
|
||||||
use_collection=req.use_collection,
|
|
||||||
model=req.model,
|
|
||||||
)
|
|
||||||
_v3_cache_put(_ck, result)
|
|
||||||
|
|
||||||
cards = result.get('cards', [])
|
cards = result.get('cards', [])
|
||||||
commander = result.get('commander', {})
|
commander = result.get('commander', {})
|
||||||
@@ -1628,26 +1524,22 @@ async def deck_gen_v3_export_shopping(req: DeckGenV3Request):
|
|||||||
if _coach._deck_gen_v3 is None:
|
if _coach._deck_gen_v3 is None:
|
||||||
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
||||||
|
|
||||||
_ck = _v3_cache_key(req)
|
result = _coach._deck_gen_v3.generate_deck(
|
||||||
result = _v3_cache_get(_ck)
|
commander_name=req.commander_name.strip(),
|
||||||
if result is None:
|
strategy=req.strategy,
|
||||||
result = _coach._deck_gen_v3.generate_deck(
|
target_bracket=req.target_bracket,
|
||||||
commander_name=req.commander_name.strip(),
|
budget_usd=req.budget_usd,
|
||||||
strategy=req.strategy,
|
budget_mode=req.budget_mode,
|
||||||
target_bracket=req.target_bracket,
|
omit_cards=req.omit_cards,
|
||||||
budget_usd=req.budget_usd,
|
use_collection=req.use_collection,
|
||||||
budget_mode=req.budget_mode,
|
model=req.model,
|
||||||
omit_cards=req.omit_cards,
|
)
|
||||||
use_collection=req.use_collection,
|
|
||||||
model=req.model,
|
|
||||||
)
|
|
||||||
_v3_cache_put(_ck, result)
|
|
||||||
|
|
||||||
cards = result.get('cards', [])
|
cards = result.get('cards', [])
|
||||||
shopping = []
|
shopping = []
|
||||||
total = 0.0
|
total = 0.0
|
||||||
for card in cards:
|
for card in cards:
|
||||||
if not card.get('from_collection', False) and card.get('status') != 'substituted':
|
if not card.get('owned', True) and card.get('status') != 'substituted':
|
||||||
price = card.get('estimated_price_usd', 0)
|
price = card.get('estimated_price_usd', 0)
|
||||||
shopping.append({
|
shopping.append({
|
||||||
'name': card.get('name', ''),
|
'name': card.get('name', ''),
|
||||||
@@ -1664,3 +1556,5 @@ async def deck_gen_v3_export_shopping(req: DeckGenV3Request):
|
|||||||
'total_missing': len(shopping),
|
'total_missing': len(shopping),
|
||||||
'estimated_cost_usd': round(total, 2),
|
'estimated_cost_usd': round(total, 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
+9
-23
@@ -31,7 +31,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from models.state import CFG
|
from models.state import CFG
|
||||||
from services.database import _get_db_conn
|
from services.database import _get_db_conn
|
||||||
from services.forge_runner import _get_deepseek_brain
|
from services.forge_runner import _get_deepseek_brain
|
||||||
from services.logging import log_sim, _ml_logging_enabled
|
from services.logging import log_sim
|
||||||
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
|
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
|
|||||||
name_b=opponent_name, **kw)
|
name_b=opponent_name, **kw)
|
||||||
game_data = result.to_dict()
|
game_data = result.to_dict()
|
||||||
game_data['gameNumber'] = i + 1
|
game_data['gameNumber'] = i + 1
|
||||||
# Phase 8: Surface player_explanation from validation in turn log
|
# Phase 8: Surface player_explanation from validation in turn log
|
||||||
for turn_entry in game_data.get('log', []):
|
for turn_entry in game_data.get('log', []):
|
||||||
v = turn_entry.get('validation')
|
v = turn_entry.get('validation')
|
||||||
if isinstance(v, dict) and v.get('player_explanation'):
|
if isinstance(v, dict) and v.get('player_explanation'):
|
||||||
@@ -208,7 +208,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
|||||||
_sim_runs[sim_id]['status'] = 'complete'
|
_sim_runs[sim_id]['status'] = 'complete'
|
||||||
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
|
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
|
||||||
|
|
||||||
# --- Persist ml-decision-*.json for coach / training dashboard ---
|
# --- Persist batch-*.json for coach / training dashboard ---
|
||||||
try:
|
try:
|
||||||
deck_name = summary.get('deckName', 'Unknown')
|
deck_name = summary.get('deckName', 'Unknown')
|
||||||
opponent_name = summary.get('opponentName', 'Training Deck')
|
opponent_name = summary.get('opponentName', 'Training Deck')
|
||||||
@@ -235,7 +235,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
|||||||
}
|
}
|
||||||
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
|
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
|
||||||
os.makedirs(results_dir, exist_ok=True)
|
os.makedirs(results_dir, exist_ok=True)
|
||||||
batch_path = os.path.join(results_dir, f'ml-decision-sim-{sim_id}.json')
|
batch_path = os.path.join(results_dir, f'batch-sim-{sim_id}.json')
|
||||||
with open(batch_path, 'w') as f:
|
with open(batch_path, 'w') as f:
|
||||||
json.dump(batch_data, f, indent=2, default=str)
|
json.dump(batch_data, f, indent=2, default=str)
|
||||||
logger.info(f'Saved batch results to {batch_path}')
|
logger.info(f'Saved batch results to {batch_path}')
|
||||||
@@ -255,8 +255,6 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
|||||||
logger.error(f'Failed to persist sim results: {e}')
|
logger.error(f'Failed to persist sim results: {e}')
|
||||||
|
|
||||||
# --- Flush ML decisions JSONL for training pipeline ---
|
# --- Flush ML decisions JSONL for training pipeline ---
|
||||||
# NOTE: DeepSeek thread flushes manually before calling _finish_sim(engine=None)
|
|
||||||
# to avoid a double-flush that would produce an empty .jsonl file.
|
|
||||||
if engine is not None and hasattr(engine, 'flush_ml_decisions'):
|
if engine is not None and hasattr(engine, 'flush_ml_decisions'):
|
||||||
ml_decisions = engine.flush_ml_decisions()
|
ml_decisions = engine.flush_ml_decisions()
|
||||||
if ml_decisions:
|
if ml_decisions:
|
||||||
@@ -266,8 +264,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
|||||||
for dec in ml_decisions:
|
for dec in ml_decisions:
|
||||||
mf.write(json.dumps(dec) + '\n')
|
mf.write(json.dumps(dec) + '\n')
|
||||||
logger.info(f'Wrote {len(ml_decisions)} ML decisions to {ml_path}')
|
logger.info(f'Wrote {len(ml_decisions)} ML decisions to {ml_path}')
|
||||||
|
|
||||||
|
|
||||||
def _fail_sim(sim_id, error):
|
def _fail_sim(sim_id, error):
|
||||||
"""Mark a sim run as errored."""
|
"""Mark a sim run as errored."""
|
||||||
with _sim_lock:
|
with _sim_lock:
|
||||||
@@ -363,32 +360,21 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
|
|||||||
game_id_prefix=f'ds-sim-{sim_id[:8]}',
|
game_id_prefix=f'ds-sim-{sim_id[:8]}',
|
||||||
archetype='midrange')
|
archetype='midrange')
|
||||||
|
|
||||||
# Flush ML decisions once here — pass engine=None to _finish_sim
|
# Write ML decision JSONL for training pipeline
|
||||||
# to prevent a second flush that would produce an empty .jsonl file.
|
|
||||||
ml_decisions = engine.flush_ml_decisions()
|
ml_decisions = engine.flush_ml_decisions()
|
||||||
if ml_decisions and brain and brain._connected:
|
if ml_decisions:
|
||||||
ml_path = os.path.join('results',
|
ml_path = os.path.join('results',
|
||||||
f'ml-decisions-sim-{sim_id[:8]}.jsonl')
|
f'ml-decisions-sim-{sim_id[:8]}.jsonl')
|
||||||
os.makedirs('results', exist_ok=True)
|
os.makedirs('results', exist_ok=True)
|
||||||
with open(ml_path, 'w', encoding='utf-8') as mf:
|
with open(ml_path, 'w', encoding='utf-8') as mf:
|
||||||
for dec in ml_decisions:
|
for dec in ml_decisions:
|
||||||
mf.write(json.dumps(dec) + '\n')
|
mf.write(json.dumps(dec) + '\n')
|
||||||
logger.info(f'[ML Data] Wrote {len(ml_decisions)} decision snapshots to {ml_path}')
|
|
||||||
elif ml_decisions and (not brain or not brain._connected):
|
|
||||||
logger.warning(
|
|
||||||
f'[ML Data] Skipping {len(ml_decisions)} snapshots for sim {sim_id} — '
|
|
||||||
f'brain not connected, all actions are heuristic fallback '
|
|
||||||
f'(not useful for supervised training)'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(f'[ML Data] No decision snapshots captured for sim {sim_id} (0 decisions)')
|
|
||||||
|
|
||||||
ds_stats = brain.get_stats() if brain else {}
|
ds_stats = brain.get_stats() if brain else {}
|
||||||
summary = _build_summary(
|
summary = _build_summary(
|
||||||
stats, deck_name, 'DeepSeek AI', num_games, elapsed,
|
stats, deck_name, 'DeepSeek AI', num_games, elapsed,
|
||||||
opponentType='deepseek', deepseekStats=ds_stats)
|
opponentType='deepseek', deepseekStats=ds_stats)
|
||||||
# engine=None: ML decisions already flushed above — do not flush again
|
_finish_sim(sim_id, summary, game_results, engine=engine)
|
||||||
_finish_sim(sim_id, summary, game_results, engine=None)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_fail_sim(sim_id, e)
|
_fail_sim(sim_id, e)
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
@@ -795,7 +781,7 @@ async def deepseek_logs():
|
|||||||
'flushedPath': flushed_path,
|
'flushedPath': flushed_path,
|
||||||
'logFiles': log_files[-20:],
|
'logFiles': log_files[-20:],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/validator/status")
|
@router.get("/api/validator/status")
|
||||||
async def validator_status():
|
async def validator_status():
|
||||||
|
|||||||
+5
-5
@@ -66,8 +66,8 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
|||||||
raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled")
|
raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled")
|
||||||
if req.numGames < 1 or req.numGames > 10000:
|
if req.numGames < 1 or req.numGames > 10000:
|
||||||
raise HTTPException(400, "numGames must be 1-10000")
|
raise HTTPException(400, "numGames must be 1-10000")
|
||||||
if req.threads < 1 or req.threads > 32:
|
if req.threads < 1 or req.threads > 16:
|
||||||
raise HTTPException(400, "threads must be 1-32")
|
raise HTTPException(400, "threads must be 1-16")
|
||||||
|
|
||||||
if not os.path.exists(CFG.forge_jar):
|
if not os.path.exists(CFG.forge_jar):
|
||||||
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
|
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
|
||||||
@@ -79,7 +79,7 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
|||||||
active_batches[batch_id] = state
|
active_batches[batch_id] = state
|
||||||
|
|
||||||
os.makedirs(CFG.results_dir, exist_ok=True)
|
os.makedirs(CFG.results_dir, exist_ok=True)
|
||||||
output_path = os.path.join(CFG.results_dir, f"ml-decision-{batch_id}.json")
|
output_path = os.path.join(CFG.results_dir, f"batch-{batch_id}.json")
|
||||||
state.result_path = output_path
|
state.result_path = output_path
|
||||||
|
|
||||||
# Auto-export deckbuilder decks to .dck if not already exported
|
# Auto-export deckbuilder decks to .dck if not already exported
|
||||||
@@ -242,7 +242,7 @@ async def list_history():
|
|||||||
if not results_dir.exists():
|
if not results_dir.exists():
|
||||||
return {"results": []}
|
return {"results": []}
|
||||||
results = []
|
results = []
|
||||||
for f in sorted(results_dir.glob("ml-decision-*.json"), reverse=True):
|
for f in sorted(results_dir.glob("batch-*.json"), reverse=True):
|
||||||
try:
|
try:
|
||||||
with open(f, "r", encoding="utf-8") as fh:
|
with open(f, "r", encoding="utf-8") as fh:
|
||||||
data = json.load(fh)
|
data = json.load(fh)
|
||||||
@@ -375,7 +375,7 @@ async def get_deck_trends(deck_name: str):
|
|||||||
if not results_dir.exists():
|
if not results_dir.exists():
|
||||||
return {"deckName": deck_name, "history": []}
|
return {"deckName": deck_name, "history": []}
|
||||||
history = []
|
history = []
|
||||||
for f in sorted(results_dir.glob("ml-decision-*.json")):
|
for f in sorted(results_dir.glob("batch-*.json")):
|
||||||
try:
|
try:
|
||||||
with open(f, "r", encoding="utf-8") as fh:
|
with open(f, "r", encoding="utf-8") as fh:
|
||||||
data = json.load(fh)
|
data = json.load(fh)
|
||||||
|
|||||||
+6
-20
@@ -33,8 +33,6 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
|
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
|
||||||
|
|
||||||
from models.state import CFG
|
from models.state import CFG
|
||||||
from services.logging import _ml_logging_enabled
|
|
||||||
|
|
||||||
log_ml = logging.getLogger("commander_ai_lab.ml")
|
log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||||
|
|
||||||
router = APIRouter(tags=["ml"])
|
router = APIRouter(tags=["ml"])
|
||||||
@@ -94,9 +92,6 @@ _ppo_lock = threading.Lock()
|
|||||||
_tournament_state = _TournamentState()
|
_tournament_state = _TournamentState()
|
||||||
_tournament_lock = threading.Lock()
|
_tournament_lock = threading.Lock()
|
||||||
|
|
||||||
# module-level toggle (local copy; the shared one is for batch runs)
|
|
||||||
_ml_logging_enabled_local = _ml_logging_enabled
|
|
||||||
|
|
||||||
|
|
||||||
def _get_policy_service():
|
def _get_policy_service():
|
||||||
"""Get or initialize the policy inference service (lazy loading)."""
|
"""Get or initialize the policy inference service (lazy loading)."""
|
||||||
@@ -1097,7 +1092,7 @@ _elo_tournament_state = _EloTournamentState()
|
|||||||
_elo_tournament_lock = threading.Lock()
|
_elo_tournament_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _run_elo_tournament_pipeline(episodes: int, playstyle: str, top_n: int):
|
def _run_elo_tournament_pipeline(episodes: int, playstyle: str):
|
||||||
"""Run ELO tournament in a background thread."""
|
"""Run ELO tournament in a background thread."""
|
||||||
try:
|
try:
|
||||||
import sys
|
import sys
|
||||||
@@ -1108,7 +1103,7 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str, top_n: int):
|
|||||||
with _elo_tournament_lock:
|
with _elo_tournament_lock:
|
||||||
_elo_tournament_state.running = True
|
_elo_tournament_state.running = True
|
||||||
_elo_tournament_state.phase = "running"
|
_elo_tournament_state.phase = "running"
|
||||||
_elo_tournament_state.message = f"Running ELO tournament (top {top_n} generations)..."
|
_elo_tournament_state.message = "Running ELO tournament..."
|
||||||
_elo_tournament_state.error = None
|
_elo_tournament_state.error = None
|
||||||
_elo_tournament_state.result = None
|
_elo_tournament_state.result = None
|
||||||
|
|
||||||
@@ -1121,7 +1116,6 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str, top_n: int):
|
|||||||
checkpoint_dir=ckpt_dir,
|
checkpoint_dir=ckpt_dir,
|
||||||
episodes_per_matchup=episodes,
|
episodes_per_matchup=episodes,
|
||||||
playstyle=playstyle,
|
playstyle=playstyle,
|
||||||
top_n=top_n,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to ELO history
|
# Save to ELO history
|
||||||
@@ -1156,37 +1150,29 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str, top_n: int):
|
|||||||
|
|
||||||
@router.post("/api/ml/elo/tournament")
|
@router.post("/api/ml/elo/tournament")
|
||||||
async def ml_start_elo_tournament(request: FastAPIRequest):
|
async def ml_start_elo_tournament(request: FastAPIRequest):
|
||||||
"""Start an ELO-rated tournament across generation checkpoints.
|
"""Start an ELO-rated tournament across all generation checkpoints."""
|
||||||
|
|
||||||
Body params:
|
|
||||||
episodes (int): Games per matchup pair (default 50)
|
|
||||||
playstyle (str): Game playstyle (default midrange)
|
|
||||||
topN (int): Max number of most-recent gen checkpoints to include
|
|
||||||
(default 10, 0 = unlimited)
|
|
||||||
"""
|
|
||||||
if _elo_tournament_state.running:
|
if _elo_tournament_state.running:
|
||||||
raise HTTPException(409, "ELO tournament already in progress")
|
raise HTTPException(409, "ELO tournament already in progress")
|
||||||
|
|
||||||
body = await request.json() if await request.body() else {}
|
body = await request.json() if await request.body() else {}
|
||||||
episodes = body.get("episodes", 50)
|
episodes = body.get("episodes", 50)
|
||||||
playstyle = body.get("playstyle", "midrange")
|
playstyle = body.get("playstyle", "midrange")
|
||||||
top_n = body.get("topN", 10)
|
|
||||||
|
|
||||||
with _elo_tournament_lock:
|
with _elo_tournament_lock:
|
||||||
_elo_tournament_state.running = True
|
_elo_tournament_state.running = True
|
||||||
_elo_tournament_state.phase = "starting"
|
_elo_tournament_state.phase = "starting"
|
||||||
_elo_tournament_state.message = f"Initializing ELO tournament (top {top_n} generations)..."
|
_elo_tournament_state.message = "Initializing ELO tournament..."
|
||||||
_elo_tournament_state.result = None
|
_elo_tournament_state.result = None
|
||||||
_elo_tournament_state.error = None
|
_elo_tournament_state.error = None
|
||||||
|
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=_run_elo_tournament_pipeline,
|
target=_run_elo_tournament_pipeline,
|
||||||
args=(episodes, playstyle, top_n),
|
args=(episodes, playstyle),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
return {"status": "started", "episodes": episodes, "top_n": top_n}
|
return {"status": "started", "episodes": episodes}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/ml/elo/tournament/status")
|
@router.get("/api/ml/elo/tournament/status")
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
"""Commander AI Lab — Policy Decision Routes (Phase 2)
|
|
||||||
════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
Live Forge ↔ policy server IPC endpoints for online training.
|
|
||||||
These endpoints are purpose-built for the real-time decision loop
|
|
||||||
where Forge's Java side calls the Python policy server at each
|
|
||||||
game decision point.
|
|
||||||
|
|
||||||
Endpoints:
|
|
||||||
POST /api/policy/decide — Accept full Forge game state, return action + log_prob + value
|
|
||||||
POST /api/policy/collect — Accept batch of (state, action, reward) tuples
|
|
||||||
POST /api/policy/reward — Submit end-of-game reward
|
|
||||||
GET /api/policy/health — Readiness check for the live IPC loop
|
|
||||||
GET /api/policy/stats — Online learning session statistics
|
|
||||||
|
|
||||||
Refs: Issue #83 Phase 2.2
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
logger = logging.getLogger("routes.policy")
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/policy", tags=["policy"])
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Request / Response Models
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
class PlayerZoneState(BaseModel):
|
|
||||||
"""Per-player zone state from Forge."""
|
|
||||||
seat: int = 0
|
|
||||||
name: str = ""
|
|
||||||
life: int = 40
|
|
||||||
poison: int = 0
|
|
||||||
cmdr_dmg: Dict[str, int] = Field(default_factory=dict)
|
|
||||||
cmdr_tax: int = 0
|
|
||||||
mana_available: int = 0
|
|
||||||
mana_pool: Dict[str, int] = Field(default_factory=dict)
|
|
||||||
# Full zone contents
|
|
||||||
hand: List[str] = Field(default_factory=list)
|
|
||||||
hand_count: int = 0
|
|
||||||
battlefield: List[Dict] = Field(default_factory=list)
|
|
||||||
graveyard: List[str] = Field(default_factory=list)
|
|
||||||
exile: List[str] = Field(default_factory=list)
|
|
||||||
command_zone: List[str] = Field(default_factory=list)
|
|
||||||
library_count: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class DecideRequest(BaseModel):
|
|
||||||
"""Full Forge game state for live decision-making."""
|
|
||||||
# Game metadata
|
|
||||||
game_id: str = ""
|
|
||||||
turn: int = 1
|
|
||||||
phase: str = "main_1"
|
|
||||||
active_player: int = 0
|
|
||||||
priority_player: int = 0
|
|
||||||
# Per-player state with full zones
|
|
||||||
players: List[PlayerZoneState] = Field(default_factory=list)
|
|
||||||
# Stack (spells/abilities currently resolving)
|
|
||||||
stack: List[Dict] = Field(default_factory=list)
|
|
||||||
# Legal actions from Forge
|
|
||||||
legal_actions: List[Dict] = Field(default_factory=list)
|
|
||||||
# Inference params
|
|
||||||
playstyle: str = "midrange"
|
|
||||||
greedy: bool = False
|
|
||||||
temperature: float = 1.0
|
|
||||||
# Optional commander info
|
|
||||||
commander: str = ""
|
|
||||||
deck_name: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class DecideResponse(BaseModel):
|
|
||||||
"""Action decision with metadata for online learning."""
|
|
||||||
action: str
|
|
||||||
action_index: int
|
|
||||||
confidence: float
|
|
||||||
log_prob: float = 0.0
|
|
||||||
value: float = 0.0
|
|
||||||
probabilities: Dict[str, float] = Field(default_factory=dict)
|
|
||||||
inference_ms: float = 0.0
|
|
||||||
error: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class CollectRequest(BaseModel):
|
|
||||||
"""Batch of online learning tuples from live Forge games."""
|
|
||||||
tuples: List[Dict] = Field(default_factory=list)
|
|
||||||
count: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class RewardRequest(BaseModel):
|
|
||||||
"""End-of-game reward submission."""
|
|
||||||
game_id: str
|
|
||||||
reward: float
|
|
||||||
winner_seat: int = -1
|
|
||||||
turns_played: int = 0
|
|
||||||
reason: str = "" # e.g., "life", "commander_damage", "concede"
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Module-level state (initialized by register_policy_routes)
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
_policy_service = None
|
|
||||||
_online_store = None
|
|
||||||
_session_stats = {
|
|
||||||
"total_decisions": 0,
|
|
||||||
"total_tuples_collected": 0,
|
|
||||||
"total_rewards_submitted": 0,
|
|
||||||
"games_completed": 0,
|
|
||||||
"session_start": 0.0,
|
|
||||||
"last_decision_ms": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def register_policy_routes(app, policy_service, online_store=None):
|
|
||||||
"""Register the policy routes on the FastAPI app.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
app: FastAPI application instance
|
|
||||||
policy_service: PolicyInferenceService from ml.serving.policy_server
|
|
||||||
online_store: Optional OnlineLearningStore for tuple collection
|
|
||||||
"""
|
|
||||||
global _policy_service, _online_store
|
|
||||||
_policy_service = policy_service
|
|
||||||
_online_store = online_store
|
|
||||||
_session_stats["session_start"] = time.time()
|
|
||||||
app.include_router(router)
|
|
||||||
logger.info("Policy routes registered (online_store=%s)",
|
|
||||||
"enabled" if online_store else "disabled")
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# POST /api/policy/decide
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@router.post("/decide", response_model=DecideResponse)
|
|
||||||
async def decide(req: DecideRequest):
|
|
||||||
"""Accept full Forge game state, run policy inference, return action.
|
|
||||||
|
|
||||||
This is the primary endpoint for live Forge IPC. The Java
|
|
||||||
PolicyClient calls this at each decision point during a real
|
|
||||||
Forge game.
|
|
||||||
|
|
||||||
Returns the macro-action plus log_prob and value estimate
|
|
||||||
needed for online PPO updates.
|
|
||||||
"""
|
|
||||||
if _policy_service is None or not _policy_service._loaded:
|
|
||||||
raise HTTPException(
|
|
||||||
503,
|
|
||||||
detail="Policy model not loaded"
|
|
||||||
)
|
|
||||||
|
|
||||||
t_start = time.time()
|
|
||||||
_session_stats["total_decisions"] += 1
|
|
||||||
|
|
||||||
# Convert request to snapshot dict for the encoder
|
|
||||||
snapshot = req.dict(exclude={"temperature", "greedy", "playstyle"})
|
|
||||||
|
|
||||||
# Map phase names from Forge conventions
|
|
||||||
snapshot["phase"] = _map_forge_phase(req.phase)
|
|
||||||
snapshot["active_seat"] = req.active_player
|
|
||||||
|
|
||||||
result = _policy_service.predict(
|
|
||||||
snapshot,
|
|
||||||
playstyle=req.playstyle,
|
|
||||||
temperature=req.temperature,
|
|
||||||
greedy=req.greedy,
|
|
||||||
)
|
|
||||||
|
|
||||||
elapsed_ms = (time.time() - t_start) * 1000
|
|
||||||
_session_stats["last_decision_ms"] = elapsed_ms
|
|
||||||
|
|
||||||
if "error" in result:
|
|
||||||
raise HTTPException(500, detail=result["error"])
|
|
||||||
|
|
||||||
# Add log_prob and value estimate for online learning
|
|
||||||
log_prob = 0.0
|
|
||||||
value = 0.0
|
|
||||||
try:
|
|
||||||
import math
|
|
||||||
conf = result.get("confidence", 0.5)
|
|
||||||
log_prob = math.log(max(conf, 1e-8))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return DecideResponse(
|
|
||||||
action=result["action"],
|
|
||||||
action_index=result["action_index"],
|
|
||||||
confidence=result["confidence"],
|
|
||||||
log_prob=round(log_prob, 6),
|
|
||||||
value=round(value, 6),
|
|
||||||
probabilities=result.get("probabilities", {}),
|
|
||||||
inference_ms=round(elapsed_ms, 2),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# POST /api/policy/collect
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@router.post("/collect")
|
|
||||||
async def collect_tuples(req: CollectRequest):
|
|
||||||
"""Accept a batch of (state, action, reward) tuples from live games
|
|
||||||
for online PPO updates.
|
|
||||||
"""
|
|
||||||
count = len(req.tuples)
|
|
||||||
_session_stats["total_tuples_collected"] += count
|
|
||||||
|
|
||||||
if _online_store is not None:
|
|
||||||
try:
|
|
||||||
_online_store.add_tuples(req.tuples)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to store tuples: %s", e)
|
|
||||||
raise HTTPException(500, detail=str(e))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"accepted": count,
|
|
||||||
"total_buffered": (
|
|
||||||
_online_store.size() if _online_store else count
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# POST /api/policy/reward
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@router.post("/reward")
|
|
||||||
async def submit_reward(req: RewardRequest):
|
|
||||||
"""Submit end-of-game reward for all tuples in a game.
|
|
||||||
Propagates the terminal reward back through the game's trajectory.
|
|
||||||
"""
|
|
||||||
_session_stats["total_rewards_submitted"] += 1
|
|
||||||
_session_stats["games_completed"] += 1
|
|
||||||
|
|
||||||
if _online_store is not None:
|
|
||||||
try:
|
|
||||||
_online_store.assign_reward(
|
|
||||||
game_id=req.game_id,
|
|
||||||
reward=req.reward,
|
|
||||||
winner_seat=req.winner_seat,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to assign reward: %s", e)
|
|
||||||
raise HTTPException(500, detail=str(e))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"game_id": req.game_id,
|
|
||||||
"reward": req.reward,
|
|
||||||
"status": "accepted",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# GET /api/policy/health
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@router.get("/health")
|
|
||||||
async def policy_health():
|
|
||||||
"""Readiness check for the live Forge IPC loop."""
|
|
||||||
model_loaded = (
|
|
||||||
_policy_service is not None and _policy_service._loaded
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"ready": model_loaded,
|
|
||||||
"status": "ok" if model_loaded else "degraded",
|
|
||||||
"model_loaded": model_loaded,
|
|
||||||
"online_store": _online_store is not None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# GET /api/policy/stats
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
async def policy_stats():
|
|
||||||
"""Online learning session statistics."""
|
|
||||||
uptime = time.time() - _session_stats["session_start"]
|
|
||||||
return {
|
|
||||||
**_session_stats,
|
|
||||||
"uptime_seconds": round(uptime, 1),
|
|
||||||
"decisions_per_second": (
|
|
||||||
round(_session_stats["total_decisions"] / max(uptime, 1), 2)
|
|
||||||
),
|
|
||||||
"online_store_size": (
|
|
||||||
_online_store.size() if _online_store else 0
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Helpers
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
_FORGE_PHASE_MAP = {
|
|
||||||
"UNTAP": "main_1",
|
|
||||||
"UPKEEP": "main_1",
|
|
||||||
"DRAW": "main_1",
|
|
||||||
"MAIN1": "main_1",
|
|
||||||
"BEGIN_COMBAT": "combat",
|
|
||||||
"DECLARE_ATTACKERS": "combat",
|
|
||||||
"DECLARE_BLOCKERS": "combat",
|
|
||||||
"DAMAGE": "combat",
|
|
||||||
"COMBAT_DAMAGE": "combat",
|
|
||||||
"END_COMBAT": "combat",
|
|
||||||
"MAIN2": "main_2",
|
|
||||||
"END": "end",
|
|
||||||
"END_STEP": "end",
|
|
||||||
"CLEANUP": "end",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _map_forge_phase(phase: str) -> str:
|
|
||||||
"""Map Forge phase string to ML GamePhase enum value."""
|
|
||||||
return _FORGE_PHASE_MAP.get(phase.upper(), "main_1")
|
|
||||||
+2
-3
@@ -16,9 +16,8 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from models.state import CFG
|
from models.state import CFG
|
||||||
from services.precon_service import download_precon_database, PRECON_INDEX
|
from services.precon_service import download_precon_database, PRECON_INDEX, _get_precon_dir
|
||||||
from services.logging import log
|
from services.logging import log
|
||||||
from services.precon_service import _get_precon_dir
|
|
||||||
|
|
||||||
router = APIRouter(tags=["precon"])
|
router = APIRouter(tags=["precon"])
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ router = APIRouter(tags=["precon"])
|
|||||||
@router.get("/api/lab/precons")
|
@router.get("/api/lab/precons")
|
||||||
async def list_precons():
|
async def list_precons():
|
||||||
"""List all available precon decks."""
|
"""List all available precon decks."""
|
||||||
return {"precons": PRECON_INDEX} # reads current value every call
|
return {"precons": PRECON_INDEX}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/lab/precons/install")
|
@router.post("/api/lab/precons/install")
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
"""WebSocket route for real-time game state streaming.
|
|
||||||
|
|
||||||
Endpoints:
|
|
||||||
WS /ws/game/{game_id} — Stream state deltas to Unity
|
|
||||||
POST /api/ws/game/{game_id}/push — Push a state update (called by Forge IPC)
|
|
||||||
POST /api/ws/game/{game_id}/decision — Push AI decision event
|
|
||||||
POST /api/ws/game/{game_id}/end — Signal game over
|
|
||||||
GET /api/ws/stats — Connection statistics
|
|
||||||
|
|
||||||
Unity connects to the WebSocket endpoint and receives:
|
|
||||||
1. An initial full snapshot on first state push
|
|
||||||
2. Subsequent delta-only messages as state changes
|
|
||||||
3. Decision events when the AI acts
|
|
||||||
4. A game_over event when the game ends
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from ws.connection_manager import game_manager
|
|
||||||
from ws.game_state import get_tracker, remove_tracker
|
|
||||||
|
|
||||||
log = logging.getLogger("routes.ws_game")
|
|
||||||
|
|
||||||
router = APIRouter(tags=["websocket"])
|
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────
|
|
||||||
# WebSocket endpoint — Unity connects here
|
|
||||||
# ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.websocket("/ws/game/{game_id}")
|
|
||||||
async def game_ws(websocket: WebSocket, game_id: str):
|
|
||||||
"""WebSocket endpoint for Unity game-state streaming.
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
client_id — optional unique identifier for this client
|
|
||||||
|
|
||||||
The connection stays open for the duration of the game.
|
|
||||||
The server pushes messages; the client can send:
|
|
||||||
{"type": "ping"} — keep-alive
|
|
||||||
{"type": "request_snapshot"} — request a full re-sync
|
|
||||||
"""
|
|
||||||
client_id = (
|
|
||||||
websocket.query_params.get("client_id")
|
|
||||||
or f"unity-{uuid.uuid4().hex[:8]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
client = await game_manager.connect(websocket, game_id, client_id)
|
|
||||||
|
|
||||||
# If the tracker already has a baseline, send the current state
|
|
||||||
tracker = get_tracker(game_id)
|
|
||||||
if tracker.has_baseline:
|
|
||||||
snapshot_msg = tracker.update(tracker._last_snapshot)
|
|
||||||
await websocket.send_json(snapshot_msg)
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
data = await websocket.receive_json()
|
|
||||||
msg_type = data.get("type", "")
|
|
||||||
|
|
||||||
if msg_type == "ping":
|
|
||||||
await websocket.send_json({"type": "pong"})
|
|
||||||
|
|
||||||
elif msg_type == "request_snapshot":
|
|
||||||
if tracker.has_baseline:
|
|
||||||
# Re-send current state as full snapshot
|
|
||||||
import copy
|
|
||||||
snap = copy.deepcopy(tracker._last_snapshot)
|
|
||||||
tracker._seq += 1
|
|
||||||
import time
|
|
||||||
await websocket.send_json({
|
|
||||||
"type": "snapshot",
|
|
||||||
"seq": tracker._seq,
|
|
||||||
"game_id": game_id,
|
|
||||||
"timestamp": time.time(),
|
|
||||||
"state": snap,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
await websocket.send_json({
|
|
||||||
"type": "error",
|
|
||||||
"message": "No game state available yet",
|
|
||||||
})
|
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("WS error game=%s client=%s: %s", game_id, client_id, e)
|
|
||||||
finally:
|
|
||||||
await game_manager.disconnect(game_id, client_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────
|
|
||||||
# REST endpoints — called by Forge IPC / policy server
|
|
||||||
# ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class PushStateRequest(BaseModel):
|
|
||||||
"""Game state snapshot pushed from Forge."""
|
|
||||||
game_id: str = ""
|
|
||||||
turn: int = 0
|
|
||||||
phase: str = ""
|
|
||||||
active_player: int = 0
|
|
||||||
players: list = Field(default_factory=list)
|
|
||||||
stack: list = Field(default_factory=list)
|
|
||||||
legal_actions: list = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class DecisionEvent(BaseModel):
|
|
||||||
"""AI decision event pushed after policy inference."""
|
|
||||||
action: str
|
|
||||||
action_index: int = 0
|
|
||||||
confidence: float = 0.0
|
|
||||||
probabilities: dict = Field(default_factory=dict)
|
|
||||||
inference_ms: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
class GameOverEvent(BaseModel):
|
|
||||||
"""Game-over event."""
|
|
||||||
winner_seat: int = -1
|
|
||||||
reason: str = ""
|
|
||||||
turns_played: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/ws/game/{game_id}/push")
|
|
||||||
async def push_game_state(game_id: str, req: PushStateRequest):
|
|
||||||
"""Push a game state update to all connected Unity clients.
|
|
||||||
|
|
||||||
Called by the Forge IPC bridge or policy server after each
|
|
||||||
state change. The delta engine diffs against the previous
|
|
||||||
state and broadcasts only what changed.
|
|
||||||
"""
|
|
||||||
tracker = get_tracker(game_id)
|
|
||||||
snapshot = req.dict()
|
|
||||||
delta_msg = tracker.update(snapshot)
|
|
||||||
sent = await game_manager.broadcast_to_game(game_id, delta_msg)
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"type": delta_msg["type"],
|
|
||||||
"seq": delta_msg["seq"],
|
|
||||||
"clients_notified": sent,
|
|
||||||
"changes": len(delta_msg.get("changes", [])),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/ws/game/{game_id}/decision")
|
|
||||||
async def push_decision(game_id: str, event: DecisionEvent):
|
|
||||||
"""Push an AI decision event to connected Unity clients."""
|
|
||||||
tracker = get_tracker(game_id)
|
|
||||||
msg = tracker.make_decision_event(
|
|
||||||
action=event.action,
|
|
||||||
action_index=event.action_index,
|
|
||||||
confidence=event.confidence,
|
|
||||||
probabilities=event.probabilities,
|
|
||||||
inference_ms=event.inference_ms,
|
|
||||||
)
|
|
||||||
sent = await game_manager.broadcast_to_game(game_id, msg)
|
|
||||||
return {"status": "ok", "clients_notified": sent}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/ws/game/{game_id}/end")
|
|
||||||
async def push_game_over(game_id: str, event: GameOverEvent):
|
|
||||||
"""Signal game over to all connected clients and clean up."""
|
|
||||||
tracker = get_tracker(game_id)
|
|
||||||
msg = tracker.make_game_over_event(
|
|
||||||
winner_seat=event.winner_seat,
|
|
||||||
reason=event.reason,
|
|
||||||
turns_played=event.turns_played,
|
|
||||||
)
|
|
||||||
sent = await game_manager.broadcast_to_game(game_id, msg)
|
|
||||||
remove_tracker(game_id)
|
|
||||||
return {"status": "ok", "clients_notified": sent}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/ws/stats")
|
|
||||||
async def ws_stats():
|
|
||||||
"""Return WebSocket connection statistics."""
|
|
||||||
return game_manager.stats()
|
|
||||||
@@ -85,7 +85,7 @@ def start_batch(base_url, decks, num_games, playstyle, threads):
|
|||||||
def poll_batch(base_url, batch_id, poll_interval=5):
|
def poll_batch(base_url, batch_id, poll_interval=5):
|
||||||
"""Poll batch status until complete. Returns final status."""
|
"""Poll batch status until complete. Returns final status."""
|
||||||
while True:
|
while True:
|
||||||
result = api_call(base_url, f"/api/lab/status?batchId={batch_id}")
|
result = api_call(base_url, f"/api/lab/status/{batch_id}")
|
||||||
if result is None:
|
if result is None:
|
||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
"""Async SQLite wrapper for FastAPI — eliminates event-loop blocking.
|
|
||||||
|
|
||||||
Drop-in async replacements for the sync helpers in services/database.py.
|
|
||||||
Uses aiosqlite under the hood so every DB call yields back to the
|
|
||||||
event loop instead of freezing it.
|
|
||||||
|
|
||||||
Usage in route handlers:
|
|
||||||
from services.async_db import async_get_db, async_execute, async_fetchall
|
|
||||||
|
|
||||||
rows = await async_fetchall("SELECT * FROM decks")
|
|
||||||
row = await async_fetchone("SELECT * FROM decks WHERE id = ?", (1,))
|
|
||||||
await async_execute("INSERT INTO ...", params)
|
|
||||||
await async_execute_commit("DELETE FROM ...", params)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
import aiosqlite
|
|
||||||
|
|
||||||
log = logging.getLogger("commander_ai_lab.async_db")
|
|
||||||
|
|
||||||
# Re-use the same DB path as the sync module
|
|
||||||
COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db"
|
|
||||||
|
|
||||||
# Module-level connection (lazy-init, one per process)
|
|
||||||
_aconn: Optional[aiosqlite.Connection] = None
|
|
||||||
|
|
||||||
|
|
||||||
async def async_get_db() -> aiosqlite.Connection:
|
|
||||||
"""Return (or create) the module-level async SQLite connection."""
|
|
||||||
global _aconn
|
|
||||||
if _aconn is None:
|
|
||||||
_aconn = await aiosqlite.connect(str(COLLECTION_DB_PATH))
|
|
||||||
_aconn.row_factory = aiosqlite.Row
|
|
||||||
await _aconn.execute("PRAGMA journal_mode=WAL")
|
|
||||||
log.info("Async SQLite connection opened: %s", COLLECTION_DB_PATH)
|
|
||||||
return _aconn
|
|
||||||
|
|
||||||
|
|
||||||
async def async_close_db() -> None:
|
|
||||||
"""Close the module-level connection (call on shutdown)."""
|
|
||||||
global _aconn
|
|
||||||
if _aconn is not None:
|
|
||||||
await _aconn.close()
|
|
||||||
_aconn = None
|
|
||||||
log.info("Async SQLite connection closed.")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Convenience helpers ─────────────────────────────────────────
|
|
||||||
|
|
||||||
async def async_fetchall(
|
|
||||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Execute a SELECT and return all rows as dicts."""
|
|
||||||
db = conn or await async_get_db()
|
|
||||||
async with db.execute(sql, params) as cursor:
|
|
||||||
columns = [d[0] for d in cursor.description]
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
return [dict(zip(columns, row)) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_fetchone(
|
|
||||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
|
||||||
) -> Optional[dict]:
|
|
||||||
"""Execute a SELECT and return one row as a dict (or None)."""
|
|
||||||
db = conn or await async_get_db()
|
|
||||||
async with db.execute(sql, params) as cursor:
|
|
||||||
columns = [d[0] for d in cursor.description]
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
return dict(zip(columns, row)) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
async def async_execute(
|
|
||||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
|
||||||
) -> int:
|
|
||||||
"""Execute a write statement. Returns lastrowid."""
|
|
||||||
db = conn or await async_get_db()
|
|
||||||
cursor = await db.execute(sql, params)
|
|
||||||
return cursor.lastrowid
|
|
||||||
|
|
||||||
|
|
||||||
async def async_execute_commit(
|
|
||||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
|
||||||
) -> int:
|
|
||||||
"""Execute a write statement and commit. Returns lastrowid."""
|
|
||||||
db = conn or await async_get_db()
|
|
||||||
cursor = await db.execute(sql, params)
|
|
||||||
await db.commit()
|
|
||||||
return cursor.lastrowid
|
|
||||||
|
|
||||||
|
|
||||||
async def async_executemany_commit(
|
|
||||||
sql: str, params_seq, *, conn: aiosqlite.Connection = None
|
|
||||||
) -> None:
|
|
||||||
"""Execute many and commit."""
|
|
||||||
db = conn or await async_get_db()
|
|
||||||
await db.executemany(sql, params_seq)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def async_commit(*, conn: aiosqlite.Connection = None) -> None:
|
|
||||||
"""Commit the current transaction."""
|
|
||||||
db = conn or await async_get_db()
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ── JSON field helpers (mirrors database._row_to_dict) ──────────
|
|
||||||
|
|
||||||
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
|
|
||||||
|
|
||||||
|
|
||||||
def row_to_dict_parsed(row: dict) -> dict:
|
|
||||||
"""Parse JSON string columns into Python lists (same as database._row_to_dict)."""
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
d = dict(row)
|
|
||||||
for f in _JSON_FIELDS:
|
|
||||||
if f in d and isinstance(d[f], str):
|
|
||||||
try:
|
|
||||||
d[f] = json.loads(d[f])
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
d[f] = []
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
# ── Coach session async helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
async def async_save_coach_session(session_data: dict) -> int:
|
|
||||||
"""Insert or update a coach session. Returns row id."""
|
|
||||||
db = await async_get_db()
|
|
||||||
existing = await async_fetchone(
|
|
||||||
"SELECT id FROM coach_sessions WHERE session_id = ?",
|
|
||||||
(session_data["session_id"],),
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
await db.execute(
|
|
||||||
"""UPDATE coach_sessions SET
|
|
||||||
deck_id=?, timestamp=?, model_used=?, prompt_tokens=?,
|
|
||||||
completion_tokens=?, summary=?, goals_json=?, cuts_json=?,
|
|
||||||
adds_json=?, heuristic_hints_json=?, mana_base_advice=?,
|
|
||||||
raw_text=?
|
|
||||||
WHERE session_id=?""",
|
|
||||||
(
|
|
||||||
session_data.get("deck_id", ""),
|
|
||||||
session_data.get("timestamp", ""),
|
|
||||||
session_data.get("model_used", ""),
|
|
||||||
session_data.get("prompt_tokens", 0),
|
|
||||||
session_data.get("completion_tokens", 0),
|
|
||||||
session_data.get("summary", ""),
|
|
||||||
session_data.get("goals_json", "{}"),
|
|
||||||
session_data.get("cuts_json", "[]"),
|
|
||||||
session_data.get("adds_json", "[]"),
|
|
||||||
session_data.get("heuristic_hints_json", "[]"),
|
|
||||||
session_data.get("mana_base_advice", ""),
|
|
||||||
session_data.get("raw_text", ""),
|
|
||||||
session_data["session_id"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return existing["id"]
|
|
||||||
else:
|
|
||||||
cursor = await db.execute(
|
|
||||||
"""INSERT INTO coach_sessions
|
|
||||||
(session_id, deck_id, timestamp, model_used, prompt_tokens,
|
|
||||||
completion_tokens, summary, goals_json, cuts_json, adds_json,
|
|
||||||
heuristic_hints_json, mana_base_advice, raw_text)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
||||||
(
|
|
||||||
session_data["session_id"],
|
|
||||||
session_data.get("deck_id", ""),
|
|
||||||
session_data.get("timestamp", ""),
|
|
||||||
session_data.get("model_used", ""),
|
|
||||||
session_data.get("prompt_tokens", 0),
|
|
||||||
session_data.get("completion_tokens", 0),
|
|
||||||
session_data.get("summary", ""),
|
|
||||||
session_data.get("goals_json", "{}"),
|
|
||||||
session_data.get("cuts_json", "[]"),
|
|
||||||
session_data.get("adds_json", "[]"),
|
|
||||||
session_data.get("heuristic_hints_json", "[]"),
|
|
||||||
session_data.get("mana_base_advice", ""),
|
|
||||||
session_data.get("raw_text", ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return cursor.lastrowid
|
|
||||||
|
|
||||||
|
|
||||||
async def async_load_coach_session(session_id: str) -> Optional[dict]:
|
|
||||||
"""Load a single coach session by session_id."""
|
|
||||||
return await async_fetchone(
|
|
||||||
"SELECT * FROM coach_sessions WHERE session_id = ?", (session_id,)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_list_coach_sessions(deck_id: str = None) -> list[dict]:
|
|
||||||
"""List coach sessions, optionally filtered by deck_id."""
|
|
||||||
if deck_id:
|
|
||||||
return await async_fetchall(
|
|
||||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
|
||||||
"FROM coach_sessions WHERE deck_id = ? ORDER BY timestamp DESC",
|
|
||||||
(deck_id,),
|
|
||||||
)
|
|
||||||
return await async_fetchall(
|
|
||||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
|
||||||
"FROM coach_sessions ORDER BY timestamp DESC"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_delete_coach_session(session_id: str) -> bool:
|
|
||||||
"""Delete a coach session. Returns True if a row was deleted."""
|
|
||||||
db = await async_get_db()
|
|
||||||
cursor = await db.execute(
|
|
||||||
"DELETE FROM coach_sessions WHERE session_id = ?", (session_id,)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
@@ -136,28 +136,6 @@ def init_collection_db() -> None:
|
|||||||
FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE
|
FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS coach_sessions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
session_id TEXT NOT NULL UNIQUE,
|
|
||||||
deck_id TEXT NOT NULL,
|
|
||||||
timestamp TEXT NOT NULL,
|
|
||||||
model_used TEXT DEFAULT '',
|
|
||||||
prompt_tokens INTEGER DEFAULT 0,
|
|
||||||
completion_tokens INTEGER DEFAULT 0,
|
|
||||||
summary TEXT DEFAULT '',
|
|
||||||
goals_json TEXT DEFAULT '{}',
|
|
||||||
cuts_json TEXT DEFAULT '[]',
|
|
||||||
adds_json TEXT DEFAULT '[]',
|
|
||||||
heuristic_hints_json TEXT DEFAULT '[]',
|
|
||||||
mana_base_advice TEXT DEFAULT '',
|
|
||||||
raw_text TEXT DEFAULT '',
|
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_cs_session_id ON coach_sessions(session_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_cs_deck_id ON coach_sessions(deck_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_cs_timestamp ON coach_sessions(timestamp);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id);
|
CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id);
|
CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
|
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
|
||||||
@@ -324,105 +302,3 @@ def _build_collection_filters(
|
|||||||
conditions.append("quantity <= ?"); params.append(qtyMax)
|
conditions.append("quantity <= ?"); params.append(qtyMax)
|
||||||
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
return where_str, params
|
return where_str, params
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# Coach Session DB Helpers
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
|
|
||||||
def save_coach_session(session_data: dict) -> int:
|
|
||||||
"""Insert or update a coach session in SQLite. Returns the row id."""
|
|
||||||
conn = _get_db_conn()
|
|
||||||
existing = conn.execute(
|
|
||||||
"SELECT id FROM coach_sessions WHERE session_id = ?",
|
|
||||||
(session_data["session_id"],),
|
|
||||||
).fetchone()
|
|
||||||
if existing:
|
|
||||||
conn.execute(
|
|
||||||
"""UPDATE coach_sessions SET
|
|
||||||
deck_id=?, timestamp=?, model_used=?, prompt_tokens=?,
|
|
||||||
completion_tokens=?, summary=?, goals_json=?, cuts_json=?,
|
|
||||||
adds_json=?, heuristic_hints_json=?, mana_base_advice=?,
|
|
||||||
raw_text=?
|
|
||||||
WHERE session_id=?""",
|
|
||||||
(
|
|
||||||
session_data.get("deck_id", ""),
|
|
||||||
session_data.get("timestamp", ""),
|
|
||||||
session_data.get("model_used", ""),
|
|
||||||
session_data.get("prompt_tokens", 0),
|
|
||||||
session_data.get("completion_tokens", 0),
|
|
||||||
session_data.get("summary", ""),
|
|
||||||
session_data.get("goals_json", "{}"),
|
|
||||||
session_data.get("cuts_json", "[]"),
|
|
||||||
session_data.get("adds_json", "[]"),
|
|
||||||
session_data.get("heuristic_hints_json", "[]"),
|
|
||||||
session_data.get("mana_base_advice", ""),
|
|
||||||
session_data.get("raw_text", ""),
|
|
||||||
session_data["session_id"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
return existing["id"]
|
|
||||||
else:
|
|
||||||
cur = conn.execute(
|
|
||||||
"""INSERT INTO coach_sessions
|
|
||||||
(session_id, deck_id, timestamp, model_used, prompt_tokens,
|
|
||||||
completion_tokens, summary, goals_json, cuts_json, adds_json,
|
|
||||||
heuristic_hints_json, mana_base_advice, raw_text)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
||||||
(
|
|
||||||
session_data["session_id"],
|
|
||||||
session_data.get("deck_id", ""),
|
|
||||||
session_data.get("timestamp", ""),
|
|
||||||
session_data.get("model_used", ""),
|
|
||||||
session_data.get("prompt_tokens", 0),
|
|
||||||
session_data.get("completion_tokens", 0),
|
|
||||||
session_data.get("summary", ""),
|
|
||||||
session_data.get("goals_json", "{}"),
|
|
||||||
session_data.get("cuts_json", "[]"),
|
|
||||||
session_data.get("adds_json", "[]"),
|
|
||||||
session_data.get("heuristic_hints_json", "[]"),
|
|
||||||
session_data.get("mana_base_advice", ""),
|
|
||||||
session_data.get("raw_text", ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
return cur.lastrowid
|
|
||||||
|
|
||||||
|
|
||||||
def load_coach_session(session_id: str) -> Optional[dict]:
|
|
||||||
"""Load a single coach session by session_id."""
|
|
||||||
conn = _get_db_conn()
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM coach_sessions WHERE session_id = ?", (session_id,)
|
|
||||||
).fetchone()
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def list_coach_sessions(deck_id: str = None) -> list:
|
|
||||||
"""List coach sessions, optionally filtered by deck_id."""
|
|
||||||
conn = _get_db_conn()
|
|
||||||
if deck_id:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
|
||||||
"FROM coach_sessions WHERE deck_id = ? ORDER BY timestamp DESC",
|
|
||||||
(deck_id,),
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
|
||||||
"FROM coach_sessions ORDER BY timestamp DESC"
|
|
||||||
).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def delete_coach_session(session_id: str) -> bool:
|
|
||||||
"""Delete a coach session. Returns True if a row was deleted."""
|
|
||||||
conn = _get_db_conn()
|
|
||||||
cur = conn.execute(
|
|
||||||
"DELETE FROM coach_sessions WHERE session_id = ?", (session_id,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
return cur.rowcount > 0
|
|
||||||
|
|
||||||
@@ -361,7 +361,7 @@ def _run_deepseek_batch_thread(
|
|||||||
results_dir = CFG.results_dir
|
results_dir = CFG.results_dir
|
||||||
if os.path.isdir(results_dir):
|
if os.path.isdir(results_dir):
|
||||||
for fname in sorted(os.listdir(results_dir), reverse=True):
|
for fname in sorted(os.listdir(results_dir), reverse=True):
|
||||||
if fname.startswith('ml-decision-') and fname.endswith('.json'):
|
if fname.startswith('batch-') and fname.endswith('.json'):
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(results_dir, fname), 'r') as rf:
|
with open(os.path.join(results_dir, fname), 'r') as rf:
|
||||||
past = json.loads(rf.read())
|
past = json.loads(rf.read())
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def chat(
|
|||||||
return response.choices[0].message.content or ""
|
return response.choices[0].message.content or ""
|
||||||
|
|
||||||
|
|
||||||
# ── 1. Analyze synergies ─────────────────────────────────────────────
|
# ── 1. Analyze synergies ─────────────────────────────────────────
|
||||||
|
|
||||||
def analyze_synergies(
|
def analyze_synergies(
|
||||||
commander_name: str,
|
commander_name: str,
|
||||||
@@ -145,7 +145,7 @@ Cards to evaluate:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
# ── 2. Suggest cards for a category ────────────────────────────────
|
# ── 2. Suggest cards for a category ──────────────────────────────
|
||||||
|
|
||||||
def suggest_cards(
|
def suggest_cards(
|
||||||
commander_name: str,
|
commander_name: str,
|
||||||
@@ -154,23 +154,13 @@ def suggest_cards(
|
|||||||
count: int = 10,
|
count: int = 10,
|
||||||
exclude: Optional[List[str]] = None,
|
exclude: Optional[List[str]] = None,
|
||||||
strategy_notes: Optional[str] = None,
|
strategy_notes: Optional[str] = None,
|
||||||
sim_context: str = "",
|
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Ask the model to suggest cards for a specific deck category.
|
Ask the model to suggest cards for a specific deck category.
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
sim_context:
|
|
||||||
Optional natural-language insight string from get_sim_insights().
|
|
||||||
When non-empty, appended to the prompt so the LLM can factor in
|
|
||||||
statistically successful card traits from simulation data.
|
|
||||||
|
|
||||||
Returns a list of card names.
|
Returns a list of card names.
|
||||||
"""
|
"""
|
||||||
exclude_str = f"\nDo NOT suggest these cards: {json.dumps(exclude)}" if exclude else ""
|
exclude_str = f"\nDo NOT suggest these cards: {json.dumps(exclude)}" if exclude else ""
|
||||||
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes else ""
|
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes else ""
|
||||||
sim_block = f"\n\n{sim_context}" if sim_context else ""
|
|
||||||
|
|
||||||
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
||||||
|
|
||||||
@@ -178,7 +168,7 @@ Commander: {commander_name}
|
|||||||
Color identity: {', '.join(color_identity)}
|
Color identity: {', '.join(color_identity)}
|
||||||
Category: {category}
|
Category: {category}
|
||||||
{strategy}
|
{strategy}
|
||||||
{exclude_str}{sim_block}
|
{exclude_str}
|
||||||
|
|
||||||
Suggest exactly {count} cards for the {category} category that work well with this commander.
|
Suggest exactly {count} cards for the {category} category that work well with this commander.
|
||||||
Only suggest cards that are legal in Commander and match the color identity.
|
Only suggest cards that are legal in Commander and match the color identity.
|
||||||
@@ -210,7 +200,7 @@ Return ONLY a JSON array of card name strings, like: ["Card A", "Card B", ...]""
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
# ── 3. Filter by color identity ────────────────────────────────────
|
# ── 3. Filter by color identity ──────────────────────────────────
|
||||||
|
|
||||||
def filter_color_identity(
|
def filter_color_identity(
|
||||||
card_names: List[str],
|
card_names: List[str],
|
||||||
@@ -252,28 +242,17 @@ Cards:
|
|||||||
return card_names
|
return card_names
|
||||||
|
|
||||||
|
|
||||||
# ── 4. Enforce deck ratios ───────────────────────────────────────────
|
# ── 4. Enforce deck ratios ───────────────────────────────────────
|
||||||
|
|
||||||
def enforce_deck_ratios(
|
def enforce_deck_ratios(
|
||||||
cards_by_category: Dict[str, List[str]],
|
cards_by_category: Dict[str, List[str]],
|
||||||
target_ratios: Dict[str, int],
|
target_ratios: Dict[str, int],
|
||||||
commander_name: str,
|
commander_name: str,
|
||||||
sim_context: str = "",
|
|
||||||
) -> Dict[str, List[str]]:
|
) -> Dict[str, List[str]]:
|
||||||
"""
|
"""
|
||||||
Ask the model to trim/expand categories to hit target counts.
|
Ask the model to trim/expand categories to hit target counts.
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
sim_context:
|
|
||||||
Optional natural-language insight string from get_sim_insights().
|
|
||||||
When non-empty, appended to the prompt so the LLM preferentially
|
|
||||||
retains cards matching statistically high-value traits when trimming.
|
|
||||||
|
|
||||||
Returns adjusted dict of category -> card names.
|
Returns adjusted dict of category -> card names.
|
||||||
"""
|
"""
|
||||||
sim_block = f"\n\n{sim_context}" if sim_context else ""
|
|
||||||
|
|
||||||
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
||||||
|
|
||||||
Commander: {commander_name}
|
Commander: {commander_name}
|
||||||
@@ -286,7 +265,7 @@ Target card counts per category:
|
|||||||
|
|
||||||
Adjust each category to match the target count. Remove the weakest cards from
|
Adjust each category to match the target count. Remove the weakest cards from
|
||||||
over-filled categories. For under-filled categories, suggest additional cards.
|
over-filled categories. For under-filled categories, suggest additional cards.
|
||||||
The total across all categories must be exactly 99.{sim_block}
|
The total across all categories must be exactly 99.
|
||||||
|
|
||||||
Return a JSON object with the same category keys, each mapping to an array of card name strings."""
|
Return a JSON object with the same category keys, each mapping to an array of card name strings."""
|
||||||
|
|
||||||
@@ -305,7 +284,7 @@ Return a JSON object with the same category keys, each mapping to an array of ca
|
|||||||
return cards_by_category
|
return cards_by_category
|
||||||
|
|
||||||
|
|
||||||
# ── 5. Assemble final deck JSON ─────────────────────────────────────────
|
# ── 5. Assemble final deck JSON ──────────────────────────────────
|
||||||
|
|
||||||
def assemble_deck_json(
|
def assemble_deck_json(
|
||||||
commander_name: str,
|
commander_name: str,
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
"""
|
|
||||||
sim_insights.py
|
|
||||||
===============
|
|
||||||
Translates learned simulation weights (learned_weights.json) into concise
|
|
||||||
natural-language insight strings that can be injected into Ollama prompts.
|
|
||||||
|
|
||||||
This bridges the gap between the Monte Carlo engine's outcome-weighted
|
|
||||||
reinforcement learning and the LLM-driven deck builder: keywords that
|
|
||||||
statistically correlate with wins are surfaced to Ollama so it can
|
|
||||||
prioritize those card traits when suggesting and trimming cards.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
from commander_ai_lab.deck_builder.api.sim_insights import get_sim_insights
|
|
||||||
|
|
||||||
context = get_sim_insights() # returns "" if no meaningful learning yet
|
|
||||||
if context:
|
|
||||||
prompt += f"\n\n{context}"
|
|
||||||
|
|
||||||
The function is intentionally safe to call when learned_weights.json does
|
|
||||||
not yet exist (load_weights() falls back to AI_DEFAULT_WEIGHTS silently),
|
|
||||||
and returns an empty string when no weight has drifted far enough from the
|
|
||||||
default to be worth mentioning — so existing prompt behaviour is unchanged
|
|
||||||
until there is real simulation data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from commander_ai_lab.sim.rules import AI_DEFAULT_WEIGHTS, load_weights
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Maps weight keys → human-readable MTG concepts shown to the LLM.
|
|
||||||
# Only keys the LLM can meaningfully act on are included.
|
|
||||||
_KEY_TO_CONCEPT: dict[str, str] = {
|
|
||||||
"kw_flying": "flying (evasion)",
|
|
||||||
"kw_haste": "haste",
|
|
||||||
"kw_deathtouch": "deathtouch",
|
|
||||||
"kw_indestructible": "indestructible",
|
|
||||||
"kw_doubleStrike": "double strike",
|
|
||||||
"kw_hexproof": "hexproof",
|
|
||||||
"kw_trample": "trample",
|
|
||||||
"kw_lifelink": "lifelink",
|
|
||||||
"kw_menace": "menace",
|
|
||||||
"kw_ward": "ward",
|
|
||||||
"kw_annihilator": "annihilator",
|
|
||||||
"kw_cascade": "cascade",
|
|
||||||
"spell_ramp": "mana ramp spells",
|
|
||||||
"spell_destroy": "targeted removal",
|
|
||||||
"spell_draw": "card draw",
|
|
||||||
"spell_counter": "counterspells and board control",
|
|
||||||
"trig_etb": "enters-the-battlefield triggers",
|
|
||||||
"trig_dies": "death triggers",
|
|
||||||
"trig_attack": "attack triggers",
|
|
||||||
"card_ptBonus": "high power/toughness creatures",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_sim_insights(
|
|
||||||
top_n: int = 6,
|
|
||||||
threshold_delta: float = 0.05,
|
|
||||||
weights_path: Optional[str] = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Return a concise insight paragraph for injecting into LLM prompts.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
top_n:
|
|
||||||
Maximum number of concepts to mention (avoids bloating the prompt).
|
|
||||||
threshold_delta:
|
|
||||||
Minimum absolute drift from the default weight before a concept
|
|
||||||
is considered meaningful enough to report. 0.05 means a weight
|
|
||||||
must have moved by at least 5% of its default before it matters.
|
|
||||||
weights_path:
|
|
||||||
Optional explicit path to learned_weights.json. If None, the
|
|
||||||
default location (``src/.../sim/learned_weights.json``) is used.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
str
|
|
||||||
A multi-line insight string ready to append to an LLM prompt, or
|
|
||||||
an empty string if no meaningful learning is available yet.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
learned = load_weights(weights_path)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("sim_insights: could not load weights: %s", exc)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
deltas: dict[str, float] = {}
|
|
||||||
for key, concept in _KEY_TO_CONCEPT.items():
|
|
||||||
default_val = AI_DEFAULT_WEIGHTS.get(key, 0.0)
|
|
||||||
learned_val = learned.get(key, default_val)
|
|
||||||
delta = learned_val - default_val
|
|
||||||
if abs(delta) >= threshold_delta:
|
|
||||||
deltas[concept] = delta
|
|
||||||
|
|
||||||
if not deltas:
|
|
||||||
# No meaningful drift yet — don't inject noise into prompts
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# Sort by magnitude, take top_n
|
|
||||||
sorted_items = sorted(deltas.items(), key=lambda x: -abs(x[1]))[:top_n]
|
|
||||||
boosted = [c for c, d in sorted_items if d > 0]
|
|
||||||
penalized = [c for c, d in sorted_items if d < 0]
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
"[Simulation Insights] Data from thousands of Commander games in this lab suggests:",
|
|
||||||
]
|
|
||||||
if boosted:
|
|
||||||
lines.append(f" - PRIORITIZE cards with: {', '.join(boosted)}")
|
|
||||||
if penalized:
|
|
||||||
lines.append(f" - DEPRIORITIZE cards with: {', '.join(penalized)}")
|
|
||||||
lines.append(
|
|
||||||
"Use this to guide your selections where multiple options are otherwise comparable."
|
|
||||||
)
|
|
||||||
|
|
||||||
result = "\n".join(lines)
|
|
||||||
logger.debug("sim_insights: injecting %d concept(s) into prompt", len(sorted_items))
|
|
||||||
return result
|
|
||||||
@@ -13,7 +13,6 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from ..api import edhrec, scryfall
|
from ..api import edhrec, scryfall
|
||||||
from ..api import ollama_client as ollama
|
from ..api import ollama_client as ollama
|
||||||
from ..api.sim_insights import get_sim_insights
|
|
||||||
from ..core.collection_filter import filter_names_by_collection
|
from ..core.collection_filter import filter_names_by_collection
|
||||||
from ..core.models import BuildRequest, BuildResult, CardEntry, CommanderDeck, DeckRatios
|
from ..core.models import BuildRequest, BuildResult, CardEntry, CommanderDeck, DeckRatios
|
||||||
from ..core.rules_engine import check_ban_list, filter_by_color_identity, validate_deck
|
from ..core.rules_engine import check_ban_list, filter_by_color_identity, validate_deck
|
||||||
@@ -38,7 +37,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
|||||||
warnings: List[str] = []
|
warnings: List[str] = []
|
||||||
sources: List[str] = []
|
sources: List[str] = []
|
||||||
|
|
||||||
# ── Step 1: Resolve commander ────────────────────────────────────────────
|
# ── Step 1: Resolve commander ────────────────────────────────
|
||||||
logger.info(f"Step 1: Resolving commander '{request.commander_name}'")
|
logger.info(f"Step 1: Resolving commander '{request.commander_name}'")
|
||||||
commander = scryfall.search_commander(request.commander_name)
|
commander = scryfall.search_commander(request.commander_name)
|
||||||
if not commander:
|
if not commander:
|
||||||
@@ -51,17 +50,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
|||||||
logger.info(f"Commander: {commander.name}, CI: {ci_list}")
|
logger.info(f"Commander: {commander.name}, CI: {ci_list}")
|
||||||
sources.append("scryfall")
|
sources.append("scryfall")
|
||||||
|
|
||||||
# Load simulation insights once; injected into Ollama prompts in Steps 4 & 6.
|
# ── Step 2: Fetch EDHrec recommendations ─────────────────────
|
||||||
# Returns "" when learned_weights.json doesn't exist or has no meaningful drift
|
|
||||||
# yet — so behaviour is identical to before until real sim data is available.
|
|
||||||
sim_context = get_sim_insights()
|
|
||||||
if sim_context:
|
|
||||||
logger.info("Sim insights available — injecting into Ollama prompts")
|
|
||||||
sources.append("sim_weights")
|
|
||||||
else:
|
|
||||||
logger.debug("No sim insights available (run update_weights.py after overnight sims)")
|
|
||||||
|
|
||||||
# ── Step 2: Fetch EDHrec recommendations ─────────────────────────────
|
|
||||||
logger.info(f"Step 2: Fetching EDHrec data for '{commander.name}'")
|
logger.info(f"Step 2: Fetching EDHrec data for '{commander.name}'")
|
||||||
edhrec_cards = edhrec.get_recommended_cards(commander.name)
|
edhrec_cards = edhrec.get_recommended_cards(commander.name)
|
||||||
if edhrec_cards:
|
if edhrec_cards:
|
||||||
@@ -70,7 +59,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
|||||||
else:
|
else:
|
||||||
warnings.append("EDHrec returned no data — relying on Scryfall + Ollama")
|
warnings.append("EDHrec returned no data — relying on Scryfall + Ollama")
|
||||||
|
|
||||||
# ── Step 3: Fetch Scryfall candidates by category ────────────────────────
|
# ── Step 3: Fetch Scryfall candidates by category ────────────
|
||||||
logger.info("Step 3: Fetching Scryfall candidates by category")
|
logger.info("Step 3: Fetching Scryfall candidates by category")
|
||||||
scryfall_candidates: Dict[str, List[CardEntry]] = {
|
scryfall_candidates: Dict[str, List[CardEntry]] = {
|
||||||
"ramp": scryfall.search_ramp(commander_ci),
|
"ramp": scryfall.search_ramp(commander_ci),
|
||||||
@@ -81,7 +70,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
|||||||
for cat, cards in scryfall_candidates.items():
|
for cat, cards in scryfall_candidates.items():
|
||||||
logger.info(f" Scryfall {cat}: {len(cards)} candidates")
|
logger.info(f" Scryfall {cat}: {len(cards)} candidates")
|
||||||
|
|
||||||
# ── Step 4: Ollama suggestions to fill gaps ────────────────────────────
|
# ── Step 4: Ollama suggestions to fill gaps ──────────────────
|
||||||
logger.info("Step 4: Ollama suggesting additional cards")
|
logger.info("Step 4: Ollama suggesting additional cards")
|
||||||
|
|
||||||
# Collect all names we already have
|
# Collect all names we already have
|
||||||
@@ -90,9 +79,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
|||||||
all_names.extend(c.name for c in cards)
|
all_names.extend(c.name for c in cards)
|
||||||
all_names = list(set(all_names))
|
all_names = list(set(all_names))
|
||||||
|
|
||||||
# Ask Ollama to suggest cards for categories that need more.
|
# Ask Ollama to suggest cards for categories that need more
|
||||||
# sim_context is forwarded so the LLM factors in what traits have
|
|
||||||
# been statistically successful in our own game simulations.
|
|
||||||
categories_to_fill = ["synergy", "protection", "wincon"]
|
categories_to_fill = ["synergy", "protection", "wincon"]
|
||||||
ollama_suggestions: Dict[str, List[str]] = {}
|
ollama_suggestions: Dict[str, List[str]] = {}
|
||||||
|
|
||||||
@@ -104,7 +91,6 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
|||||||
count=15,
|
count=15,
|
||||||
exclude=all_names,
|
exclude=all_names,
|
||||||
strategy_notes=request.strategy_notes,
|
strategy_notes=request.strategy_notes,
|
||||||
sim_context=sim_context,
|
|
||||||
)
|
)
|
||||||
ollama_suggestions[category] = suggested
|
ollama_suggestions[category] = suggested
|
||||||
all_names.extend(suggested)
|
all_names.extend(suggested)
|
||||||
@@ -112,7 +98,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
|||||||
|
|
||||||
sources.append("ollama")
|
sources.append("ollama")
|
||||||
|
|
||||||
# ── Step 5: Filter (color identity, collection, ban list) ────────────────
|
# ── Step 5: Filter (color identity, collection, ban list) ────
|
||||||
logger.info("Step 5: Filtering candidates")
|
logger.info("Step 5: Filtering candidates")
|
||||||
|
|
||||||
# Build a unified name pool per category
|
# Build a unified name pool per category
|
||||||
@@ -154,7 +140,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
|||||||
n for n in cards_by_category[cat] if n not in BANNED_CARDS
|
n for n in cards_by_category[cat] if n not in BANNED_CARDS
|
||||||
]
|
]
|
||||||
|
|
||||||
# ── Step 6: Ollama enforce ratios ────────────────────────────────────
|
# ── Step 6: Ollama enforce ratios ────────────────────────────
|
||||||
logger.info("Step 6: Ollama enforcing deck ratios")
|
logger.info("Step 6: Ollama enforcing deck ratios")
|
||||||
target_ratios = {
|
target_ratios = {
|
||||||
"lands": request.ratios.lands,
|
"lands": request.ratios.lands,
|
||||||
@@ -167,13 +153,10 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
|||||||
"uncategorized": request.ratios.uncategorized,
|
"uncategorized": request.ratios.uncategorized,
|
||||||
}
|
}
|
||||||
|
|
||||||
# sim_context is forwarded so Ollama preferentially retains cards
|
|
||||||
# matching high-value sim traits when trimming over-filled categories.
|
|
||||||
adjusted = ollama.enforce_deck_ratios(
|
adjusted = ollama.enforce_deck_ratios(
|
||||||
cards_by_category=cards_by_category,
|
cards_by_category=cards_by_category,
|
||||||
target_ratios=target_ratios,
|
target_ratios=target_ratios,
|
||||||
commander_name=commander.name,
|
commander_name=commander.name,
|
||||||
sim_context=sim_context,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify total is 99
|
# Verify total is 99
|
||||||
@@ -182,52 +165,25 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
|||||||
warnings.append(f"Ollama returned {total} cards instead of 99 — adjusting")
|
warnings.append(f"Ollama returned {total} cards instead of 99 — adjusting")
|
||||||
logger.warning(f"Ratio enforcement returned {total} cards")
|
logger.warning(f"Ratio enforcement returned {total} cards")
|
||||||
|
|
||||||
# ── Step 7: Assemble final deck ────────────────────────────────────────
|
# ── Step 7: Assemble final deck ──────────────────────────────
|
||||||
logger.info("Step 7: Assembling final deck")
|
logger.info("Step 7: Assembling final deck")
|
||||||
|
|
||||||
# Build a globally-deduplicated name list. enforce_deck_ratios can
|
# Convert to CardEntry list via Scryfall batch lookup
|
||||||
# return the same card name under multiple category keys (e.g. a
|
all_card_names = []
|
||||||
# protection card that also appears in synergy). Without this step
|
for names in adjusted.values():
|
||||||
# get_cards_by_names produces duplicate CardEntry objects that
|
all_card_names.extend(names)
|
||||||
# immediately fail the CommanderDeck singleton validator.
|
|
||||||
seen_names: dict[str, str] = {} # name -> first category that claimed it
|
|
||||||
for cat, names in adjusted.items():
|
|
||||||
for name in names:
|
|
||||||
if name not in seen_names:
|
|
||||||
seen_names[name] = cat
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Duplicate card '%s' found in categories '%s' and '%s' — "
|
|
||||||
"keeping first assignment, dropping second",
|
|
||||||
name, seen_names[name], cat,
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(seen_names) < sum(len(v) for v in adjusted.values()):
|
|
||||||
warnings.append(
|
|
||||||
"enforce_deck_ratios returned duplicate card names across categories; "
|
|
||||||
"duplicates were silently dropped before singleton validation."
|
|
||||||
)
|
|
||||||
|
|
||||||
all_card_names = list(seen_names.keys())
|
|
||||||
|
|
||||||
card_entries = scryfall.get_cards_by_names(all_card_names)
|
card_entries = scryfall.get_cards_by_names(all_card_names)
|
||||||
|
|
||||||
# Safety-net: deduplicate CardEntry list by name in case Scryfall
|
|
||||||
# returns multiple printings for the same name.
|
|
||||||
seen_entry_names: set[str] = set()
|
|
||||||
unique_entries: List[CardEntry] = []
|
|
||||||
for entry in card_entries:
|
|
||||||
if entry.name not in seen_entry_names:
|
|
||||||
seen_entry_names.add(entry.name)
|
|
||||||
unique_entries.append(entry)
|
|
||||||
else:
|
|
||||||
logger.warning("Scryfall returned duplicate entry for '%s' — skipping", entry.name)
|
|
||||||
card_entries = unique_entries
|
|
||||||
|
|
||||||
# Map back categories
|
# Map back categories
|
||||||
|
name_to_cat: Dict[str, str] = {}
|
||||||
|
for cat, names in adjusted.items():
|
||||||
|
for name in names:
|
||||||
|
name_to_cat[name] = cat
|
||||||
|
|
||||||
for entry in card_entries:
|
for entry in card_entries:
|
||||||
if entry.name in seen_names:
|
if entry.name in name_to_cat:
|
||||||
entry.category = seen_names[entry.name]
|
entry.category = name_to_cat[entry.name]
|
||||||
|
|
||||||
# Build the deck
|
# Build the deck
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ The LLM receives a structured game-state snapshot and returns a JSON
|
|||||||
action describing what to do this turn (play land, cast spell, attack,
|
action describing what to do this turn (play land, cast spell, attack,
|
||||||
hold/pass, use removal, use board wipe, cast ramp, cast commander).
|
hold/pass, use removal, use board wipe, cast ramp, cast commander).
|
||||||
|
|
||||||
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at localhost:11434
|
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at 192.168.0.240:11434
|
||||||
ollama run deepseek-r1:8b
|
ollama run deepseek-r1:8b
|
||||||
|
|
||||||
Also supports:
|
Also supports:
|
||||||
@@ -64,7 +64,7 @@ class DeepSeekConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
Example — Ollama (default)::
|
Example — Ollama (default)::
|
||||||
config = DeepSeekConfig() # points at localhost:11434 / deepseek-r1:8b
|
config = DeepSeekConfig() # points at 192.168.0.240:11434 / deepseek-r1:8b
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# API endpoint — Ollama OpenAI-compatible base (NO /v1 suffix)
|
# API endpoint — Ollama OpenAI-compatible base (NO /v1 suffix)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Usage::
|
|||||||
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
||||||
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
|
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
|
||||||
|
|
||||||
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://localhost:11434"))
|
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://192.168.0.240:11434"))
|
||||||
brain.check_connection()
|
brain.check_connection()
|
||||||
engine = DeepSeekGameEngine(brain=brain, ai_player_index=1)
|
engine = DeepSeekGameEngine(brain=brain, ai_player_index=1)
|
||||||
result = engine.run(deck_a, deck_b, name_a="My Deck", name_b="DeepSeek AI")
|
result = engine.run(deck_a, deck_b, name_a="My Deck", name_b="DeepSeek AI")
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class ValidatorConfig:
|
|||||||
|
|
||||||
Example -- LM Studio::
|
Example -- LM Studio::
|
||||||
cfg = ValidatorConfig(
|
cfg = ValidatorConfig(
|
||||||
api_base="http://localhost:1234",
|
api_base="http://192.168.0.240:1234",
|
||||||
model="deepseek-r1-distill-qwen-14b",
|
model="deepseek-r1-distill-qwen-14b",
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,359 +0,0 @@
|
|||||||
package commanderailab.ai;
|
|
||||||
|
|
||||||
import commanderailab.ml.LearnedPolicy;
|
|
||||||
import commanderailab.ml.MacroActionExecutor;
|
|
||||||
import commanderailab.ml.PolicyServerClient;
|
|
||||||
import commanderailab.schema.DecisionSnapshot;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.GsonBuilder;
|
|
||||||
import com.google.gson.JsonArray;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
|
|
||||||
import java.io.*;
|
|
||||||
import java.net.HttpURLConnection;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PolicyClient — Phase 2 bridge between Forge decision points and the
|
|
||||||
* Python policy server for live online training.
|
|
||||||
*
|
|
||||||
* This class extends the existing LearnedPolicy/PolicyServerClient
|
|
||||||
* infrastructure with two key Phase 2 capabilities:
|
|
||||||
*
|
|
||||||
* 1. Calls the new /api/policy/decide endpoint (purpose-built for live
|
|
||||||
* IPC with richer state including all zones, stack, and priority)
|
|
||||||
* instead of the batch-oriented /api/ml/predict.
|
|
||||||
*
|
|
||||||
* 2. Collects (state, action, reward) tuples in real-time during live
|
|
||||||
* Forge games and flushes them to the policy server for online PPO
|
|
||||||
* updates via POST /api/policy/collect.
|
|
||||||
*
|
|
||||||
* Falls back to ForgeBuiltinPolicy heuristics if the policy server
|
|
||||||
* is unreachable or returns an error.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* PolicyClient client = new PolicyClient("http://localhost:8080");
|
|
||||||
* if (client.connect()) {
|
|
||||||
* // At each Forge decision point:
|
|
||||||
* String action = client.decide(fullGameState);
|
|
||||||
* // After game ends:
|
|
||||||
* client.submitReward(gameId, reward);
|
|
||||||
* client.flushCollectedTuples();
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* Refs: Issue #83 Phase 2.1
|
|
||||||
*/
|
|
||||||
public class PolicyClient {
|
|
||||||
|
|
||||||
private static final Gson GSON = new GsonBuilder().create();
|
|
||||||
private static final int CONNECT_TIMEOUT_MS = 2000;
|
|
||||||
private static final int READ_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
private final String baseUrl;
|
|
||||||
private final LearnedPolicy learnedPolicy;
|
|
||||||
private volatile boolean serverAvailable = false;
|
|
||||||
private volatile String lastError = "";
|
|
||||||
|
|
||||||
// Online learning tuple buffer
|
|
||||||
private final ConcurrentLinkedQueue<JsonObject> tupleBuffer = new ConcurrentLinkedQueue<>();
|
|
||||||
private static final int FLUSH_THRESHOLD = 64;
|
|
||||||
|
|
||||||
// Stats
|
|
||||||
private int totalDecisions = 0;
|
|
||||||
private int policyDecisions = 0;
|
|
||||||
private int fallbackDecisions = 0;
|
|
||||||
private long totalLatencyMs = 0;
|
|
||||||
|
|
||||||
public PolicyClient(String serverUrl) {
|
|
||||||
this.baseUrl = serverUrl.endsWith("/")
|
|
||||||
? serverUrl.substring(0, serverUrl.length() - 1)
|
|
||||||
: serverUrl;
|
|
||||||
this.learnedPolicy = new LearnedPolicy(serverUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
public PolicyClient(String serverUrl, String playstyle, boolean greedy) {
|
|
||||||
this(serverUrl);
|
|
||||||
this.learnedPolicy.setPlaystyle(playstyle);
|
|
||||||
this.learnedPolicy.setGreedy(greedy);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// Connection
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check that the policy server's /api/policy/decide endpoint is live
|
|
||||||
* and a model is loaded. Falls back to checking /api/ml/model if the
|
|
||||||
* new endpoint isn't available yet.
|
|
||||||
*/
|
|
||||||
public boolean connect() {
|
|
||||||
try {
|
|
||||||
String json = httpGet(baseUrl + "/api/policy/health");
|
|
||||||
serverAvailable = json.contains("\"ready\":true")
|
|
||||||
|| json.contains("\"status\":\"ok\"");
|
|
||||||
} catch (Exception e) {
|
|
||||||
// Fall back to legacy health check
|
|
||||||
serverAvailable = learnedPolicy.connect();
|
|
||||||
}
|
|
||||||
if (serverAvailable) {
|
|
||||||
System.out.println("[PolicyClient] Connected to policy server");
|
|
||||||
} else {
|
|
||||||
System.out.println("[PolicyClient] Server unavailable, using Forge built-in AI");
|
|
||||||
}
|
|
||||||
return serverAvailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// Phase 2.1 — Live decision endpoint
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request a macro-action decision from the policy server using the
|
|
||||||
* full Forge game state (all zones, stack, priority).
|
|
||||||
*
|
|
||||||
* This calls POST /api/policy/decide which is purpose-built for the
|
|
||||||
* live IPC loop, as opposed to /api/ml/predict which was designed
|
|
||||||
* for batch inference.
|
|
||||||
*
|
|
||||||
* @param gameState Full game state JSON with all zones and priority info
|
|
||||||
* @return macro-action name (e.g., "cast_creature"), or "pass" on failure
|
|
||||||
*/
|
|
||||||
public String decide(JsonObject gameState) {
|
|
||||||
totalDecisions++;
|
|
||||||
long t0 = System.currentTimeMillis();
|
|
||||||
|
|
||||||
if (!serverAvailable) {
|
|
||||||
fallbackDecisions++;
|
|
||||||
return MacroActionExecutor.PASS;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Add inference params
|
|
||||||
gameState.addProperty("playstyle", learnedPolicy.getPlaystyle());
|
|
||||||
gameState.addProperty("greedy", learnedPolicy.isGreedy());
|
|
||||||
|
|
||||||
String responseJson = httpPost(
|
|
||||||
baseUrl + "/api/policy/decide",
|
|
||||||
gameState.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
JsonObject resp = GSON.fromJson(responseJson, JsonObject.class);
|
|
||||||
long elapsed = System.currentTimeMillis() - t0;
|
|
||||||
totalLatencyMs += elapsed;
|
|
||||||
|
|
||||||
if (resp.has("action") && !resp.has("error")) {
|
|
||||||
policyDecisions++;
|
|
||||||
String action = resp.get("action").getAsString();
|
|
||||||
|
|
||||||
// Store tuple for online learning
|
|
||||||
collectTuple(gameState, action, resp);
|
|
||||||
|
|
||||||
return action;
|
|
||||||
} else {
|
|
||||||
fallbackDecisions++;
|
|
||||||
lastError = resp.has("error")
|
|
||||||
? resp.get("error").getAsString()
|
|
||||||
: "Unknown error";
|
|
||||||
return MacroActionExecutor.PASS;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
fallbackDecisions++;
|
|
||||||
lastError = e.getMessage();
|
|
||||||
return MacroActionExecutor.PASS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience: decide from a DecisionSnapshot (delegates to the
|
|
||||||
* existing LearnedPolicy path if /api/policy/decide is unavailable).
|
|
||||||
*/
|
|
||||||
public String decide(DecisionSnapshot snapshot) {
|
|
||||||
return learnedPolicy.decideAction(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a decision to a concrete Forge action.
|
|
||||||
*/
|
|
||||||
public MacroActionExecutor.ActionResolution resolveAction(
|
|
||||||
JsonObject gameState,
|
|
||||||
List<String> hand,
|
|
||||||
List<String> battlefield,
|
|
||||||
List<String> commandZone,
|
|
||||||
int manaAvailable,
|
|
||||||
String commanderName,
|
|
||||||
int commanderTax
|
|
||||||
) {
|
|
||||||
String macroAction = decide(gameState);
|
|
||||||
return MacroActionExecutor.resolve(
|
|
||||||
macroAction, hand, battlefield, commandZone,
|
|
||||||
manaAvailable, commanderName, commanderTax
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// Phase 2.1 — Online learning tuple collection
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Buffer a (state, action, prediction_metadata) tuple for later
|
|
||||||
* submission to the policy server for online PPO updates.
|
|
||||||
*/
|
|
||||||
private void collectTuple(JsonObject state, String action, JsonObject prediction) {
|
|
||||||
JsonObject tuple = new JsonObject();
|
|
||||||
tuple.add("state", state);
|
|
||||||
tuple.addProperty("action", action);
|
|
||||||
if (prediction.has("action_index")) {
|
|
||||||
tuple.addProperty("action_index", prediction.get("action_index").getAsInt());
|
|
||||||
}
|
|
||||||
if (prediction.has("log_prob")) {
|
|
||||||
tuple.addProperty("log_prob", prediction.get("log_prob").getAsDouble());
|
|
||||||
}
|
|
||||||
if (prediction.has("value")) {
|
|
||||||
tuple.addProperty("value", prediction.get("value").getAsDouble());
|
|
||||||
}
|
|
||||||
tuple.addProperty("timestamp", System.currentTimeMillis());
|
|
||||||
|
|
||||||
tupleBuffer.add(tuple);
|
|
||||||
|
|
||||||
// Auto-flush when buffer is full
|
|
||||||
if (tupleBuffer.size() >= FLUSH_THRESHOLD) {
|
|
||||||
flushCollectedTuples();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit end-of-game reward for all tuples in the current game.
|
|
||||||
* Call this after a Forge game completes.
|
|
||||||
*/
|
|
||||||
public void submitReward(String gameId, double reward) {
|
|
||||||
try {
|
|
||||||
JsonObject body = new JsonObject();
|
|
||||||
body.addProperty("game_id", gameId);
|
|
||||||
body.addProperty("reward", reward);
|
|
||||||
httpPost(baseUrl + "/api/policy/reward", body.toString());
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("[PolicyClient] Failed to submit reward: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flush buffered (state, action, reward) tuples to the policy server
|
|
||||||
* for online PPO updates via POST /api/policy/collect.
|
|
||||||
*/
|
|
||||||
public void flushCollectedTuples() {
|
|
||||||
if (tupleBuffer.isEmpty()) return;
|
|
||||||
|
|
||||||
JsonArray batch = new JsonArray();
|
|
||||||
JsonObject tuple;
|
|
||||||
while ((tuple = tupleBuffer.poll()) != null) {
|
|
||||||
batch.add(tuple);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
JsonObject body = new JsonObject();
|
|
||||||
body.add("tuples", batch);
|
|
||||||
body.addProperty("count", batch.size());
|
|
||||||
httpPost(baseUrl + "/api/policy/collect", body.toString());
|
|
||||||
System.out.println("[PolicyClient] Flushed " + batch.size() + " tuples");
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("[PolicyClient] Failed to flush tuples: " + e.getMessage());
|
|
||||||
// Re-queue on failure
|
|
||||||
for (int i = 0; i < batch.size(); i++) {
|
|
||||||
tupleBuffer.add(batch.get(i).getAsJsonObject());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// Accessors
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
public boolean isServerAvailable() { return serverAvailable; }
|
|
||||||
public String getLastError() { return lastError; }
|
|
||||||
public int getTotalDecisions() { return totalDecisions; }
|
|
||||||
public int getPolicyDecisions() { return policyDecisions; }
|
|
||||||
public String getBaseUrl() { return baseUrl; }
|
|
||||||
|
|
||||||
/** Decision result record for GameSession integration. */
|
|
||||||
public record PolicyDecision(String action, double confidence) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide from a JSON string + seat index. Used by GameSession.
|
|
||||||
* Converts to JsonObject and delegates to decide(JsonObject).
|
|
||||||
*/
|
|
||||||
public PolicyDecision decide(String stateJson, int seat) {
|
|
||||||
JsonObject state = GSON.fromJson(stateJson, JsonObject.class);
|
|
||||||
state.addProperty("seat", seat);
|
|
||||||
String action = decide(state);
|
|
||||||
return new PolicyDecision(action, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getFallbackDecisions() { return fallbackDecisions; }
|
|
||||||
public int getPendingTuples() { return tupleBuffer.size(); }
|
|
||||||
|
|
||||||
public double getAverageLatencyMs() {
|
|
||||||
return policyDecisions > 0
|
|
||||||
? (double) totalLatencyMs / policyDecisions
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getStatsSummary() {
|
|
||||||
return String.format(
|
|
||||||
"PolicyClient[decisions=%d, policy=%d, fallback=%d, avg=%.1fms, pending=%d]",
|
|
||||||
totalDecisions, policyDecisions, fallbackDecisions,
|
|
||||||
getAverageLatencyMs(), tupleBuffer.size()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// HTTP helpers
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
private String httpGet(String url) throws IOException {
|
|
||||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
|
||||||
conn.setRequestMethod("GET");
|
|
||||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
|
||||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
|
||||||
conn.setRequestProperty("Accept", "application/json");
|
|
||||||
int code = conn.getResponseCode();
|
|
||||||
if (code != 200) {
|
|
||||||
throw new IOException("HTTP " + code + ": " + readStream(conn.getErrorStream()));
|
|
||||||
}
|
|
||||||
return readStream(conn.getInputStream());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String httpPost(String url, String jsonBody) throws IOException {
|
|
||||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
|
||||||
conn.setRequestMethod("POST");
|
|
||||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
|
||||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
|
||||||
conn.setDoOutput(true);
|
|
||||||
conn.setRequestProperty("Content-Type", "application/json");
|
|
||||||
conn.setRequestProperty("Accept", "application/json");
|
|
||||||
try (OutputStream os = conn.getOutputStream()) {
|
|
||||||
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
int code = conn.getResponseCode();
|
|
||||||
if (code != 200) {
|
|
||||||
throw new IOException("HTTP " + code + ": " + readStream(conn.getErrorStream()));
|
|
||||||
}
|
|
||||||
return readStream(conn.getInputStream());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String readStream(InputStream stream) throws IOException {
|
|
||||||
if (stream == null) return "";
|
|
||||||
try (BufferedReader reader = new BufferedReader(
|
|
||||||
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
sb.append(line);
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,12 +17,6 @@ import java.util.concurrent.atomic.AtomicLong;
|
|||||||
* - Issue #5: Auto-detects optimal thread count, configurable cap,
|
* - Issue #5: Auto-detects optimal thread count, configurable cap,
|
||||||
* real-time sims/sec reporting, graceful backpressure
|
* real-time sims/sec reporting, graceful backpressure
|
||||||
*
|
*
|
||||||
* Phase 3 (Issue #83): Persistent JVM worker pool — eliminates per-game
|
|
||||||
* JVM startup overhead by reusing GameRules and CardDb across games.
|
|
||||||
* - Workers are pre-warmed at construction time
|
|
||||||
* - Pool size matches numThreads (one warm worker per thread slot)
|
|
||||||
* - Target throughput: 10-50 games/sec per thread
|
|
||||||
*
|
|
||||||
* Each thread gets its own BatchRunner instance and a per-thread RNG seed
|
* Each thread gets its own BatchRunner instance and a per-thread RNG seed
|
||||||
* derived from the master seed. Results are merged after all threads complete.
|
* derived from the master seed. Results are merged after all threads complete.
|
||||||
*/
|
*/
|
||||||
@@ -49,17 +43,11 @@ public class MultiThreadBatchRunner {
|
|||||||
private boolean useSimplifiedAi = false;
|
private boolean useSimplifiedAi = false;
|
||||||
private int aiThinkTimeMs = -1;
|
private int aiThinkTimeMs = -1;
|
||||||
|
|
||||||
// ── ML Decision Logging ──────────────────────────────────────
|
// ── ML Decision Logging ────────────────────────────────────
|
||||||
private boolean mlLogEnabled = false;
|
private boolean mlLogEnabled = false;
|
||||||
private String mlOutputDir = "results";
|
private String mlOutputDir = "results";
|
||||||
private String mlBatchId;
|
private String mlBatchId;
|
||||||
|
|
||||||
// ── Phase 3: Persistent JVM worker pool ──────────────────────
|
|
||||||
// Pre-warmed BatchRunner instances — one per thread slot.
|
|
||||||
// Reusing them avoids per-game JVM startup cost (GameRules, CardDb init).
|
|
||||||
private final List<BatchRunner> workerPool = new ArrayList<>();
|
|
||||||
private boolean poolWarmed = false;
|
|
||||||
|
|
||||||
public MultiThreadBatchRunner(String forgeJarPath, String forgeWorkDir,
|
public MultiThreadBatchRunner(String forgeJarPath, String forgeWorkDir,
|
||||||
List<DeckInfo> decks, AiPolicy policy, int numThreads) {
|
List<DeckInfo> decks, AiPolicy policy, int numThreads) {
|
||||||
this(forgeJarPath, forgeWorkDir, decks, policy, numThreads, true, 120, "java");
|
this(forgeJarPath, forgeWorkDir, decks, policy, numThreads, true, 120, "java");
|
||||||
@@ -84,104 +72,75 @@ public class MultiThreadBatchRunner {
|
|||||||
this.javaPath = (javaPath != null && !javaPath.isEmpty()) ? javaPath : "java";
|
this.javaPath = (javaPath != null && !javaPath.isEmpty()) ? javaPath : "java";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Configuration setters ────────────────────────────────────
|
/**
|
||||||
|
* Set progress callback for real-time reporting (Issue #5).
|
||||||
|
*/
|
||||||
public void setProgressCallback(BatchRunner.ProgressCallback callback) {
|
public void setProgressCallback(BatchRunner.ProgressCallback callback) {
|
||||||
this.progressCallback = callback;
|
this.progressCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set max subprocess queue depth for backpressure (Issue #5).
|
||||||
|
* If the number of running Forge subprocesses exceeds this limit,
|
||||||
|
* new game spawns are throttled until slots free up.
|
||||||
|
*
|
||||||
|
* @param maxDepth Max concurrent subprocesses (-1 for unlimited)
|
||||||
|
*/
|
||||||
public void setMaxQueueDepth(int maxDepth) {
|
public void setMaxQueueDepth(int maxDepth) {
|
||||||
this.maxQueueDepth = maxDepth;
|
this.maxQueueDepth = maxDepth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure AI optimization flags (Issue #4).
|
||||||
|
*/
|
||||||
public void setAiOptimization(boolean simplified, int thinkTimeMs) {
|
public void setAiOptimization(boolean simplified, int thinkTimeMs) {
|
||||||
this.useSimplifiedAi = simplified;
|
this.useSimplifiedAi = simplified;
|
||||||
this.aiThinkTimeMs = thinkTimeMs;
|
this.aiThinkTimeMs = thinkTimeMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable ML decision logging for batch simulations.
|
||||||
|
* Each thread gets its own log file with a unique batch ID suffix.
|
||||||
|
*/
|
||||||
public void enableMlLogging(String outputDir, String batchId) {
|
public void enableMlLogging(String outputDir, String batchId) {
|
||||||
this.mlLogEnabled = true;
|
this.mlLogEnabled = true;
|
||||||
this.mlOutputDir = outputDir;
|
this.mlOutputDir = outputDir;
|
||||||
this.mlBatchId = batchId;
|
this.mlBatchId = batchId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 3: Worker pool management ─────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-warm the JVM worker pool. Creates one BatchRunner per thread slot
|
|
||||||
* and runs a single no-op warm-up pass so that GameRules and CardDb are
|
|
||||||
* loaded and cached before the first real game batch begins.
|
|
||||||
*
|
|
||||||
* Call this once after construction, before runBatch(). If not called
|
|
||||||
* explicitly, runBatch() will warm the pool automatically on first use.
|
|
||||||
*
|
|
||||||
* Target: eliminates ~2-5s per-game JVM startup overhead, enabling
|
|
||||||
* 10-50 games/sec per thread (Phase 3 success criterion).
|
|
||||||
*/
|
|
||||||
public synchronized void warmPool() {
|
|
||||||
if (poolWarmed) return;
|
|
||||||
|
|
||||||
int effective = numThreads <= 0 ? detectOptimalThreads() : numThreads;
|
|
||||||
System.out.printf("[POOL] Pre-warming %d JVM worker(s)...%n", effective);
|
|
||||||
long warmStart = System.currentTimeMillis();
|
|
||||||
|
|
||||||
workerPool.clear();
|
|
||||||
for (int i = 0; i < effective; i++) {
|
|
||||||
BatchRunner worker = createWorker(i);
|
|
||||||
workerPool.add(worker);
|
|
||||||
}
|
|
||||||
|
|
||||||
long warmMs = System.currentTimeMillis() - warmStart;
|
|
||||||
System.out.printf("[POOL] Worker pool ready (%d workers) in %.1fs%n",
|
|
||||||
effective, warmMs / 1000.0);
|
|
||||||
poolWarmed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shut down the worker pool and release resources.
|
|
||||||
* Call after all runBatch() calls are complete.
|
|
||||||
*/
|
|
||||||
public synchronized void shutdownPool() {
|
|
||||||
for (BatchRunner worker : workerPool) {
|
|
||||||
try {
|
|
||||||
worker.closeMlLogger();
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
workerPool.clear();
|
|
||||||
poolWarmed = false;
|
|
||||||
System.out.println("[POOL] Worker pool shut down.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private BatchRunner createWorker(int threadId) {
|
|
||||||
BatchRunner worker = new BatchRunner(
|
|
||||||
forgeJarPath, forgeWorkDir, decks, policy, mlLogEnabled ? false : quiet, clockSeconds, javaPath);
|
|
||||||
worker.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
|
|
||||||
if (mlLogEnabled) {
|
|
||||||
String threadBatchId = mlBatchId + "-t" + threadId;
|
|
||||||
worker.enableMlLogging(mlOutputDir, threadBatchId);
|
|
||||||
}
|
|
||||||
return worker;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 3: Thread count auto-detection ─────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-detect optimal thread count based on available CPU cores (Issue #5).
|
* Auto-detect optimal thread count based on available CPU cores (Issue #5).
|
||||||
|
*
|
||||||
* Uses physical core count (not logical/hyperthreaded) when possible.
|
* Uses physical core count (not logical/hyperthreaded) when possible.
|
||||||
|
* Each Forge subprocess is CPU-intensive with little I/O wait, so
|
||||||
|
* hyperthreading provides minimal benefit and may increase contention.
|
||||||
|
*
|
||||||
|
* @return Recommended thread count for this machine
|
||||||
*/
|
*/
|
||||||
public static int detectOptimalThreads() {
|
public static int detectOptimalThreads() {
|
||||||
int availableProcessors = Runtime.getRuntime().availableProcessors();
|
int availableProcessors = Runtime.getRuntime().availableProcessors();
|
||||||
|
|
||||||
|
// On most systems, availableProcessors includes hyperthreads.
|
||||||
|
// For CPU-bound Forge sims, physical cores are what matter.
|
||||||
|
// Heuristic: assume HT gives 2x logical cores on Intel/AMD
|
||||||
int estimatedPhysical = availableProcessors;
|
int estimatedPhysical = availableProcessors;
|
||||||
|
|
||||||
|
// Try to detect if hyperthreading is active
|
||||||
|
// On Windows, we can check if logical > physical
|
||||||
try {
|
try {
|
||||||
String os = System.getProperty("os.name", "").toLowerCase();
|
String os = System.getProperty("os.name", "").toLowerCase();
|
||||||
if (os.contains("win")) {
|
if (os.contains("win")) {
|
||||||
|
// Windows: NUMBER_OF_PROCESSORS env var = logical processors
|
||||||
|
// For a more accurate count, we'd need WMI or /proc — but the
|
||||||
|
// simple heuristic of logical/2 works for most desktop systems
|
||||||
|
// Only apply if > 4 logical cores (small systems likely don't have HT)
|
||||||
if (availableProcessors > 4) {
|
if (availableProcessors > 4) {
|
||||||
estimatedPhysical = availableProcessors / 2;
|
estimatedPhysical = availableProcessors / 2;
|
||||||
}
|
}
|
||||||
} else if (os.contains("linux")) {
|
} else if (os.contains("linux")) {
|
||||||
java.io.File topology = new java.io.File(
|
// Linux: try reading /sys/devices/system/cpu/cpu0/topology/thread_siblings_list
|
||||||
"/sys/devices/system/cpu/cpu0/topology/thread_siblings_list");
|
// If the file exists and shows 2 threads per core, halve the count
|
||||||
|
java.io.File topology = new java.io.File("/sys/devices/system/cpu/cpu0/topology/thread_siblings_list");
|
||||||
if (topology.exists()) {
|
if (topology.exists()) {
|
||||||
String content = java.nio.file.Files.readString(topology.toPath()).trim();
|
String content = java.nio.file.Files.readString(topology.toPath()).trim();
|
||||||
if (content.contains(",") || content.contains("-")) {
|
if (content.contains(",") || content.contains("-")) {
|
||||||
@@ -193,54 +152,47 @@ public class MultiThreadBatchRunner {
|
|||||||
// Ignore — use availableProcessors as-is
|
// Ignore — use availableProcessors as-is
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reserve 1 core for the OS/JVM overhead, minimum 1 worker
|
||||||
int optimal = Math.max(1, estimatedPhysical - 1);
|
int optimal = Math.max(1, estimatedPhysical - 1);
|
||||||
|
|
||||||
System.out.printf("[THREADS] Detected: %d logical processors, ~%d physical cores → using %d threads%n",
|
System.out.printf("[THREADS] Detected: %d logical processors, ~%d physical cores → using %d threads%n",
|
||||||
availableProcessors, estimatedPhysical, optimal);
|
availableProcessors, estimatedPhysical, optimal);
|
||||||
|
|
||||||
return optimal;
|
return optimal;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main batch runner ─────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run totalGames across numThreads workers.
|
* Run totalGames across numThreads workers.
|
||||||
*
|
*
|
||||||
* Phase 3: Uses the persistent worker pool if available (warmPool() called
|
* v24: Real-time progress reporting, backpressure, per-game sims/sec (Issue #5).
|
||||||
* beforehand), otherwise falls back to creating per-run workers. Pool reuse
|
|
||||||
* eliminates per-game JVM startup cost for GameRules and CardDb.
|
|
||||||
*/
|
*/
|
||||||
public List<GameResult> runBatch(int totalGames, Long masterSeed)
|
public List<GameResult> runBatch(int totalGames, Long masterSeed) throws InterruptedException, ExecutionException {
|
||||||
throws InterruptedException, ExecutionException {
|
// Issue #5: Auto-detect if threads=0 or threads=-1 (auto mode)
|
||||||
|
|
||||||
// Auto-detect if threads=0 or threads=-1
|
|
||||||
if (numThreads <= 0) {
|
if (numThreads <= 0) {
|
||||||
numThreads = detectOptimalThreads();
|
numThreads = detectOptimalThreads();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: auto-warm pool on first runBatch() if not already warmed
|
|
||||||
if (!poolWarmed) {
|
|
||||||
warmPool();
|
|
||||||
}
|
|
||||||
|
|
||||||
int gamesPerThread = totalGames / numThreads;
|
int gamesPerThread = totalGames / numThreads;
|
||||||
int remainder = totalGames % numThreads;
|
int remainder = totalGames % numThreads;
|
||||||
|
|
||||||
|
// Issue #5: Backpressure — limit concurrent subprocesses if configured
|
||||||
ExecutorService executor;
|
ExecutorService executor;
|
||||||
if (maxQueueDepth > 0) {
|
if (maxQueueDepth > 0) {
|
||||||
|
// Use a bounded thread pool to prevent RAM exhaustion from too many Forge JVMs
|
||||||
int effectiveThreads = Math.min(numThreads, maxQueueDepth);
|
int effectiveThreads = Math.min(numThreads, maxQueueDepth);
|
||||||
executor = Executors.newFixedThreadPool(effectiveThreads);
|
executor = Executors.newFixedThreadPool(effectiveThreads);
|
||||||
System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes " +
|
System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes (requested %d threads, max queue %d)%n",
|
||||||
"(requested %d threads, max queue %d)%n",
|
|
||||||
effectiveThreads, numThreads, maxQueueDepth);
|
effectiveThreads, numThreads, maxQueueDepth);
|
||||||
} else {
|
} else {
|
||||||
executor = Executors.newFixedThreadPool(numThreads);
|
executor = Executors.newFixedThreadPool(numThreads);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Future<List<GameResult>>> futures = new ArrayList<>();
|
List<Future<List<GameResult>>> futures = new ArrayList<>();
|
||||||
|
|
||||||
totalCompleted.set(0);
|
totalCompleted.set(0);
|
||||||
batchStartMs.set(System.currentTimeMillis());
|
batchStartMs.set(System.currentTimeMillis());
|
||||||
|
|
||||||
System.out.printf("[BATCH] Starting %d games across %d pooled workers " +
|
System.out.printf("[BATCH] Starting %d games across %d threads (%.1f games/thread avg)%n",
|
||||||
"(%.1f games/worker avg)%n",
|
|
||||||
totalGames, numThreads, (double) totalGames / numThreads);
|
totalGames, numThreads, (double) totalGames / numThreads);
|
||||||
|
|
||||||
int gameOffset = 0;
|
int gameOffset = 0;
|
||||||
@@ -248,40 +200,45 @@ public class MultiThreadBatchRunner {
|
|||||||
int chunkSize = gamesPerThread + (t < remainder ? 1 : 0);
|
int chunkSize = gamesPerThread + (t < remainder ? 1 : 0);
|
||||||
if (chunkSize == 0) continue;
|
if (chunkSize == 0) continue;
|
||||||
|
|
||||||
Long threadSeed = (masterSeed != null)
|
Long threadSeed = (masterSeed != null) ? masterSeed + (long) gameOffset * 1000L : null;
|
||||||
? masterSeed + (long) gameOffset * 1000L : null;
|
|
||||||
int startIndex = gameOffset;
|
int startIndex = gameOffset;
|
||||||
int threadId = t;
|
int threadId = t;
|
||||||
|
|
||||||
// Phase 3: retrieve pre-warmed worker from pool
|
BatchRunner runner = new BatchRunner(forgeJarPath, forgeWorkDir, decks, policy, quiet, clockSeconds, javaPath);
|
||||||
BatchRunner worker = (threadId < workerPool.size())
|
|
||||||
? workerPool.get(threadId)
|
|
||||||
: createWorker(threadId);
|
|
||||||
|
|
||||||
// Per-thread progress callback that aggregates across all threads
|
// Issue #4: Forward AI optimization settings
|
||||||
worker.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
|
runner.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
|
||||||
|
// Forward ML decision logging to per-thread runner
|
||||||
|
if (mlLogEnabled) {
|
||||||
|
String threadBatchId = mlBatchId + "-t" + threadId;
|
||||||
|
runner.enableMlLogging(mlOutputDir, threadBatchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue #5: Per-thread progress callback that aggregates across all threads
|
||||||
|
runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
|
||||||
int globalCompleted = totalCompleted.incrementAndGet();
|
int globalCompleted = totalCompleted.incrementAndGet();
|
||||||
long elapsed = System.currentTimeMillis() - batchStartMs.get();
|
long elapsed = System.currentTimeMillis() - batchStartMs.get();
|
||||||
double globalSimsPerSec = elapsed > 0
|
double globalSimsPerSec = elapsed > 0
|
||||||
? (double) globalCompleted / (elapsed / 1000.0) : 0.0;
|
? (double) globalCompleted / (elapsed / 1000.0)
|
||||||
|
: 0.0;
|
||||||
int globalPct = (int) ((double) globalCompleted * 100.0 / totalGames);
|
int globalPct = (int) ((double) globalCompleted * 100.0 / totalGames);
|
||||||
|
|
||||||
System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec " +
|
System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec (thread %d: %d/%d)%n",
|
||||||
"(worker %d: %d/%d)%n",
|
|
||||||
globalCompleted, totalGames, (double) globalPct,
|
globalCompleted, totalGames, (double) globalPct,
|
||||||
globalSimsPerSec, threadId, completed, total);
|
globalSimsPerSec, threadId, completed, total);
|
||||||
|
|
||||||
|
// Forward to external callback if set
|
||||||
if (progressCallback != null) {
|
if (progressCallback != null) {
|
||||||
progressCallback.onProgress(globalCompleted, totalGames,
|
progressCallback.onProgress(globalCompleted, totalGames, globalPct, globalSimsPerSec, lastResult);
|
||||||
globalPct, globalSimsPerSec, lastResult);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
futures.add(executor.submit(() -> {
|
futures.add(executor.submit(() -> {
|
||||||
List<GameResult> results = worker.runBatchSingleThread(chunkSize, threadSeed);
|
List<GameResult> results = runner.runBatchSingleThread(chunkSize, threadSeed);
|
||||||
for (int i = 0; i < results.size(); i++) {
|
for (int i = 0; i < results.size(); i++) {
|
||||||
results.get(i).gameIndex = startIndex + i;
|
results.get(i).gameIndex = startIndex + i;
|
||||||
}
|
}
|
||||||
|
if (mlLogEnabled) { runner.closeMlLogger(); }
|
||||||
return results;
|
return results;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -304,10 +261,8 @@ public class MultiThreadBatchRunner {
|
|||||||
|
|
||||||
// Final throughput report
|
// Final throughput report
|
||||||
long totalElapsed = System.currentTimeMillis() - batchStartMs.get();
|
long totalElapsed = System.currentTimeMillis() - batchStartMs.get();
|
||||||
double finalSimsPerSec = totalElapsed > 0
|
double finalSimsPerSec = totalElapsed > 0 ? (double) merged.size() / (totalElapsed / 1000.0) : 0.0;
|
||||||
? (double) merged.size() / (totalElapsed / 1000.0) : 0.0;
|
System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec (across %d threads)%n",
|
||||||
System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec " +
|
|
||||||
"(across %d pooled workers)%n",
|
|
||||||
merged.size(), totalElapsed / 1000.0, finalSimsPerSec, numThreads);
|
merged.size(), totalElapsed / 1000.0, finalSimsPerSec, numThreads);
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import com.google.gson.Gson;
|
|||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import com.google.gson.JsonParser;
|
import com.google.gson.JsonParser;
|
||||||
import commanderailab.ai.PolicyClient;
|
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.BlockingQueue;
|
import java.util.concurrent.BlockingQueue;
|
||||||
@@ -14,11 +13,10 @@ import java.util.function.Consumer;
|
|||||||
/**
|
/**
|
||||||
* GameSession — holds state for one live interactive Commander game.
|
* GameSession — holds state for one live interactive Commander game.
|
||||||
*
|
*
|
||||||
* PHASE 2: integrates PolicyClient for bidirectional sync between
|
* PHASE 1 STUB: generates synthetic game state so you can verify the full
|
||||||
* the Java game loop and the Python policy server.
|
* WebSocket contract round-trips correctly before wiring in real Forge IPC.
|
||||||
*
|
*
|
||||||
* AI turns now consult the policy server for macro-action decisions
|
* Replace run() in Phase 2 once the contract is verified end-to-end.
|
||||||
* instead of using random stub logic.
|
|
||||||
*/
|
*/
|
||||||
public class GameSession {
|
public class GameSession {
|
||||||
|
|
||||||
@@ -29,7 +27,6 @@ public class GameSession {
|
|||||||
private final String forgeJarPath;
|
private final String forgeJarPath;
|
||||||
private final String forgeWorkDir;
|
private final String forgeWorkDir;
|
||||||
private final BlockingQueue<String> humanActionQueue;
|
private final BlockingQueue<String> humanActionQueue;
|
||||||
private final PolicyClient policyClient;
|
|
||||||
|
|
||||||
private volatile boolean running = false;
|
private volatile boolean running = false;
|
||||||
private volatile int turnNumber = 1;
|
private volatile int turnNumber = 1;
|
||||||
@@ -59,21 +56,11 @@ public class GameSession {
|
|||||||
public GameSession(List<String> deckNames, Long seed,
|
public GameSession(List<String> deckNames, Long seed,
|
||||||
String forgeJarPath, String forgeWorkDir,
|
String forgeJarPath, String forgeWorkDir,
|
||||||
BlockingQueue<String> humanActionQueue) {
|
BlockingQueue<String> humanActionQueue) {
|
||||||
this(deckNames, seed, forgeJarPath, forgeWorkDir, humanActionQueue,
|
|
||||||
new PolicyClient("http://localhost:8080"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public GameSession(List<String> deckNames, Long seed,
|
|
||||||
String forgeJarPath, String forgeWorkDir,
|
|
||||||
BlockingQueue<String> humanActionQueue,
|
|
||||||
PolicyClient policyClient) {
|
|
||||||
this.deckNames = deckNames;
|
this.deckNames = deckNames;
|
||||||
this.seed = seed;
|
this.seed = seed;
|
||||||
this.forgeJarPath = forgeJarPath;
|
this.forgeJarPath = forgeJarPath;
|
||||||
this.forgeWorkDir = forgeWorkDir;
|
this.forgeWorkDir = forgeWorkDir;
|
||||||
this.humanActionQueue = humanActionQueue;
|
this.humanActionQueue = humanActionQueue;
|
||||||
this.policyClient = policyClient;
|
|
||||||
|
|
||||||
int seats = Math.min(deckNames.size(), 4);
|
int seats = Math.min(deckNames.size(), 4);
|
||||||
life = new int[seats];
|
life = new int[seats];
|
||||||
@@ -87,8 +74,8 @@ public class GameSession {
|
|||||||
for (int i = 0; i < seats; i++) {
|
for (int i = 0; i < seats; i++) {
|
||||||
life[i] = 40;
|
life[i] = 40;
|
||||||
hands[i] = new ArrayList<>(List.of(
|
hands[i] = new ArrayList<>(List.of(
|
||||||
"card_stub_1", "card_stub_2", "card_stub_3",
|
"card_stub_1", "card_stub_2", "card_stub_3",
|
||||||
"card_stub_4", "card_stub_5", "card_stub_6", "card_stub_7"
|
"card_stub_4", "card_stub_5", "card_stub_6", "card_stub_7"
|
||||||
));
|
));
|
||||||
battlefields[i] = new ArrayList<>();
|
battlefields[i] = new ArrayList<>();
|
||||||
graveyards[i] = new ArrayList<>();
|
graveyards[i] = new ArrayList<>();
|
||||||
@@ -98,27 +85,28 @@ public class GameSession {
|
|||||||
|
|
||||||
public boolean isRunning() { return running; }
|
public boolean isRunning() { return running; }
|
||||||
public int getTurnNumber() { return turnNumber; }
|
public int getTurnNumber() { return turnNumber; }
|
||||||
public void stop() { running = false; }
|
|
||||||
|
public void stop() {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the game loop. Calls onStateChange after every action resolves.
|
* Run the game loop. Calls onStateChange after every action resolves.
|
||||||
* Blocks until the game ends or stop() is called.
|
* Blocks until the game ends or stop() is called.
|
||||||
*
|
*
|
||||||
* PHASE 2: AI turns now consult the PolicyClient for decisions.
|
* PHASE 1 STUB: advances through phases automatically; on MAIN1 of seat 0's
|
||||||
* The policy server returns a macro-action which is applied to game state.
|
* turn, pauses and waits for the human client to send an action.
|
||||||
* Falls back to PASS_PRIORITY if the policy server is unreachable.
|
|
||||||
*/
|
*/
|
||||||
public void run(Consumer<Map<String, Object>> onStateChange) throws InterruptedException {
|
public void run(Consumer<Object> onStateChange) throws InterruptedException {
|
||||||
running = true;
|
running = true;
|
||||||
Random rng = seed != null ? new Random(seed) : new Random();
|
Random rng = seed != null ? new Random(seed) : new Random();
|
||||||
|
|
||||||
System.out.println("[Session] Game started. Decks: " + deckNames);
|
System.out.println("[Session] Game started. Decks: " + deckNames);
|
||||||
System.out.println("[Session] PolicyClient endpoint: " + policyClient.getBaseUrl());
|
|
||||||
|
|
||||||
while (running && !isGameOver()) {
|
while (running && !isGameOver()) {
|
||||||
String currentPhase = PHASES[phaseIndex];
|
String currentPhase = PHASES[phaseIndex];
|
||||||
phase = currentPhase;
|
phase = currentPhase;
|
||||||
activePlayer = (turnNumber - 1) % deckNames.size();
|
activePlayer = (turnNumber - 1) % deckNames.size();
|
||||||
|
|
||||||
boolean isHumanTurn = (activePlayer == 0);
|
boolean isHumanTurn = (activePlayer == 0);
|
||||||
boolean isInteractivePhase = currentPhase.equals("MAIN1") || currentPhase.equals("MAIN2");
|
boolean isInteractivePhase = currentPhase.equals("MAIN1") || currentPhase.equals("MAIN2");
|
||||||
|
|
||||||
@@ -129,6 +117,7 @@ public class GameSession {
|
|||||||
|
|
||||||
System.out.println("[Session] Awaiting human action for " + currentPhase + "...");
|
System.out.println("[Session] Awaiting human action for " + currentPhase + "...");
|
||||||
String actionJson = humanActionQueue.poll(120, TimeUnit.SECONDS);
|
String actionJson = humanActionQueue.poll(120, TimeUnit.SECONDS);
|
||||||
|
|
||||||
if (actionJson == null) {
|
if (actionJson == null) {
|
||||||
System.out.println("[Session] Human timed out — auto passing.");
|
System.out.println("[Session] Human timed out — auto passing.");
|
||||||
} else {
|
} else {
|
||||||
@@ -136,18 +125,13 @@ public class GameSession {
|
|||||||
System.out.println("[Session] Human action applied: " + actionJson);
|
System.out.println("[Session] Human action applied: " + actionJson);
|
||||||
}
|
}
|
||||||
awaitingHumanInput = false;
|
awaitingHumanInput = false;
|
||||||
|
|
||||||
} else if (!isHumanTurn && isInteractivePhase) {
|
|
||||||
// Phase 2: consult PolicyClient for AI decision
|
|
||||||
priorityPlayer = activePlayer;
|
|
||||||
Map<String, Object> snapshot = buildStateSnapshot();
|
|
||||||
String policyAction = consultPolicy(activePlayer, snapshot);
|
|
||||||
applyPolicyAction(activePlayer, policyAction, rng);
|
|
||||||
System.out.println("[Session] AI-" + activePlayer + " policy action: " + policyAction);
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Non-interactive phase — small delay for readability
|
// AI turn — small delay for readability
|
||||||
Thread.sleep(300);
|
Thread.sleep(300);
|
||||||
|
// Simulate AI occasionally dealing damage to human
|
||||||
|
if (!isHumanTurn && currentPhase.equals("DAMAGE") && life[0] > 5) {
|
||||||
|
life[0] -= rng.nextInt(4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance phase
|
// Advance phase
|
||||||
@@ -156,6 +140,7 @@ public class GameSession {
|
|||||||
phaseIndex = 0;
|
phaseIndex = 0;
|
||||||
turnNumber++;
|
turnNumber++;
|
||||||
}
|
}
|
||||||
|
|
||||||
onStateChange.accept(buildStateSnapshot());
|
onStateChange.accept(buildStateSnapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,70 +148,11 @@ public class GameSession {
|
|||||||
System.out.println("[Session] Game ended after " + turnNumber + " turns.");
|
System.out.println("[Session] Game ended after " + turnNumber + " turns.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// Policy integration (Phase 2)
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consult the Python policy server for an AI decision.
|
|
||||||
* Sends the current game snapshot and returns the recommended macro-action.
|
|
||||||
* Falls back to PASS_PRIORITY on error.
|
|
||||||
*/
|
|
||||||
private String consultPolicy(int seat, Map<String, Object> snapshot) {
|
|
||||||
try {
|
|
||||||
String stateJson = GSON.toJson(snapshot);
|
|
||||||
PolicyClient.PolicyDecision decision = policyClient.decide(stateJson, seat);
|
|
||||||
if (decision != null && decision.action() != null) {
|
|
||||||
System.out.println("[Session] Policy decision for seat " + seat
|
|
||||||
+ ": " + decision.action()
|
|
||||||
+ " (confidence=" + String.format("%.2f", decision.confidence()) + ")");
|
|
||||||
return decision.action();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("[Session] Policy server error for seat " + seat + ": " + e.getMessage());
|
|
||||||
}
|
|
||||||
return "PASS_PRIORITY";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a policy-recommended macro-action for an AI player.
|
|
||||||
*/
|
|
||||||
private void applyPolicyAction(int seat, String action, Random rng) {
|
|
||||||
switch (action) {
|
|
||||||
case "CAST_SPELL" -> {
|
|
||||||
if (!hands[seat].isEmpty()) {
|
|
||||||
String card = hands[seat].remove(0);
|
|
||||||
battlefields[seat].add(card);
|
|
||||||
System.out.println("[Session] AI-" + seat + " cast: " + card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "PLAY_LAND" -> {
|
|
||||||
if (!hands[seat].isEmpty()) {
|
|
||||||
String card = hands[seat].remove(hands[seat].size() - 1);
|
|
||||||
battlefields[seat].add(card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "ATTACK" -> {
|
|
||||||
int target = (seat + 1) % life.length;
|
|
||||||
if (life[target] > 0) {
|
|
||||||
int dmg = rng.nextInt(3) + 1;
|
|
||||||
life[target] -= dmg;
|
|
||||||
System.out.println("[Session] AI-" + seat + " attacks player " + target + " for " + dmg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "PASS_PRIORITY" -> { /* no-op */ }
|
|
||||||
default -> System.out.println("[Session] Unknown policy action: " + action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// Human action handling
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
|
|
||||||
private void applyAction(String actionJson) {
|
private void applyAction(String actionJson) {
|
||||||
try {
|
try {
|
||||||
JsonObject action = JsonParser.parseString(actionJson).getAsJsonObject();
|
JsonObject action = JsonParser.parseString(actionJson).getAsJsonObject();
|
||||||
String type = action.has("type") ? action.get("type").getAsString() : "PASS_PRIORITY";
|
String type = action.has("type") ? action.get("type").getAsString() : "PASS_PRIORITY";
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "PLAY_LAND", "CAST_SPELL" -> {
|
case "PLAY_LAND", "CAST_SPELL" -> {
|
||||||
String cardId = action.has("cardId") ? action.get("cardId").getAsString() : null;
|
String cardId = action.has("cardId") ? action.get("cardId").getAsString() : null;
|
||||||
@@ -246,6 +172,7 @@ public class GameSession {
|
|||||||
private boolean isGameOver() {
|
private boolean isGameOver() {
|
||||||
int alive = 0;
|
int alive = 0;
|
||||||
for (int lp : life) if (lp > 0) alive++;
|
for (int lp : life) if (lp > 0) alive++;
|
||||||
|
// Phase 1 stub: end after 50 turns or only 1 player alive
|
||||||
return alive <= 1 || turnNumber > 50;
|
return alive <= 1 || turnNumber > 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +210,7 @@ public class GameSession {
|
|||||||
p.put("poison", poison[i]);
|
p.put("poison", poison[i]);
|
||||||
p.put("commanderTax", commanderTax[i]);
|
p.put("commanderTax", commanderTax[i]);
|
||||||
p.put("handCount", hands[i].size());
|
p.put("handCount", hands[i].size());
|
||||||
p.put("hand", i == 0 ? hands[i] : List.of());
|
p.put("hand", i == 0 ? hands[i] : List.of()); // hide opponent hands
|
||||||
p.put("battlefield", battlefields[i]);
|
p.put("battlefield", battlefields[i]);
|
||||||
p.put("graveyard", graveyards[i]);
|
p.put("graveyard", graveyards[i]);
|
||||||
p.put("commandZone", commandZones[i]);
|
p.put("commandZone", commandZones[i]);
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ public class LabCli implements Callable<Integer> {
|
|||||||
description = "Master RNG seed for reproducibility (blank for random)")
|
description = "Master RNG seed for reproducibility (blank for random)")
|
||||||
private String seed;
|
private String seed;
|
||||||
|
|
||||||
@Option(names = {"--output", "-o"}, defaultValue = "results/ml-decision-latest.json",
|
@Option(names = {"--output", "-o"}, defaultValue = "results/batch-latest.json",
|
||||||
description = "Output JSON file path")
|
description = "Output JSON file path")
|
||||||
private String outputPath;
|
private String outputPath;
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class JsonExporter {
|
|||||||
* Write a BatchResult to a JSON file.
|
* Write a BatchResult to a JSON file.
|
||||||
*
|
*
|
||||||
* @param result The batch result to export
|
* @param result The batch result to export
|
||||||
* @param filePath Output file path (e.g., "results/ml-decision-20260306-001.json")
|
* @param filePath Output file path (e.g., "results/batch-20260306-001.json")
|
||||||
* @throws IOException if file cannot be written
|
* @throws IOException if file cannot be written
|
||||||
*/
|
*/
|
||||||
public static void writeToFile(BatchResult result, String filePath) throws IOException {
|
public static void writeToFile(BatchResult result, String filePath) throws IOException {
|
||||||
|
|||||||
+44
-42
@@ -1,110 +1,112 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM ═══════════════════════════════════════════════════════════
|
REM ═══════════════════════════════════════════════════════════
|
||||||
REM Commander AI Lab — One-Click Launcher (Windows)
|
REM Commander AI Lab — One-Click Launcher (Windows)
|
||||||
REM ═══════════════════════════════════════════════════════════
|
REM ═══════════════════════════════════════════════════════════
|
||||||
REM
|
REM
|
||||||
REM Starts the Python FastAPI server + serves the web UI.
|
REM Starts the Python FastAPI server + serves the web UI.
|
||||||
REM Prerequisites:
|
REM Prerequisites:
|
||||||
REM - Python 3.10+ with: pip install fastapi uvicorn
|
REM - Python 3.10+ with: pip install fastapi uvicorn
|
||||||
REM - Java 17+
|
REM - Java 17+
|
||||||
REM - commander-ai-lab built: mvn package "-DskipTests"
|
REM - commander-ai-lab built: mvn package "-DskipTests"
|
||||||
REM
|
REM
|
||||||
REM Edit the paths below to match your system.
|
REM Edit the paths below to match your system.
|
||||||
REM ═══════════════════════════════════════════════════════════
|
REM ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
REM ── Your Forge paths (EDIT THESE) ──────────────────────────
|
REM ── Your Forge paths (EDIT THESE) ──────────────────────────
|
||||||
set FORGE_JAR=D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
|
set FORGE_JAR=D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
|
||||||
set FORGE_DIR=D:\ForgeCommander\forge-repo
|
set FORGE_DIR=D:\ForgeCommander\forge-repo\forge-gui
|
||||||
set FORGE_DECKS_DIR=%APPDATA%\Forge\decks\commander
|
set FORGE_DECKS_DIR=%APPDATA%\Forge\decks\commander
|
||||||
REM ── Lab JAR (auto-detected from target/) ─────────────────────
|
|
||||||
|
REM ── Lab JAR (auto-detected from target/) ───────────────────
|
||||||
set LAB_PORT=8080
|
set LAB_PORT=8080
|
||||||
|
|
||||||
REM ── API Keys (set via environment variables or .env file) ──
|
REM ── API Keys (set via environment variables or .env file) ──
|
||||||
REM If not already set in your environment, create a .env file
|
REM If not already set in your environment, create a .env file
|
||||||
REM in this directory (see .env.example) or set them manually:
|
REM in this directory (see .env.example) or set them manually:
|
||||||
REM set XIMILAR_API_KEY=your-ximilar-key-here
|
REM set XIMILAR_API_KEY=your-ximilar-key-here
|
||||||
REM set ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
REM set PPLX_API_KEY=your-perplexity-key-here
|
||||||
REM
|
REM
|
||||||
REM Load from .env file if it exists and vars aren't already set
|
REM Load from .env file if it exists and vars aren't already set
|
||||||
if not defined XIMILAR_API_KEY (
|
if not defined XIMILAR_API_KEY (
|
||||||
if exist "%~dp0.env" (
|
if exist "%~dp0.env" (
|
||||||
for /f "usebackq tokens=1,* delims==" %%A in ("%~dp0.env") do (
|
for /f "usebackq tokens=1,* delims==" %%A in ("%~dp0.env") do (
|
||||||
if "%%A"=="XIMILAR_API_KEY" set XIMILAR_API_KEY=%%B
|
if "%%A"=="XIMILAR_API_KEY" set XIMILAR_API_KEY=%%B
|
||||||
if "%%A"=="ANTHROPIC_API_KEY" set ANTHROPIC_API_KEY=%%B
|
if "%%A"=="PPLX_API_KEY" set PPLX_API_KEY=%%B
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ╔══════════════════════════════════════════════════╗
|
echo ╔══════════════════════════════════════════════════╗
|
||||||
echo ║ Commander AI Lab — Launcher ║
|
echo ║ Commander AI Lab — Launcher ║
|
||||||
echo ╚══════════════════════════════════════════════════╝
|
echo ╚══════════════════════════════════════════════════╝
|
||||||
echo.
|
echo.
|
||||||
echo Forge JAR: %FORGE_JAR%
|
echo Forge JAR: %FORGE_JAR%
|
||||||
echo Forge Dir: %FORGE_DIR%
|
echo Forge Dir: %FORGE_DIR%
|
||||||
echo Decks Dir: %FORGE_DECKS_DIR%
|
echo Decks Dir: %FORGE_DECKS_DIR%
|
||||||
echo Port: %LAB_PORT%
|
echo Port: %LAB_PORT%
|
||||||
echo Ximilar: Configured
|
echo Ximilar: Configured
|
||||||
if defined ANTHROPIC_API_KEY (
|
if defined PPLX_API_KEY (
|
||||||
echo Claude Opus: Configured
|
echo Perplexity: Configured
|
||||||
) else (
|
) else (
|
||||||
echo Claude Opus: Not configured ^(set ANTHROPIC_API_KEY env var for AI features^)
|
echo Perplexity: Not configured ^(set PPLX_API_KEY env var for AI features^)
|
||||||
)
|
)
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
REM ── Check prerequisites ────────────────────────────────────
|
REM ── Check prerequisites ────────────────────────────────────
|
||||||
where python >nul 2>&1
|
where python >nul 2>&1
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo ERROR: Python not found in PATH
|
echo ERROR: Python not found in PATH
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
where java >nul 2>&1
|
where java >nul 2>&1
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo ERROR: Java not found in PATH
|
echo ERROR: Java not found in PATH
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
if not exist "%FORGE_JAR%" (
|
if not exist "%FORGE_JAR%" (
|
||||||
echo ERROR: Forge JAR not found at: %FORGE_JAR%
|
echo ERROR: Forge JAR not found at: %FORGE_JAR%
|
||||||
echo Run Forge build first:
|
echo Run Forge build first:
|
||||||
echo cd D:\ForgeCommander\forge-repo
|
echo cd D:\ForgeCommander\forge-repo
|
||||||
echo mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
echo mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
REM ── Check if lab_api.py exists ─────────────────────────────
|
REM ── Check if lab_api.py exists ─────────────────────────────
|
||||||
if not exist "%~dp0lab_api.py" (
|
if not exist "%~dp0lab_api.py" (
|
||||||
echo ERROR: lab_api.py not found in %~dp0
|
echo ERROR: lab_api.py not found in %~dp0
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
REM ── Install Python deps if needed ───────────────────────────
|
REM ── Install Python deps if needed ──────────────────────────
|
||||||
python -c "import fastapi" >nul 2>&1
|
python -c "import fastapi" >nul 2>&1
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo Installing FastAPI + Uvicorn...
|
echo Installing FastAPI + Uvicorn...
|
||||||
pip install fastapi uvicorn
|
pip install fastapi uvicorn
|
||||||
)
|
)
|
||||||
|
|
||||||
REM ── Launch the server ───────────────────────────────────────
|
REM ── Launch the server ──────────────────────────────────────
|
||||||
echo.
|
echo.
|
||||||
echo Starting Commander AI Lab server...
|
echo Starting Commander AI Lab server...
|
||||||
echo Web UI: http://localhost:%LAB_PORT%
|
echo Web UI: http://localhost:%LAB_PORT%
|
||||||
echo API: http://localhost:%LAB_PORT%/docs
|
echo API: http://localhost:%LAB_PORT%/docs
|
||||||
echo Press Ctrl+C to stop.
|
echo Press Ctrl+C to stop.
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
if defined ANTHROPIC_API_KEY (
|
if defined PPLX_API_KEY (
|
||||||
python "%~dp0lab_api.py" ^
|
python "%~dp0lab_api.py" ^
|
||||||
--forge-jar "%FORGE_JAR%" ^
|
--forge-jar "%FORGE_JAR%" ^
|
||||||
--forge-dir "%FORGE_DIR%" ^
|
--forge-dir "%FORGE_DIR%" ^
|
||||||
--forge-decks-dir "%FORGE_DECKS_DIR%" ^
|
--forge-decks-dir "%FORGE_DECKS_DIR%" ^
|
||||||
--port %LAB_PORT% ^
|
--port %LAB_PORT% ^
|
||||||
--ximilar-key "%XIMILAR_API_KEY%" ^
|
--ximilar-key "%XIMILAR_API_KEY%" ^
|
||||||
--anthropic-key "%ANTHROPIC_API_KEY%"
|
--pplx-key "%PPLX_API_KEY%"
|
||||||
) else (
|
) else (
|
||||||
python "%~dp0lab_api.py" ^
|
python "%~dp0lab_api.py" ^
|
||||||
--forge-jar "%FORGE_JAR%" ^
|
--forge-jar "%FORGE_JAR%" ^
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ def _make_conn() -> sqlite3.Connection:
|
|||||||
|
|
||||||
|
|
||||||
def _make_generator(conn: sqlite3.Connection) -> DeckGeneratorV3:
|
def _make_generator(conn: sqlite3.Connection) -> DeckGeneratorV3:
|
||||||
"""Build a DeckGeneratorV3 with a mock Anthropic Claude Opus client."""
|
"""Build a DeckGeneratorV3 with a mock Perplexity client."""
|
||||||
return DeckGeneratorV3(
|
return DeckGeneratorV3(
|
||||||
anthropic_client=MagicMock(),
|
pplx_client=MagicMock(),
|
||||||
db_conn_factory=lambda: conn,
|
db_conn_factory=lambda: conn,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -165,9 +165,8 @@ const AiLab = (() => {
|
|||||||
<option value="2">2</option>
|
<option value="2">2</option>
|
||||||
<option value="4" selected>4</option>
|
<option value="4" selected>4</option>
|
||||||
<option value="8">8</option>
|
<option value="8">8</option>
|
||||||
<option value="16">16</option>
|
<option value="12">12</option>
|
||||||
<option value="24">24</option>
|
<option value="16">16</option>
|
||||||
<option value="32">32</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="lab-control-group">
|
<div class="lab-control-group">
|
||||||
|
|||||||
+1
-5
@@ -1132,11 +1132,7 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
td[data-card-name] .coach-card-name,
|
td[data-card-name] .coach-card-name {
|
||||||
[data-card-name].coach-replacement-chip,
|
|
||||||
[data-card-name].coach-synergy-chip,
|
|
||||||
[data-card-name].coach-pill,
|
|
||||||
[data-card-name].coach-cl-name {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px dashed var(--text-secondary, #888);
|
border-bottom: 1px dashed var(--text-secondary, #888);
|
||||||
}
|
}
|
||||||
+9
-24
@@ -23,7 +23,7 @@
|
|||||||
<div class="coach-status-items">
|
<div class="coach-status-items">
|
||||||
<div class="coach-status-item" id="status-llm">
|
<div class="coach-status-item" id="status-llm">
|
||||||
<span class="status-dot status-unknown"></span>
|
<span class="status-dot status-unknown"></span>
|
||||||
<span class="status-label">Claude Opus</span>
|
<span class="status-label">LM Studio</span>
|
||||||
<span class="status-value" id="status-llm-value">Checking...</span>
|
<span class="status-value" id="status-llm-value">Checking...</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="coach-status-item" id="status-embeddings">
|
<div class="coach-status-item" id="status-embeddings">
|
||||||
@@ -58,10 +58,7 @@
|
|||||||
|
|
||||||
<!-- Power Level Slider -->
|
<!-- Power Level Slider -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="coach-power-level">
|
<label class="form-label" for="coach-power-level">Target Power Level</label>
|
||||||
Target Power Level
|
|
||||||
<span class="tip-icon tip-below" data-tip="Guides the LLM on how competitive suggestions should be. 1-3 = casual jank; 4-6 = upgraded precon; 7-8 = high power; 9-10 = cEDH with fast mana and infinite combos.">?</span>
|
|
||||||
</label>
|
|
||||||
<div class="coach-slider-row">
|
<div class="coach-slider-row">
|
||||||
<input type="range" id="coach-power-level" min="1" max="10" value="7" />
|
<input type="range" id="coach-power-level" min="1" max="10" value="7" />
|
||||||
<span class="coach-slider-value" id="coach-power-value">7</span>
|
<span class="coach-slider-value" id="coach-power-value">7</span>
|
||||||
@@ -71,10 +68,7 @@
|
|||||||
|
|
||||||
<!-- Meta Focus -->
|
<!-- Meta Focus -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="coach-meta-focus">
|
<label class="form-label" for="coach-meta-focus">Meta Focus</label>
|
||||||
Meta Focus
|
|
||||||
<span class="tip-icon tip-below" data-tip="The game plan the coach optimizes for. Aggro = fast damage; Control = answers and card advantage; Combo = tutors and win-con protection; Stax = slow everyone down.">?</span>
|
|
||||||
</label>
|
|
||||||
<select id="coach-meta-focus" class="form-select">
|
<select id="coach-meta-focus" class="form-select">
|
||||||
<option value="">Any / Balanced</option>
|
<option value="">Any / Balanced</option>
|
||||||
<option value="aggro">Aggro</option>
|
<option value="aggro">Aggro</option>
|
||||||
@@ -87,10 +81,7 @@
|
|||||||
|
|
||||||
<!-- Budget -->
|
<!-- Budget -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="coach-budget">
|
<label class="form-label" for="coach-budget">Budget</label>
|
||||||
Budget
|
|
||||||
<span class="tip-icon tip-below" data-tip="Restricts suggestions to cards within a price range. Budget caps cards at ~$2 each; Medium at ~$10. No Limit allows any card including Reserved List staples.">?</span>
|
|
||||||
</label>
|
|
||||||
<select id="coach-budget" class="form-select">
|
<select id="coach-budget" class="form-select">
|
||||||
<option value="">No Limit</option>
|
<option value="">No Limit</option>
|
||||||
<option value="budget">Budget (< $2 per card)</option>
|
<option value="budget">Budget (< $2 per card)</option>
|
||||||
@@ -101,10 +92,7 @@
|
|||||||
|
|
||||||
<!-- Focus Areas -->
|
<!-- Focus Areas -->
|
||||||
<div class="form-group coach-field-wide">
|
<div class="form-group coach-field-wide">
|
||||||
<label class="form-label">
|
<label class="form-label">Focus Areas (optional)</label>
|
||||||
Focus Areas (optional)
|
|
||||||
<span class="tip-icon tip-below" data-tip="Narrows the coach to specific deck categories. Leaving all unchecked triggers a full-deck general analysis across all categories.">?</span>
|
|
||||||
</label>
|
|
||||||
<div class="coach-focus-tags">
|
<div class="coach-focus-tags">
|
||||||
<label class="coach-tag"><input type="checkbox" value="ramp" /> Ramp</label>
|
<label class="coach-tag"><input type="checkbox" value="ramp" /> Ramp</label>
|
||||||
<label class="coach-tag"><input type="checkbox" value="card draw" /> Card Draw</label>
|
<label class="coach-tag"><input type="checkbox" value="card draw" /> Card Draw</label>
|
||||||
@@ -133,7 +121,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Multi-turn Chat Panel -->
|
<!-- ═══ Multi-turn Chat Panel ═══ -->
|
||||||
<section class="panel coach-chat-section" id="coach-chat-section">
|
<section class="panel coach-chat-section" id="coach-chat-section">
|
||||||
<div class="panel-header coach-chat-header">
|
<div class="panel-header coach-chat-header">
|
||||||
<h2>Chat with Coach</h2>
|
<h2>Chat with Coach</h2>
|
||||||
@@ -155,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Cards-Like Search Panel -->
|
<!-- ═══ Cards-Like Search Panel ═══ -->
|
||||||
<section class="panel coach-cardslike-section" id="coach-cardslike-section">
|
<section class="panel coach-cardslike-section" id="coach-cardslike-section">
|
||||||
<div class="panel-header coach-panel-collapsible" id="cardslike-header">
|
<div class="panel-header coach-panel-collapsible" id="cardslike-header">
|
||||||
<h2>Find Similar Cards</h2>
|
<h2>Find Similar Cards</h2>
|
||||||
@@ -175,10 +163,7 @@
|
|||||||
<button class="coach-color-pip coach-pip-G" data-color="G" title="Green">G</button>
|
<button class="coach-color-pip coach-pip-G" data-color="G" title="Green">G</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group coach-cardslike-topn">
|
<div class="form-group coach-cardslike-topn">
|
||||||
<label class="form-label">
|
<label class="form-label">Results</label>
|
||||||
Results
|
|
||||||
<span class="tip-icon tip-below" data-tip="How many similar cards to return. Uses vector embeddings to find cards with the most semantically similar oracle text and mechanics to your query card.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="cardslike-topn" class="form-input" value="10" min="1" max="50" />
|
<input type="number" id="cardslike-topn" class="form-input" value="10" min="1" max="50" />
|
||||||
</div>
|
</div>
|
||||||
<button id="cardslike-search-btn" class="btn btn-accent btn-sm">Search</button>
|
<button id="cardslike-search-btn" class="btn btn-accent btn-sm">Search</button>
|
||||||
@@ -188,7 +173,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- One-Shot Results Section -->
|
<!-- ═══ One-Shot Results Section ═══ -->
|
||||||
<section id="coach-results" class="coach-results" style="display:none;">
|
<section id="coach-results" class="coach-results" style="display:none;">
|
||||||
|
|
||||||
<!-- Apply Suggestions Bar -->
|
<!-- Apply Suggestions Bar -->
|
||||||
|
|||||||
+6
-8
@@ -63,7 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _attachCardHover(container) {
|
function _attachCardHover(container) {
|
||||||
container.querySelectorAll('[data-card-name]').forEach(td => {
|
container.querySelectorAll('td[data-card-name]').forEach(td => {
|
||||||
td.addEventListener('mouseenter', e => _showCardPreview(td.dataset.cardName, e));
|
td.addEventListener('mouseenter', e => _showCardPreview(td.dataset.cardName, e));
|
||||||
td.addEventListener('mousemove', e => _positionPreview(e));
|
td.addEventListener('mousemove', e => _positionPreview(e));
|
||||||
td.addEventListener('mouseleave', _hideCardPreview);
|
td.addEventListener('mouseleave', _hideCardPreview);
|
||||||
@@ -433,7 +433,7 @@
|
|||||||
html += '<span class="coach-session-detail-label">Cuts:</span>';
|
html += '<span class="coach-session-detail-label">Cuts:</span>';
|
||||||
html += '<div class="coach-session-pills">';
|
html += '<div class="coach-session-pills">';
|
||||||
for (const cut of cuts) {
|
for (const cut of cuts) {
|
||||||
html += '<span class="coach-pill coach-pill-cut" data-card-name="' + escHtml(cut.cardName) + '" title="' + escHtml(cut.reason || '') + '">' + escHtml(cut.cardName) + '</span>';
|
html += '<span class="coach-pill coach-pill-cut" title="' + escHtml(cut.reason || '') + '">' + escHtml(cut.cardName) + '</span>';
|
||||||
}
|
}
|
||||||
html += '</div></div>';
|
html += '</div></div>';
|
||||||
}
|
}
|
||||||
@@ -444,7 +444,7 @@
|
|||||||
html += '<span class="coach-session-detail-label">Adds:</span>';
|
html += '<span class="coach-session-detail-label">Adds:</span>';
|
||||||
html += '<div class="coach-session-pills">';
|
html += '<div class="coach-session-pills">';
|
||||||
for (const add of adds) {
|
for (const add of adds) {
|
||||||
html += '<span class="coach-pill coach-pill-add" data-card-name="' + escHtml(add.cardName) + '" title="' + escHtml(add.reason || '') + '">' + escHtml(add.cardName) + '</span>';
|
html += '<span class="coach-pill coach-pill-add" title="' + escHtml(add.reason || '') + '">' + escHtml(add.cardName) + '</span>';
|
||||||
}
|
}
|
||||||
html += '</div></div>';
|
html += '</div></div>';
|
||||||
}
|
}
|
||||||
@@ -457,7 +457,6 @@
|
|||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
detailEl.innerHTML = html;
|
detailEl.innerHTML = html;
|
||||||
_attachCardHover(detailEl);
|
|
||||||
|
|
||||||
// Bind buttons
|
// Bind buttons
|
||||||
detailEl.querySelector('.coach-load-chat-btn')?.addEventListener('click', (e) => {
|
detailEl.querySelector('.coach-load-chat-btn')?.addEventListener('click', (e) => {
|
||||||
@@ -600,7 +599,7 @@
|
|||||||
'<td>' + escHtml(cut.reason) + '</td>' +
|
'<td>' + escHtml(cut.reason) + '</td>' +
|
||||||
'<td><div class="coach-replacements">' +
|
'<td><div class="coach-replacements">' +
|
||||||
(cut.replacementOptions || []).map(r =>
|
(cut.replacementOptions || []).map(r =>
|
||||||
'<span class="coach-replacement-chip" data-card-name="' + escHtml(r) + '">' + escHtml(r) + '</span>'
|
'<span class="coach-replacement-chip">' + escHtml(r) + '</span>'
|
||||||
).join('') +
|
).join('') +
|
||||||
'</div></td>' +
|
'</div></td>' +
|
||||||
'<td>' + formatImpact(cut.currentImpactScore) + '</td>';
|
'<td>' + formatImpact(cut.currentImpactScore) + '</td>';
|
||||||
@@ -623,7 +622,7 @@
|
|||||||
'<td>' + escHtml(add.reason) + '</td>' +
|
'<td>' + escHtml(add.reason) + '</td>' +
|
||||||
'<td><div class="coach-replacements">' +
|
'<td><div class="coach-replacements">' +
|
||||||
(add.synergyWith || []).map(s =>
|
(add.synergyWith || []).map(s =>
|
||||||
'<span class="coach-synergy-chip" data-card-name="' + escHtml(s) + '">' + escHtml(s) + '</span>'
|
'<span class="coach-synergy-chip">' + escHtml(s) + '</span>'
|
||||||
).join('') +
|
).join('') +
|
||||||
'</div></td>';
|
'</div></td>';
|
||||||
tr.querySelector('.coach-card-name').parentElement.dataset.cardName = add.cardName;
|
tr.querySelector('.coach-card-name').parentElement.dataset.cardName = add.cardName;
|
||||||
@@ -919,7 +918,7 @@
|
|||||||
|
|
||||||
div.innerHTML =
|
div.innerHTML =
|
||||||
'<div class="coach-cl-top">' +
|
'<div class="coach-cl-top">' +
|
||||||
'<span class="coach-cl-name" data-card-name="' + escHtml(card.name) + '">' + escHtml(card.name) + '</span>' +
|
'<span class="coach-cl-name">' + escHtml(card.name) + '</span>' +
|
||||||
'<span class="coach-cl-sim">' + simPct + '%</span>' +
|
'<span class="coach-cl-sim">' + simPct + '%</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="coach-cl-meta">' +
|
'<div class="coach-cl-meta">' +
|
||||||
@@ -940,7 +939,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
cardslikeResults.appendChild(div);
|
cardslikeResults.appendChild(div);
|
||||||
_attachCardHover(div);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
cardslikeResults.innerHTML = '<div class="coach-cardslike-empty">Error: ' + escHtml(e.message) + '</div>';
|
cardslikeResults.innerHTML = '<div class="coach-cardslike-empty">Error: ' + escHtml(e.message) + '</div>';
|
||||||
|
|||||||
@@ -2841,394 +2841,3 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 17. Stats Dashboard ─────────────────────────────────────── */
|
|
||||||
|
|
||||||
.coll-stats-btn {
|
|
||||||
background: rgba(99,102,241,0.15);
|
|
||||||
border: 1px solid rgba(99,102,241,0.4);
|
|
||||||
color: var(--accent, #6366f1);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.coll-stats-btn:hover {
|
|
||||||
background: rgba(99,102,241,0.3);
|
|
||||||
border-color: var(--accent, #6366f1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.coll-stats-panel {
|
|
||||||
background: var(--surface, #161b22);
|
|
||||||
border: 1px solid var(--border, #30363d);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 20px 24px;
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
animation: coll-fade-in 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coll-stats-loading {
|
|
||||||
color: var(--text-secondary, #8b949e);
|
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coll-stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Summary KPI cards */
|
|
||||||
.coll-stats-summary {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.coll-stat-card {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 100px;
|
|
||||||
background: var(--bg, #0d1117);
|
|
||||||
border: 1px solid var(--border, #30363d);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.coll-stat-num {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text, #e6edf3);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
.coll-stat-label {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--text-secondary, #8b949e);
|
|
||||||
margin-top: 4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section headers */
|
|
||||||
.coll-stats-section {
|
|
||||||
border-top: 1px solid var(--border, #30363d);
|
|
||||||
padding-top: 14px;
|
|
||||||
}
|
|
||||||
.coll-stats-section h4 {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary, #8b949e);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CMC vertical bar chart */
|
|
||||||
.coll-stat-bar-chart {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
height: 64px;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.coll-stat-bar-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
justify-content: flex-end;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.coll-stat-bar {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--accent, #6366f1);
|
|
||||||
border-radius: 3px 3px 0 0;
|
|
||||||
min-height: 2px;
|
|
||||||
transition: height 0.3s ease;
|
|
||||||
}
|
|
||||||
.coll-stat-bar-label {
|
|
||||||
position: absolute;
|
|
||||||
bottom: -18px;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
color: var(--text-secondary, #8b949e);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Horizontal bar rows (colors, types, rarity, categories) */
|
|
||||||
.coll-stat-color-bar,
|
|
||||||
.coll-stat-type-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
.coll-stat-color-pip {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.coll-stat-type-label {
|
|
||||||
width: 90px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text, #e6edf3);
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.coll-stat-hbar-track {
|
|
||||||
flex: 1;
|
|
||||||
height: 10px;
|
|
||||||
background: var(--bg, #0d1117);
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--border, #30363d);
|
|
||||||
}
|
|
||||||
.coll-stat-hbar {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 5px;
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
}
|
|
||||||
.coll-stat-hbar-type {
|
|
||||||
background: var(--accent, #6366f1);
|
|
||||||
}
|
|
||||||
.coll-stat-hbar-cat {
|
|
||||||
background: #2ea043;
|
|
||||||
}
|
|
||||||
.coll-stat-hbar-val {
|
|
||||||
width: 36px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--text-secondary, #8b949e);
|
|
||||||
text-align: right;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Top value cards list */
|
|
||||||
.coll-stat-top-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
padding: 3px 0;
|
|
||||||
border-bottom: 1px solid var(--border, #30363d);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.coll-stat-top-row span:first-child {
|
|
||||||
color: var(--text, #e6edf3);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.coll-stat-top-row span:last-child {
|
|
||||||
color: #3fb950;
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coll-stats-empty {
|
|
||||||
color: var(--text-secondary, #8b949e);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
|
||||||
.coll-stats-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
.coll-stats-summary {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (min-width: 1400px) {
|
|
||||||
.coll-stats-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ── 18. Column Visibility Panel ───────────────────────────── */
|
|
||||||
|
|
||||||
.coll-col-toggle-btn {
|
|
||||||
background: rgba(139,148,158,0.1);
|
|
||||||
border: 1px solid var(--border, #30363d);
|
|
||||||
color: var(--text-secondary, #8b949e);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.coll-col-toggle-btn:hover {
|
|
||||||
background: rgba(139,148,158,0.2);
|
|
||||||
color: var(--text, #e6edf3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.coll-col-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
right: 0;
|
|
||||||
z-index: 500;
|
|
||||||
background: var(--surface, #161b22);
|
|
||||||
border: 1px solid var(--border, #30363d);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
||||||
min-width: 180px;
|
|
||||||
animation: coll-fade-in 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coll-col-panel-inner {
|
|
||||||
padding: 10px 12px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coll-col-panel-hdr {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary, #8b949e);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
border-bottom: 1px solid var(--border, #30363d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.coll-col-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary, #8b949e);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.coll-col-close:hover { color: var(--text, #e6edf3); }
|
|
||||||
|
|
||||||
.coll-col-toggle-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 4px 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text, #e6edf3);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.coll-col-toggle-item input[type="checkbox"] {
|
|
||||||
accent-color: var(--accent, #6366f1);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.coll-col-toggle-item:hover {
|
|
||||||
color: var(--accent, #6366f1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.coll-col-reset {
|
|
||||||
margin-top: 10px;
|
|
||||||
width: 100%;
|
|
||||||
background: rgba(99,102,241,0.1);
|
|
||||||
border: 1px solid rgba(99,102,241,0.3);
|
|
||||||
color: var(--accent, #6366f1);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 5px 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.coll-col-reset:hover {
|
|
||||||
background: rgba(99,102,241,0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Bulk Action Bar ──────────────────────────────────────────── */
|
|
||||||
.coll-bulk-bar {
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--bg-card, #1e1e1e);
|
|
||||||
border-top: 1px solid var(--border, #30363d);
|
|
||||||
border-radius: 0 0 8px 8px;
|
|
||||||
z-index: 50;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
box-shadow: 0 -2px 8px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
.coll-bulk-count {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent, #6366f1);
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
.coll-bulk-sep {
|
|
||||||
color: var(--text-secondary, #8b949e);
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
.coll-bulk-label {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-secondary, #8b949e);
|
|
||||||
}
|
|
||||||
.coll-bulk-btn {
|
|
||||||
background: rgba(99,102,241,0.15);
|
|
||||||
border: 1px solid rgba(99,102,241,0.35);
|
|
||||||
color: var(--text, #e6edf3);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.coll-bulk-btn:hover:not(:disabled) {
|
|
||||||
background: rgba(99,102,241,0.3);
|
|
||||||
}
|
|
||||||
.coll-bulk-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.coll-bulk-set-btn {
|
|
||||||
background: rgba(99,102,241,0.25);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.coll-bulk-qty-input {
|
|
||||||
width: 56px;
|
|
||||||
background: var(--bg, #0d1117);
|
|
||||||
border: 1px solid var(--border, #30363d);
|
|
||||||
color: var(--text, #e6edf3);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 3px 6px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Select Checkbox Column ─────────────────────────────────── */
|
|
||||||
.coll-th-select {
|
|
||||||
width: 32px;
|
|
||||||
min-width: 32px;
|
|
||||||
text-align: center;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
.coll-td-select {
|
|
||||||
width: 32px;
|
|
||||||
min-width: 32px;
|
|
||||||
text-align: center;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
.coll-row-check {
|
|
||||||
cursor: pointer;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
accent-color: var(--accent, #6366f1);
|
|
||||||
}
|
|
||||||
.coll-row.selected-row {
|
|
||||||
background: rgba(99,102,241,0.08);
|
|
||||||
}
|
|
||||||
+9
-352
@@ -164,7 +164,6 @@ const Collection = (() => {
|
|||||||
importHeaders: [],
|
importHeaders: [],
|
||||||
importMissingFinishNormal: true,
|
importMissingFinishNormal: true,
|
||||||
exportOpen: false,
|
exportOpen: false,
|
||||||
selectedRows: new Set(),
|
|
||||||
preferences: {
|
preferences: {
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
sortField: 'name',
|
sortField: 'name',
|
||||||
@@ -411,148 +410,6 @@ const Collection = (() => {
|
|||||||
renderCountBadge();
|
renderCountBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stats Dashboard ──────────────────────────────────────
|
|
||||||
let statsData = null;
|
|
||||||
let statsOpen = false;
|
|
||||||
|
|
||||||
async function fetchStats() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/api/collection/stats`);
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
statsData = await res.json();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Collection] fetchStats error:', err);
|
|
||||||
statsData = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleStats() {
|
|
||||||
statsOpen = !statsOpen;
|
|
||||||
const panel = document.getElementById('coll-stats-panel');
|
|
||||||
if (!panel) return;
|
|
||||||
if (statsOpen) {
|
|
||||||
if (!statsData) {
|
|
||||||
panel.innerHTML = '<div class="coll-stats-loading">Loading stats…</div>';
|
|
||||||
panel.style.display = 'block';
|
|
||||||
fetchStats().then(() => renderStatsPanel());
|
|
||||||
} else {
|
|
||||||
renderStatsPanel();
|
|
||||||
}
|
|
||||||
panel.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
panel.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStatsPanel() {
|
|
||||||
const panel = document.getElementById('coll-stats-panel');
|
|
||||||
if (!panel || !statsData) return;
|
|
||||||
const s = statsData;
|
|
||||||
|
|
||||||
// CMC curve bar chart (CSS-only)
|
|
||||||
const cmcMax = Math.max(...Object.values(s.cmcCurve || {}), 1);
|
|
||||||
const cmcBars = [0,1,2,3,4,5,6,7].map(i => {
|
|
||||||
const count = (s.cmcCurve || {})[String(i)] || 0;
|
|
||||||
const pct = Math.round((count / cmcMax) * 100);
|
|
||||||
const label = i === 7 ? '7+' : String(i);
|
|
||||||
return `<div class="coll-stat-bar-col">
|
|
||||||
<div class="coll-stat-bar" style="height:${pct}%" title="${count}"></div>
|
|
||||||
<span class="coll-stat-bar-label">${label}</span>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Color distribution
|
|
||||||
const colorTotal = Math.max(Object.values(s.colorDistribution || {}).reduce((a,b) => a+b, 0), 1);
|
|
||||||
const colorBars = ['W','U','B','R','G','C'].map(c => {
|
|
||||||
const count = (s.colorDistribution || {})[c] || 0;
|
|
||||||
const pct = Math.round((count / colorTotal) * 100);
|
|
||||||
const ci = COLOR_MAP[c];
|
|
||||||
return `<div class="coll-stat-color-bar">
|
|
||||||
<span class="coll-stat-color-pip" style="background:${ci.bg};color:${ci.color}">${ci.label}</span>
|
|
||||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar" style="width:${pct}%;background:${ci.bg}"></div></div>
|
|
||||||
<span class="coll-stat-hbar-val">${count}</span>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Type distribution
|
|
||||||
const typeTotal = Math.max(Object.values(s.typeDistribution || {}).reduce((a,b) => a+b, 0), 1);
|
|
||||||
const typeBars = Object.entries(s.typeDistribution || {}).sort((a,b) => b[1]-a[1]).map(([t, count]) => {
|
|
||||||
const pct = Math.round((count / typeTotal) * 100);
|
|
||||||
return `<div class="coll-stat-type-row">
|
|
||||||
<span class="coll-stat-type-label">${escapeHtml(t)}</span>
|
|
||||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar coll-stat-hbar-type" style="width:${pct}%"></div></div>
|
|
||||||
<span class="coll-stat-hbar-val">${count}</span>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Rarity distribution
|
|
||||||
const rarityOrder = ['common','uncommon','rare','mythic'];
|
|
||||||
const rarityColors = {common:'#706f6f', uncommon:'#859ba6', rare:'#c9a849', mythic:'#d35400'};
|
|
||||||
const rarityTotal = Math.max(Object.values(s.rarityDistribution || {}).reduce((a,b) => a+b, 0), 1);
|
|
||||||
const rarityBars = rarityOrder.map(r => {
|
|
||||||
const count = (s.rarityDistribution || {})[r] || 0;
|
|
||||||
const pct = Math.round((count / rarityTotal) * 100);
|
|
||||||
return `<div class="coll-stat-type-row">
|
|
||||||
<span class="coll-stat-type-label" style="color:${rarityColors[r] || '#ccc'}">${r.charAt(0).toUpperCase() + r.slice(1)}</span>
|
|
||||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar" style="width:${pct}%;background:${rarityColors[r] || '#555'}"></div></div>
|
|
||||||
<span class="coll-stat-hbar-val">${count}</span>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Top value cards
|
|
||||||
const topCards = (s.topValueCards || []).map(c =>
|
|
||||||
`<div class="coll-stat-top-row"><span>${escapeHtml(c.name)}</span><span>$${Number(c.price).toFixed(2)}</span></div>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
// Category/role coverage
|
|
||||||
const catEntries = Object.entries(s.categoryDistribution || {}).sort((a,b) => b[1]-a[1]).slice(0, 12);
|
|
||||||
const catMax = Math.max(...catEntries.map(e => e[1]), 1);
|
|
||||||
const catBars = catEntries.map(([cat, count]) => {
|
|
||||||
const pct = Math.round((count / catMax) * 100);
|
|
||||||
return `<div class="coll-stat-type-row">
|
|
||||||
<span class="coll-stat-type-label">${escapeHtml(cat)}</span>
|
|
||||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar coll-stat-hbar-cat" style="width:${pct}%"></div></div>
|
|
||||||
<span class="coll-stat-hbar-val">${count}</span>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
panel.innerHTML = `
|
|
||||||
<div class="coll-stats-grid">
|
|
||||||
<div class="coll-stats-summary">
|
|
||||||
<div class="coll-stat-card"><div class="coll-stat-num">${(s.totalCards || 0).toLocaleString()}</div><div class="coll-stat-label">Total Cards</div></div>
|
|
||||||
<div class="coll-stat-card"><div class="coll-stat-num">${(s.totalUniqueCards || 0).toLocaleString()}</div><div class="coll-stat-label">Unique</div></div>
|
|
||||||
<div class="coll-stat-card"><div class="coll-stat-num">$${(s.totalValue || 0).toLocaleString(undefined,{minimumFractionDigits:2})}</div><div class="coll-stat-label">Total Value</div></div>
|
|
||||||
<div class="coll-stat-card"><div class="coll-stat-num">${s.avgCmc || 0}</div><div class="coll-stat-label">Avg CMC</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="coll-stats-section">
|
|
||||||
<h4>CMC Curve</h4>
|
|
||||||
<div class="coll-stat-bar-chart">${cmcBars}</div>
|
|
||||||
</div>
|
|
||||||
<div class="coll-stats-section">
|
|
||||||
<h4>Color Distribution</h4>
|
|
||||||
${colorBars}
|
|
||||||
</div>
|
|
||||||
<div class="coll-stats-section">
|
|
||||||
<h4>Type Distribution</h4>
|
|
||||||
${typeBars}
|
|
||||||
</div>
|
|
||||||
<div class="coll-stats-section">
|
|
||||||
<h4>Rarity</h4>
|
|
||||||
${rarityBars}
|
|
||||||
</div>
|
|
||||||
<div class="coll-stats-section">
|
|
||||||
<h4>Role Coverage</h4>
|
|
||||||
${catBars || '<div class="coll-stats-empty">No categories assigned yet</div>'}
|
|
||||||
</div>
|
|
||||||
<div class="coll-stats-section">
|
|
||||||
<h4>Top Value Cards</h4>
|
|
||||||
${topCards || '<div class="coll-stats-empty">No priced cards</div>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function fetchCardDetail(cardId) {
|
async function fetchCardDetail(cardId) {
|
||||||
const res = await fetch(`${API_BASE}/api/collection/${cardId}`);
|
const res = await fetch(`${API_BASE}/api/collection/${cardId}`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
@@ -587,8 +444,7 @@ const Collection = (() => {
|
|||||||
// Pre-load set/keyword caches for filter autocomplete
|
// Pre-load set/keyword caches for filter autocomplete
|
||||||
loadSetsCache();
|
loadSetsCache();
|
||||||
loadKeywordsCache();
|
loadKeywordsCache();
|
||||||
applyColumnVisibility();
|
await fetchCollection();
|
||||||
await fetchCollection();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adjust table container height based on actual topbar height */
|
/** Adjust table container height based on actual topbar height */
|
||||||
@@ -633,17 +489,10 @@ const Collection = (() => {
|
|||||||
<button class="coll-export-btn" onclick="Collection.openExport()">Export</button>
|
<button class="coll-export-btn" onclick="Collection.openExport()">Export</button>
|
||||||
<button class="coll-autoclassify-btn" onclick="Collection.autoClassify()" title="Auto-detect categories (Ramp, Draw, Removal, etc.) for uncategorized cards">Auto-Classify</button>
|
<button class="coll-autoclassify-btn" onclick="Collection.autoClassify()" title="Auto-detect categories (Ramp, Draw, Removal, etc.) for uncategorized cards">Auto-Classify</button>
|
||||||
<button class="coll-reenrich-btn" onclick="Collection.reEnrich()" title="Re-fetch all card data from Scryfall">Refresh Data</button>
|
<button class="coll-reenrich-btn" onclick="Collection.reEnrich()" title="Re-fetch all card data from Scryfall">Refresh Data</button>
|
||||||
<button class="coll-stats-btn" onclick="Collection.toggleStats()" title="Show collection statistics dashboard">📊 Stats</button>
|
<a href="deckbuilder.html" class="coll-nav-link" style="color:var(--lab-purple);border-color:var(--lab-purple)">⚔ Deck Builder</a>
|
||||||
<div style="position:relative">
|
|
||||||
<button class="coll-col-toggle-btn" onclick="Collection.openColumnPanel()" title="Show/hide table columns">☰ Columns</button>
|
|
||||||
<div id="coll-col-panel" class="coll-col-panel" style="display:none"></div>
|
|
||||||
</div>
|
|
||||||
<a href="deckbuilder.html" class="coll-nav-link" style="color:var(--lab-purple);border-color:var(--lab-purple)">⚔ Deck Builder</a>
|
|
||||||
<a href="deckgenerator.html" class="coll-nav-link" style="color:var(--lab-warning);border-color:var(--lab-warning)">⚡ Auto Gen</a>
|
<a href="deckgenerator.html" class="coll-nav-link" style="color:var(--lab-warning);border-color:var(--lab-warning)">⚡ Auto Gen</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Stats Dashboard Panel -->
|
|
||||||
<div id="coll-stats-panel" class="coll-stats-panel" style="display:none"></div>
|
|
||||||
|
|
||||||
<!-- Main -->
|
<!-- Main -->
|
||||||
<div class="coll-main">
|
<div class="coll-main">
|
||||||
@@ -663,18 +512,6 @@ const Collection = (() => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="coll-pagination" id="coll-pagination"></div>
|
<div class="coll-pagination" id="coll-pagination"></div>
|
||||||
<!-- Bulk Action Bar -->
|
|
||||||
<div id="coll-bulk-bar" class="coll-bulk-bar" style="display:none">
|
|
||||||
<span id="coll-bulk-count" class="coll-bulk-count">0 selected</span>
|
|
||||||
<button class="coll-bulk-btn" onclick="Collection.selectAllRows()">Select All</button>
|
|
||||||
<button class="coll-bulk-btn" onclick="Collection.clearSelection()">Clear</button>
|
|
||||||
<span class="coll-bulk-sep">|</span>
|
|
||||||
<span class="coll-bulk-label">Qty:</span>
|
|
||||||
<button class="coll-bulk-btn" onclick="Collection.bulkAdjustQuantity(-1)" title="Subtract 1">−</button>
|
|
||||||
<button class="coll-bulk-btn" onclick="Collection.bulkAdjustQuantity(1)" title="Add 1">+</button>
|
|
||||||
<input type="number" id="coll-bulk-qty-input" class="coll-bulk-qty-input" min="0" value="1" placeholder="n">
|
|
||||||
<button class="coll-bulk-btn coll-bulk-set-btn" onclick="Collection.bulkSetQuantity()">Set</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -984,79 +821,6 @@ const Collection = (() => {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Column Visibility ──────────────────────────────────────
|
|
||||||
function colVis(key) {
|
|
||||||
return state.preferences.visibleColumns.includes(key) ? '' : 'display:none;';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleColumn(key) {
|
|
||||||
const cols = state.preferences.visibleColumns;
|
|
||||||
const idx = cols.indexOf(key);
|
|
||||||
if (idx >= 0) {
|
|
||||||
if (cols.length <= 1) return; // keep at least 1 column
|
|
||||||
cols.splice(idx, 1);
|
|
||||||
} else {
|
|
||||||
cols.push(key);
|
|
||||||
}
|
|
||||||
savePreferences();
|
|
||||||
renderTable();
|
|
||||||
applyColumnVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderColumnPanel() {
|
|
||||||
const panel = document.getElementById('coll-col-panel');
|
|
||||||
if (!panel) return;
|
|
||||||
const cols = COLUMNS.map(col => {
|
|
||||||
const checked = state.preferences.visibleColumns.includes(col.key) ? 'checked' : '';
|
|
||||||
return `<label class="coll-col-toggle-item">
|
|
||||||
<input type="checkbox" ${checked} onchange="Collection.toggleColumn('${col.key}')">
|
|
||||||
${escapeHtml(col.label)}
|
|
||||||
</label>`;
|
|
||||||
}).join('');
|
|
||||||
panel.innerHTML = `
|
|
||||||
<div class="coll-col-panel-inner">
|
|
||||||
<div class="coll-col-panel-hdr">Columns <button class="coll-col-close" onclick="Collection.closeColumnPanel()">×</button></div>
|
|
||||||
${cols}
|
|
||||||
<button class="coll-col-reset" onclick="Collection.resetColumns()">Reset Defaults</button>
|
|
||||||
</div>`;
|
|
||||||
panel.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function openColumnPanel() {
|
|
||||||
const panel = document.getElementById('coll-col-panel');
|
|
||||||
if (!panel) return;
|
|
||||||
if (panel.style.display === 'block') {
|
|
||||||
panel.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
renderColumnPanel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeColumnPanel() {
|
|
||||||
const panel = document.getElementById('coll-col-panel');
|
|
||||||
if (panel) panel.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetColumns() {
|
|
||||||
state.preferences.visibleColumns = ['quantity','name','mana_cost','type_line','color_identity','cmc','power_toughness','rarity','set_name','tcg_price','edhrec_rank','salt_score','category','finish'];
|
|
||||||
savePreferences();
|
|
||||||
renderTable();
|
|
||||||
renderColumnPanel();
|
|
||||||
}
|
|
||||||
function applyColumnVisibility() {
|
|
||||||
let styleEl = document.getElementById('coll-col-vis-style');
|
|
||||||
if (!styleEl) {
|
|
||||||
styleEl = document.createElement('style');
|
|
||||||
styleEl.id = 'coll-col-vis-style';
|
|
||||||
document.head.appendChild(styleEl);
|
|
||||||
}
|
|
||||||
const hidden = COLUMNS.filter(col => !state.preferences.visibleColumns.includes(col.key));
|
|
||||||
styleEl.textContent = hidden.map(col =>
|
|
||||||
`.coll-th-${col.key}, .coll-td-${col.key} { display: none !important; }`
|
|
||||||
).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── Table Header ────────────────────────────────────────
|
// ── Table Header ────────────────────────────────────────
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
@@ -1079,8 +843,8 @@ const Collection = (() => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
function renderTableHeaderHtml() {
|
function renderTableHeaderHtml() {
|
||||||
const visibleCols = COLUMNS.filter(col => state.preferences.visibleColumns.includes(col.key));
|
const headers = COLUMNS.map(col => {
|
||||||
const headers = visibleCols.map(col => { if (!col.sortable) {
|
if (!col.sortable) {
|
||||||
return `<th class="coll-th coll-th-${col.key}">${col.label}</th>`;
|
return `<th class="coll-th coll-th-${col.key}">${col.label}</th>`;
|
||||||
}
|
}
|
||||||
const isActive = state.sortField === col.key;
|
const isActive = state.sortField === col.key;
|
||||||
@@ -1090,7 +854,7 @@ const Collection = (() => {
|
|||||||
return `<th class="coll-th coll-th-${col.key} coll-th-sortable${activeClass}"
|
return `<th class="coll-th coll-th-${col.key} coll-th-sortable${activeClass}"
|
||||||
onclick="Collection.setSort('${col.key}')">${col.label}${arrow}</th>`;
|
onclick="Collection.setSort('${col.key}')">${col.label}${arrow}</th>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
return `<thead><tr>$<th class="coll-th-select"><input type="checkbox" id="coll-select-all" onclick="Collection.toggleSelectAll(this.checked)" title="Select all"></th>{headers}</tr></thead>`;
|
return `<thead><tr>${headers}</tr></thead>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Table Body ──────────────────────────────────────────
|
// ── Table Body ──────────────────────────────────────────
|
||||||
@@ -1109,12 +873,12 @@ const Collection = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
tbody.innerHTML = `<tr><td colspan="${state.preferences.visibleColumns.length}" class="coll-empty">Loading…</td></tr>`;
|
tbody.innerHTML = `<tr><td colspan="${COLUMNS.length}" class="coll-empty">Loading…</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.items.length === 0) {
|
if (state.items.length === 0) {
|
||||||
tbody.innerHTML = `<tr><td colspan="${state.preferences.visibleColumns.length}" class="coll-empty">
|
tbody.innerHTML = `<tr><td colspan="${COLUMNS.length}" class="coll-empty">
|
||||||
<div class="coll-empty-state">
|
<div class="coll-empty-state">
|
||||||
<div class="coll-empty-icon">🃏</div>
|
<div class="coll-empty-icon">🃏</div>
|
||||||
<div>No cards in collection</div>
|
<div>No cards in collection</div>
|
||||||
@@ -1140,7 +904,6 @@ const Collection = (() => {
|
|||||||
const edhrecRank = (card.edhrecRank || card.edhrec_rank) ? `#${card.edhrecRank || card.edhrec_rank}` : '—';
|
const edhrecRank = (card.edhrecRank || card.edhrec_rank) ? `#${card.edhrecRank || card.edhrec_rank}` : '—';
|
||||||
|
|
||||||
return `<tr class="coll-row" onclick="Collection.openDrawer(${card.id})" data-id="${card.id}" data-img-url="${card.imageUrl || ''}">
|
return `<tr class="coll-row" onclick="Collection.openDrawer(${card.id})" data-id="${card.id}" data-img-url="${card.imageUrl || ''}">
|
||||||
<td class="coll-td coll-td-select" onclick="event.stopPropagation()"><input type="checkbox" class="coll-row-check" data-id="${card.id}" ${state.selectedRows.has(card.id) ? 'checked' : ''} onchange="Collection.toggleRowSelect(${card.id}, this.checked)"></td>
|
|
||||||
<td class="coll-td coll-td-quantity">${escapeHtml(card.quantity ?? 1)}</td>
|
<td class="coll-td coll-td-quantity">${escapeHtml(card.quantity ?? 1)}</td>
|
||||||
<td class="coll-td coll-td-name">
|
<td class="coll-td coll-td-name">
|
||||||
<span class="coll-card-name">${escapeHtml(card.name)}</span>
|
<span class="coll-card-name">${escapeHtml(card.name)}</span>
|
||||||
@@ -2336,8 +2099,7 @@ const Collection = (() => {
|
|||||||
if (footer) footer.style.display = 'none';
|
if (footer) footer.style.display = 'none';
|
||||||
|
|
||||||
// Refresh collection
|
// Refresh collection
|
||||||
applyColumnVisibility();
|
await fetchCollection();
|
||||||
await fetchCollection();
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
@@ -2491,98 +2253,6 @@ const Collection = (() => {
|
|||||||
window.location.href = '/index.html';
|
window.location.href = '/index.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bulk Row Selection & Quantity Edit ─────────────────────
|
|
||||||
function toggleRowSelect(cardId, checked) {
|
|
||||||
if (checked) {
|
|
||||||
state.selectedRows.add(cardId);
|
|
||||||
} else {
|
|
||||||
state.selectedRows.delete(cardId);
|
|
||||||
}
|
|
||||||
updateBulkBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectAll(checked) {
|
|
||||||
if (checked) {
|
|
||||||
state.items.forEach(c => state.selectedRows.add(c.id));
|
|
||||||
} else {
|
|
||||||
state.selectedRows.clear();
|
|
||||||
}
|
|
||||||
// Update all checkboxes
|
|
||||||
document.querySelectorAll('.coll-row-check').forEach(cb => {
|
|
||||||
cb.checked = checked;
|
|
||||||
});
|
|
||||||
updateBulkBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectAllRows() {
|
|
||||||
state.items.forEach(c => state.selectedRows.add(c.id));
|
|
||||||
document.querySelectorAll('.coll-row-check').forEach(cb => cb.checked = true);
|
|
||||||
const selectAllCb = document.getElementById('coll-select-all');
|
|
||||||
if (selectAllCb) selectAllCb.checked = true;
|
|
||||||
updateBulkBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelection() {
|
|
||||||
state.selectedRows.clear();
|
|
||||||
document.querySelectorAll('.coll-row-check').forEach(cb => cb.checked = false);
|
|
||||||
const selectAllCb = document.getElementById('coll-select-all');
|
|
||||||
if (selectAllCb) selectAllCb.checked = false;
|
|
||||||
updateBulkBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateBulkBar() {
|
|
||||||
const bar = document.getElementById('coll-bulk-bar');
|
|
||||||
const countEl = document.getElementById('coll-bulk-count');
|
|
||||||
const n = state.selectedRows.size;
|
|
||||||
if (bar) bar.style.display = n > 0 ? 'flex' : 'none';
|
|
||||||
if (countEl) countEl.textContent = `${n} selected`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bulkAdjustQuantity(delta) {
|
|
||||||
if (state.selectedRows.size === 0) return;
|
|
||||||
const ids = [...state.selectedRows];
|
|
||||||
const btn = event.target;
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
let successCount = 0;
|
|
||||||
for (const id of ids) {
|
|
||||||
const card = state.items.find(c => c.id === id);
|
|
||||||
if (!card) continue;
|
|
||||||
const newQty = Math.max(0, (card.quantity ?? 1) + delta);
|
|
||||||
try {
|
|
||||||
await patchCard(id, { quantity: newQty });
|
|
||||||
card.quantity = newQty;
|
|
||||||
successCount++;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Bulk] Failed to update card', id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
renderTable();
|
|
||||||
applyColumnVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bulkSetQuantity() {
|
|
||||||
if (state.selectedRows.size === 0) return;
|
|
||||||
const input = document.getElementById('coll-bulk-qty-input');
|
|
||||||
const newQty = input ? Math.max(0, parseInt(input.value, 10) || 0) : 0;
|
|
||||||
const ids = [...state.selectedRows];
|
|
||||||
const btn = document.querySelector('.coll-bulk-set-btn');
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
for (const id of ids) {
|
|
||||||
const card = state.items.find(c => c.id === id);
|
|
||||||
if (!card) continue;
|
|
||||||
try {
|
|
||||||
await patchCard(id, { quantity: newQty });
|
|
||||||
card.quantity = newQty;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Bulk] Failed to set card', id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
renderTable();
|
|
||||||
applyColumnVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event Binding ────────────────────────────────────────
|
// ── Event Binding ────────────────────────────────────────
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
@@ -3066,7 +2736,7 @@ const Collection = (() => {
|
|||||||
// Refresh collection after short delay
|
// Refresh collection after short delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
closeScan();
|
closeScan();
|
||||||
fetchCollection();
|
loadCollection();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (statusEl) statusEl.innerHTML = '<div class="coll-scan-error-msg">Failed: ' + escapeHtml(e.message) + '</div>';
|
if (statusEl) statusEl.innerHTML = '<div class="coll-scan-error-msg">Failed: ' + escapeHtml(e.message) + '</div>';
|
||||||
@@ -3181,18 +2851,5 @@ const Collection = (() => {
|
|||||||
popoverAddCatFromInput,
|
popoverAddCatFromInput,
|
||||||
reEnrich,
|
reEnrich,
|
||||||
autoClassify,
|
autoClassify,
|
||||||
toggleStats,
|
|
||||||
// Column visibility
|
|
||||||
toggleColumn,
|
|
||||||
openColumnPanel,
|
|
||||||
closeColumnPanel,
|
|
||||||
resetColumns,
|
|
||||||
// Bulk selection & quantity
|
|
||||||
toggleRowSelect,
|
|
||||||
toggleSelectAll,
|
|
||||||
selectAllRows,
|
|
||||||
clearSelection,
|
|
||||||
bulkAdjustQuantity,
|
|
||||||
bulkSetQuantity,
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
+2
-2
@@ -284,7 +284,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="deck-ai-loading" id="ai-research-loading" style="display:none">
|
<div class="deck-ai-loading" id="ai-research-loading" style="display:none">
|
||||||
<div class="deck-ai-spinner"></div>
|
<div class="deck-ai-spinner"></div>
|
||||||
<span>Analyzing with Claude Opus...</span>
|
<span>Analyzing with Perplexity AI...</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="deck-ai-results" id="ai-research-results" style="display:none"></div>
|
<div class="deck-ai-results" id="ai-research-results" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,7 +329,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="deck-ai-loading" id="ai-gen-loading" style="display:none">
|
<div class="deck-ai-loading" id="ai-gen-loading" style="display:none">
|
||||||
<div class="deck-ai-spinner"></div>
|
<div class="deck-ai-spinner"></div>
|
||||||
<span>Generating deck with Claude Opus...</span>
|
<span>Generating deck with Perplexity AI...</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="deck-ai-results" id="ai-gen-results" style="display:none"></div>
|
<div class="deck-ai-results" id="ai-gen-results" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-103
@@ -1297,106 +1297,4 @@
|
|||||||
|
|
||||||
.dg-log-error {
|
.dg-log-error {
|
||||||
color: #f59e0b;
|
color: #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Progress Bar (Deck Generation) ──────────────────── */
|
|
||||||
.dg-progress-wrap {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-progress-bar {
|
|
||||||
height: 6px;
|
|
||||||
background: var(--surface-2, #2a2a3a);
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
width: 0%;
|
|
||||||
background: linear-gradient(90deg, var(--accent, #7c6ff7), #a78bfa);
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: width 1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-progress-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-progress-phase {
|
|
||||||
color: var(--text-muted, #999);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-progress-elapsed {
|
|
||||||
color: var(--text-muted, #999);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — Omit Cards Chips ————————————————————————— */
|
|
||||||
.dg-omit-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border: 1px solid var(--border, #333);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--bg-input, #1a1a2e);
|
|
||||||
min-height: 38px;
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-omit-wrap:focus-within {
|
|
||||||
border-color: var(--accent, #6c5ce7);
|
|
||||||
box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-omit-chips {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--accent, #6c5ce7);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-chip-x {
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-chip-x:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-omit-input {
|
|
||||||
flex: 1 1 120px;
|
|
||||||
border: none !important;
|
|
||||||
background: transparent !important;
|
|
||||||
padding: 2px 4px !important;
|
|
||||||
min-width: 120px;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dg-hint {
|
|
||||||
display: block;
|
|
||||||
margin-top: 6px;
|
|
||||||
color: var(--text-muted, #999);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
+41
-53
@@ -62,13 +62,9 @@
|
|||||||
class="dg-input"
|
class="dg-input"
|
||||||
id="dg-strategy"
|
id="dg-strategy"
|
||||||
placeholder="e.g. zombie tokens, voltron, spellslinger..."
|
placeholder="e.g. zombie tokens, voltron, spellslinger..."
|
||||||
title="Describe your deck strategy — the AI uses this to pick thematically aligned cards"
|
|
||||||
/>
|
/>
|
||||||
<div class="dg-bracket-row">
|
<div class="dg-bracket-row">
|
||||||
<label class="dg-label">
|
<label class="dg-label">Target Bracket</label>
|
||||||
Target Bracket
|
|
||||||
<span class="tip-icon" data-tip="B1 = precon-level casual. B2 = upgraded with synergies. B3 = high power with strong combos. B4 = cEDH with maximum optimization, fast mana, and instant-speed interaction.">?</span>
|
|
||||||
</label>
|
|
||||||
<div class="dg-bracket-btns" id="dg-bracket-btns">
|
<div class="dg-bracket-btns" id="dg-bracket-btns">
|
||||||
<button class="dg-bracket-btn" data-bracket="1" title="Casual — precon level">B1</button>
|
<button class="dg-bracket-btn" data-bracket="1" title="Casual — precon level">B1</button>
|
||||||
<button class="dg-bracket-btn" data-bracket="2" title="Upgraded Casual">B2</button>
|
<button class="dg-bracket-btn" data-bracket="2" title="Upgraded Casual">B2</button>
|
||||||
@@ -109,7 +105,7 @@
|
|||||||
min="0"
|
min="0"
|
||||||
step="25"
|
step="25"
|
||||||
/>
|
/>
|
||||||
<select class="dg-select" id="dg-budget-mode" title="Total Deck: caps the combined price of all 100 cards. Per Card Max: caps each individual card price.">
|
<select class="dg-select" id="dg-budget-mode">
|
||||||
<option value="total">Total Deck $</option>
|
<option value="total">Total Deck $</option>
|
||||||
<option value="per_card">Per Card Max $</option>
|
<option value="per_card">Per Card Max $</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -124,61 +120,33 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="dg-panel-body">
|
<div class="dg-panel-body">
|
||||||
<label class="dg-toggle-row">
|
<label class="dg-toggle-row">
|
||||||
<span class="dg-toggle-label">
|
<span class="dg-toggle-label">Use my collection</span>
|
||||||
Use my collection
|
|
||||||
<span class="tip-icon" data-tip="Prioritizes cards you already own before pulling from the full card pool. Saves money and ensures the deck is ready to play. Requires an imported collection.">?</span>
|
|
||||||
</span>
|
|
||||||
<input type="checkbox" class="dg-toggle-check" id="dg-use-collection" checked />
|
<input type="checkbox" class="dg-toggle-check" id="dg-use-collection" checked />
|
||||||
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
|
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
|
||||||
</label>
|
</label>
|
||||||
<label class="dg-toggle-row">
|
<label class="dg-toggle-row">
|
||||||
<span class="dg-toggle-label">
|
<span class="dg-toggle-label">Smart Substitution</span>
|
||||||
Smart Substitution
|
|
||||||
<span class="tip-icon" data-tip="When a suggested card isn't in your collection, automatically finds the closest functional replacement you do own. Keeps the deck playable without buying extra cards.">?</span>
|
|
||||||
</span>
|
|
||||||
<input type="checkbox" class="dg-toggle-check" id="dg-substitution" checked />
|
<input type="checkbox" class="dg-toggle-check" id="dg-substitution" checked />
|
||||||
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
|
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="dg-model-row">
|
<div class="dg-model-row">
|
||||||
<label class="dg-label">
|
<label class="dg-label">AI Model</label>
|
||||||
AI Model
|
|
||||||
<span class="tip-icon" data-tip="The local LLM used to generate the deck list. Larger models produce better card selections but are slower. Requires Ollama running locally with the selected model pulled.">?</span>
|
|
||||||
</label>
|
|
||||||
<select class="dg-select" id="dg-model">
|
<select class="dg-select" id="dg-model">
|
||||||
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
|
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Omit Cards (chips input) -->
|
|
||||||
<div class="dg-panel">
|
|
||||||
<div class="dg-panel-header">
|
|
||||||
<span class="dg-panel-title">Omit Cards</span>
|
|
||||||
</div>
|
|
||||||
<div class="dg-panel-body">
|
|
||||||
<div class="dg-omit-wrap" id="dg-omit-wrap">
|
|
||||||
<div class="dg-omit-chips" id="dg-omit-chips"></div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="dg-input dg-omit-input"
|
|
||||||
id="dg-omit-input"
|
|
||||||
placeholder="Type a card name and press Enter…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<small class="dg-hint">Cards listed here will be excluded from the generated deck.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="dg-actions">
|
<div class="dg-actions">
|
||||||
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
|
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
|
||||||
<span class="dg-btn-icon">⚡</span> Generate Deck
|
<span class="dg-btn-icon">⚡</span> Generate Deck
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Conversation Log -->
|
<!-- Conversation Log -->
|
||||||
<div id="dg-conversation-log" class="dg-conversation-log">
|
<div id="dg-conversation-log" class="dg-conversation-log">
|
||||||
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
|
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -188,9 +156,9 @@
|
|||||||
<section class="dg-col-results">
|
<section class="dg-col-results">
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div class="dg-empty-state" id="dg-empty-state">
|
<div class="dg-empty-state" id="dg-empty-state">
|
||||||
<div class="dg-empty-icon">⚡</div>
|
<div class="dg-empty-icon">⚡</div>
|
||||||
<div class="dg-empty-title">V3 AI Deck Generator</div>
|
<div class="dg-empty-title">V3 AI Deck Generator</div>
|
||||||
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.</div>
|
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Perplexity AI with structured output.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
@@ -198,18 +166,38 @@
|
|||||||
<div class="dg-spinner"></div>
|
<div class="dg-spinner"></div>
|
||||||
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
|
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
|
|
||||||
<div class="dg-progress-bar">
|
<!-- Results -->
|
||||||
<div class="dg-progress-fill" id="dg-progress-fill"></div>
|
<div class="dg-results" id="dg-results" style="display:none">
|
||||||
</div>
|
|
||||||
<div class="dg-progress-meta">
|
<!-- Strategy & Bracket Summary -->
|
||||||
<span class="dg-progress-phase" id="dg-progress-phase"></span>
|
<div class="dg-strategy-bar" id="dg-strategy-bar"></div>
|
||||||
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
|
|
||||||
|
<!-- Stats Bar -->
|
||||||
|
<div class="dg-stats-bar" id="dg-stats-bar"></div>
|
||||||
|
|
||||||
|
<!-- Substitution Summary -->
|
||||||
|
<div class="dg-sub-summary" id="dg-sub-summary" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- Card Groups -->
|
||||||
|
<div class="dg-card-groups" id="dg-card-groups"></div>
|
||||||
|
|
||||||
|
<!-- Export & Save Bar -->
|
||||||
|
<div class="dg-save-bar" id="dg-save-bar">
|
||||||
|
<button class="dg-btn dg-btn-save" id="dg-save-btn">
|
||||||
|
<span class="dg-btn-icon">💾</span> Save to Deck Builder
|
||||||
|
</button>
|
||||||
|
<button class="dg-btn dg-btn-ghost" id="dg-regenerate-btn">
|
||||||
|
<span class="dg-btn-icon">🔄</span> Regenerate
|
||||||
|
</button>
|
||||||
|
<div class="dg-export-group">
|
||||||
|
<button class="dg-btn dg-btn-export" id="dg-export-csv">📊 CSV</button>
|
||||||
|
<button class="dg-btn dg-btn-export" id="dg-export-dck">🎮 Forge .dck</button>
|
||||||
|
<button class="dg-btn dg-btn-export" id="dg-export-moxfield">📋 Moxfield</button>
|
||||||
|
<button class="dg-btn dg-btn-export" id="dg-export-shopping">🛒 Shopping List</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
|
|
||||||
<div class="dg-results" id="dg-results" style="display:none"></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+1
-115
@@ -53,95 +53,6 @@ const DeckGenerator = (() => {
|
|||||||
|
|
||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
|
|
||||||
// ── Progress bar state ──────────────────────────────
|
|
||||||
const PROGRESS_PHASES = [
|
|
||||||
{ at: 0, pct: 5, msg: 'Connecting to AI model...' },
|
|
||||||
{ at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' },
|
|
||||||
{ at: 6, pct: 18, msg: 'Building collection summary...' },
|
|
||||||
{ at: 10, pct: 25, msg: 'Sending prompt to LLM...' },
|
|
||||||
{ at: 20, pct: 40, msg: 'AI is building the 99...' },
|
|
||||||
{ at: 35, pct: 55, msg: 'Selecting cards and filling slots...' },
|
|
||||||
{ at: 50, pct: 65, msg: 'Running smart substitution...' },
|
|
||||||
{ at: 70, pct: 78, msg: 'Cross-referencing with collection...' },
|
|
||||||
{ at: 90, pct: 88, msg: 'Almost there — finalizing deck...' },
|
|
||||||
{ at: 110, pct: 93, msg: 'Still working — large model can take a moment...' },
|
|
||||||
];
|
|
||||||
let _progressInterval = null;
|
|
||||||
let _progressStart = 0;
|
|
||||||
|
|
||||||
function startProgress() {
|
|
||||||
_progressStart = Date.now();
|
|
||||||
const wrap = $('dg-progress-wrap');
|
|
||||||
const fill = $('dg-progress-fill');
|
|
||||||
const phase = $('dg-progress-phase');
|
|
||||||
const elapsed = $('dg-progress-elapsed');
|
|
||||||
if (!wrap) return;
|
|
||||||
wrap.style.display = 'block';
|
|
||||||
fill.style.width = '0%';
|
|
||||||
phase.textContent = '';
|
|
||||||
elapsed.textContent = '0s elapsed';
|
|
||||||
let lastPhaseIdx = -1;
|
|
||||||
_progressInterval = setInterval(() => {
|
|
||||||
const secs = Math.floor((Date.now() - _progressStart) / 1000);
|
|
||||||
elapsed.textContent = secs + 's elapsed';
|
|
||||||
for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) {
|
|
||||||
if (secs >= PROGRESS_PHASES[i].at) {
|
|
||||||
fill.style.width = PROGRESS_PHASES[i].pct + '%';
|
|
||||||
if (i !== lastPhaseIdx) {
|
|
||||||
phase.textContent = PROGRESS_PHASES[i].msg;
|
|
||||||
logMessage(PROGRESS_PHASES[i].msg, 'info');
|
|
||||||
lastPhaseIdx = i;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopProgress(success) {
|
|
||||||
if (_progressInterval) {
|
|
||||||
clearInterval(_progressInterval);
|
|
||||||
_progressInterval = null;
|
|
||||||
}
|
|
||||||
const fill = $('dg-progress-fill');
|
|
||||||
const phase = $('dg-progress-phase');
|
|
||||||
const elapsed = $('dg-progress-elapsed');
|
|
||||||
if (fill) fill.style.width = success ? '100%' : '0%';
|
|
||||||
if (phase) phase.textContent = success ? 'Done!' : 'Failed';
|
|
||||||
const secs = Math.floor((Date.now() - _progressStart) / 1000);
|
|
||||||
if (elapsed) elapsed.textContent = secs + 's total';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Omit Cards (chips) ─────────────────────────────
|
|
||||||
let omitCards = [];
|
|
||||||
|
|
||||||
function addOmitCard(name) {
|
|
||||||
const trimmed = name.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return;
|
|
||||||
omitCards.push(trimmed);
|
|
||||||
renderOmitChips();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeOmitCard(idx) {
|
|
||||||
omitCards.splice(idx, 1);
|
|
||||||
renderOmitChips();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOmitChips() {
|
|
||||||
const container = $('dg-omit-chips');
|
|
||||||
if (!container) return;
|
|
||||||
container.innerHTML = omitCards.map((name, i) =>
|
|
||||||
'<span class="dg-chip">' +
|
|
||||||
escHtml(name) +
|
|
||||||
'<span class="dg-chip-x" data-idx="' + i + '">×</span>' +
|
|
||||||
'</span>'
|
|
||||||
).join('');
|
|
||||||
container.querySelectorAll('.dg-chip-x').forEach(el => {
|
|
||||||
el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
// ── Init ────────────────────────────────────────────────
|
// ── Init ────────────────────────────────────────────────
|
||||||
@@ -207,22 +118,6 @@ const DeckGenerator = (() => {
|
|||||||
$('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
|
$('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
|
||||||
$('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
|
$('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
|
||||||
$('dg-export-shopping').addEventListener('click', () => exportShopping());
|
$('dg-export-shopping').addEventListener('click', () => exportShopping());
|
||||||
|
|
||||||
// Omit cards chips input
|
|
||||||
const omitInput = $('dg-omit-input');
|
|
||||||
if (omitInput) {
|
|
||||||
omitInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
addOmitCard(omitInput.value);
|
|
||||||
omitInput.value = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const omitWrap = $('dg-omit-wrap');
|
|
||||||
if (omitWrap) {
|
|
||||||
omitWrap.addEventListener('click', () => omitInput && omitInput.focus());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkQueryParams() {
|
function checkQueryParams() {
|
||||||
@@ -243,7 +138,7 @@ const DeckGenerator = (() => {
|
|||||||
target_bracket: state.targetBracket,
|
target_bracket: state.targetBracket,
|
||||||
budget_usd: budget,
|
budget_usd: budget,
|
||||||
budget_mode: $('dg-budget-mode').value,
|
budget_mode: $('dg-budget-mode').value,
|
||||||
omit_cards: omitCards.slice(),
|
omit_cards: [],
|
||||||
use_collection: $('dg-use-collection').checked,
|
use_collection: $('dg-use-collection').checked,
|
||||||
run_substitution: $('dg-substitution').checked,
|
run_substitution: $('dg-substitution').checked,
|
||||||
model: $('dg-model').value,
|
model: $('dg-model').value,
|
||||||
@@ -345,12 +240,6 @@ const DeckGenerator = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$('dg-preview-btn').disabled = false;
|
$('dg-preview-btn').disabled = false;
|
||||||
|
|
||||||
// Auto-fill deck name if empty
|
|
||||||
const deckNameInput = $('dg-deck-name');
|
|
||||||
if (deckNameInput && !deckNameInput.value.trim()) {
|
|
||||||
deckNameInput.value = 'Auto - ' + cmdr.name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCommander() {
|
function clearCommander() {
|
||||||
@@ -395,7 +284,6 @@ const DeckGenerator = (() => {
|
|||||||
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
|
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
|
||||||
|
|
||||||
setLoading(true, 'Generating deck via Local AI...');
|
setLoading(true, 'Generating deck via Local AI...');
|
||||||
startProgress();
|
|
||||||
|
|
||||||
const body = buildRequestBody();
|
const body = buildRequestBody();
|
||||||
logMessage('Sending request to AI model... this may take a moment.', 'info');
|
logMessage('Sending request to AI model... this may take a moment.', 'info');
|
||||||
@@ -419,12 +307,10 @@ const DeckGenerator = (() => {
|
|||||||
const stats = state.previewResult.stats || {};
|
const stats = state.previewResult.stats || {};
|
||||||
const cardCount = (stats.total_cards || 0);
|
const cardCount = (stats.total_cards || 0);
|
||||||
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
|
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
|
||||||
stopProgress(true);
|
|
||||||
renderResults(state.previewResult);
|
renderResults(state.previewResult);
|
||||||
|
|
||||||
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
|
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
stopProgress(false);
|
|
||||||
toast(e.message || 'Generation failed', 'error');
|
toast(e.message || 'Generation failed', 'error');
|
||||||
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
|
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
+12
-44
@@ -16,7 +16,7 @@
|
|||||||
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between;">
|
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||||
<div>
|
<div>
|
||||||
<h1>Closed-Loop Distillation</h1>
|
<h1>Closed-Loop Distillation</h1>
|
||||||
<p>Iterative supervised → PPO → quality gate → merge pipeline</p>
|
<p>Iterative supervised → PPO → quality gate → merge pipeline</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="btn-refresh" class="btn btn-secondary" onclick="refreshAll()" title="Refresh status">
|
<button id="btn-refresh" class="btn btn-secondary" onclick="refreshAll()" title="Refresh status">
|
||||||
<span class="btn-icon" id="refresh-icon">↻</span> Refresh
|
<span class="btn-icon" id="refresh-icon">↻</span> Refresh
|
||||||
@@ -59,70 +59,43 @@
|
|||||||
<div class="train-controls">
|
<div class="train-controls">
|
||||||
<div class="param-grid form-row">
|
<div class="param-grid form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-iterations">
|
<label class="form-label" for="param-iterations">Max Generations</label>
|
||||||
Max Generations
|
|
||||||
<span class="tip-icon" data-tip="How many distillation cycles to run. Each cycle: supervised train → PPO fine-tune → quality gate. Stops early if convergence is detected.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-iterations" class="form-input" value="10" min="1" max="100" />
|
<input type="number" id="param-iterations" class="form-input" value="10" min="1" max="100" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-conv-window">
|
<label class="form-label" for="param-conv-window">Convergence Window</label>
|
||||||
Convergence Window
|
|
||||||
<span class="tip-icon" data-tip="Number of recent generations to check for plateau. If ELO gain stays below threshold across this many gens, training stops early to save time.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-conv-window" class="form-input" value="3" min="2" max="10" />
|
<input type="number" id="param-conv-window" class="form-input" value="3" min="2" max="10" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-sup-epochs">
|
<label class="form-label" for="param-sup-epochs">Supervised Epochs</label>
|
||||||
Supervised Epochs
|
|
||||||
<span class="tip-icon" data-tip="Training epochs for the supervised step each generation. Fewer is fine since each cycle trains on incrementally better data. 30 is a solid starting point.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-sup-epochs" class="form-input" value="30" min="5" max="200" />
|
<input type="number" id="param-sup-epochs" class="form-input" value="30" min="5" max="200" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-sup-lr">
|
<label class="form-label" for="param-sup-lr">Supervised LR</label>
|
||||||
Supervised LR
|
|
||||||
<span class="tip-icon" data-tip="Learning rate for the supervised phase. 0.001 is a solid default. Lower it in later generations if the model is already strong.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-sup-lr" class="form-input" value="0.001" step="0.0001" min="0.00001" max="0.1" />
|
<input type="number" id="param-sup-lr" class="form-input" value="0.001" step="0.0001" min="0.00001" max="0.1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-grid form-row">
|
<div class="param-grid form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-ppo-iters">
|
<label class="form-label" for="param-ppo-iters">PPO Iterations</label>
|
||||||
PPO Iterations
|
|
||||||
<span class="tip-icon" data-tip="Number of PPO update steps per generation. Each step samples game episodes, computes advantages, and updates policy weights.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-ppo-iters" class="form-input" value="50" min="10" max="500" />
|
<input type="number" id="param-ppo-iters" class="form-input" value="50" min="10" max="500" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-ppo-episodes">
|
<label class="form-label" for="param-ppo-episodes">PPO Episodes/Iter</label>
|
||||||
PPO Episodes/Iter
|
|
||||||
<span class="tip-icon" data-tip="Game simulations per PPO update step. More episodes = more stable gradients but slower iterations. 64 balances speed and stability well.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-ppo-episodes" class="form-input" value="64" min="8" max="512" step="8" />
|
<input type="number" id="param-ppo-episodes" class="form-input" value="64" min="8" max="512" step="8" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-ppo-lr">
|
<label class="form-label" for="param-ppo-lr">PPO Learning Rate</label>
|
||||||
PPO Learning Rate
|
|
||||||
<span class="tip-icon" data-tip="PPO step size. Keep this lower than the supervised LR. 0.0003 is the standard RL default and works well here.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-ppo-lr" class="form-input" value="0.0003" step="0.0001" min="0.00001" max="0.01" />
|
<input type="number" id="param-ppo-lr" class="form-input" value="0.0003" step="0.0001" min="0.00001" max="0.01" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-min-wr">
|
<label class="form-label" for="param-min-wr">Min Win Rate Gate</label>
|
||||||
Min Win Rate Gate
|
|
||||||
<span class="tip-icon" data-tip="Quality gate threshold. A generation must beat the heuristic opponent at this win rate to be accepted and merged into the next cycle. 0.30 is a lenient starting gate.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-min-wr" class="form-input" value="0.30" step="0.05" min="0.1" max="0.9" />
|
<input type="number" id="param-min-wr" class="form-input" value="0.30" step="0.05" min="0.1" max="0.9" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-grid form-row distill-bottom-params">
|
<div class="param-grid form-row distill-bottom-params">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-opponent">
|
<label class="form-label" for="param-opponent">Opponent</label>
|
||||||
Opponent
|
|
||||||
<span class="tip-icon" data-tip="Who the agent trains against during PPO. Heuristic is recommended for a consistent challenge. Self-play can become unstable without a strong supervised baseline first.">?</span>
|
|
||||||
</label>
|
|
||||||
<select id="param-opponent" class="form-select">
|
<select id="param-opponent" class="form-select">
|
||||||
<option value="heuristic">Heuristic</option>
|
<option value="heuristic">Heuristic</option>
|
||||||
<option value="random">Random</option>
|
<option value="random">Random</option>
|
||||||
@@ -130,10 +103,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-playstyle">
|
<label class="form-label" for="param-playstyle">Playstyle</label>
|
||||||
Playstyle
|
|
||||||
<span class="tip-icon" data-tip="Deck archetype used for simulations in this distillation run. Midrange is the most balanced test bed. Match this to the archetype you want the model to master.">?</span>
|
|
||||||
</label>
|
|
||||||
<select id="param-playstyle" class="form-select">
|
<select id="param-playstyle" class="form-select">
|
||||||
<option value="midrange">Midrange</option>
|
<option value="midrange">Midrange</option>
|
||||||
<option value="aggro">Aggro</option>
|
<option value="aggro">Aggro</option>
|
||||||
@@ -194,9 +164,7 @@
|
|||||||
<!-- ELO Rating Chart -->
|
<!-- ELO Rating Chart -->
|
||||||
<section class="panel" id="elo-panel">
|
<section class="panel" id="elo-panel">
|
||||||
<div class="panel-header elo-panel-header">
|
<div class="panel-header elo-panel-header">
|
||||||
<h2>ELO Ratings
|
<h2>ELO Ratings</h2>
|
||||||
<span class="tip-icon tip-below" data-tip="ELO scores measure relative strength. Baseline is 1200. Scores above that mean the model wins more than it loses. Tournament runs the top 10 most recent checkpoints head-to-head.">?</span>
|
|
||||||
</h2>
|
|
||||||
<button id="btn-run-elo" class="btn btn-sm btn-secondary" onclick="runEloTournament()">
|
<button id="btn-run-elo" class="btn btn-sm btn-secondary" onclick="runEloTournament()">
|
||||||
<span class="btn-icon">🏆</span> Run Tournament
|
<span class="btn-icon">🏆</span> Run Tournament
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -864,92 +864,3 @@ body {
|
|||||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||||
box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
|
box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════════
|
|
||||||
Tooltip System (.tip-icon + data-tip)
|
|
||||||
Shared across all pages — do not duplicate in page-specific CSS
|
|
||||||
══════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
.tip-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--surface-3);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
cursor: default;
|
|
||||||
position: relative;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-left: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-icon:hover {
|
|
||||||
background: var(--accent-dim);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-icon::after {
|
|
||||||
content: attr(data-tip);
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 8px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--surface-0, #1a1a2e);
|
|
||||||
color: var(--text, #e0e0e0);
|
|
||||||
border: 1px solid var(--border, #333);
|
|
||||||
border-radius: var(--radius-md, 6px);
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: normal;
|
|
||||||
width: 240px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
z-index: 1000;
|
|
||||||
text-align: left;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-icon::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 3px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
border: 5px solid transparent;
|
|
||||||
border-top-color: var(--border, #333);
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
z-index: 1001;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-icon:hover::after,
|
|
||||||
.tip-icon:hover::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-icon.tip-below::after {
|
|
||||||
bottom: auto;
|
|
||||||
top: calc(100% + 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-icon.tip-below::before {
|
|
||||||
bottom: auto;
|
|
||||||
top: calc(100% + 3px);
|
|
||||||
border-top-color: transparent;
|
|
||||||
border-bottom-color: var(--border, #333);
|
|
||||||
}
|
|
||||||
+7
-26
@@ -28,27 +28,20 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group sim-field-auto">
|
<div class="form-group sim-field-auto">
|
||||||
<label class="form-label" for="sim-opponent-select">
|
<label class="form-label" for="sim-opponent-select">Opponent</label>
|
||||||
Opponent
|
|
||||||
<span class="tip-icon tip-below" data-tip="Heuristic AI uses rule-based logic — fast and deterministic. DeepSeek AI routes decisions through your local Ollama LLM for smarter but slower play.">?</span>
|
|
||||||
</label>
|
|
||||||
<select id="sim-opponent-select" class="form-select">
|
<select id="sim-opponent-select" class="form-select">
|
||||||
<option value="heuristic">Heuristic AI</option>
|
<option value="heuristic">Heuristic AI</option>
|
||||||
<option value="deepseek">DeepSeek AI</option>
|
<option value="deepseek">DeepSeek AI</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group sim-field-auto">
|
<div class="form-group sim-field-auto">
|
||||||
<label class="form-label" for="sim-num-games">
|
<label class="form-label" for="sim-num-games">Games</label>
|
||||||
Games
|
|
||||||
<span class="tip-icon tip-below" data-tip="Number of games to simulate. 10 is quick for a gut-check; 100+ gives statistically meaningful win rates. Each game is fully simulated end-to-end.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="sim-num-games" class="form-input sim-num-input" value="10" min="1" max="1000" />
|
<input type="number" id="sim-num-games" class="form-input sim-num-input" value="10" min="1" max="1000" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group sim-field-auto sim-field-end">
|
<div class="form-group sim-field-auto sim-field-end">
|
||||||
<label class="form-checkbox">
|
<label class="form-checkbox">
|
||||||
<input type="checkbox" id="sim-record-logs" checked />
|
<input type="checkbox" id="sim-record-logs" checked />
|
||||||
Record turn logs
|
Record turn logs
|
||||||
<span class="tip-icon tip-below" data-tip="Saves per-turn decision snapshots to .jsonl files. Required for ML training data collection. Slightly slower — disable if you only need win rate stats.">?</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group sim-field-auto sim-field-end">
|
<div class="form-group sim-field-auto sim-field-end">
|
||||||
@@ -68,31 +61,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="deepseek-config-row">
|
<div class="deepseek-config-row">
|
||||||
<div class="form-group sim-field-grow">
|
<div class="form-group sim-field-grow">
|
||||||
<label class="form-label" for="ds-api-base">
|
<label class="form-label" for="ds-api-base">API Endpoint</label>
|
||||||
API Endpoint
|
<input type="text" id="ds-api-base" class="form-input" value="http://192.168.0.240:11434" placeholder="http://192.168.0.240:11434" />
|
||||||
<span class="tip-icon tip-below" data-tip="Base URL of your Ollama server. Default is localhost:11434. Change this if Ollama is running on a different machine or WSL2 network address.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" id="ds-api-base" class="form-input" value="http://localhost:11434" placeholder="http://localhost:11434" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group sim-field-auto">
|
<div class="form-group sim-field-auto">
|
||||||
<label class="form-label" for="ds-model">
|
<label class="form-label" for="ds-model">Model</label>
|
||||||
Model
|
|
||||||
<span class="tip-icon tip-below" data-tip="Ollama model tag to use for AI decisions. Leave blank to auto-detect the best available model. Examples: deepseek-r1:7b, llama3:8b, mistral.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" id="ds-model" class="form-input" value="" placeholder="Auto-detect" style="width:200px" />
|
<input type="text" id="ds-model" class="form-input" value="" placeholder="Auto-detect" style="width:200px" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group sim-field-auto">
|
<div class="form-group sim-field-auto">
|
||||||
<label class="form-label" for="ds-temp">
|
<label class="form-label" for="ds-temp">Temp</label>
|
||||||
Temp
|
|
||||||
<span class="tip-icon tip-below" data-tip="LLM sampling temperature. Lower = more deterministic decisions. 0.3 is a good balance — fully greedy (0.0) can get stuck in repetitive play patterns.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="ds-temp" class="form-input sim-num-input" value="0.3" min="0" max="2" step="0.1" />
|
<input type="number" id="ds-temp" class="form-input sim-num-input" value="0.3" min="0" max="2" step="0.1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group sim-field-auto">
|
<div class="form-group sim-field-auto">
|
||||||
<label class="form-label" for="ds-timeout">
|
<label class="form-label" for="ds-timeout">Timeout (s)</label>
|
||||||
Timeout (s)
|
|
||||||
<span class="tip-icon tip-below" data-tip="Max seconds to wait for an LLM response per decision. If exceeded, falls back to heuristic AI automatically. Increase if your GPU is slow on cold inference.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="ds-timeout" class="form-input sim-num-input" value="10" min="1" max="60" />
|
<input type="number" id="ds-timeout" class="form-input sim-num-input" value="10" min="1" max="60" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-179
@@ -358,182 +358,3 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════════
|
|
||||||
Intro / Getting Started Banner
|
|
||||||
══════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
.intro-banner {
|
|
||||||
background: color-mix(in srgb, var(--accent) 8%, var(--surface-1));
|
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border));
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--sp-4) var(--sp-5);
|
|
||||||
margin-bottom: var(--sp-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
margin-bottom: var(--sp-3);
|
|
||||||
font-size: 15px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner-icon {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner-header strong {
|
|
||||||
flex: 1;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
line-height: 1;
|
|
||||||
transition: color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner-close:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--surface-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner-lead {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0 0 var(--sp-3) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner-steps {
|
|
||||||
margin: 0 0 var(--sp-4) 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner-steps li {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner-steps li strong {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner-steps li em {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro-banner-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-top: var(--sp-3);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════════
|
|
||||||
Tooltip System (.tip-icon + data-tip)
|
|
||||||
══════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
.tip-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--surface-3);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
cursor: default;
|
|
||||||
position: relative;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-left: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
/* prevent tooltip from triggering form-label hover states */
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-icon:hover {
|
|
||||||
background: var(--accent-dim);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The tooltip bubble itself */
|
|
||||||
.tip-icon::after {
|
|
||||||
content: attr(data-tip);
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 8px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--surface-0, #1a1a2e);
|
|
||||||
color: var(--text, #e0e0e0);
|
|
||||||
border: 1px solid var(--border, #333);
|
|
||||||
border-radius: var(--radius-md, 6px);
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: normal;
|
|
||||||
width: 240px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
z-index: 1000;
|
|
||||||
text-align: left;
|
|
||||||
/* reset inherited label styles */
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Arrow pointing down */
|
|
||||||
.tip-icon::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 3px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
border: 5px solid transparent;
|
|
||||||
border-top-color: var(--border, #333);
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
z-index: 1001;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-icon:hover::after,
|
|
||||||
.tip-icon:hover::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Flip tooltip below when near the top of the viewport */
|
|
||||||
.tip-icon.tip-below::after {
|
|
||||||
bottom: auto;
|
|
||||||
top: calc(100% + 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-icon.tip-below::before {
|
|
||||||
bottom: auto;
|
|
||||||
top: calc(100% + 3px);
|
|
||||||
border-top-color: transparent;
|
|
||||||
border-bottom-color: var(--border, #333);
|
|
||||||
}
|
|
||||||
+13
-71
@@ -18,35 +18,9 @@
|
|||||||
<h1>ML Training Dashboard</h1>
|
<h1>ML Training Dashboard</h1>
|
||||||
<p>Train, evaluate, and deploy the AI opponent policy network</p>
|
<p>Train, evaluate, and deploy the AI opponent policy network</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
<button id="btn-refresh-data" class="btn btn-secondary" onclick="refreshAll()" title="Refresh training data status">
|
||||||
<button class="btn btn-ghost btn-sm" onclick="showIntroBanner()" title="Show getting started guide">❓ Help</button>
|
<span class="btn-icon" id="refresh-icon">↻</span> Refresh
|
||||||
<button id="btn-refresh-data" class="btn btn-secondary" onclick="refreshAll()" title="Refresh training data status">
|
</button>
|
||||||
<span class="btn-icon" id="refresh-icon">↻</span> Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Intro / Getting Started Banner -->
|
|
||||||
<div id="training-intro-banner" class="intro-banner hidden">
|
|
||||||
<div class="intro-banner-header">
|
|
||||||
<span class="intro-banner-icon">🎓</span>
|
|
||||||
<strong>Getting Started with ML Training</strong>
|
|
||||||
<button class="intro-banner-close" onclick="dismissIntroBanner()" title="Dismiss">✕</button>
|
|
||||||
</div>
|
|
||||||
<p class="intro-banner-lead">This page trains a neural network to play Commander decisions. Follow these steps in order on your first run:</p>
|
|
||||||
<ol class="intro-banner-steps">
|
|
||||||
<li><strong>Run batch simulations</strong> with ML logging enabled (from the Simulator page) to generate decision log files.</li>
|
|
||||||
<li><strong>Start Training</strong> below — leave “Rebuild dataset” checked so it parses your logs into a dataset automatically.</li>
|
|
||||||
<li>Check <strong>Evaluation Results</strong> after training. Aim for >60% test accuracy before moving on.</li>
|
|
||||||
<li><em>(Optional)</em> Run <strong>PPO reinforcement learning</strong> to fine-tune the policy via self-play. Only recommended after a solid supervised baseline.</li>
|
|
||||||
<li>Run a <strong>Tournament</strong> to benchmark your model against heuristic and random agents and get a win-rate score.</li>
|
|
||||||
</ol>
|
|
||||||
<div class="intro-banner-footer">
|
|
||||||
<label class="form-checkbox" style="font-size:12px;">
|
|
||||||
<input type="checkbox" id="intro-dont-show" /> Don’t show this again
|
|
||||||
</label>
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="dismissIntroBanner()">Got it</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status Overview Cards -->
|
<!-- Status Overview Cards -->
|
||||||
@@ -85,31 +59,19 @@
|
|||||||
<div class="train-controls">
|
<div class="train-controls">
|
||||||
<div class="param-grid form-row">
|
<div class="param-grid form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-epochs">
|
<label class="form-label" for="param-epochs">Epochs</label>
|
||||||
Epochs
|
|
||||||
<span class="tip-icon" data-tip="Number of full passes over the training dataset. More epochs improve fit but risk overfitting. 50 is a safe starting point.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-epochs" class="form-input" value="50" min="1" max="500" />
|
<input type="number" id="param-epochs" class="form-input" value="50" min="1" max="500" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-lr">
|
<label class="form-label" for="param-lr">Learning Rate</label>
|
||||||
Learning Rate
|
|
||||||
<span class="tip-icon" data-tip="Controls how large each weight update is. Too high causes instability; too low is very slow. 0.001 is a reliable default.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-lr" class="form-input" value="0.001" step="0.0001" min="0.00001" max="0.1" />
|
<input type="number" id="param-lr" class="form-input" value="0.001" step="0.0001" min="0.00001" max="0.1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-batch">
|
<label class="form-label" for="param-batch">Batch Size</label>
|
||||||
Batch Size
|
|
||||||
<span class="tip-icon" data-tip="Samples processed per gradient update. Larger batches are faster on GPU but use more VRAM. 256 is recommended for most setups.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-batch" class="form-input" value="256" min="16" max="2048" step="16" />
|
<input type="number" id="param-batch" class="form-input" value="256" min="16" max="2048" step="16" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="param-patience">
|
<label class="form-label" for="param-patience">Early Stop Patience</label>
|
||||||
Early Stop Patience
|
|
||||||
<span class="tip-icon" data-tip="Stops training if validation loss doesn't improve for this many epochs in a row. Prevents wasted compute. 10 is a sensible default.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="param-patience" class="form-input" value="10" min="3" max="100" />
|
<input type="number" id="param-patience" class="form-input" value="10" min="3" max="100" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +79,6 @@
|
|||||||
<label class="form-checkbox checkbox-label">
|
<label class="form-checkbox checkbox-label">
|
||||||
<input type="checkbox" id="param-rebuild" checked />
|
<input type="checkbox" id="param-rebuild" checked />
|
||||||
Rebuild dataset from decision logs
|
Rebuild dataset from decision logs
|
||||||
<span class="tip-icon" data-tip="Re-parses all .jsonl decision log files into a fresh dataset. Uncheck to reuse the last built dataset and save time on re-runs.">?</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="train-actions">
|
<div class="train-actions">
|
||||||
@@ -171,31 +132,19 @@
|
|||||||
<div class="train-controls">
|
<div class="train-controls">
|
||||||
<div class="param-grid form-row">
|
<div class="param-grid form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="ppo-iterations">
|
<label class="form-label" for="ppo-iterations">Iterations</label>
|
||||||
Iterations
|
|
||||||
<span class="tip-icon" data-tip="Each iteration runs N episodes, computes returns, and updates the policy. 100 iterations is a typical starting point.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="ppo-iterations" class="form-input" value="100" min="1" max="1000" />
|
<input type="number" id="ppo-iterations" class="form-input" value="100" min="1" max="1000" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="ppo-episodes">
|
<label class="form-label" for="ppo-episodes">Episodes/Iter</label>
|
||||||
Episodes/Iter
|
|
||||||
<span class="tip-icon" data-tip="Number of full game simulations per PPO update step. More episodes produce more stable gradients but slow down each iteration.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="ppo-episodes" class="form-input" value="64" min="8" max="512" step="8" />
|
<input type="number" id="ppo-episodes" class="form-input" value="64" min="8" max="512" step="8" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="ppo-lr">
|
<label class="form-label" for="ppo-lr">Learning Rate</label>
|
||||||
Learning Rate
|
|
||||||
<span class="tip-icon" data-tip="PPO learning rate. Lower than supervised training is usually better — 0.0003 is a standard RL starting value.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="ppo-lr" class="form-input" value="0.0003" step="0.0001" />
|
<input type="number" id="ppo-lr" class="form-input" value="0.0003" step="0.0001" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="ppo-opponent">
|
<label class="form-label" for="ppo-opponent">Opponent</label>
|
||||||
Opponent
|
|
||||||
<span class="tip-icon" data-tip="Who the agent plays against during training. Heuristic is the best first opponent. Self-Play is more advanced and can be unstable early on.">?</span>
|
|
||||||
</label>
|
|
||||||
<select id="ppo-opponent" class="form-select">
|
<select id="ppo-opponent" class="form-select">
|
||||||
<option value="heuristic">Heuristic</option>
|
<option value="heuristic">Heuristic</option>
|
||||||
<option value="random">Random</option>
|
<option value="random">Random</option>
|
||||||
@@ -207,7 +156,6 @@
|
|||||||
<label class="form-checkbox checkbox-label">
|
<label class="form-checkbox checkbox-label">
|
||||||
<input type="checkbox" id="ppo-load-supervised" />
|
<input type="checkbox" id="ppo-load-supervised" />
|
||||||
Initialize from supervised checkpoint (warm start)
|
Initialize from supervised checkpoint (warm start)
|
||||||
<span class="tip-icon" data-tip="Strongly recommended. Starts PPO from your best supervised model instead of random weights. PPO from scratch is very slow and unstable.">?</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="train-actions">
|
<div class="train-actions">
|
||||||
@@ -236,17 +184,11 @@
|
|||||||
<div class="train-controls">
|
<div class="train-controls">
|
||||||
<div class="param-grid form-row tourney-param-grid">
|
<div class="param-grid form-row tourney-param-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="tourney-episodes">
|
<label class="form-label" for="tourney-episodes">Games per Matchup</label>
|
||||||
Games per Matchup
|
|
||||||
<span class="tip-icon" data-tip="Number of games played per policy pair. Use at least 50–100 games for a statistically reliable win rate.">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="tourney-episodes" class="form-input" value="50" min="10" max="500" />
|
<input type="number" id="tourney-episodes" class="form-input" value="50" min="10" max="500" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="tourney-style">
|
<label class="form-label" for="tourney-style">Playstyle</label>
|
||||||
Playstyle
|
|
||||||
<span class="tip-icon" data-tip="The deck archetype used during tournament games. Try Midrange first — it's the most balanced and representative of typical Commander gameplay.">?</span>
|
|
||||||
</label>
|
|
||||||
<select id="tourney-style" class="form-select">
|
<select id="tourney-style" class="form-select">
|
||||||
<option value="midrange">Midrange</option>
|
<option value="midrange">Midrange</option>
|
||||||
<option value="aggro">Aggro</option>
|
<option value="aggro">Aggro</option>
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ let isTraining = false;
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
refreshDataStatus();
|
refreshDataStatus();
|
||||||
refreshTrainingStatus();
|
refreshTrainingStatus();
|
||||||
initIntroBanner();
|
|
||||||
initTooltipFlip();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
@@ -38,51 +36,6 @@ async function refreshAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
|
||||||
// Intro Banner
|
|
||||||
// ═══════════════════════════════════════════
|
|
||||||
|
|
||||||
const INTRO_STORAGE_KEY = 'trainingIntroSeen';
|
|
||||||
|
|
||||||
function initIntroBanner() {
|
|
||||||
const seen = localStorage.getItem(INTRO_STORAGE_KEY);
|
|
||||||
if (!seen) {
|
|
||||||
showIntroBanner();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showIntroBanner() {
|
|
||||||
const banner = document.getElementById('training-intro-banner');
|
|
||||||
if (banner) banner.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissIntroBanner() {
|
|
||||||
const banner = document.getElementById('training-intro-banner');
|
|
||||||
const dontShow = document.getElementById('intro-dont-show');
|
|
||||||
if (banner) banner.classList.add('hidden');
|
|
||||||
if (dontShow && dontShow.checked) {
|
|
||||||
localStorage.setItem(INTRO_STORAGE_KEY, '1');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
|
||||||
// Tooltip flip — prevent overflow at top
|
|
||||||
// ═══════════════════════════════════════════
|
|
||||||
|
|
||||||
function initTooltipFlip() {
|
|
||||||
document.querySelectorAll('.tip-icon').forEach(el => {
|
|
||||||
el.addEventListener('mouseenter', () => {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
// If less than 120px above the element, flip tooltip below
|
|
||||||
if (rect.top < 120) {
|
|
||||||
el.classList.add('tip-below');
|
|
||||||
} else {
|
|
||||||
el.classList.remove('tip-below');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// Data Status
|
// Data Status
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""WebSocket module for real-time game state streaming to Unity."""
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
"""WebSocket connection manager for game-state streaming.
|
|
||||||
|
|
||||||
Manages per-game-session WebSocket connections so the server can
|
|
||||||
push state deltas to every connected Unity client in real time.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Dict, List, Optional, Set
|
|
||||||
|
|
||||||
from fastapi import WebSocket, WebSocketDisconnect
|
|
||||||
|
|
||||||
log = logging.getLogger("ws.connection_manager")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ClientInfo:
|
|
||||||
"""Metadata for a single connected WebSocket client."""
|
|
||||||
ws: WebSocket
|
|
||||||
client_id: str
|
|
||||||
game_id: str
|
|
||||||
connected_at: float = field(default_factory=time.time)
|
|
||||||
last_ping: float = field(default_factory=time.time)
|
|
||||||
messages_sent: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
|
||||||
"""Manages WebSocket connections grouped by game_id.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
manager = ConnectionManager()
|
|
||||||
await manager.connect(websocket, game_id, client_id)
|
|
||||||
await manager.broadcast_to_game(game_id, payload)
|
|
||||||
manager.disconnect(websocket, game_id)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, heartbeat_interval: float = 30.0):
|
|
||||||
# game_id -> set of ClientInfo
|
|
||||||
self._games: Dict[str, Dict[str, ClientInfo]] = {}
|
|
||||||
self._heartbeat_interval = heartbeat_interval
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
# ── Connect / Disconnect ───────────────────────────────
|
|
||||||
|
|
||||||
async def connect(
|
|
||||||
self, ws: WebSocket, game_id: str, client_id: str
|
|
||||||
) -> ClientInfo:
|
|
||||||
"""Accept and register a WebSocket connection."""
|
|
||||||
await ws.accept()
|
|
||||||
client = ClientInfo(ws=ws, client_id=client_id, game_id=game_id)
|
|
||||||
async with self._lock:
|
|
||||||
if game_id not in self._games:
|
|
||||||
self._games[game_id] = {}
|
|
||||||
self._games[game_id][client_id] = client
|
|
||||||
log.info(
|
|
||||||
"WS connected: client=%s game=%s (total=%d)",
|
|
||||||
client_id, game_id, len(self._games[game_id]),
|
|
||||||
)
|
|
||||||
return client
|
|
||||||
|
|
||||||
async def disconnect(self, game_id: str, client_id: str) -> None:
|
|
||||||
"""Remove a client from the game session."""
|
|
||||||
async with self._lock:
|
|
||||||
game = self._games.get(game_id)
|
|
||||||
if game and client_id in game:
|
|
||||||
del game[client_id]
|
|
||||||
if not game:
|
|
||||||
del self._games[game_id]
|
|
||||||
log.info("WS disconnected: client=%s game=%s", client_id, game_id)
|
|
||||||
|
|
||||||
# ── Broadcasting ───────────────────────────────────────
|
|
||||||
|
|
||||||
async def broadcast_to_game(
|
|
||||||
self, game_id: str, payload: dict
|
|
||||||
) -> int:
|
|
||||||
"""Send a JSON payload to every client in a game session.
|
|
||||||
|
|
||||||
Returns the number of clients that received the message.
|
|
||||||
Silently removes clients whose connections are broken.
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
game = self._games.get(game_id)
|
|
||||||
if not game:
|
|
||||||
return 0
|
|
||||||
clients = list(game.values())
|
|
||||||
|
|
||||||
sent = 0
|
|
||||||
dead: List[str] = []
|
|
||||||
for client in clients:
|
|
||||||
try:
|
|
||||||
await client.ws.send_json(payload)
|
|
||||||
client.messages_sent += 1
|
|
||||||
sent += 1
|
|
||||||
except Exception:
|
|
||||||
dead.append(client.client_id)
|
|
||||||
|
|
||||||
# Clean up broken connections
|
|
||||||
if dead:
|
|
||||||
async with self._lock:
|
|
||||||
game = self._games.get(game_id)
|
|
||||||
if game:
|
|
||||||
for cid in dead:
|
|
||||||
game.pop(cid, None)
|
|
||||||
if not game:
|
|
||||||
del self._games[game_id]
|
|
||||||
log.warning(
|
|
||||||
"Removed %d dead connection(s) from game %s",
|
|
||||||
len(dead), game_id,
|
|
||||||
)
|
|
||||||
return sent
|
|
||||||
|
|
||||||
async def send_to_client(
|
|
||||||
self, game_id: str, client_id: str, payload: dict
|
|
||||||
) -> bool:
|
|
||||||
"""Send a JSON payload to a specific client."""
|
|
||||||
async with self._lock:
|
|
||||||
game = self._games.get(game_id)
|
|
||||||
if not game:
|
|
||||||
return False
|
|
||||||
client = game.get(client_id)
|
|
||||||
if not client:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
await client.ws.send_json(payload)
|
|
||||||
client.messages_sent += 1
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
await self.disconnect(game_id, client_id)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ── Queries ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_game_clients(self, game_id: str) -> List[str]:
|
|
||||||
"""Return client IDs connected to a game."""
|
|
||||||
game = self._games.get(game_id, {})
|
|
||||||
return list(game.keys())
|
|
||||||
|
|
||||||
def active_games(self) -> List[str]:
|
|
||||||
"""Return all game IDs with active connections."""
|
|
||||||
return list(self._games.keys())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def total_connections(self) -> int:
|
|
||||||
return sum(len(g) for g in self._games.values())
|
|
||||||
|
|
||||||
def stats(self) -> dict:
|
|
||||||
"""Return connection statistics."""
|
|
||||||
return {
|
|
||||||
"active_games": len(self._games),
|
|
||||||
"total_connections": self.total_connections,
|
|
||||||
"games": {
|
|
||||||
gid: {
|
|
||||||
"clients": len(clients),
|
|
||||||
"client_ids": list(clients.keys()),
|
|
||||||
}
|
|
||||||
for gid, clients in self._games.items()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Module-level singleton
|
|
||||||
game_manager = ConnectionManager()
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
"""Game-state delta engine.
|
|
||||||
|
|
||||||
Compares successive game-state snapshots and produces minimal
|
|
||||||
JSON deltas so that Unity only receives what changed — not
|
|
||||||
the full 4-player board every tick.
|
|
||||||
|
|
||||||
Delta format sent over WebSocket:
|
|
||||||
{
|
|
||||||
"type": "delta" | "snapshot" | "decision" | "game_over",
|
|
||||||
"seq": <monotonic int>,
|
|
||||||
"game_id": "...",
|
|
||||||
"turn": 5,
|
|
||||||
"phase": "main_1",
|
|
||||||
"timestamp": 1711400000.0,
|
|
||||||
"changes": [
|
|
||||||
{"path": "players.0.life", "op": "replace", "value": 37},
|
|
||||||
{"path": "players.1.battlefield", "op": "add", "value": {"name": "Sol Ring", ...}},
|
|
||||||
{"path": "stack", "op": "replace", "value": [...]},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import copy
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
log = logging.getLogger("ws.game_state")
|
|
||||||
|
|
||||||
|
|
||||||
def _deep_diff(
|
|
||||||
old: Any, new: Any, path: str = ""
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Recursively diff two JSON-serialisable structures.
|
|
||||||
|
|
||||||
Returns a list of RFC-6902-style patch operations:
|
|
||||||
{"op": "replace"|"add"|"remove", "path": "...", "value": ...}
|
|
||||||
"""
|
|
||||||
changes: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
if old is None and new is not None:
|
|
||||||
return [{"op": "add", "path": path, "value": new}]
|
|
||||||
if old is not None and new is None:
|
|
||||||
return [{"op": "remove", "path": path}]
|
|
||||||
if type(old) != type(new):
|
|
||||||
return [{"op": "replace", "path": path, "value": new}]
|
|
||||||
|
|
||||||
if isinstance(new, dict):
|
|
||||||
all_keys = set(list(old.keys()) + list(new.keys()))
|
|
||||||
for key in sorted(all_keys):
|
|
||||||
child_path = f"{path}.{key}" if path else key
|
|
||||||
if key not in old:
|
|
||||||
changes.append({"op": "add", "path": child_path, "value": new[key]})
|
|
||||||
elif key not in new:
|
|
||||||
changes.append({"op": "remove", "path": child_path})
|
|
||||||
else:
|
|
||||||
changes.extend(_deep_diff(old[key], new[key], child_path))
|
|
||||||
return changes
|
|
||||||
|
|
||||||
if isinstance(new, list):
|
|
||||||
# For lists (battlefield, hand, graveyard, stack) we do a
|
|
||||||
# simple length+element comparison. Zone lists change
|
|
||||||
# frequently and a full LCS diff is overkill for game state.
|
|
||||||
if old == new:
|
|
||||||
return []
|
|
||||||
# If the list is short or changed significantly, send whole list
|
|
||||||
if len(old) != len(new) or _list_diff_ratio(old, new) > 0.5:
|
|
||||||
return [{"op": "replace", "path": path, "value": new}]
|
|
||||||
# Element-wise diff for same-length lists with few changes
|
|
||||||
for i, (o, n) in enumerate(zip(old, new)):
|
|
||||||
child_path = f"{path}.{i}"
|
|
||||||
changes.extend(_deep_diff(o, n, child_path))
|
|
||||||
return changes
|
|
||||||
|
|
||||||
# Scalar comparison
|
|
||||||
if old != new:
|
|
||||||
return [{"op": "replace", "path": path, "value": new}]
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _list_diff_ratio(old: list, new: list) -> float:
|
|
||||||
"""Fraction of elements that differ between two same-length lists."""
|
|
||||||
if not old:
|
|
||||||
return 0.0
|
|
||||||
diffs = sum(1 for a, b in zip(old, new) if a != b)
|
|
||||||
return diffs / len(old)
|
|
||||||
|
|
||||||
|
|
||||||
class GameStateTracker:
|
|
||||||
"""Tracks per-game state and emits deltas on each update.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
tracker = GameStateTracker(game_id)
|
|
||||||
delta = tracker.update(new_snapshot)
|
|
||||||
# delta is a dict ready to send via WebSocket
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, game_id: str):
|
|
||||||
self.game_id = game_id
|
|
||||||
self._last_snapshot: Optional[Dict] = None
|
|
||||||
self._seq: int = 0
|
|
||||||
self._created_at: float = time.time()
|
|
||||||
|
|
||||||
def update(self, snapshot: Dict) -> Dict:
|
|
||||||
"""Compare snapshot with previous state, return delta message.
|
|
||||||
|
|
||||||
First call returns a full snapshot message (type="snapshot").
|
|
||||||
Subsequent calls return delta messages (type="delta").
|
|
||||||
"""
|
|
||||||
self._seq += 1
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
if self._last_snapshot is None:
|
|
||||||
# First update — send full snapshot
|
|
||||||
self._last_snapshot = copy.deepcopy(snapshot)
|
|
||||||
return {
|
|
||||||
"type": "snapshot",
|
|
||||||
"seq": self._seq,
|
|
||||||
"game_id": self.game_id,
|
|
||||||
"turn": snapshot.get("turn", 0),
|
|
||||||
"phase": snapshot.get("phase", ""),
|
|
||||||
"timestamp": now,
|
|
||||||
"state": snapshot,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Compute delta
|
|
||||||
changes = _deep_diff(self._last_snapshot, snapshot)
|
|
||||||
self._last_snapshot = copy.deepcopy(snapshot)
|
|
||||||
|
|
||||||
if not changes:
|
|
||||||
return {
|
|
||||||
"type": "heartbeat",
|
|
||||||
"seq": self._seq,
|
|
||||||
"game_id": self.game_id,
|
|
||||||
"timestamp": now,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"type": "delta",
|
|
||||||
"seq": self._seq,
|
|
||||||
"game_id": self.game_id,
|
|
||||||
"turn": snapshot.get("turn", 0),
|
|
||||||
"phase": snapshot.get("phase", ""),
|
|
||||||
"timestamp": now,
|
|
||||||
"changes": changes,
|
|
||||||
}
|
|
||||||
|
|
||||||
def make_decision_event(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
action_index: int,
|
|
||||||
confidence: float,
|
|
||||||
probabilities: Optional[Dict[str, float]] = None,
|
|
||||||
inference_ms: float = 0.0,
|
|
||||||
) -> Dict:
|
|
||||||
"""Build a decision event message for the Unity client."""
|
|
||||||
self._seq += 1
|
|
||||||
return {
|
|
||||||
"type": "decision",
|
|
||||||
"seq": self._seq,
|
|
||||||
"game_id": self.game_id,
|
|
||||||
"timestamp": time.time(),
|
|
||||||
"action": action,
|
|
||||||
"action_index": action_index,
|
|
||||||
"confidence": confidence,
|
|
||||||
"probabilities": probabilities or {},
|
|
||||||
"inference_ms": inference_ms,
|
|
||||||
}
|
|
||||||
|
|
||||||
def make_game_over_event(
|
|
||||||
self,
|
|
||||||
winner_seat: int,
|
|
||||||
reason: str = "",
|
|
||||||
turns_played: int = 0,
|
|
||||||
) -> Dict:
|
|
||||||
"""Build a game-over event message."""
|
|
||||||
self._seq += 1
|
|
||||||
return {
|
|
||||||
"type": "game_over",
|
|
||||||
"seq": self._seq,
|
|
||||||
"game_id": self.game_id,
|
|
||||||
"timestamp": time.time(),
|
|
||||||
"winner_seat": winner_seat,
|
|
||||||
"reason": reason,
|
|
||||||
"turns_played": turns_played,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sequence(self) -> int:
|
|
||||||
return self._seq
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_baseline(self) -> bool:
|
|
||||||
return self._last_snapshot is not None
|
|
||||||
|
|
||||||
|
|
||||||
# Per-game tracker registry
|
|
||||||
_trackers: Dict[str, GameStateTracker] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def get_tracker(game_id: str) -> GameStateTracker:
|
|
||||||
"""Get or create a state tracker for a game."""
|
|
||||||
if game_id not in _trackers:
|
|
||||||
_trackers[game_id] = GameStateTracker(game_id)
|
|
||||||
return _trackers[game_id]
|
|
||||||
|
|
||||||
|
|
||||||
def remove_tracker(game_id: str) -> None:
|
|
||||||
"""Remove a game tracker (called on game end)."""
|
|
||||||
_trackers.pop(game_id, None)
|
|
||||||
Reference in new issue
Block a user