czakop commited on
Commit
e6572e5
·
1 Parent(s): f574541

create MCP server

Browse files
Files changed (5) hide show
  1. .gitignore +1 -0
  2. app.py +1183 -0
  3. packages.txt +3 -0
  4. postBuild +9 -0
  5. requirements.txt +3 -0
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ data/
app.py ADDED
@@ -0,0 +1,1183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import Counter
2
+
3
+ import chess
4
+ import gradio as gr
5
+ import pandas as pd
6
+ from gradio_chessboard import Chessboard
7
+
8
+
9
+ def get_position(fen: str) -> dict:
10
+ """
11
+ Describe the current chess position from a FEN string, plus a material summary.
12
+
13
+ Attempts to classify the opening, and if successful, adds the opening information to the position.
14
+ Otherwise, it adds a piece map with the current pieces and the list of legal moves.
15
+
16
+ Args:
17
+ fen (str): The FEN string representing the chess position.
18
+
19
+ """
20
+ board = chess.Board(fen)
21
+
22
+ position = {
23
+ "turn": _get_color_name(board.turn),
24
+ "castling": {
25
+ "white": {
26
+ "kingside": board.has_kingside_castling_rights(chess.WHITE),
27
+ "queenside": board.has_queenside_castling_rights(chess.WHITE),
28
+ },
29
+ "black": {
30
+ "kingside": board.has_kingside_castling_rights(chess.BLACK),
31
+ "queenside": board.has_queenside_castling_rights(chess.BLACK),
32
+ },
33
+ },
34
+ "en_passant": chess.square_name(board.ep_square) if board.ep_square else None,
35
+ "mate": board.is_checkmate(),
36
+ "stalemate": board.is_stalemate(),
37
+ }
38
+
39
+ opening = classify_opening(board.fen())
40
+ if "error" not in opening:
41
+ # If the opening classification was successful, add it to the position
42
+ position["opening"] = opening
43
+ elif board.fen() == board.starting_fen:
44
+ # If the position is the starting position, add a default opening
45
+ position["opening"] = {"name": "Starting Position"}
46
+ else:
47
+ # If there was an error, just add a piece map (potentionally with fewer pieces)
48
+ position["pieces"] = (
49
+ [
50
+ f"{chess.square_name(s)}: {_get_color_name(p.color)} {chess.piece_name(p.piece_type)}"
51
+ for s, p in board.piece_map().items()
52
+ ],
53
+ )
54
+ position["legal_moves"] = ([move.uci() for move in board.legal_moves],)
55
+
56
+ white_counts = Counter(
57
+ piece.piece_type
58
+ for square, piece in board.piece_map().items()
59
+ if piece.color == chess.WHITE
60
+ )
61
+ black_counts = Counter(
62
+ piece.piece_type
63
+ for square, piece in board.piece_map().items()
64
+ if piece.color == chess.BLACK
65
+ )
66
+
67
+ def format_counts(counter):
68
+ order = [chess.QUEEN, chess.ROOK, chess.BISHOP, chess.KNIGHT, chess.PAWN]
69
+ symbol_map = {
70
+ chess.QUEEN: "Q",
71
+ chess.ROOK: "R",
72
+ chess.BISHOP: "B",
73
+ chess.KNIGHT: "N",
74
+ chess.PAWN: "P",
75
+ }
76
+ parts = []
77
+ for p_type in order:
78
+ cnt = counter.get(p_type, 0)
79
+ parts.append(f"{symbol_map[p_type]}={cnt}")
80
+ return ", ".join(parts)
81
+
82
+ material_count = {
83
+ "white": format_counts(white_counts),
84
+ "black": format_counts(black_counts),
85
+ }
86
+
87
+ diff = {
88
+ p_type: white_counts.get(p_type, 0) - black_counts.get(p_type, 0)
89
+ for p_type in (chess.QUEEN, chess.ROOK, chess.BISHOP, chess.KNIGHT, chess.PAWN)
90
+ }
91
+
92
+ white_adv = [(ptype, diff[ptype]) for ptype in diff if diff[ptype] > 0]
93
+ black_adv = [(ptype, -diff[ptype]) for ptype in diff if diff[ptype] < 0]
94
+
95
+ def summarize_advantages(side_name, adv_list):
96
+ """
97
+ adv_list: list of tuples (piece_type, count), count > 0
98
+ Returns phrases like "1 rook and 2 pawns"
99
+ """
100
+ if not adv_list:
101
+ return ""
102
+ piece_names = {
103
+ chess.QUEEN: "queen",
104
+ chess.ROOK: "rook",
105
+ chess.BISHOP: "bishop",
106
+ chess.KNIGHT: "knight",
107
+ chess.PAWN: "pawn",
108
+ }
109
+ parts = []
110
+ for ptype, cnt in adv_list:
111
+ name = piece_names[ptype]
112
+ # pluralize
113
+ if cnt > 1:
114
+ name += "s"
115
+ parts.append(f"{cnt} {name}")
116
+ # join with " and "
117
+ joined = " and ".join(parts)
118
+ return f"{side_name} is up {joined}"
119
+
120
+ white_summary = summarize_advantages("White", white_adv)
121
+ black_summary = summarize_advantages("Black", black_adv)
122
+
123
+ if white_summary and black_summary:
124
+ # If both sides have something (e.g. piece‐for‐pawn imbalances), combine
125
+ imbalance = f"Mixed: {white_summary}; {black_summary}"
126
+ elif white_summary:
127
+ imbalance = white_summary
128
+ elif black_summary:
129
+ imbalance = black_summary
130
+ else:
131
+ imbalance = "Material is equal"
132
+
133
+ position["material_count"] = material_count
134
+ position["imbalance"] = imbalance
135
+
136
+ return position
137
+
138
+
139
+ def get_square_info(fen: str, square_name: str) -> dict:
140
+ """Get information about a specific square in the chess position.
141
+
142
+ This function retrieves the piece on the specified square, as well as the attackers and defenders of that square.
143
+
144
+ Args:
145
+ fen (str): The FEN string representing the chess position.
146
+ square_name (str): The name of the square (e.g., 'e4').
147
+ """
148
+ board = chess.Board(fen)
149
+ square = chess.parse_square(square_name)
150
+ return {
151
+ "square": square_name,
152
+ "piece": _get_piece_info_on_square(board, square),
153
+ "attackers/defenders": [
154
+ _get_attackers(board, square, color) for color in (chess.WHITE, chess.BLACK)
155
+ ],
156
+ }
157
+
158
+
159
+ def get_top_moves(fen: str, top_n: int = 5) -> dict:
160
+ """Get the top N moves for a given chess position using StockFish.
161
+
162
+ DISCLAIMER: This function uses the Stockfish chess engine, ONLY use it if explicitly allowed.
163
+
164
+ Args:
165
+ fen (str): The FEN string representing the chess position.
166
+ top_n (int): The number of top moves to return.
167
+ """
168
+ import chess.engine
169
+
170
+ board = chess.Board(fen)
171
+ with chess.engine.SimpleEngine.popen_uci("stockfish") as engine:
172
+ info = engine.analyse(board, chess.engine.Limit(time=2.0), multipv=top_n)
173
+ top_moves = [
174
+ {
175
+ "move": move["pv"][0].uci(),
176
+ "score": move["score"].relative.score(),
177
+ "mate": move["score"].is_mate(),
178
+ }
179
+ for move in info
180
+ ]
181
+ return {"top_moves": top_moves}
182
+
183
+
184
+ def analyze_pawn_structure(fen):
185
+ """
186
+ Analyze pawn‐structure features for both White and Black from a given FEN string.
187
+
188
+ Args:
189
+ fen (str): The FEN string representing the chess position.
190
+ """
191
+ board = chess.Board(fen)
192
+
193
+ white_pawns = list(board.pieces(chess.PAWN, chess.WHITE))
194
+ black_pawns = list(board.pieces(chess.PAWN, chess.BLACK))
195
+
196
+ def pawn_islands_and_doubles(pawn_squares):
197
+ """
198
+ Given a list of pawn squares (for one color), compute:
199
+ - num_islands: how many contiguous runs of files have at least one pawn
200
+ - doubled_files: [file_letters ...] where there are 2+ pawns on that file
201
+ - files_with_pawns: set of file indices that have ≥1 pawn
202
+ - file_to_count: dict mapping file→count_of_pawns
203
+ """
204
+ file_counts = {}
205
+ for sq in pawn_squares:
206
+ f = chess.square_file(sq)
207
+ file_counts[f] = file_counts.get(f, 0) + 1
208
+
209
+ files_with_pawns = set(file_counts.keys())
210
+
211
+ # Count how many contiguous runs of True in an 8‐long boolean array
212
+ num_islands = 0
213
+ in_run = False
214
+ for f in range(8):
215
+ if f in files_with_pawns:
216
+ if not in_run:
217
+ num_islands += 1
218
+ in_run = True
219
+ else:
220
+ in_run = False
221
+
222
+ doubled_files = [
223
+ chess.FILE_NAMES[f] for f, cnt in file_counts.items() if cnt > 1
224
+ ]
225
+
226
+ return num_islands, doubled_files, files_with_pawns, file_counts
227
+
228
+ # White: islands, doubled, and helper sets
229
+ w_islands, w_doubled, w_files, w_file_count = pawn_islands_and_doubles(white_pawns)
230
+ # Black: same
231
+ b_islands, b_doubled, b_files, b_file_count = pawn_islands_and_doubles(black_pawns)
232
+
233
+ # 2) Isolated pawns: a pawn whose file f has no friendly pawn on f-1 or f+1
234
+ def find_isolated(pawn_sqs, files_with, color):
235
+ """
236
+ Returns [square_name ...] where each pawn is isolated:
237
+ - its file f has no friendly pawn on f-1 or f+1.
238
+ """
239
+ isolated = []
240
+ for sq in pawn_sqs:
241
+ f = chess.square_file(sq)
242
+ # check adjacent files
243
+ if (f - 1) not in files_with and (f + 1) not in files_with:
244
+ isolated.append(chess.square_name(sq))
245
+ return isolated
246
+
247
+ w_isolated = find_isolated(white_pawns, w_files, chess.WHITE)
248
+ b_isolated = find_isolated(black_pawns, b_files, chess.BLACK)
249
+
250
+ # 3) Passed pawns: a pawn with no enemy pawn ahead of it on same or adjacent file
251
+ def find_passed(pawn_sqs, enemy_sqs, is_white):
252
+ """
253
+ For each pawn of 'is_white' color:
254
+ - Let (f,r) be its file and rank index (0..7), where r=0 means rank 1, r=7 means rank 8.
255
+ - If is_white: check enemy pawns on files f-1,f,f+1 with rank_index > r. If none, it's passed.
256
+ - If black: check enemy pawns on files f-1,f,f+1 with rank_index < r. If none, it's passed.
257
+ """
258
+ passed = []
259
+ # Pre‐compute enemy file/rank for quick checks
260
+ enemy_positions = [
261
+ (chess.square_file(e), chess.square_rank(e)) for e in enemy_sqs
262
+ ]
263
+
264
+ for sq in pawn_sqs:
265
+ f = chess.square_file(sq)
266
+ r = chess.square_rank(sq)
267
+ is_passed = True
268
+
269
+ for ef, er in enemy_positions:
270
+ if abs(ef - f) <= 1:
271
+ if is_white:
272
+ if er > r:
273
+ # an enemy pawn is “in front” on same/adjacent file
274
+ is_passed = False
275
+ break
276
+ else:
277
+ if er < r:
278
+ is_passed = False
279
+ break
280
+ if is_passed:
281
+ passed.append(chess.square_name(sq))
282
+
283
+ return passed
284
+
285
+ w_passed = find_passed(white_pawns, black_pawns, True)
286
+ b_passed = find_passed(black_pawns, white_pawns, False)
287
+
288
+ # 4) Backward pawns: heuristic:
289
+ # - No friendly pawn on adjacent file with rank ≤ r
290
+ # - The square in front is either occupied or attacked by an enemy pawn
291
+ def find_backward(pawn_sqs, friend_sqs, enemy_sqs, is_white):
292
+ """
293
+ For each pawn sq of this color:
294
+ - Let f,r be its file/rank
295
+ - Condition A: No friendly pawn on file f-1 or f+1 with rank ≤ r (for white) or ≥ r (for black)
296
+ - Condition B: The square in front (r+1 for white; r-1 for black) is either occupied or attacked by an enemy pawn
297
+ - If both hold → mark as backward.
298
+ """
299
+ backward = []
300
+
301
+ friend_pos = [
302
+ (chess.square_file(fsq), chess.square_rank(fsq)) for fsq in friend_sqs
303
+ ]
304
+ enemy_pawn_positions = set(enemy_sqs) # for quick “occupied‐by‐pawn” checks
305
+
306
+ for sq in pawn_sqs:
307
+ f = chess.square_file(sq)
308
+ r = chess.square_rank(sq)
309
+
310
+ # 4A) no friendly adjacent “supporter”
311
+ has_support = False
312
+ for ff, rr in friend_pos:
313
+ if abs(ff - f) == 1:
314
+ if is_white:
315
+ if rr <= r:
316
+ has_support = True
317
+ break
318
+ else:
319
+ if rr >= r:
320
+ has_support = True
321
+ break
322
+ if has_support:
323
+ continue # NOT backward if there is a supporting pawn
324
+
325
+ # 4B) check the square in front
326
+ if is_white:
327
+ if r == 7:
328
+ continue # already on rank 8 → can’t be “backward” in the usual sense
329
+ front_sq = chess.square(f, r + 1)
330
+ else:
331
+ if r == 0:
332
+ continue
333
+ front_sq = chess.square(f, r - 1)
334
+
335
+ # If front‐square is occupied by any piece OR attacked by an enemy pawn → block
336
+ if board.piece_at(front_sq) is not None:
337
+ blocked = True
338
+ else:
339
+ # attacked by an enemy pawn?
340
+ attackers = board.attackers(
341
+ chess.BLACK if is_white else chess.WHITE, front_sq
342
+ )
343
+ # see if any of those attackers is an enemy pawn:
344
+ attacked_by_pawn = False
345
+ for attacker_sq in attackers:
346
+ p = board.piece_at(attacker_sq)
347
+ if (
348
+ p is not None
349
+ and p.piece_type == chess.PAWN
350
+ and p.color != board.piece_at(sq).color
351
+ ):
352
+ attacked_by_pawn = True
353
+ break
354
+ blocked = attacked_by_pawn
355
+
356
+ if blocked:
357
+ backward.append(chess.square_name(sq))
358
+
359
+ return backward
360
+
361
+ w_backward = find_backward(white_pawns, white_pawns, black_pawns, True)
362
+ b_backward = find_backward(black_pawns, black_pawns, white_pawns, False)
363
+
364
+ # 5) Potential break squares:
365
+ # For each pawn of a side, if front‐square is empty and there is an enemy pawn diagonally ahead,
366
+ # then that front‐square is a “break point” where advancing would challenge the enemy pawn.
367
+ def find_break_sqs(pawn_sqs, is_white):
368
+ """
369
+ For each pawn sq:
370
+ - Compute front = (f, r+1) if white; (f, r-1) if black
371
+ - If front is on board, empty, and has an enemy pawn on one of its diagonals, add front.
372
+ """
373
+ breaks = set()
374
+ for sq in pawn_sqs:
375
+ f = chess.square_file(sq)
376
+ r = chess.square_rank(sq)
377
+
378
+ if is_white and r == 7:
379
+ continue
380
+ if not is_white and r == 0:
381
+ continue
382
+
383
+ if is_white:
384
+ front = chess.square(f, r + 1)
385
+ # diagonals at (f-1, r+1) and (f+1, r+1)
386
+ diag1 = chess.square(f - 1, r + 1) if f > 0 else None
387
+ diag2 = chess.square(f + 1, r + 1) if f < 7 else None
388
+ enemy_color = chess.BLACK
389
+ else:
390
+ front = chess.square(f, r - 1)
391
+ diag1 = chess.square(f - 1, r - 1) if f > 0 else None
392
+ diag2 = chess.square(f + 1, r - 1) if f < 7 else None
393
+ enemy_color = chess.WHITE
394
+
395
+ # Must be empty to “break” into
396
+ if board.piece_at(front) is not None:
397
+ continue
398
+
399
+ # If any diagonal contains an enemy pawn, mark front as break square
400
+ for diag in (diag1, diag2):
401
+ if diag is not None:
402
+ piece = board.piece_at(diag)
403
+ if (
404
+ piece is not None
405
+ and piece.piece_type == chess.PAWN
406
+ and piece.color == enemy_color
407
+ ):
408
+ breaks.add(front)
409
+ break
410
+
411
+ return [chess.square_name(sq) for sq in sorted(breaks)]
412
+
413
+ w_breaks = find_break_sqs(white_pawns, True)
414
+ b_breaks = find_break_sqs(black_pawns, False)
415
+
416
+ # Assemble final result
417
+ return {
418
+ "pawn_islands": {"white": w_islands, "black": b_islands},
419
+ "doubled_pawns": {"white": w_doubled, "black": b_doubled},
420
+ "isolated_pawns": {"white": w_isolated, "black": b_isolated},
421
+ "passed_pawns": {"white": w_passed, "black": b_passed},
422
+ "backward_pawns": {"white": w_backward, "black": b_backward},
423
+ "break_squares": {"white": w_breaks, "black": b_breaks},
424
+ }
425
+
426
+
427
+ def analyze_tactical_patterns(fen):
428
+ """
429
+ Analyze immediate tactical patterns from a given FEN string.
430
+
431
+ This function detects:
432
+ - Potential knight forks and double attacks (refering to next move)
433
+ - Pins, skewers, discovered attacks and x‐ray attacks in the current position.
434
+
435
+ Args:
436
+ fen (str): The FEN string representing the chess position.
437
+ """
438
+
439
+ board = chess.Board(fen)
440
+
441
+ piece_name = {
442
+ chess.PAWN: "pawn",
443
+ chess.KNIGHT: "knight",
444
+ chess.BISHOP: "bishop",
445
+ chess.ROOK: "rook",
446
+ chess.QUEEN: "queen",
447
+ chess.KING: "king",
448
+ }
449
+
450
+ def find_forks_and_double_attacks(color):
451
+ """
452
+ For each legal move by 'color', detect:
453
+ - Knight forks: moved knight attacks ≥2 enemy pieces
454
+ - Double attacks: moved non-knight piece attacks ≥2 enemy pieces
455
+ Returns two lists of descriptive strings.
456
+ """
457
+ forks = []
458
+ double_attacks = []
459
+ b = board.copy()
460
+ b.turn = color
461
+
462
+ for move in b.legal_moves:
463
+ moving_piece = b.piece_at(move.from_square)
464
+ if moving_piece is None:
465
+ continue
466
+
467
+ b.push(move)
468
+ to_sq = move.to_square
469
+ attacked_squares = b.attacks(to_sq)
470
+ attacked_pieces = []
471
+ for sq in attacked_squares:
472
+ piece = b.piece_at(sq)
473
+ if piece is not None and piece.color != color:
474
+ attacked_pieces.append((sq, piece))
475
+
476
+ if len(attacked_pieces) >= 2:
477
+ mover_symbol = moving_piece.symbol().upper()
478
+ dest = chess.square_name(to_sq)
479
+ targets = [
480
+ f"{piece_name[p.piece_type]} on {chess.square_name(sq)}"
481
+ for sq, p in attacked_pieces
482
+ ]
483
+ target_str = " and ".join(targets)
484
+ if moving_piece.piece_type == chess.KNIGHT:
485
+ forks.append(f"{mover_symbol}{dest} forks {target_str}")
486
+ else:
487
+ double_attacks.append(
488
+ f"{mover_symbol}{dest} double‐attacks {target_str}"
489
+ )
490
+ b.pop()
491
+
492
+ return forks, double_attacks
493
+
494
+ def find_pins(color):
495
+ """
496
+ Find pinned pieces of 'color'. For each pinned piece, identify the pinning piece.
497
+ Returns list of descriptive strings.
498
+ """
499
+ pins = []
500
+ king_sq = board.king(color)
501
+ if king_sq is None:
502
+ return pins
503
+
504
+ for sq in (
505
+ board.pieces(chess.PAWN, color)
506
+ | board.pieces(chess.KNIGHT, color)
507
+ | board.pieces(chess.BISHOP, color)
508
+ | board.pieces(chess.ROOK, color)
509
+ | board.pieces(chess.QUEEN, color)
510
+ ):
511
+ if sq == king_sq:
512
+ continue
513
+ if board.is_pinned(color, sq):
514
+ # Compute direction from king to this pinned piece
515
+ f_k, r_k = chess.square_file(king_sq), chess.square_rank(king_sq)
516
+ f_p, r_p = chess.square_file(sq), chess.square_rank(sq)
517
+ df = f_p - f_k
518
+ dr = r_p - r_k
519
+ # Normalize direction to unit step
520
+ df_norm = (df // abs(df)) if df != 0 else 0
521
+ dr_norm = (dr // abs(dr)) if dr != 0 else 0
522
+ # Move from pinned piece outward to find pinning slider
523
+ cur_f, cur_r = f_p + df_norm, r_p + dr_norm
524
+ while 0 <= cur_f < 8 and 0 <= cur_r < 8:
525
+ cur_sq = chess.square(cur_f, cur_r)
526
+ piece = board.piece_at(cur_sq)
527
+ if piece is not None and piece.color != color:
528
+ # Check if this piece type can pin along this direction
529
+ if dr_norm == 0 and piece.piece_type in (
530
+ chess.ROOK,
531
+ chess.QUEEN,
532
+ ):
533
+ pinning = piece
534
+ elif df_norm == 0 and piece.piece_type in (
535
+ chess.ROOK,
536
+ chess.QUEEN,
537
+ ):
538
+ pinning = piece
539
+ elif abs(df_norm) == abs(dr_norm) and piece.piece_type in (
540
+ chess.BISHOP,
541
+ chess.QUEEN,
542
+ ):
543
+ pinning = piece
544
+ else:
545
+ pinning = None
546
+ if pinning is not None:
547
+ pin_sym = pinning.symbol().upper()
548
+ pin_sq = chess.square_name(cur_sq)
549
+ pinned_sym = board.piece_at(sq).piece_type
550
+ pinned_name = piece_name[board.piece_at(sq).piece_type]
551
+ pinned_sq_name = chess.square_name(sq)
552
+ king_sq_name = chess.square_name(king_sq)
553
+ pins.append(
554
+ f"{pin_sym}{pin_sq} pins {pinned_name} on {pinned_sq_name} to king on {king_sq_name}"
555
+ )
556
+ break
557
+ if piece is not None:
558
+ # Non-sliding or same-color piece blocks further search
559
+ break
560
+ cur_f += df_norm
561
+ cur_r += dr_norm
562
+
563
+ return pins
564
+
565
+ def find_skewers(color):
566
+ """
567
+ Find static skewers: slider attacks a high-value enemy piece, behind it on same ray is a lower-value enemy piece.
568
+ Returns list of descriptive strings.
569
+ """
570
+ skewers = []
571
+ enemy_color = not color
572
+
573
+ for s_sq in (
574
+ board.pieces(chess.BISHOP, color)
575
+ | board.pieces(chess.ROOK, color)
576
+ | board.pieces(chess.QUEEN, color)
577
+ ):
578
+ s_f, s_r = chess.square_file(s_sq), chess.square_rank(s_sq)
579
+ # Directions for this slider
580
+ directions = []
581
+ if board.piece_at(s_sq).piece_type == chess.BISHOP:
582
+ directions = [(-1, -1), (-1, 1), (1, -1), (1, 1)]
583
+ elif board.piece_at(s_sq).piece_type == chess.ROOK:
584
+ directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
585
+ else: # Queen
586
+ directions = [
587
+ (-1, -1),
588
+ (-1, 1),
589
+ (1, -1),
590
+ (1, 1),
591
+ (-1, 0),
592
+ (1, 0),
593
+ (0, -1),
594
+ (0, 1),
595
+ ]
596
+
597
+ for df, dr in directions:
598
+ cur_f, cur_r = s_f + df, s_r + dr
599
+ # Look for first enemy piece
600
+ first_found = False
601
+ first_sq = None
602
+ first_piece = None
603
+ while 0 <= cur_f < 8 and 0 <= cur_r < 8:
604
+ sq = chess.square(cur_f, cur_r)
605
+ piece = board.piece_at(sq)
606
+ if piece is not None:
607
+ if not first_found and piece.color == enemy_color:
608
+ first_found = True
609
+ first_sq = sq
610
+ first_piece = piece
611
+ else:
612
+ if first_found and piece.color == enemy_color:
613
+ # We have A (first_sq, first_piece) and B (sq, piece)
614
+ # Check that first_piece has higher value than piece
615
+ values = {
616
+ chess.KING: 1000,
617
+ chess.QUEEN: 9,
618
+ chess.ROOK: 5,
619
+ chess.BISHOP: 3,
620
+ chess.KNIGHT: 3,
621
+ chess.PAWN: 1,
622
+ }
623
+ if (
624
+ values[first_piece.piece_type]
625
+ > values[piece.piece_type]
626
+ ):
627
+ s_sym = board.piece_at(s_sq).symbol().upper()
628
+ s_sq_name = chess.square_name(s_sq)
629
+ high_name = piece_name[first_piece.piece_type]
630
+ high_sq = chess.square_name(first_sq)
631
+ low_name = piece_name[piece.piece_type]
632
+ low_sq = chess.square_name(sq)
633
+ skewers.append(
634
+ f"{s_sym}{s_sq_name} skewers {high_name} on {high_sq} to {low_name} on {low_sq}"
635
+ )
636
+ break
637
+ else:
638
+ # Something that breaks the ray (friendly piece or no second enemy)
639
+ break
640
+ cur_f += df
641
+ cur_r += dr
642
+
643
+ return skewers
644
+
645
+ def find_discovered_attacks(color):
646
+ """
647
+ Static discovered‐attack patterns: a friendly slider is currently blocked by one friendly piece from attacking an enemy target.
648
+ Returns list of descriptive strings.
649
+ """
650
+ discovered = []
651
+ enemy_color = not color
652
+
653
+ for s_sq in (
654
+ board.pieces(chess.BISHOP, color)
655
+ | board.pieces(chess.ROOK, color)
656
+ | board.pieces(chess.QUEEN, color)
657
+ ):
658
+ s_f, s_r = chess.square_file(s_sq), chess.square_rank(s_sq)
659
+ # Determine directions like in skewers
660
+ if board.piece_at(s_sq).piece_type == chess.BISHOP:
661
+ directions = [(-1, -1), (-1, 1), (1, -1), (1, 1)]
662
+ elif board.piece_at(s_sq).piece_type == chess.ROOK:
663
+ directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
664
+ else: # Queen
665
+ directions = [
666
+ (-1, -1),
667
+ (-1, 1),
668
+ (1, -1),
669
+ (1, 1),
670
+ (-1, 0),
671
+ (1, 0),
672
+ (0, -1),
673
+ (0, 1),
674
+ ]
675
+
676
+ for df, dr in directions:
677
+ cur_f, cur_r = s_f + df, s_r + dr
678
+ blocker_sq = None
679
+ blocker_piece = None
680
+ while 0 <= cur_f < 8 and 0 <= cur_r < 8:
681
+ sq = chess.square(cur_f, cur_r)
682
+ piece = board.piece_at(sq)
683
+ if piece is not None:
684
+ if piece.color == color and blocker_sq is None:
685
+ # first friendly piece blocks the ray
686
+ blocker_sq = sq
687
+ blocker_piece = piece
688
+ else:
689
+ # either second piece or enemy piece
690
+ if blocker_sq is not None and piece.color == enemy_color:
691
+ # Discovered attack: blocker_sq moving would allow slider at s_sq to attack this piece at sq
692
+ s_sym = board.piece_at(s_sq).symbol().upper()
693
+ blocker_name = piece_name[blocker_piece.piece_type]
694
+ blocker_loc = chess.square_name(blocker_sq)
695
+ target_name = piece_name[piece.piece_type]
696
+ target_loc = chess.square_name(sq)
697
+ discovered.append(
698
+ f"Moving {blocker_name} from {blocker_loc} uncovers {s_sym}{chess.square_name(s_sq)} attacking {target_name} on {target_loc}"
699
+ )
700
+ break
701
+ cur_f += df
702
+ cur_r += dr
703
+
704
+ return discovered
705
+
706
+ def find_xray_attacks(color):
707
+ """
708
+ Static x‐ray attacks: slider attacks through one piece (friendly or enemy) to an enemy target behind it.
709
+ Returns list of descriptive strings.
710
+ """
711
+ xray = []
712
+ enemy_color = not color
713
+
714
+ for s_sq in (
715
+ board.pieces(chess.BISHOP, color)
716
+ | board.pieces(chess.ROOK, color)
717
+ | board.pieces(chess.QUEEN, color)
718
+ ):
719
+ s_f, s_r = chess.square_file(s_sq), chess.square_rank(s_sq)
720
+ if board.piece_at(s_sq).piece_type == chess.BISHOP:
721
+ directions = [(-1, -1), (-1, 1), (1, -1), (1, 1)]
722
+ elif board.piece_at(s_sq).piece_type == chess.ROOK:
723
+ directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
724
+ else:
725
+ directions = [
726
+ (-1, -1),
727
+ (-1, 1),
728
+ (1, -1),
729
+ (1, 1),
730
+ (-1, 0),
731
+ (1, 0),
732
+ (0, -1),
733
+ (0, 1),
734
+ ]
735
+
736
+ for df, dr in directions:
737
+ cur_f, cur_r = s_f + df, s_r + dr
738
+ first_blocker = None
739
+ first_blocker_sq = None
740
+ while 0 <= cur_f < 8 and 0 <= cur_r < 8:
741
+ sq = chess.square(cur_f, cur_r)
742
+ piece = board.piece_at(sq)
743
+ if piece is not None:
744
+ if first_blocker is None:
745
+ first_blocker = piece
746
+ first_blocker_sq = sq
747
+ else:
748
+ if piece.color == enemy_color:
749
+ # X-ray: slider at s_sq x‐rays this piece at sq through first_blocker at first_blocker_sq
750
+ s_sym = board.piece_at(s_sq).symbol().upper()
751
+ target_name = piece_name[piece.piece_type]
752
+ target_loc = chess.square_name(sq)
753
+ blocker_name = piece_name[first_blocker.piece_type]
754
+ blocker_loc = chess.square_name(first_blocker_sq)
755
+ xray.append(
756
+ f"{s_sym}{chess.square_name(s_sq)} x‐rays {target_name} on {target_loc} through {blocker_name} on {blocker_loc}"
757
+ )
758
+ break
759
+ cur_f += df
760
+ cur_r += dr
761
+
762
+ return xray
763
+
764
+ # Build result structure
765
+ result = {
766
+ "forks": {"white": [], "black": []},
767
+ "double_attacks": {"white": [], "black": []},
768
+ "pins": {"white": [], "black": []},
769
+ "skewers": {"white": [], "black": []},
770
+ "discovered_attacks": {"white": [], "black": []},
771
+ "xray_attacks": {"white": [], "black": []},
772
+ }
773
+
774
+ # White patterns
775
+ w_forks, w_double = find_forks_and_double_attacks(chess.WHITE)
776
+ result["forks"]["white"] = w_forks
777
+ result["double_attacks"]["white"] = w_double
778
+ result["pins"]["white"] = find_pins(chess.WHITE)
779
+ result["skewers"]["white"] = find_skewers(chess.WHITE)
780
+ result["discovered_attacks"]["white"] = find_discovered_attacks(chess.WHITE)
781
+ result["xray_attacks"]["white"] = find_xray_attacks(chess.WHITE)
782
+
783
+ # Black patterns
784
+ b_forks, b_double = find_forks_and_double_attacks(chess.BLACK)
785
+ result["forks"]["black"] = b_forks
786
+ result["double_attacks"]["black"] = b_double
787
+ result["pins"]["black"] = find_pins(chess.BLACK)
788
+ result["skewers"]["black"] = find_skewers(chess.BLACK)
789
+ result["discovered_attacks"]["black"] = find_discovered_attacks(chess.BLACK)
790
+ result["xray_attacks"]["black"] = find_xray_attacks(chess.BLACK)
791
+
792
+ return result
793
+
794
+
795
+ def evaluate_king_safety(fen):
796
+ """
797
+ Evaluate king safety for both White and Black from a given FEN string.
798
+
799
+ Args:
800
+ fen (str): The FEN string representing the chess position.
801
+ """
802
+ board = chess.Board(fen)
803
+
804
+ def get_shield_and_files(color):
805
+ """
806
+ For 'color', find:
807
+ - pawn_shield: count of own pawns directly in front of king on files f-1,f,f+1.
808
+ - max_shield: maximum possible shield pawns (1-3 depending on king file at edge).
809
+ - open_or_semi_open_files: list of file names (adjacent to king) that are open or semi-open.
810
+ """
811
+ king_sq = board.king(color)
812
+ if king_sq is None:
813
+ return 0, 0, []
814
+
815
+ kf = chess.square_file(king_sq)
816
+ kr = chess.square_rank(king_sq)
817
+ # Direction “forward” for shield pawns
818
+ ranks_dir = 1 if color == chess.WHITE else -1
819
+ shield_rank = kr + ranks_dir
820
+ files_to_check = [f for f in (kf - 1, kf, kf + 1) if 0 <= f < 8]
821
+ max_shield = len(files_to_check)
822
+
823
+ # Count intact shield pawns: own pawn at (file, shield_rank)
824
+ shield_count = 0
825
+ for f in files_to_check:
826
+ sq = chess.square(f, shield_rank) if 0 <= shield_rank < 8 else None
827
+ if sq is not None:
828
+ piece = board.piece_at(sq)
829
+ if (
830
+ piece is not None
831
+ and piece.piece_type == chess.PAWN
832
+ and piece.color == color
833
+ ):
834
+ shield_count += 1
835
+
836
+ # Determine open or semi‐open files among files_to_check
837
+ open_or_semi_open = []
838
+ for f in files_to_check:
839
+ # Gather all pawns on that file
840
+ pawns_on_file = [
841
+ board.piece_at(chess.square(f, r))
842
+ for r in range(8)
843
+ if (p := board.piece_at(chess.square(f, r))) is not None
844
+ and p.piece_type == chess.PAWN
845
+ ]
846
+ has_friendly = any(p.color == color for p in pawns_on_file)
847
+ has_enemy = any(p.color != color for p in pawns_on_file)
848
+ file_name = chess.FILE_NAMES[f]
849
+ if not pawns_on_file:
850
+ # fully open file
851
+ open_or_semi_open.append(file_name)
852
+ elif has_enemy and not has_friendly:
853
+ # semi‐open (enemy pawn only)
854
+ open_or_semi_open.append(file_name)
855
+
856
+ return shield_count, max_shield, open_or_semi_open
857
+
858
+ def get_attacker_count(color):
859
+ """
860
+ Count unique enemy pieces attacking any of the up to 8 squares adjacent to the king.
861
+ """
862
+ king_sq = board.king(color)
863
+ if king_sq is None:
864
+ return 0
865
+ enemy_color = not color
866
+ kf = chess.square_file(king_sq)
867
+ kr = chess.square_rank(king_sq)
868
+
869
+ attackers = set()
870
+ # Loop over the eight neighbors
871
+ for df in (-1, 0, 1):
872
+ for dr in (-1, 0, 1):
873
+ if df == 0 and dr == 0:
874
+ continue
875
+ f = kf + df
876
+ r = kr + dr
877
+ if 0 <= f < 8 and 0 <= r < 8:
878
+ sq = chess.square(f, r)
879
+ for attacker_sq in board.attackers(enemy_color, sq):
880
+ attackers.add(attacker_sq)
881
+ return len(attackers)
882
+
883
+ def compute_shelter_score(shield_count, max_shield, open_count, attacker_count):
884
+ """
885
+ Compute a composite shelter score in [0, 1], combining:
886
+ - shield_factor: shield_count / max_shield
887
+ - file_factor: 1 - (open_count / max_shield)
888
+ - attacker_factor: 1 - min(attacker_count, 8) / 8
889
+ Return average of the three, rounded to 2 decimals.
890
+ """
891
+ if max_shield == 0:
892
+ shield_factor = 0
893
+ file_factor = 0
894
+ else:
895
+ shield_factor = shield_count / max_shield
896
+ file_factor = 1 - (open_count / max_shield)
897
+ attacker_factor = 1 - min(attacker_count, 8) / 8
898
+ return round((shield_factor + file_factor + attacker_factor) / 3, 2)
899
+
900
+ result = {
901
+ "pawn_shield": {"white": "", "black": ""},
902
+ "open_files": {"white": [], "black": []},
903
+ "attacker_count": {"white": 0, "black": 0},
904
+ "shelter_score": {"white": 0.0, "black": 0.0},
905
+ }
906
+
907
+ # White evaluation
908
+ w_shield, w_max_shield, w_open = get_shield_and_files(chess.WHITE)
909
+ w_attackers = get_attacker_count(chess.WHITE)
910
+ w_shelter = compute_shelter_score(w_shield, w_max_shield, len(w_open), w_attackers)
911
+ result["pawn_shield"]["white"] = f"{w_shield} of {w_max_shield} shield pawns"
912
+ result["open_files"]["white"] = w_open
913
+ result["attacker_count"]["white"] = w_attackers
914
+ result["shelter_score"]["white"] = w_shelter
915
+
916
+ # Black evaluation
917
+ b_shield, b_max_shield, b_open = get_shield_and_files(chess.BLACK)
918
+ b_attackers = get_attacker_count(chess.BLACK)
919
+ b_shelter = compute_shelter_score(b_shield, b_max_shield, len(b_open), b_attackers)
920
+ result["pawn_shield"]["black"] = f"{b_shield} of {b_max_shield} shield pawns"
921
+ result["open_files"]["black"] = b_open
922
+ result["attacker_count"]["black"] = b_attackers
923
+ result["shelter_score"]["black"] = b_shelter
924
+
925
+ return result
926
+
927
+
928
+ def classify_opening(fen: str) -> dict:
929
+ """
930
+ Attempt to classify a chess opening using the Lichess openings database.
931
+ Return the ECO code, name, moves and main sub-variations of the opening.
932
+
933
+ Args:
934
+ fen (str): The FEN string representing the chess position.
935
+ """
936
+ board = chess.Board(fen)
937
+ epd_key = board.epd()
938
+
939
+ df = _load_lichess_openings()
940
+ match = df[df["epd"] == epd_key]
941
+ if match.empty:
942
+ return {"error": f"No ECO code found for position: {fen}"}
943
+
944
+ eco_code = match.iloc[0]["eco"]
945
+ opening_name = match.iloc[0]["name"]
946
+ base_pgn = match.iloc[0]["pgn"]
947
+ base_uci = match.iloc[0]["uci"]
948
+ base_len = len(base_uci.split())
949
+
950
+ def next_move(uci_str: str) -> str | None:
951
+ parts = uci_str.split()
952
+ if not parts[:base_len] == base_uci.split():
953
+ return None
954
+ return parts[base_len] if len(parts) > base_len else None
955
+
956
+ df["next_move"] = df["uci"].apply(next_move)
957
+ subs = (
958
+ df[df["next_move"].notna()]
959
+ .sort_values("uci")
960
+ .drop_duplicates("next_move", keep="first")
961
+ )
962
+
963
+ subvariants = [
964
+ {"name": row["name"], "pgn": row["pgn"], "fen": row["epd"]}
965
+ for _, row in subs.iterrows()
966
+ ]
967
+
968
+ return {
969
+ "eco": eco_code,
970
+ "name": opening_name,
971
+ "pgn": base_pgn,
972
+ "subvariants": subvariants,
973
+ }
974
+
975
+
976
+ def find_opening_by_name(name: str) -> dict:
977
+ """
978
+ Search for a chess opening by its name in the Lichess openings database.
979
+ Return the ECO code, name, PGN, FEN and sub-variations of a chess opening by its name.
980
+ The name is matched case-insensitively.
981
+
982
+ Args:
983
+ name (str): The name of the chess opening to search for (e.g. Caro-Kann Defense: Advance Variation).
984
+ """
985
+ df = _load_lichess_openings()
986
+
987
+ mask = df["name"].str.contains(name, case=False, regex=False)
988
+ matches = df[mask]
989
+ if matches.empty:
990
+ return {"error": f"No opening found matching name: '{name}'"}
991
+
992
+ row = matches.iloc[0]
993
+ eco_code = row["eco"]
994
+ full_name = row["name"]
995
+ base_pgn = row["pgn"]
996
+ base_uci = row["uci"]
997
+ epd = row["epd"]
998
+ fen = f"{epd} 0 1"
999
+
1000
+ base_moves = base_uci.split()
1001
+ base_len = len(base_moves)
1002
+
1003
+ def next_move(uci_str: str) -> str | None:
1004
+ parts = uci_str.split()
1005
+ if not parts[:base_len] == base_uci.split():
1006
+ return None
1007
+ return parts[base_len] if len(parts) > base_len else None
1008
+
1009
+ df["next_move"] = df["uci"].apply(next_move)
1010
+ subs = (
1011
+ df[df["next_move"].notna()]
1012
+ .sort_values("uci")
1013
+ .drop_duplicates("next_move", keep="first")
1014
+ )
1015
+
1016
+ subvariants = [
1017
+ {"name": sub_row["name"], "pgn": sub_row["pgn"], "fen": sub_row["epd"]}
1018
+ for _, sub_row in subs.iterrows()
1019
+ ]
1020
+
1021
+ return {
1022
+ "eco": eco_code,
1023
+ "name": full_name,
1024
+ "pgn": base_pgn,
1025
+ "fen": fen,
1026
+ "subvariants": subvariants,
1027
+ }
1028
+
1029
+
1030
+ def _get_color_name(color: chess.Color) -> str:
1031
+ return "white" if color == chess.WHITE else "black"
1032
+
1033
+
1034
+ def _get_piece_info_on_square(board: chess.Board, square: chess.Square) -> str:
1035
+ piece = board.piece_at(square)
1036
+ if piece is None:
1037
+ return f"No piece on {chess.square_name(square)}"
1038
+ color = _get_color_name(piece.color)
1039
+ result = f"There is a {color} {chess.piece_name(piece.piece_type)} on {chess.square_name(square)}."
1040
+ legal_moves = [
1041
+ chess.square_name(m.to_square)
1042
+ for m in board.legal_moves
1043
+ if m.from_square == square
1044
+ ]
1045
+ if not legal_moves:
1046
+ result += f" It can't move because"
1047
+ if board.turn != piece.color:
1048
+ result += f" it is not {_get_color_name(piece.color)}'s turn."
1049
+ elif board.is_pinned(piece.color, square):
1050
+ result += " it is pinned."
1051
+ elif board.is_check():
1052
+ result += f" it is a check and the {chess.piece_name(piece.piece_type)} can't block"
1053
+ else:
1054
+ result += " it is blocked."
1055
+ result += f" However, it attacks the following squares: {', '.join([chess.square_name(s) for s in board.attacks(square)])}."
1056
+ else:
1057
+ result += f" It can move to the following squares: {', '.join(legal_moves)}."
1058
+ return result
1059
+
1060
+
1061
+ def _get_attackers(board: chess.Board, square: chess.Square, color: chess.Color) -> str:
1062
+ piece = board.piece_at(square)
1063
+ title = "attackers" if piece is None or piece.color != color else "defenders"
1064
+ attackers = board.attackers(color, square)
1065
+ color_name = _get_color_name(color)
1066
+ if not attackers:
1067
+ return f"No {color_name} {title} for {chess.square_name(square)}"
1068
+ return (
1069
+ f"{len(attackers)} {color_name.title()} {title} for {chess.square_name(square)}: "
1070
+ + ", ".join(
1071
+ [
1072
+ f"{chess.piece_name(board.piece_at(s).piece_type)} on {chess.square_name(s)}"
1073
+ for s in attackers
1074
+ ]
1075
+ )
1076
+ )
1077
+
1078
+
1079
+ def _load_lichess_openings(
1080
+ path_prefix: str = "data/lichess_openings/dist/",
1081
+ ) -> pd.DataFrame:
1082
+ """Load Lichess openings data from TSV files.
1083
+ Assumes files 'a.tsv', 'b.tsv', 'c.tsv', 'd.tsv', 'e.tsv' are in path_prefix.
1084
+ Each has columns: eco, name, pgn, uci, epd.
1085
+ """
1086
+ files = [f"{path_prefix}{vol}.tsv" for vol in ("a", "b", "c", "d", "e")]
1087
+ dfs = []
1088
+ for fn in files:
1089
+ df = pd.read_csv(fn, sep="\t", usecols=["eco", "name", "pgn", "uci", "epd"])
1090
+ dfs.append(df)
1091
+ return pd.concat(dfs, ignore_index=True)
1092
+
1093
+
1094
+ get_position_tool = gr.Interface(
1095
+ fn=get_position,
1096
+ inputs=Chessboard(label="FEN String"),
1097
+ outputs=gr.JSON(label="Chess Position"),
1098
+ title="Chess Position Viewer",
1099
+ description="Enter a FEN string to view the current chess position.",
1100
+ )
1101
+
1102
+ get_square_info_tool = gr.Interface(
1103
+ fn=get_square_info,
1104
+ inputs=[Chessboard(label="FEN String"), gr.Textbox(label="Square Name")],
1105
+ outputs=gr.JSON(label="Square Info"),
1106
+ title="Chess Square Info",
1107
+ description="Enter a FEN string and a square name (e.g., 'e4') to get information about the piece on that square.",
1108
+ )
1109
+
1110
+ get_top_moves_tool = gr.Interface(
1111
+ fn=get_top_moves,
1112
+ inputs=[Chessboard(label="FEN String"), gr.Number(value=5, label="Top N Moves")],
1113
+ outputs=gr.JSON(label="Top Moves"),
1114
+ title="Top Moves Analyzer",
1115
+ description="Enter a FEN string to get the top moves for the current position using StockFish.",
1116
+ )
1117
+
1118
+ analyze_pawn_structure_tool = gr.Interface(
1119
+ fn=analyze_pawn_structure,
1120
+ inputs=Chessboard(label="FEN String"),
1121
+ outputs=gr.JSON(label="Pawn Structure Analysis"),
1122
+ title="Pawn Structure Analyzer",
1123
+ description="Enter a FEN string to analyze the pawn structure features for both White and Black.",
1124
+ )
1125
+
1126
+ analyze_tactical_patterns_tool = gr.Interface(
1127
+ fn=analyze_tactical_patterns,
1128
+ inputs=Chessboard(label="FEN String"),
1129
+ outputs=gr.JSON(label="Tactical Patterns Analysis"),
1130
+ title="Tactical Patterns Analyzer",
1131
+ description="Enter a FEN string to analyze immediate tactical patterns for both White and Black.",
1132
+ )
1133
+
1134
+ evaluate_king_safety_tool = gr.Interface(
1135
+ fn=evaluate_king_safety,
1136
+ inputs=Chessboard(label="FEN String"),
1137
+ outputs=gr.JSON(label="King Safety Evaluation"),
1138
+ title="King Safety Evaluator",
1139
+ description="Enter a FEN string to evaluate the safety of both kings in the current position.",
1140
+ )
1141
+
1142
+ classify_opening_tool = gr.Interface(
1143
+ fn=classify_opening,
1144
+ inputs=Chessboard(label="FEN String"),
1145
+ outputs=gr.JSON(label="Opening Classification"),
1146
+ title="Opening Classifier",
1147
+ description="Enter a FEN string to classify the opening and get its ECO code, name, and sub-variations.",
1148
+ )
1149
+
1150
+ find_opening_by_name_tool = gr.Interface(
1151
+ fn=find_opening_by_name,
1152
+ inputs=gr.Textbox(label="Opening Name"),
1153
+ outputs=gr.JSON(label="Opening Details"),
1154
+ title="Find Opening by Name",
1155
+ description="Enter the name of a chess opening to find its ECO code, PGN, FEN, and sub-variations.",
1156
+ )
1157
+
1158
+ app = gr.TabbedInterface(
1159
+ [
1160
+ get_position_tool,
1161
+ get_square_info_tool,
1162
+ get_top_moves_tool,
1163
+ analyze_pawn_structure_tool,
1164
+ analyze_tactical_patterns_tool,
1165
+ evaluate_king_safety_tool,
1166
+ classify_opening_tool,
1167
+ find_opening_by_name_tool,
1168
+ ],
1169
+ tab_names=[
1170
+ "Get Position",
1171
+ "Get Square Info",
1172
+ "Get Top Moves",
1173
+ "Analyze Pawn Structure",
1174
+ "Analyze Tactical Patterns",
1175
+ "Evaluate King Safety",
1176
+ "Classify Opening",
1177
+ "Find Opening by Name",
1178
+ ],
1179
+ title="Chess Tools",
1180
+ )
1181
+
1182
+ if __name__ == "__main__":
1183
+ app.launch(mcp_server=True)
packages.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ stockfish
2
+ make
3
+ git
postBuild ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ # 1. Clone the Lichess openings TSV dataset
5
+ git clone https://github.com/lichess-org/chess-openings.git data/lichess_openings
6
+
7
+ # 2. Build/prep with make
8
+ cd data/lichess_openings
9
+ make
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio[mcp]
2
+ gradio_chessboard
3
+ chess