"""Engine abstraction for optional exemplar move analysis.""" from __future__ import annotations import shutil from dataclasses import dataclass from pathlib import Path from typing import Literal, Optional, Protocol, Tuple import chess from chess_tutor.lc0_uci import resolve_lc0_executable EngineBackend = Literal["off", "stockfish", "lc0"] EngineAnalysisMode = Literal["off", "auto", "stockfish", "lc0"] @dataclass(frozen=True) class EngineAdvice: bestmove_uci: str pv_uci: Tuple[str, ...] eval_cp_white: Optional[int] played_eval_cp_white: Optional[int] class MoveAdvisor(Protocol): def analyze_position( self, fen: str, *, movetime_ms: int = 500, ) -> EngineAdvice: ... def close(self) -> None: ... def uci_to_san(fen: str, uci: str) -> str: board = chess.Board(fen) move = chess.Move.from_uci(uci) return board.san(move) def eval_after_move_cp_white(fen: str, uci: str, advisor: MoveAdvisor, *, movetime_ms: int) -> Optional[int]: board = chess.Board(fen) move = chess.Move.from_uci(uci) if move not in board.legal_moves: return None board.push(move) advice = advisor.analyze_position(board.fen(), movetime_ms=movetime_ms) return advice.eval_cp_white def resolve_engine_backend( mode: EngineAnalysisMode | str, *, stockfish_path: str | None = None, lc0_exec: str | None = None, ) -> EngineBackend: """Map CLI/catalog mode to a concrete backend; ``auto`` tries Stockfish then LC0.""" name = str(mode or "off").strip().lower() if name == "off": return "off" if name == "stockfish": return "stockfish" if name == "lc0": return "lc0" if name != "auto": raise ValueError(f"Unknown engine mode: {mode!r}") if stockfish_path: if Path(stockfish_path).is_file(): return "stockfish" elif shutil.which("stockfish"): return "stockfish" try: resolve_lc0_executable(lc0_exec) from chess_tutor.onnx_probe_config import load_onnx_probe_config cfg = load_onnx_probe_config() if Path(cfg.weights_path).is_file(): return "lc0" except (FileNotFoundError, ValueError): pass return "off" def create_move_advisor( backend: EngineAnalysisMode | EngineBackend | str, *, stockfish_path: str | None = None, lc0_exec: str | None = None, lc0_weights: str | None = None, ) -> MoveAdvisor | None: resolved = resolve_engine_backend( backend, stockfish_path=stockfish_path, lc0_exec=lc0_exec, ) if resolved == "off": return None if resolved == "stockfish": from chess_tutor.engine.stockfish_uci import StockfishMoveAdvisor return StockfishMoveAdvisor(executable=stockfish_path) if resolved == "lc0": from chess_tutor.engine.lc0_adapter import Lc0MoveAdvisor return Lc0MoveAdvisor(executable=lc0_exec, weights=lc0_weights) return None