carolinacon commited on
Commit
7d310bb
·
1 Parent(s): 2e38934

added chess tool

Browse files
Files changed (4) hide show
  1. config/prompts.yaml +29 -5
  2. nodes/nodes.py +2 -0
  3. requirements.txt +2 -1
  4. tools/chess_tool.py +362 -0
config/prompts.yaml CHANGED
@@ -16,10 +16,11 @@ prompts:
16
  {{summary}}
17
  </summary>
18
 
19
- For mathematical questions or problems delegate them to the math_tool.
20
-
21
- Include citations for all the information you retrieve, ensuring you know exactly where the data comes from.
22
- If you have the information inside your knowledge, still call a tool in order to confirm it.
 
23
 
24
  **Guidelines for Conducting Research:**
25
 
@@ -145,4 +146,27 @@ prompts:
145
  type: sub_agent
146
  variables: []
147
  version: 1.0
148
- description: "Core system prompt for the math agent"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  {{summary}}
17
  </summary>
18
 
19
+ For mathematical questions or problems delegate them to the math_tool.
20
+ For chess related questions use the chess_analysis_tool.
21
+
22
+ Include citations for all the information you retrieve, ensuring you know exactly where the data comes from.
23
+ If you have the information inside your knowledge, still call a tool in order to confirm it.
24
 
25
  **Guidelines for Conducting Research:**
26
 
 
146
  type: sub_agent
147
  variables: []
148
  version: 1.0
149
+ description: "Core system prompt for the math agent"
150
+ chess_board_detection:
151
+ content: |
152
+ You are a chess board analyst.
153
+ Please read the pieces positions carefully, and then confirm your reading with a second pass through the image.
154
+ type: tool
155
+ variables: [ ]
156
+ version: 1.0
157
+ description: "Prompt for chess board analysis tool"
158
+ chess_board_orientation:
159
+ content: |
160
+ You are a chess board analyst.
161
+ Look for file and rank labels (if visible):
162
+
163
+ Files labeled a-h from left to right indicate White's perspective
164
+ Files labeled h-a from left to right indicate Black's perspective
165
+ Ranks labeled 1-8 from bottom to top indicate White's perspective
166
+ Ranks labeled 8-1 from bottom to top indicate Black's perspective
167
+ Please read the files and ranks positions carefully, and then confirm your reading with a second pass through the image.
168
+ If the ranks and files are not labeled respond that you cannot determine the chess board orientation.
169
+ type: tool
170
+ variables: [ ]
171
+ version: 1.0
172
+ description: "Prompt for chess board orientation used by the chess analysis tool"
nodes/nodes.py CHANGED
@@ -9,6 +9,7 @@ from core.messages import attachmentHandler
9
  from core.state import State
10
  from nodes.chunking_node import OversizedContentHandler
11
  from tools.audio_tool import query_audio
 
12
  from tools.excel_tool import query_excel_file
13
  from tools.math_agent import math_tool
14
  from tools.python_executor import execute_python_code
@@ -21,6 +22,7 @@ llm_tools.append(query_audio)
21
  llm_tools.append(query_excel_file)
22
  llm_tools.append(execute_python_code)
23
  llm_tools.append(math_tool)
 
24
  model = model.bind_tools(llm_tools, parallel_tool_calls=False)
25
 
26
 
 
9
  from core.state import State
10
  from nodes.chunking_node import OversizedContentHandler
11
  from tools.audio_tool import query_audio
12
+ from tools.chess_tool import chess_analysis_tool
13
  from tools.excel_tool import query_excel_file
14
  from tools.math_agent import math_tool
15
  from tools.python_executor import execute_python_code
 
22
  llm_tools.append(query_excel_file)
23
  llm_tools.append(execute_python_code)
24
  llm_tools.append(math_tool)
25
+ llm_tools.append(chess_analysis_tool)
26
  model = model.bind_tools(llm_tools, parallel_tool_calls=False)
27
 
28
 
requirements.txt CHANGED
@@ -8,4 +8,5 @@ langchain-community
8
  faiss-cpu
9
  langchain-experimental
10
  openpyxl
11
- tabulate
 
 
8
  faiss-cpu
9
  langchain-experimental
10
  openpyxl
