| 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: |
| |
| position["opening"] = opening |
| elif board.fen() == board.starting_fen: |
| |
| position["opening"] = {"name": "Starting Position"} |
| else: |
| |
| 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] |
| |
| if cnt > 1: |
| name += "s" |
| parts.append(f"{cnt} {name}") |
| |
| 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: |
| |
| 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()) |
|
|
| |
| 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 |
|
|
| |
| w_islands, w_doubled, w_files, w_file_count = pawn_islands_and_doubles(white_pawns) |
| |
| b_islands, b_doubled, b_files, b_file_count = pawn_islands_and_doubles(black_pawns) |
|
|
| |
| 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) |
| |
| 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) |
|
|
| |
| 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 = [] |
| |
| 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: |
| |
| 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) |
|
|
| |
| |
| |
| 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 sq in pawn_sqs: |
| f = chess.square_file(sq) |
| r = chess.square_rank(sq) |
|
|
| |
| 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 |
|
|
| |
| if is_white: |
| if r == 7: |
| continue |
| front_sq = chess.square(f, r + 1) |
| else: |
| if r == 0: |
| continue |
| front_sq = chess.square(f, r - 1) |
|
|
| |
| if board.piece_at(front_sq) is not None: |
| blocked = True |
| else: |
| |
| attackers = board.attackers( |
| chess.BLACK if is_white else chess.WHITE, front_sq |
| ) |
| |
| 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) |
|
|
| |
| |
| |
| 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) |
| |
| 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 |
|
|
| |
| if board.piece_at(front) is not None: |
| continue |
|
|
| |
| 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) |
|
|
| |
| 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): |
| |
| 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 |
| |
| df_norm = (df // abs(df)) if df != 0 else 0 |
| dr_norm = (dr // abs(dr)) if dr != 0 else 0 |
| |
| 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: |
| |
| 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: |
| |
| 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 = [] |
| 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_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: |
| |
| |
| 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: |
| |
| 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) |
| |
| 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 |
| 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: |
| |
| blocker_sq = sq |
| blocker_piece = piece |
| else: |
| |
| if blocker_sq is not None and piece.color == enemy_color: |
| |
| 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: |
| |
| 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 |
|
|
| |
| result = { |
| "forks": {"white": [], "black": []}, |
| "double_attacks": {"white": [], "black": []}, |
| "pins": {"white": [], "black": []}, |
| "skewers": {"white": [], "black": []}, |
| "discovered_attacks": {"white": [], "black": []}, |
| "xray_attacks": {"white": [], "black": []}, |
| } |
|
|
| |
| 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) |
|
|
| |
| 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) |
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| open_or_semi_open = [] |
| for f in files_to_check: |
| |
| 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: |
| |
| open_or_semi_open.append(file_name) |
| elif has_enemy and not has_friendly: |
| |
| 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() |
| |
| 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}, |
| } |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|