from collections import Counter import chess import gradio as gr import pandas as pd from gradio_chessboard import Chessboard def get_position(fen: str) -> dict: """ Describe the current chess position from a FEN string, plus a material summary. Attempts to classify the opening, and if successful, adds the opening information to the position. Otherwise, it adds a piece map with the current pieces and the list of legal moves. Args: fen (str): The FEN string representing the chess position. """ board = chess.Board(fen) position = { "turn": _get_color_name(board.turn), "castling": { "white": { "kingside": board.has_kingside_castling_rights(chess.WHITE), "queenside": board.has_queenside_castling_rights(chess.WHITE), }, "black": { "kingside": board.has_kingside_castling_rights(chess.BLACK), "queenside": board.has_queenside_castling_rights(chess.BLACK), }, }, "en_passant": chess.square_name(board.ep_square) if board.ep_square else None, "mate": board.is_checkmate(), "stalemate": board.is_stalemate(), } opening = classify_opening(board.fen()) if "error" not in opening: # If the opening classification was successful, add it to the position position["opening"] = opening elif board.fen() == board.starting_fen: # If the position is the starting position, add a default opening position["opening"] = {"name": "Starting Position"} else: # If there was an error, just add a piece map (potentionally with fewer pieces) position["pieces"] = ( [ f"{chess.square_name(s)}: {_get_color_name(p.color)} {chess.piece_name(p.piece_type)}" for s, p in board.piece_map().items() ], ) position["legal_moves"] = ([move.uci() for move in board.legal_moves],) white_counts = Counter( piece.piece_type for square, piece in board.piece_map().items() if piece.color == chess.WHITE ) black_counts = Counter( piece.piece_type for square, piece in board.piece_map().items() if piece.color == chess.BLACK ) def format_counts(counter): order = [chess.QUEEN, chess.ROOK, chess.BISHOP, chess.KNIGHT, chess.PAWN] symbol_map = { chess.QUEEN: "Q", chess.ROOK: "R", chess.BISHOP: "B", chess.KNIGHT: "N", chess.PAWN: "P", } parts = [] for p_type in order: cnt = counter.get(p_type, 0) parts.append(f"{symbol_map[p_type]}={cnt}") return ", ".join(parts) material_count = { "white": format_counts(white_counts), "black": format_counts(black_counts), } diff = { p_type: white_counts.get(p_type, 0) - black_counts.get(p_type, 0) for p_type in (chess.QUEEN, chess.ROOK, chess.BISHOP, chess.KNIGHT, chess.PAWN) } white_adv = [(ptype, diff[ptype]) for ptype in diff if diff[ptype] > 0] black_adv = [(ptype, -diff[ptype]) for ptype in diff if diff[ptype] < 0] def summarize_advantages(side_name, adv_list): """ adv_list: list of tuples (piece_type, count), count > 0 Returns phrases like "1 rook and 2 pawns" """ if not adv_list: return "" piece_names = { chess.QUEEN: "queen", chess.ROOK: "rook", chess.BISHOP: "bishop", chess.KNIGHT: "knight", chess.PAWN: "pawn", } parts = [] for ptype, cnt in adv_list: name = piece_names[ptype] # pluralize if cnt > 1: name += "s" parts.append(f"{cnt} {name}") # join with " and " joined = " and ".join(parts) return f"{side_name} is up {joined}" white_summary = summarize_advantages("White", white_adv) black_summary = summarize_advantages("Black", black_adv) if white_summary and black_summary: # If both sides have something (e.g. piece‐for‐pawn imbalances), combine imbalance = f"Mixed: {white_summary}; {black_summary}" elif white_summary: imbalance = white_summary elif black_summary: imbalance = black_summary else: imbalance = "Material is equal" position["material_count"] = material_count position["imbalance"] = imbalance return position def get_square_info(fen: str, square_name: str) -> dict: """Get information about a specific square in the chess position. This function retrieves the piece on the specified square, as well as the attackers and defenders of that square. Args: fen (str): The FEN string representing the chess position. square_name (str): The name of the square (e.g., 'e4'). """ board = chess.Board(fen) square = chess.parse_square(square_name) return { "square": square_name, "piece": _get_piece_info_on_square(board, square), "attackers/defenders": [ _get_attackers(board, square, color) for color in (chess.WHITE, chess.BLACK) ], } def get_top_moves(fen: str, top_n: int = 5) -> dict: """Get the top N moves for a given chess position using StockFish. Returns a list of the top moves with their absolute scores (in centipawns) and whether they are leading to a mate. DISCLAIMER: This function uses the Stockfish chess engine, ONLY use it if explicitly allowed. Args: fen (str): The FEN string representing the chess position. top_n (int): The number of top moves to return. """ import chess.engine board = chess.Board(fen) with chess.engine.SimpleEngine.popen_uci("/usr/games/stockfish") as engine: info = engine.analyse(board, chess.engine.Limit(time=2.0), multipv=top_n) top_moves = [ { "move": move["pv"][0].uci(), "score": move["score"].white().score(), "mate": move["score"].is_mate(), } for move in info ] return {"top_moves": top_moves} def analyze_pawn_structure(fen): """ Analyze pawn‐structure features for both White and Black from a given FEN string. Args: fen (str): The FEN string representing the chess position. """ board = chess.Board(fen) white_pawns = list(board.pieces(chess.PAWN, chess.WHITE)) black_pawns = list(board.pieces(chess.PAWN, chess.BLACK)) def pawn_islands_and_doubles(pawn_squares): """ Given a list of pawn squares (for one color), compute: - num_islands: how many contiguous runs of files have at least one pawn - doubled_files: [file_letters ...] where there are 2+ pawns on that file - files_with_pawns: set of file indices that have ≥1 pawn - file_to_count: dict mapping file→count_of_pawns """ file_counts = {} for sq in pawn_squares: f = chess.square_file(sq) file_counts[f] = file_counts.get(f, 0) + 1 files_with_pawns = set(file_counts.keys()) # Count how many contiguous runs of True in an 8‐long boolean array num_islands = 0 in_run = False for f in range(8): if f in files_with_pawns: if not in_run: num_islands += 1 in_run = True else: in_run = False doubled_files = [ chess.FILE_NAMES[f] for f, cnt in file_counts.items() if cnt > 1 ] return num_islands, doubled_files, files_with_pawns, file_counts # White: islands, doubled, and helper sets w_islands, w_doubled, w_files, w_file_count = pawn_islands_and_doubles(white_pawns) # Black: same b_islands, b_doubled, b_files, b_file_count = pawn_islands_and_doubles(black_pawns) # 2) Isolated pawns: a pawn whose file f has no friendly pawn on f-1 or f+1 def find_isolated(pawn_sqs, files_with, color): """ Returns [square_name ...] where each pawn is isolated: - its file f has no friendly pawn on f-1 or f+1. """ isolated = [] for sq in pawn_sqs: f = chess.square_file(sq) # check adjacent files if (f - 1) not in files_with and (f + 1) not in files_with: isolated.append(chess.square_name(sq)) return isolated w_isolated = find_isolated(white_pawns, w_files, chess.WHITE) b_isolated = find_isolated(black_pawns, b_files, chess.BLACK) # 3) Passed pawns: a pawn with no enemy pawn ahead of it on same or adjacent file def find_passed(pawn_sqs, enemy_sqs, is_white): """ For each pawn of 'is_white' color: - Let (f,r) be its file and rank index (0..7), where r=0 means rank 1, r=7 means rank 8. - If is_white: check enemy pawns on files f-1,f,f+1 with rank_index > r. If none, it's passed. - If black: check enemy pawns on files f-1,f,f+1 with rank_index < r. If none, it's passed. """ passed = [] # Pre‐compute enemy file/rank for quick checks enemy_positions = [ (chess.square_file(e), chess.square_rank(e)) for e in enemy_sqs ] for sq in pawn_sqs: f = chess.square_file(sq) r = chess.square_rank(sq) is_passed = True for ef, er in enemy_positions: if abs(ef - f) <= 1: if is_white: if er > r: # an enemy pawn is “in front” on same/adjacent file is_passed = False break else: if er < r: is_passed = False break if is_passed: passed.append(chess.square_name(sq)) return passed w_passed = find_passed(white_pawns, black_pawns, True) b_passed = find_passed(black_pawns, white_pawns, False) # 4) Backward pawns: heuristic: # - No friendly pawn on adjacent file with rank ≤ r # - The square in front is either occupied or attacked by an enemy pawn def find_backward(pawn_sqs, friend_sqs, enemy_sqs, is_white): """ For each pawn sq of this color: - Let f,r be its file/rank - Condition A: No friendly pawn on file f-1 or f+1 with rank ≤ r (for white) or ≥ r (for black) - Condition B: The square in front (r+1 for white; r-1 for black) is either occupied or attacked by an enemy pawn - If both hold → mark as backward. """ backward = [] friend_pos = [ (chess.square_file(fsq), chess.square_rank(fsq)) for fsq in friend_sqs ] enemy_pawn_positions = set(enemy_sqs) # for quick “occupied‐by‐pawn” checks for sq in pawn_sqs: f = chess.square_file(sq) r = chess.square_rank(sq) # 4A) no friendly adjacent “supporter” has_support = False for ff, rr in friend_pos: if abs(ff - f) == 1: if is_white: if rr <= r: has_support = True break else: if rr >= r: has_support = True break if has_support: continue # NOT backward if there is a supporting pawn # 4B) check the square in front if is_white: if r == 7: continue # already on rank 8 → can’t be “backward” in the usual sense front_sq = chess.square(f, r + 1) else: if r == 0: continue front_sq = chess.square(f, r - 1) # If front‐square is occupied by any piece OR attacked by an enemy pawn → block if board.piece_at(front_sq) is not None: blocked = True else: # attacked by an enemy pawn? attackers = board.attackers( chess.BLACK if is_white else chess.WHITE, front_sq ) # see if any of those attackers is an enemy pawn: attacked_by_pawn = False for attacker_sq in attackers: p = board.piece_at(attacker_sq) if ( p is not None and p.piece_type == chess.PAWN and p.color != board.piece_at(sq).color ): attacked_by_pawn = True break blocked = attacked_by_pawn if blocked: backward.append(chess.square_name(sq)) return backward w_backward = find_backward(white_pawns, white_pawns, black_pawns, True) b_backward = find_backward(black_pawns, black_pawns, white_pawns, False) # 5) Potential break squares: # For each pawn of a side, if front‐square is empty and there is an enemy pawn diagonally ahead, # then that front‐square is a “break point” where advancing would challenge the enemy pawn. def find_break_sqs(pawn_sqs, is_white): """ For each pawn sq: - Compute front = (f, r+1) if white; (f, r-1) if black - If front is on board, empty, and has an enemy pawn on one of its diagonals, add front. """ breaks = set() for sq in pawn_sqs: f = chess.square_file(sq) r = chess.square_rank(sq) if is_white and r == 7: continue if not is_white and r == 0: continue if is_white: front = chess.square(f, r + 1) # diagonals at (f-1, r+1) and (f+1, r+1) diag1 = chess.square(f - 1, r + 1) if f > 0 else None diag2 = chess.square(f + 1, r + 1) if f < 7 else None enemy_color = chess.BLACK else: front = chess.square(f, r - 1) diag1 = chess.square(f - 1, r - 1) if f > 0 else None diag2 = chess.square(f + 1, r - 1) if f < 7 else None enemy_color = chess.WHITE # Must be empty to “break” into if board.piece_at(front) is not None: continue # If any diagonal contains an enemy pawn, mark front as break square for diag in (diag1, diag2): if diag is not None: piece = board.piece_at(diag) if ( piece is not None and piece.piece_type == chess.PAWN and piece.color == enemy_color ): breaks.add(front) break return [chess.square_name(sq) for sq in sorted(breaks)] w_breaks = find_break_sqs(white_pawns, True) b_breaks = find_break_sqs(black_pawns, False) # Assemble final result return { "pawn_islands": {"white": w_islands, "black": b_islands}, "doubled_pawns": {"white": w_doubled, "black": b_doubled}, "isolated_pawns": {"white": w_isolated, "black": b_isolated}, "passed_pawns": {"white": w_passed, "black": b_passed}, "backward_pawns": {"white": w_backward, "black": b_backward}, "break_squares": {"white": w_breaks, "black": b_breaks}, } def analyze_tactical_patterns(fen): """ Analyze immediate tactical patterns from a given FEN string. This function detects: - Potential knight forks and double attacks (refering to next move) - Pins, skewers, discovered attacks and x‐ray attacks in the current position. Args: fen (str): The FEN string representing the chess position. """ board = chess.Board(fen) piece_name = { chess.PAWN: "pawn", chess.KNIGHT: "knight", chess.BISHOP: "bishop", chess.ROOK: "rook", chess.QUEEN: "queen", chess.KING: "king", } def find_forks_and_double_attacks(color): """ For each legal move by 'color', detect: - Knight forks: moved knight attacks ≥2 enemy pieces - Double attacks: moved non-knight piece attacks ≥2 enemy pieces Returns two lists of descriptive strings. """ forks = [] double_attacks = [] b = board.copy() b.turn = color for move in b.legal_moves: moving_piece = b.piece_at(move.from_square) if moving_piece is None: continue b.push(move) to_sq = move.to_square attacked_squares = b.attacks(to_sq) attacked_pieces = [] for sq in attacked_squares: piece = b.piece_at(sq) if piece is not None and piece.color != color: attacked_pieces.append((sq, piece)) if len(attacked_pieces) >= 2: mover_symbol = moving_piece.symbol().upper() dest = chess.square_name(to_sq) targets = [ f"{piece_name[p.piece_type]} on {chess.square_name(sq)}" for sq, p in attacked_pieces ] target_str = " and ".join(targets) if moving_piece.piece_type == chess.KNIGHT: forks.append(f"{mover_symbol}{dest} forks {target_str}") else: double_attacks.append( f"{mover_symbol}{dest} double‐attacks {target_str}" ) b.pop() return forks, double_attacks def find_pins(color): """ Find pinned pieces of 'color'. For each pinned piece, identify the pinning piece. Returns list of descriptive strings. """ pins = [] king_sq = board.king(color) if king_sq is None: return pins for sq in ( board.pieces(chess.PAWN, color) | board.pieces(chess.KNIGHT, color) | board.pieces(chess.BISHOP, color) | board.pieces(chess.ROOK, color) | board.pieces(chess.QUEEN, color) ): if sq == king_sq: continue if board.is_pinned(color, sq): # Compute direction from king to this pinned piece f_k, r_k = chess.square_file(king_sq), chess.square_rank(king_sq) f_p, r_p = chess.square_file(sq), chess.square_rank(sq) df = f_p - f_k dr = r_p - r_k # Normalize direction to unit step df_norm = (df // abs(df)) if df != 0 else 0 dr_norm = (dr // abs(dr)) if dr != 0 else 0 # Move from pinned piece outward to find pinning slider cur_f, cur_r = f_p + df_norm, r_p + dr_norm while 0 <= cur_f < 8 and 0 <= cur_r < 8: cur_sq = chess.square(cur_f, cur_r) piece = board.piece_at(cur_sq) if piece is not None and piece.color != color: # Check if this piece type can pin along this direction if dr_norm == 0 and piece.piece_type in ( chess.ROOK, chess.QUEEN, ): pinning = piece elif df_norm == 0 and piece.piece_type in ( chess.ROOK, chess.QUEEN, ): pinning = piece elif abs(df_norm) == abs(dr_norm) and piece.piece_type in ( chess.BISHOP, chess.QUEEN, ): pinning = piece else: pinning = None if pinning is not None: pin_sym = pinning.symbol().upper() pin_sq = chess.square_name(cur_sq) pinned_sym = board.piece_at(sq).piece_type pinned_name = piece_name[board.piece_at(sq).piece_type] pinned_sq_name = chess.square_name(sq) king_sq_name = chess.square_name(king_sq) pins.append( f"{pin_sym}{pin_sq} pins {pinned_name} on {pinned_sq_name} to king on {king_sq_name}" ) break if piece is not None: # Non-sliding or same-color piece blocks further search break cur_f += df_norm cur_r += dr_norm return pins def find_skewers(color): """ Find static skewers: slider attacks a high-value enemy piece, behind it on same ray is a lower-value enemy piece. Returns list of descriptive strings. """ skewers = [] enemy_color = not color for s_sq in ( board.pieces(chess.BISHOP, color) | board.pieces(chess.ROOK, color) | board.pieces(chess.QUEEN, color) ): s_f, s_r = chess.square_file(s_sq), chess.square_rank(s_sq) # Directions for this slider directions = [] if board.piece_at(s_sq).piece_type == chess.BISHOP: directions = [(-1, -1), (-1, 1), (1, -1), (1, 1)] elif board.piece_at(s_sq).piece_type == chess.ROOK: directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] else: # Queen directions = [ (-1, -1), (-1, 1), (1, -1), (1, 1), (-1, 0), (1, 0), (0, -1), (0, 1), ] for df, dr in directions: cur_f, cur_r = s_f + df, s_r + dr # Look for first enemy piece first_found = False first_sq = None first_piece = None while 0 <= cur_f < 8 and 0 <= cur_r < 8: sq = chess.square(cur_f, cur_r) piece = board.piece_at(sq) if piece is not None: if not first_found and piece.color == enemy_color: first_found = True first_sq = sq first_piece = piece else: if first_found and piece.color == enemy_color: # We have A (first_sq, first_piece) and B (sq, piece) # Check that first_piece has higher value than piece values = { chess.KING: 1000, chess.QUEEN: 9, chess.ROOK: 5, chess.BISHOP: 3, chess.KNIGHT: 3, chess.PAWN: 1, } if ( values[first_piece.piece_type] > values[piece.piece_type] ): s_sym = board.piece_at(s_sq).symbol().upper() s_sq_name = chess.square_name(s_sq) high_name = piece_name[first_piece.piece_type] high_sq = chess.square_name(first_sq) low_name = piece_name[piece.piece_type] low_sq = chess.square_name(sq) skewers.append( f"{s_sym}{s_sq_name} skewers {high_name} on {high_sq} to {low_name} on {low_sq}" ) break else: # Something that breaks the ray (friendly piece or no second enemy) break cur_f += df cur_r += dr return skewers def find_discovered_attacks(color): """ Static discovered‐attack patterns: a friendly slider is currently blocked by one friendly piece from attacking an enemy target. Returns list of descriptive strings. """ discovered = [] enemy_color = not color for s_sq in ( board.pieces(chess.BISHOP, color) | board.pieces(chess.ROOK, color) | board.pieces(chess.QUEEN, color) ): s_f, s_r = chess.square_file(s_sq), chess.square_rank(s_sq) # Determine directions like in skewers if board.piece_at(s_sq).piece_type == chess.BISHOP: directions = [(-1, -1), (-1, 1), (1, -1), (1, 1)] elif board.piece_at(s_sq).piece_type == chess.ROOK: directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] else: # Queen directions = [ (-1, -1), (-1, 1), (1, -1), (1, 1), (-1, 0), (1, 0), (0, -1), (0, 1), ] for df, dr in directions: cur_f, cur_r = s_f + df, s_r + dr blocker_sq = None blocker_piece = None while 0 <= cur_f < 8 and 0 <= cur_r < 8: sq = chess.square(cur_f, cur_r) piece = board.piece_at(sq) if piece is not None: if piece.color == color and blocker_sq is None: # first friendly piece blocks the ray blocker_sq = sq blocker_piece = piece else: # either second piece or enemy piece if blocker_sq is not None and piece.color == enemy_color: # Discovered attack: blocker_sq moving would allow slider at s_sq to attack this piece at sq s_sym = board.piece_at(s_sq).symbol().upper() blocker_name = piece_name[blocker_piece.piece_type] blocker_loc = chess.square_name(blocker_sq) target_name = piece_name[piece.piece_type] target_loc = chess.square_name(sq) discovered.append( f"Moving {blocker_name} from {blocker_loc} uncovers {s_sym}{chess.square_name(s_sq)} attacking {target_name} on {target_loc}" ) break cur_f += df cur_r += dr return discovered def find_xray_attacks(color): """ Static x‐ray attacks: slider attacks through one piece (friendly or enemy) to an enemy target behind it. Returns list of descriptive strings. """ xray = [] enemy_color = not color for s_sq in ( board.pieces(chess.BISHOP, color) | board.pieces(chess.ROOK, color) | board.pieces(chess.QUEEN, color) ): s_f, s_r = chess.square_file(s_sq), chess.square_rank(s_sq) if board.piece_at(s_sq).piece_type == chess.BISHOP: directions = [(-1, -1), (-1, 1), (1, -1), (1, 1)] elif board.piece_at(s_sq).piece_type == chess.ROOK: directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] else: directions = [ (-1, -1), (-1, 1), (1, -1), (1, 1), (-1, 0), (1, 0), (0, -1), (0, 1), ] for df, dr in directions: cur_f, cur_r = s_f + df, s_r + dr first_blocker = None first_blocker_sq = None while 0 <= cur_f < 8 and 0 <= cur_r < 8: sq = chess.square(cur_f, cur_r) piece = board.piece_at(sq) if piece is not None: if first_blocker is None: first_blocker = piece first_blocker_sq = sq else: if piece.color == enemy_color: # X-ray: slider at s_sq x‐rays this piece at sq through first_blocker at first_blocker_sq s_sym = board.piece_at(s_sq).symbol().upper() target_name = piece_name[piece.piece_type] target_loc = chess.square_name(sq) blocker_name = piece_name[first_blocker.piece_type] blocker_loc = chess.square_name(first_blocker_sq) xray.append( f"{s_sym}{chess.square_name(s_sq)} x‐rays {target_name} on {target_loc} through {blocker_name} on {blocker_loc}" ) break cur_f += df cur_r += dr return xray # Build result structure result = { "forks": {"white": [], "black": []}, "double_attacks": {"white": [], "black": []}, "pins": {"white": [], "black": []}, "skewers": {"white": [], "black": []}, "discovered_attacks": {"white": [], "black": []}, "xray_attacks": {"white": [], "black": []}, } # White patterns w_forks, w_double = find_forks_and_double_attacks(chess.WHITE) result["forks"]["white"] = w_forks result["double_attacks"]["white"] = w_double result["pins"]["white"] = find_pins(chess.WHITE) result["skewers"]["white"] = find_skewers(chess.WHITE) result["discovered_attacks"]["white"] = find_discovered_attacks(chess.WHITE) result["xray_attacks"]["white"] = find_xray_attacks(chess.WHITE) # Black patterns b_forks, b_double = find_forks_and_double_attacks(chess.BLACK) result["forks"]["black"] = b_forks result["double_attacks"]["black"] = b_double result["pins"]["black"] = find_pins(chess.BLACK) result["skewers"]["black"] = find_skewers(chess.BLACK) result["discovered_attacks"]["black"] = find_discovered_attacks(chess.BLACK) result["xray_attacks"]["black"] = find_xray_attacks(chess.BLACK) return result def evaluate_king_safety(fen): """ Evaluate king safety for both White and Black from a given FEN string. Args: fen (str): The FEN string representing the chess position. """ board = chess.Board(fen) def get_shield_and_files(color): """ For 'color', find: - pawn_shield: count of own pawns directly in front of king on files f-1,f,f+1. - max_shield: maximum possible shield pawns (1-3 depending on king file at edge). - open_or_semi_open_files: list of file names (adjacent to king) that are open or semi-open. """ king_sq = board.king(color) if king_sq is None: return 0, 0, [] kf = chess.square_file(king_sq) kr = chess.square_rank(king_sq) # Direction “forward” for shield pawns ranks_dir = 1 if color == chess.WHITE else -1 shield_rank = kr + ranks_dir files_to_check = [f for f in (kf - 1, kf, kf + 1) if 0 <= f < 8] max_shield = len(files_to_check) # Count intact shield pawns: own pawn at (file, shield_rank) shield_count = 0 for f in files_to_check: sq = chess.square(f, shield_rank) if 0 <= shield_rank < 8 else None if sq is not None: piece = board.piece_at(sq) if ( piece is not None and piece.piece_type == chess.PAWN and piece.color == color ): shield_count += 1 # Determine open or semi‐open files among files_to_check open_or_semi_open = [] for f in files_to_check: # Gather all pawns on that file pawns_on_file = [ board.piece_at(chess.square(f, r)) for r in range(8) if (p := board.piece_at(chess.square(f, r))) is not None and p.piece_type == chess.PAWN ] has_friendly = any(p.color == color for p in pawns_on_file) has_enemy = any(p.color != color for p in pawns_on_file) file_name = chess.FILE_NAMES[f] if not pawns_on_file: # fully open file open_or_semi_open.append(file_name) elif has_enemy and not has_friendly: # semi‐open (enemy pawn only) open_or_semi_open.append(file_name) return shield_count, max_shield, open_or_semi_open def get_attacker_count(color): """ Count unique enemy pieces attacking any of the up to 8 squares adjacent to the king. """ king_sq = board.king(color) if king_sq is None: return 0 enemy_color = not color kf = chess.square_file(king_sq) kr = chess.square_rank(king_sq) attackers = set() # Loop over the eight neighbors for df in (-1, 0, 1): for dr in (-1, 0, 1): if df == 0 and dr == 0: continue f = kf + df r = kr + dr if 0 <= f < 8 and 0 <= r < 8: sq = chess.square(f, r) for attacker_sq in board.attackers(enemy_color, sq): attackers.add(attacker_sq) return len(attackers) def compute_shelter_score(shield_count, max_shield, open_count, attacker_count): """ Compute a composite shelter score in [0, 1], combining: - shield_factor: shield_count / max_shield - file_factor: 1 - (open_count / max_shield) - attacker_factor: 1 - min(attacker_count, 8) / 8 Return average of the three, rounded to 2 decimals. """ if max_shield == 0: shield_factor = 0 file_factor = 0 else: shield_factor = shield_count / max_shield file_factor = 1 - (open_count / max_shield) attacker_factor = 1 - min(attacker_count, 8) / 8 return round((shield_factor + file_factor + attacker_factor) / 3, 2) result = { "pawn_shield": {"white": "", "black": ""}, "open_files": {"white": [], "black": []}, "attacker_count": {"white": 0, "black": 0}, "shelter_score": {"white": 0.0, "black": 0.0}, } # White evaluation w_shield, w_max_shield, w_open = get_shield_and_files(chess.WHITE) w_attackers = get_attacker_count(chess.WHITE) w_shelter = compute_shelter_score(w_shield, w_max_shield, len(w_open), w_attackers) result["pawn_shield"]["white"] = f"{w_shield} of {w_max_shield} shield pawns" result["open_files"]["white"] = w_open result["attacker_count"]["white"] = w_attackers result["shelter_score"]["white"] = w_shelter # Black evaluation b_shield, b_max_shield, b_open = get_shield_and_files(chess.BLACK) b_attackers = get_attacker_count(chess.BLACK) b_shelter = compute_shelter_score(b_shield, b_max_shield, len(b_open), b_attackers) result["pawn_shield"]["black"] = f"{b_shield} of {b_max_shield} shield pawns" result["open_files"]["black"] = b_open result["attacker_count"]["black"] = b_attackers result["shelter_score"]["black"] = b_shelter return result def classify_opening(fen: str) -> dict: """ Attempt to classify a chess opening using the Lichess openings database. Return the ECO code, name, moves and main sub-variations of the opening. Args: fen (str): The FEN string representing the chess position. """ board = chess.Board(fen) epd_key = board.epd() df = _load_lichess_openings() match = df[df["epd"] == epd_key] if match.empty: return {"error": f"No ECO code found for position: {fen}"} eco_code = match.iloc[0]["eco"] opening_name = match.iloc[0]["name"] base_pgn = match.iloc[0]["pgn"] base_uci = match.iloc[0]["uci"] base_len = len(base_uci.split()) def next_move(uci_str: str) -> str | None: parts = uci_str.split() if not parts[:base_len] == base_uci.split(): return None return parts[base_len] if len(parts) > base_len else None df["next_move"] = df["uci"].apply(next_move) subs = ( df[df["next_move"].notna()] .sort_values("uci") .drop_duplicates("next_move", keep="first") ) subvariants = [ {"name": row["name"], "pgn": row["pgn"], "fen": row["epd"]} for _, row in subs.iterrows() ] return { "eco": eco_code, "name": opening_name, "pgn": base_pgn, "subvariants": subvariants, } def find_opening_by_name(name: str) -> dict: """ Search for a chess opening by its name in the Lichess openings database. Return the ECO code, name, PGN, FEN and sub-variations of a chess opening by its name. The name is matched case-insensitively. Args: name (str): The name of the chess opening to search for (e.g. Caro-Kann Defense: Advance Variation). """ df = _load_lichess_openings() mask = df["name"].str.contains(name, case=False, regex=False) matches = df[mask] if matches.empty: return {"error": f"No opening found matching name: '{name}'"} row = matches.iloc[0] eco_code = row["eco"] full_name = row["name"] base_pgn = row["pgn"] base_uci = row["uci"] epd = row["epd"] fen = f"{epd} 0 1" base_moves = base_uci.split() base_len = len(base_moves) def next_move(uci_str: str) -> str | None: parts = uci_str.split() if not parts[:base_len] == base_uci.split(): return None return parts[base_len] if len(parts) > base_len else None df["next_move"] = df["uci"].apply(next_move) subs = ( df[df["next_move"].notna()] .sort_values("uci") .drop_duplicates("next_move", keep="first") ) subvariants = [ {"name": sub_row["name"], "pgn": sub_row["pgn"], "fen": sub_row["epd"]} for _, sub_row in subs.iterrows() ] return { "eco": eco_code, "name": full_name, "pgn": base_pgn, "fen": fen, "subvariants": subvariants, } def _get_color_name(color: chess.Color) -> str: return "white" if color == chess.WHITE else "black" def _get_piece_info_on_square(board: chess.Board, square: chess.Square) -> str: piece = board.piece_at(square) if piece is None: return f"No piece on {chess.square_name(square)}" color = _get_color_name(piece.color) result = f"There is a {color} {chess.piece_name(piece.piece_type)} on {chess.square_name(square)}." legal_moves = [ chess.square_name(m.to_square) for m in board.legal_moves if m.from_square == square ] if not legal_moves: result += f" It can't move because" if board.turn != piece.color: result += f" it is not {_get_color_name(piece.color)}'s turn." elif board.is_pinned(piece.color, square): result += " it is pinned." elif board.is_check(): result += f" it is a check and the {chess.piece_name(piece.piece_type)} can't block" else: result += " it is blocked." result += f" However, it attacks the following squares: {', '.join([chess.square_name(s) for s in board.attacks(square)])}." else: result += f" It can move to the following squares: {', '.join(legal_moves)}." return result def _get_attackers(board: chess.Board, square: chess.Square, color: chess.Color) -> str: piece = board.piece_at(square) title = "attackers" if piece is None or piece.color != color else "defenders" attackers = board.attackers(color, square) color_name = _get_color_name(color) if not attackers: return f"No {color_name} {title} for {chess.square_name(square)}" return ( f"{len(attackers)} {color_name.title()} {title} for {chess.square_name(square)}: " + ", ".join( [ f"{chess.piece_name(board.piece_at(s).piece_type)} on {chess.square_name(s)}" for s in attackers ] ) ) def _load_lichess_openings( path_prefix: str = "/app/data/lichess_openings/dist/", ) -> pd.DataFrame: """Load Lichess openings data from TSV files. Assumes files 'a.tsv', 'b.tsv', 'c.tsv', 'd.tsv', 'e.tsv' are in path_prefix. Each has columns: eco, name, pgn, uci, epd. """ files = [f"{path_prefix}{vol}.tsv" for vol in ("a", "b", "c", "d", "e")] dfs = [] for fn in files: df = pd.read_csv(fn, sep="\t", usecols=["eco", "name", "pgn", "uci", "epd"]) dfs.append(df) return pd.concat(dfs, ignore_index=True) get_position_tool = gr.Interface( fn=get_position, inputs=Chessboard(label="FEN String"), outputs=gr.JSON(label="Chess Position"), title="Chess Position Viewer", description="Enter a FEN string to view the current chess position.", ) get_square_info_tool = gr.Interface( fn=get_square_info, inputs=[Chessboard(label="FEN String"), gr.Textbox(label="Square Name")], outputs=gr.JSON(label="Square Info"), title="Chess Square Info", description="Enter a FEN string and a square name (e.g., 'e4') to get information about the piece on that square.", ) get_top_moves_tool = gr.Interface( fn=get_top_moves, inputs=[Chessboard(label="FEN String"), gr.Number(value=5, label="Top N Moves")], outputs=gr.JSON(label="Top Moves"), title="Top Moves Analyzer", description="Enter a FEN string to get the top moves for the current position using StockFish.", ) analyze_pawn_structure_tool = gr.Interface( fn=analyze_pawn_structure, inputs=Chessboard(label="FEN String"), outputs=gr.JSON(label="Pawn Structure Analysis"), title="Pawn Structure Analyzer", description="Enter a FEN string to analyze the pawn structure features for both White and Black.", ) analyze_tactical_patterns_tool = gr.Interface( fn=analyze_tactical_patterns, inputs=Chessboard(label="FEN String"), outputs=gr.JSON(label="Tactical Patterns Analysis"), title="Tactical Patterns Analyzer", description="Enter a FEN string to analyze immediate tactical patterns for both White and Black.", ) evaluate_king_safety_tool = gr.Interface( fn=evaluate_king_safety, inputs=Chessboard(label="FEN String"), outputs=gr.JSON(label="King Safety Evaluation"), title="King Safety Evaluator", description="Enter a FEN string to evaluate the safety of both kings in the current position.", ) classify_opening_tool = gr.Interface( fn=classify_opening, inputs=Chessboard(label="FEN String"), outputs=gr.JSON(label="Opening Classification"), title="Opening Classifier", description="Enter a FEN string to classify the opening and get its ECO code, name, and sub-variations.", ) find_opening_by_name_tool = gr.Interface( fn=find_opening_by_name, inputs=gr.Textbox(label="Opening Name"), outputs=gr.JSON(label="Opening Details"), title="Find Opening by Name", description="Enter the name of a chess opening to find its ECO code, PGN, FEN, and sub-variations.", ) app = gr.TabbedInterface( [ get_position_tool, get_square_info_tool, get_top_moves_tool, analyze_pawn_structure_tool, analyze_tactical_patterns_tool, evaluate_king_safety_tool, classify_opening_tool, find_opening_by_name_tool, ], tab_names=[ "Get Position", "Get Square Info", "Get Top Moves", "Analyze Pawn Structure", "Analyze Tactical Patterns", "Evaluate King Safety", "Classify Opening", "Find Opening by Name", ], title="Chess Tools", ) if __name__ == "__main__": app.launch(mcp_server=True)