11
+ tabulate
12
+ chess
tools/chess_tool.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import os
4
+ from typing import Dict, List, Optional, Any
5
+
6
+ import chess
7
+ import chess.engine
8
+ from langchain.chat_models import init_chat_model
9
+ from langchain.schema import SystemMessage, HumanMessage
10
+ from langchain.tools import tool
11
+ from langchain_google_genai import ChatGoogleGenerativeAI
12
+ from langchain_openai import ChatOpenAI
13
+ from pydantic import BaseModel, Field
14
+
15
+ from utils.prompt_manager import prompt_mgmt
16
+
17
+
18
+ def encode_image_to_base64(image_path: str) -> str:
19
+ """Encode image to base64 for API consumption"""
20
+ with open(image_path, "rb") as image_file:
21
+ return base64.b64encode(image_file.read()).decode('utf-8')
22
+
23
+
24
+ class ChessPiecePosition(BaseModel):
25
+ """Model for chess piece position"""
26
+ square: str = Field(..., description="Chess square notation (e.g., 'e4', 'a1')")
27
+ piece: str = Field(..., description="Piece type and color (e.g., 'white_king', 'black_queen')")
28
+
29
+
30
+ class ChessBoardAnalysis(BaseModel):
31
+ """Model for complete chess board analysis"""
32
+ positions: List[ChessPiecePosition] = Field(..., description="List of all piece positions on the board")
33
+
34
+ def add_positions(self, positions: List[ChessPiecePosition]) -> None:
35
+ """Add multiple positions to the analysis"""
36
+ for position in positions:
37
+ self.positions.append(position)
38
+
39
+ def merge_with(self, other: 'ChessBoardAnalysis') -> None:
40
+ """Merge another analysis into this one (overwriting conflicts)"""
41
+ self.add_positions(other.positions)
42
+
43
+ def to_fen(self, active_color) -> str:
44
+ """Convert the analysis to FEN notation (simplified)"""
45
+ # Create an 8x8 board representation
46
+ board = [['' for _ in range(8)] for _ in range(8)]
47
+
48
+ for position in self.positions:
49
+ file_idx = ord(position.square[0]) - ord('a')
50
+ rank_idx = 8 - int(position.square[1])
51
+
52
+ if 0 <= file_idx < 8 and 0 <= rank_idx < 8:
53
+ piece_char = self._piece_to_char(position.piece)
54
+ board[rank_idx][file_idx] = piece_char
55
+
56
+ # Convert to FEN string
57
+ fen_rows = []
58
+ for row in board:
59
+ fen_row = ''
60
+ empty_count = 0
61
+
62
+ for cell in row:
63
+ if cell == '':
64
+ empty_count += 1
65
+ else:
66
+ if empty_count > 0:
67
+ fen_row += str(empty_count)
68
+ empty_count = 0
69
+ fen_row += cell
70
+
71
+ if empty_count > 0:
72
+ fen_row += str(empty_count)
73
+
74
+ fen_rows.append(fen_row)
75
+
76
+ piece_placement = '/'.join(fen_rows)
77
+ # Determine active color
78
+ active_color_char = 'w' if active_color.lower() == 'white' else 'b'
79
+ # Build complete FEN string
80
+ castling_rights = "-"
81
+ en_passant = "-"
82
+ halfmove_clock = 0
83
+ fullmove_number = 1
84
+ fen_parts = [
85
+ piece_placement,
86
+ active_color_char,
87
+ castling_rights,
88
+ en_passant,
89
+ str(halfmove_clock),
90
+ str(fullmove_number)
91
+ ]
92
+
93
+ return ' '.join(fen_parts)
94
+
95
+ def _piece_to_char(self, piece: str) -> str:
96
+ """Convert piece description to FEN character"""
97
+ color, piece_type = piece.split('_')
98
+ piece_chars = {
99
+ 'king': 'K', 'queen': 'Q', 'rook': 'R',
100
+ 'bishop': 'B', 'knight': 'N', 'pawn': 'P'
101
+ }
102
+ char = piece_chars.get(piece_type, '')
103
+ return char.lower() if color == 'black' else char
104
+
105
+
106
+ class ChessVisionAnalyzer:
107
+ def __init__(self):
108
+ self.llm1 = init_chat_model(model="openai:gpt-4.1", temperature=0.0)
109
+ self.llm2 = ChatGoogleGenerativeAI(model="gemini-2.5-flash")
110
+
111
+ def analyze_board_orientation(self, active_color: str, image_path: str) -> str:
112
+ """Analyze chess board image and return FEN notation"""
113
+ base64_image = encode_image_to_base64(image_path)
114
+
115
+ messages = [
116
+ SystemMessage(
117
+ content=prompt_mgmt.render_template("chess_board_orientation", {})),
118
+ HumanMessage(content=[
119
+ {
120
+ "type": "text",
121
+ "text": "Analyze this chess board image and return the chess board orientation. "
122
+
123
+ },
124
+ {
125
+ "type": "image_url",
126
+ "image_url": {
127
+ "url": f"data:image/jpeg;base64,{base64_image}", "detail": "high"
128
+ }
129
+
130
+ }
131
+ ])
132
+ ]
133
+
134
+ response = self.llm1.invoke(messages)
135
+ return response.content
136
+
137
+ def analyze_board_from_image(self, active_color: str, image_path: str, llm_no: int,
138
+ squares: Optional[list] = None) -> Optional[ChessBoardAnalysis]:
139
+ """Analyze chess board image and return FEN notation"""
140
+ base64_image = encode_image_to_base64(image_path)
141
+
142
+ squares_text = ""
143
+ if squares:
144
+ squares_text = (f"Focus only on these squares {sorted(squares)} "
145
+ f"*** Important: make sure you detect correctly the squares. Take into account the board "
146
+ f"orientation."
147
+ f"If a square is empty do not return that square.")
148
+
149
+ messages = [
150
+ SystemMessage(
151
+ content=prompt_mgmt.render_template("chess_board_detection", {})),
152
+ HumanMessage(content=[
153
+ {
154
+ "type": "text",
155
+ "text": f"""Analyze this chess board image and return the pieces positions.
156
+ The chess board orientation is from **Black's perspective**.
157
+ - The files are labeled from **h to a** (left to right).
158
+ - The ranks are labeled from **8 to 1** (bottom to top).
159
+ This matches the standard orientation for Black's perspective.
160
+ {squares_text}
161
+ Return the positions of all pieces in JSON format.
162
+ Use the following schema for each piece:
163
+ [{{
164
+ "square": "chess notation (e.g., 'e4', 'a1')",
165
+ "piece": "color_piece (e.g., 'white_king', 'black_queen')"
166
+ }},...
167
+
168
+ {{
169
+ "square": "chess notation (e.g., 'e4', 'a1')",
170
+ "piece": "color_piece (e.g., 'white_king', 'black_queen')"
171
+ }}
172
+ ]
173
+ Return only this list.
174
+ """
175
+
176
+ },
177
+ {
178
+ "type": "image_url",
179
+ "image_url": {
180
+ "url": f"data:image/jpeg;base64,{base64_image}", "detail": "high"
181
+ }
182
+
183
+ }
184
+ ])
185
+ ]
186
+ if llm_no == 1:
187
+ response = self.llm1.invoke(messages)
188
+ else:
189
+ response = self.llm2.invoke(messages)
190
+ return self._parse_llm_response(response.content)
191
+
192
+ def analyze_board(self, active_color: str, file_reference: str) -> str:
193
+ first_analysis_res = self.analyze_board_from_image(active_color, file_reference, 1)
194
+ second_analysis_res = self.analyze_board_from_image(active_color, file_reference, 2)
195
+
196
+ result = self.compare_analyses(first_analysis_res, second_analysis_res)
197
+ if result['conflicts'] is not None and len(result['conflicts']) > 0:
198
+ arbitrage_result = self.arbitrate_conflicts(result, active_color, file_reference, 3)
199
+
200
+ # todo: if there are still conflicts let one of the llms win
201
+ return arbitrage_result.get("consensus").to_fen(active_color)
202
+ else:
203
+ result.get("consensus").to_fen(active_color)
204
+
205
+ def _parse_llm_response(self, response: str) -> Optional[ChessBoardAnalysis]:
206
+ """Parse LLM response into ChessBoardAnalysis"""
207
+ try:
208
+ # Extract JSON from response
209
+ json_str = response.strip()
210
+ if "```json" in json_str:
211
+ json_str = json_str.split("```json")[1].split("```")[0].strip()
212
+ elif "```" in json_str:
213
+ json_str = json_str.split("```")[1].split("```")[0].strip()
214
+
215
+ data = json.loads(json_str)
216
+ print(data)
217
+ # Filter out items with null or None positions
218
+ positions = []
219
+ for item in data:
220
+ if item["piece"]:
221
+ positions.append(ChessPiecePosition(**item))
222
+
223
+ return ChessBoardAnalysis(positions=positions)
224
+ except Exception as e:
225
+ print(f"Failed to parse LLM response: {e}")
226
+ return None
227
+
228
+ def compare_analyses(self, analysis_1: ChessBoardAnalysis, analysis_2: ChessBoardAnalysis) -> dict:
229
+ """Compare two analyses and identify conflicts"""
230
+ print("Comparing analyses")
231
+
232
+ if not analysis_1 or not analysis_2:
233
+ return {"conflicts": [], "consensus": None, "need_arbitration": False}
234
+
235
+ # Convert to dictionaries for easier comparison
236
+ dict_1 = {pos.square: pos.piece for pos in analysis_1.positions}
237
+ dict_2 = {pos.square: pos.piece for pos in analysis_2.positions}
238
+
239
+ conflicts = []
240
+ consensus = []
241
+
242
+ # Check all squares
243
+ all_squares = set(dict_1.keys()) | set(dict_2.keys())
244
+
245
+ for square in all_squares:
246
+ piece_1 = dict_1.get(square)
247
+ piece_2 = dict_2.get(square)
248
+
249
+ if piece_1 == piece_2:
250
+ if piece_1: # Only add if there's actually a piece
251
+ consensus.append(ChessPiecePosition(square=square, piece=piece_1))
252
+ else:
253
+ conflicts.append({
254
+ "square": square,
255
+ "analysis_1": piece_1,
256
+ "analysis_2": piece_2
257
+ })
258
+
259
+ need_arbitration = len(conflicts) > 0
260
+
261
+ return {
262
+ "conflicts": conflicts,
263
+ "consensus": ChessBoardAnalysis(positions=consensus),
264
+ "need_arbitration": need_arbitration
265
+ }
266
+
267
+ def arbitrate_conflicts(self, state: dict, active_color: str, image_path: str, depth: int = 1) -> dict:
268
+ """Arbitrate conflicting piece positions"""
269
+ print(f"Arbitrating conflicts with depth {depth}")
270
+
271
+ conflicts = state.get("conflicts", [])
272
+ conflicts_sqares = []
273
+ for conflict in conflicts:
274
+ conflicts_sqares.append(conflict["square"])
275
+
276
+ print("Squares with conflicts:", conflicts_sqares)
277
+
278
+
279
+ first_analysis_res = self.analyze_board_from_image(active_color, image_path, 1, conflicts_sqares)
280
+ second_analysis_res = self.analyze_board_from_image(active_color, image_path, 2, conflicts_sqares)
281
+ result = self.compare_analyses(first_analysis_res, second_analysis_res)
282
+ result.get("consensus").merge_with(state.get("consensus"))
283
+ if result['conflicts'] is not None and len(result['conflicts']) > 0 and depth > 0:
284
+ depth -= 1
285
+ result = self.arbitrate_conflicts(result, active_color, image_path, depth)
286
+ return result
287
+
288
+
289
+ class ChessEngineAnalyzer:
290
+ def __init__(self, stockfish_path: str = "stockfish"):
291
+ self.engine = chess.engine.SimpleEngine.popen_uci(stockfish_path)
292
+
293
+ def analyze_position(self, fen: str, depth: int = 18) -> Dict[str, Any]:
294
+ """Analyze chess position using Stockfish"""
295
+ board = chess.Board(fen)
296
+
297
+ # Get top moves analysis
298
+ info = self.engine.analyse(board, chess.engine.Limit(depth=depth))
299
+
300
+ best_move = info.get("pv", [])[0] if info.get("pv") else None
301
+ evaluation = info.get("score", chess.engine.PovScore(chess.engine.Cp(0), chess.WHITE))
302
+
303
+ return {
304
+ "best_move": best_move.uci() if best_move else None,
305
+ "evaluation": str(evaluation),
306
+ "depth": depth,
307
+ "analysis": info
308
+ }
309
+
310
+ def close(self):
311
+ self.engine.quit()
312
+
313
+
314
+ class ChessMoveExplainer:
315
+ def __init__(self):
316
+ self.llm = ChatOpenAI(
317
+ model="gpt-4"
318
+ )
319
+
320
+ def explain_move(self, fen: str, move: str, analysis: Dict) -> str:
321
+ """Generate human-readable explanation of the recommended move"""
322
+ board = chess.Board(fen)
323
+ san_move = board.san(chess.Move.from_uci(move))
324
+
325
+ prompt = f"""
326
+ Chess position FEN: {fen}
327
+ Recommended move: {san_move} ({move})
328
+ Engine evaluation: {analysis['evaluation']}
329
+ Analysis depth: {analysis['depth']}
330
+
331
+ Explain this move recommendation in simple terms. Consider:
332
+ 1. Why this move is strong
333
+ 2. What threats it creates or prevents
334
+ 3. The strategic implications
335
+ 4. Alternative moves and why they're inferior
336
+ 5. Keep it concise but informative for an intermediate player
337
+ """
338
+
339
+ response = self.llm([HumanMessage(content=prompt)])
340
+ return response.content
341
+
342
+
343
+ @tool
344
+ def chess_analysis_tool(active_color: str, file_reference: str) -> str:
345
+ """
346
+ Tool for analyzing a chess board images and recommending moves
347
+ :param active_color: The color that should execute the next move
348
+ :param file_reference: the reference of the image to be analyzed
349
+ :return: the recommended move along with an analysis
350
+ """
351
+ vision_analyzer = ChessVisionAnalyzer()
352
+ engine_analyzer = ChessEngineAnalyzer(os.getenv("CHESS_ENGINE_PATH"))
353
+ move_explainer = ChessMoveExplainer()
354
+ fen = vision_analyzer.analyze_board(active_color, file_reference)
355
+
356
+ print(f"Got fen {fen}")
357
+ analysis_result = engine_analyzer.analyze_position(fen)
358
+ print(f"Got analysis reslut {analysis_result}")
359
+ engine_analyzer.close()
360
+ explanation = move_explainer.explain_move(fen, analysis_result["best_move"], analysis_result)
361
+
362
+ return explanation