55A Flask-based web application to visualize AI agent game trajectories
66"""
77
8+ import functools
89import json
910import logging
1011import shutil
12+ import time
1113from concurrent .futures import ThreadPoolExecutor , as_completed
1214from dataclasses import dataclass
1315from pathlib import Path
1820from codeclash .analysis .significance import calculate_p_value
1921from codeclash .tournaments .utils .git_utils import filter_git_diff , split_git_diff_by_files
2022
23+ # Configure logging
24+ logging .basicConfig (
25+ level = logging .INFO ,
26+ format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" ,
27+ datefmt = "%H:%M:%S" ,
28+ )
2129logger = logging .getLogger (__name__ )
2230
2331
32+ def print_timing (func ):
33+ """Decorator to log timing information for functions/routes"""
34+
35+ @functools .wraps (func )
36+ def wrapper (* args , ** kwargs ):
37+ start_time = time .time ()
38+ logger .info (f"Starting { func .__name__ } " )
39+ try :
40+ return func (* args , ** kwargs )
41+ finally :
42+ elapsed = time .time () - start_time
43+ logger .info (f"Completed { func .__name__ } in { elapsed :.2f} s" )
44+
45+ return wrapper
46+
47+
2448class Metadata :
2549 """A wrapper around metadata dictionary with convenient access methods"""
2650
@@ -206,64 +230,38 @@ def _load_game_metadata(folder_info: dict[str, Any]) -> dict[str, Any]:
206230 return folder_info
207231
208232
233+ @print_timing
209234def find_all_game_folders (base_dir : Path ) -> list [dict [str , Any ]]:
210- """Recursively find all folders and mark which ones contain metadata.json
235+ """Find all game folders by locating metadata.json files
211236
212237 Uses parallel loading to speed up metadata.json reading for many game folders.
213238 """
214- all_folders = []
215- game_folders = set () # Track which folders are actual game folders
216- game_folder_infos = [] # Folders that need metadata loaded
217-
218- def scan_directory (directory : Path , relative_path : str = "" ):
219- if not directory .exists () or not directory .is_dir ():
220- return
221-
222- try :
223- for item in directory .iterdir ():
224- if item .is_dir ():
225- current_relative = relative_path + "/" + item .name if relative_path else item .name
226- depth = current_relative .count ("/" )
227-
228- # Check if this directory is a game folder
229- if is_game_folder (item ):
230- game_folders .add (current_relative )
231- # Store minimal info, will load metadata in parallel later
232- folder_info = {
233- "name" : current_relative ,
234- "full_path" : str (item ),
235- "is_game" : True ,
236- "depth" : depth ,
237- "parent" : relative_path if relative_path else None ,
238- }
239- all_folders .append (folder_info )
240- game_folder_infos .append (folder_info )
241- else :
242- # Add as intermediate folder
243- all_folders .append (
244- {
245- "name" : current_relative ,
246- "full_path" : str (item ),
247- "round_info" : None ,
248- "models" : [],
249- "game_name" : "" ,
250- "created_timestamp" : None ,
251- "is_game" : False ,
252- "depth" : depth ,
253- "parent" : relative_path if relative_path else None ,
254- }
255- )
256-
257- # Recursively scan subdirectories
258- scan_directory (item , current_relative )
259- except (PermissionError , OSError ):
260- # Skip directories we can't access
261- pass
262-
263- # First pass: scan directory structure
264- scan_directory (base_dir )
239+ if not base_dir .exists ():
240+ return []
241+
242+ # Find all metadata.json files directly
243+ metadata_files = list (base_dir .rglob ("metadata.json" ))
244+
245+ # Create folder info for each game folder
246+ game_folder_infos = []
247+ game_folder_paths = set ()
248+
249+ for metadata_file in metadata_files :
250+ game_dir = metadata_file .parent
251+ relative_path = str (game_dir .relative_to (base_dir ))
252+ game_folder_paths .add (relative_path )
253+ depth = relative_path .count ("/" )
254+
255+ folder_info = {
256+ "name" : relative_path ,
257+ "full_path" : str (game_dir ),
258+ "is_game" : True ,
259+ "depth" : depth ,
260+ "parent" : str (game_dir .parent .relative_to (base_dir )) if game_dir .parent != base_dir else None ,
261+ }
262+ game_folder_infos .append (folder_info )
265263
266- # Second pass: load metadata.json files in parallel
264+ # Load metadata.json files in parallel
267265 if game_folder_infos :
268266 with ThreadPoolExecutor (max_workers = min (32 , len (game_folder_infos ))) as executor :
269267 futures = {
@@ -281,20 +279,38 @@ def scan_directory(directory: Path, relative_path: str = ""):
281279 folder_info ["created_timestamp" ] = None
282280 logger .warning (f"Failed to load metadata for { folder_info ['name' ]} : { e } " )
283281
284- # Filter out intermediate folders that don't lead to any game folders
285- filtered_folders = []
286- for folder in sorted (all_folders , key = lambda x : x ["name" ]):
287- if folder ["is_game" ]:
288- # Always include game folders
289- filtered_folders .append (folder )
290- else :
291- # Include intermediate folders only if they have game folders as descendants
292- folder_path = folder ["name" ]
293- has_game_descendants = any (game_path .startswith (folder_path + "/" ) for game_path in game_folders )
294- if has_game_descendants :
295- filtered_folders .append (folder )
282+ # Create intermediate folder entries for all parent directories
283+ intermediate_folders = set ()
284+ for game_path in game_folder_paths :
285+ # Extract all parent paths
286+ parts = game_path .split ("/" )
287+ for i in range (1 , len (parts )):
288+ parent_path = "/" .join (parts [:i ])
289+ if parent_path not in game_folder_paths : # Only add if not a game folder itself
290+ intermediate_folders .add (parent_path )
291+
292+ # Add intermediate folders to the result
293+ all_folders = game_folder_infos .copy ()
294+ for intermediate_path in intermediate_folders :
295+ depth = intermediate_path .count ("/" )
296+ parent_parts = intermediate_path .split ("/" )
297+ parent_path = "/" .join (parent_parts [:- 1 ]) if len (parent_parts ) > 1 else None
298+
299+ all_folders .append (
300+ {
301+ "name" : intermediate_path ,
302+ "full_path" : str (base_dir / intermediate_path ),
303+ "is_game" : False ,
304+ "depth" : depth ,
305+ "parent" : parent_path ,
306+ "round_info" : None ,
307+ "models" : [],
308+ "game_name" : "" ,
309+ "created_timestamp" : None ,
310+ }
311+ )
296312
297- return filtered_folders
313+ return sorted ( all_folders , key = lambda x : x [ "name" ])
298314
299315
300316@dataclass
@@ -820,6 +836,7 @@ def get_navigation_info(selected_folder: str) -> dict[str, str | None]:
820836 return {"previous" : previous_game , "next" : next_game }
821837
822838
839+ @print_timing
823840def render_game_viewer (folder_path : Path , selected_folder : str ) -> str :
824841 """Common logic for rendering game viewer pages"""
825842 # Parse the selected game
@@ -865,6 +882,7 @@ def render_game_viewer(folder_path: Path, selected_folder: str) -> str:
865882
866883
867884@app .route ("/" )
885+ @print_timing
868886def index ():
869887 """Main viewer page - now redirects to picker if no folder is selected"""
870888 selected_folder = request .args .get ("folder" )
@@ -882,6 +900,7 @@ def index():
882900
883901
884902@app .route ("/game/<path:folder_path>" )
903+ @print_timing
885904def game_view (folder_path ):
886905 """Static-friendly game viewer route using path parameters"""
887906 # Validate the selected folder exists and is a game folder
@@ -894,6 +913,7 @@ def game_view(folder_path):
894913
895914
896915@app .route ("/picker" )
916+ @print_timing
897917def game_picker ():
898918 """Game picker page with recursive folder support"""
899919 logs_dir = LOG_BASE_DIR
@@ -1186,6 +1206,7 @@ def move_folder():
11861206
11871207
11881208@app .route ("/analysis/line-counts" )
1209+ @print_timing
11891210def analysis_line_counts ():
11901211 """Get line count analysis data for the current game"""
11911212 selected_folder = request .args .get ("folder" )
@@ -1277,6 +1298,7 @@ def load_log():
12771298
12781299
12791300@app .route ("/load-trajectory-diffs" )
1301+ @print_timing
12801302def load_trajectory_diffs ():
12811303 """Load trajectory diff data on demand"""
12821304 selected_folder = request .args .get ("folder" )
0 commit comments