Blablablab/audio-classification
0
1#!/usr/bin/env python32"""3Potato Preview CLI4 5A command-line tool for previewing annotation task configurations.6Helps administrators validate configs and see how schemas will render7without running the full server.8 9Usage:10 potato preview config.yaml # Summary output (default)11 potato preview config.yaml --format html # HTML output12 potato preview config.yaml --format json # JSON output13 14 # Or run as module:15 python -m potato.preview_cli config.yaml16"""17 18import argparse19import json20import os21import sys22import yaml23import logging24from typing import Dict, Any, List, Tuple, Optional25 26# Set up logging27logging.basicConfig(level=logging.WARNING)28logger = logging.getLogger(__name__)29 30 31def load_config(config_path: str) -> Dict[str, Any]:32 """33 Load and parse a YAML configuration file.34 35 Args:36 config_path: Path to the configuration file37 38 Returns:39 Parsed configuration dictionary40 41 Raises:42 FileNotFoundError: If config file doesn't exist43 yaml.YAMLError: If config is invalid YAML44 """45 if not os.path.exists(config_path):46 raise FileNotFoundError(f"Configuration file not found: {config_path}")47 48 with open(config_path, 'r', encoding='utf-8') as f:49 config = yaml.safe_load(f)50 51 if not isinstance(config, dict):52 raise ValueError("Configuration must be a YAML object (dictionary)")53 54 return config55 56 57def validate_config(config: Dict[str, Any]) -> List[str]:58 """59 Validate configuration and return list of issues.60 61 Args:62 config: Configuration dictionary63 64 Returns:65 List of validation error/warning messages66 """67 issues = []68 69 # Required fields70 required = ['annotation_task_name', 'item_properties', 'task_dir', 'output_annotation_dir']71 for field in required:72 if field not in config:73 issues.append(f"ERROR: Missing required field '{field}'")74 75 # Data source validation76 has_data_files = config.get('data_files') and len(config.get('data_files', [])) > 077 has_data_directory = bool(config.get('data_directory'))78 if not has_data_files and not has_data_directory:79 issues.append("ERROR: Must have either 'data_files' or 'data_directory'")80 81 # Annotation schemes validation82 has_schemes = 'annotation_schemes' in config83 has_phases = 'phases' in config and config['phases']84 85 if not has_schemes and not has_phases:86 issues.append("ERROR: Must have either 'annotation_schemes' or 'phases'")87 88 if has_schemes and has_phases:89 # Check for potential conflict90 if isinstance(config['phases'], list):91 phases_with_schemes = [p.get('name', f'phase[{i}]')92 for i, p in enumerate(config['phases'])93 if 'annotation_schemes' in p]94 else:95 phases_with_schemes = [name for name, p in config['phases'].items()96 if name != 'order' and isinstance(p, dict) and 'annotation_schemes' in p]97 98 if phases_with_schemes:99 issues.append(f"ERROR: Both top-level and phase-level annotation_schemes found in: {', '.join(phases_with_schemes)}")100 101 return issues102 103 104def get_annotation_schemes(config: Dict[str, Any]) -> List[Dict[str, Any]]:105 """106 Extract all annotation schemes from config.107 108 Args:109 config: Configuration dictionary110 111 Returns:112 List of annotation scheme dictionaries113 """114 schemes = []115 116 if 'annotation_schemes' in config:117 schemes.extend(config['annotation_schemes'])118 119 if 'phases' in config and config['phases']:120 phases = config['phases']121 if isinstance(phases, list):122 for phase in phases:123 if 'annotation_schemes' in phase:124 schemes.extend(phase['annotation_schemes'])125 else:126 for name, phase in phases.items():127 if name != 'order' and isinstance(phase, dict) and 'annotation_schemes' in phase:128 schemes.extend(phase['annotation_schemes'])129 130 return schemes131 132 133def detect_keybinding_conflicts(schemes: List[Dict[str, Any]]) -> List[str]:134 """135 Detect keyboard shortcut conflicts across all schemes.136 137 Args:138 schemes: List of annotation scheme dictionaries139 140 Returns:141 List of conflict warning messages142 """143 conflicts = []144 global_keys = {} # key -> (schema_name, label)145 146 for scheme in schemes:147 schema_name = scheme.get('name', 'unknown')148 labels = scheme.get('labels', [])149 150 for i, label_data in enumerate(labels):151 key_value = None152 153 # Check for explicit key_value154 if isinstance(label_data, dict) and 'key_value' in label_data:155 key_value = str(label_data['key_value'])156 label_name = label_data.get('name', f'label[{i}]')157 elif scheme.get('sequential_key_binding') and len(labels) <= 10:158 key_value = str((i + 1) % 10)159 label_name = label_data if isinstance(label_data, str) else label_data.get('name', f'label[{i}]')160 else:161 continue162 163 if key_value:164 key_id = f"{key_value}"165 if key_id in global_keys:166 prev_schema, prev_label = global_keys[key_id]167 if prev_schema != schema_name: # Only warn for cross-schema conflicts168 conflicts.append(169 f"WARNING: Key '{key_value}' used by both "170 f"'{prev_schema}:{prev_label}' and '{schema_name}:{label_name}'"171 )172 else:173 global_keys[key_id] = (schema_name, label_name)174 175 return conflicts176 177 178def generate_preview_html(schemes: List[Dict[str, Any]]) -> str:179 """180 Generate HTML preview for annotation schemes.181 182 Args:183 schemes: List of annotation scheme dictionaries184 185 Returns:186 HTML string with rendered schemes187 """188 from potato.server_utils.schemas.registry import schema_registry189 190 html_parts = []191 all_keybindings = []192 193 html_parts.append("""194<!DOCTYPE html>195<html>196<head>197 <title>Annotation Preview</title>198 <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">199 <style>200 body { padding: 20px; font-family: system-ui, sans-serif; }201 .scheme-preview { border: 1px solid #ddd; padding: 20px; margin: 10px 0; border-radius: 8px; }202 .scheme-title { color: #333; margin-bottom: 10px; }203 .scheme-type { color: #666; font-size: 0.9em; }204 .error { color: #dc3545; background: #f8d7da; padding: 10px; border-radius: 4px; }205 </style>206</head>207<body>208<div class="container">209<h1>Annotation Preview</h1>210""")211 212 for idx, scheme in enumerate(schemes):213 scheme_name = scheme.get('name', 'unknown')214 scheme_type = scheme.get('annotation_type', 'unknown')215 216 html_parts.append(f"""217<div class="scheme-preview">218 <h3 class="scheme-title">{scheme_name}</h3>219 <p class="scheme-type">Type: {scheme_type}</p>220 <div class="scheme-content">221""")222 223 try:224 # Set annotation_id before generating (required by schema generators)225 scheme["annotation_id"] = idx226 html, keybindings = schema_registry.generate(scheme)227 html_parts.append(html)228 all_keybindings.extend(keybindings)229 except Exception as e:230 html_parts.append(f'<div class="error">Error generating preview: {str(e)}</div>')231 232 html_parts.append("</div></div>")233 234 # Add keybindings summary235 if all_keybindings:236 html_parts.append("<h2>Keyboard Shortcuts</h2><table class='table'><thead><tr><th>Key</th><th>Action</th></tr></thead><tbody>")237 for key, action in all_keybindings:238 html_parts.append(f"<tr><td><kbd>{key}</kbd></td><td>{action}</td></tr>")239 html_parts.append("</tbody></table>")240 241 html_parts.append("</div></body></html>")242 243 return "\n".join(html_parts)244 245 246def generate_preview_json(config: Dict[str, Any], schemes: List[Dict[str, Any]], issues: List[str]) -> str:247 """248 Generate JSON preview output.249 250 Args:251 config: Full configuration dictionary252 schemes: List of annotation schemes253 issues: List of validation issues254 255 Returns:256 JSON string with preview data257 """258 from potato.server_utils.schemas.registry import schema_registry259 260 result = {261 "task_name": config.get('annotation_task_name', 'Unknown'),262 "validation_issues": issues,263 "schema_count": len(schemes),264 "schemas": []265 }266 267 for idx, scheme in enumerate(schemes):268 schema_info = {269 "name": scheme.get('name'),270 "type": scheme.get('annotation_type'),271 "description": scheme.get('description'),272 "labels": None,273 "keybindings": [],274 "error": None275 }276 277 # Extract labels278 if 'labels' in scheme:279 labels = scheme['labels']280 schema_info['labels'] = [281 l if isinstance(l, str) else l.get('name', str(l))282 for l in labels283 ]284 285 # Try to generate and get keybindings286 try:287 # Set annotation_id before generating (required by schema generators)288 scheme["annotation_id"] = idx289 _, keybindings = schema_registry.generate(scheme)290 schema_info['keybindings'] = [{"key": k, "action": a} for k, a in keybindings]291 except Exception as e:292 schema_info['error'] = str(e)293 294 result['schemas'].append(schema_info)295 296 return json.dumps(result, indent=2)297 298 299def generate_preview_summary(config: Dict[str, Any], schemes: List[Dict[str, Any]],300 issues: List[str], conflicts: List[str]) -> str:301 """302 Generate text summary preview.303 304 Args:305 config: Full configuration dictionary306 schemes: List of annotation schemes307 issues: List of validation issues308 conflicts: List of keybinding conflicts309 310 Returns:311 Text summary string312 """313 lines = []314 lines.append("=" * 60)315 lines.append(f"ANNOTATION TASK PREVIEW")316 lines.append("=" * 60)317 lines.append(f"Task Name: {config.get('annotation_task_name', 'Unknown')}")318 lines.append(f"Task Directory: {config.get('task_dir', 'Not set')}")319 lines.append("")320 321 # Validation issues322 if issues:323 lines.append("VALIDATION ISSUES:")324 for issue in issues:325 lines.append(f" {issue}")326 lines.append("")327 else:328 lines.append("Validation: PASSED")329 lines.append("")330 331 # Keybinding conflicts332 if conflicts:333 lines.append("KEYBINDING CONFLICTS:")334 for conflict in conflicts:335 lines.append(f" {conflict}")336 lines.append("")337 338 # Schema summary339 lines.append(f"ANNOTATION SCHEMAS ({len(schemes)} total):")340 lines.append("-" * 40)341 342 from potato.server_utils.schemas.registry import schema_registry343 344 for idx, scheme in enumerate(schemes):345 name = scheme.get('name', 'unknown')346 ann_type = scheme.get('annotation_type', 'unknown')347 desc = scheme.get('description', '')[:50]348 349 lines.append(f" [{ann_type}] {name}")350 if desc:351 lines.append(f" {desc}...")352 353 # Count labels if present354 if 'labels' in scheme:355 label_count = len(scheme['labels'])356 lines.append(f" Labels: {label_count}")357 358 # Try to get keybindings359 try:360 # Set annotation_id before generating (required by schema generators)361 scheme["annotation_id"] = idx362 _, keybindings = schema_registry.generate(scheme)363 if keybindings:364 lines.append(f" Keybindings: {len(keybindings)}")365 except Exception as e:366 lines.append(f" ERROR: {str(e)}")367 368 lines.append("")369 370 lines.append("=" * 60)371 return "\n".join(lines)372 373 374def generate_layout_html(schemes: List[Dict[str, Any]]) -> str:375 """376 Generate just the task layout HTML snippet (no wrapper page).377 378 This outputs the HTML that would go inside {{ TASK_LAYOUT }} in the379 annotation template, allowing admins to prototype and debug their380 task layout without running the full server.381 382 Args:383 schemes: List of annotation scheme dictionaries384 385 Returns:386 HTML string with the annotation schema div and all schema forms387 """388 from potato.server_utils.schemas.registry import schema_registry389 390 html_parts = []391 html_parts.append('<div class="annotation_schema">')392 393 for idx, scheme in enumerate(schemes):394 # Set annotation_id before generating (required by schema generators)395 scheme["annotation_id"] = idx396 try:397 html, _ = schema_registry.generate(scheme)398 html_parts.append(html)399 except Exception as e:400 schema_name = scheme.get('name', 'unknown')401 html_parts.append(f'<!-- Error generating {schema_name}: {e} -->')402 403 html_parts.append('</div>')404 return "\n".join(html_parts)405 406 407def main():408 """Main entry point for preview CLI."""409 parser = argparse.ArgumentParser(410 description="Preview annotation task configuration",411 formatter_class=argparse.RawDescriptionHelpFormatter,412 epilog="""413Examples:414 python -m potato.preview_cli config.yaml # Summary output415 python -m potato.preview_cli config.yaml --format html # Full HTML page preview416 python -m potato.preview_cli config.yaml --format json # JSON output417 python -m potato.preview_cli config.yaml --layout-only # Just the task layout HTML snippet418 419 # Save HTML to file:420 python -m potato.preview_cli config.yaml --format html > preview.html421 422 # Get just the annotation schema div for embedding:423 python -m potato.preview_cli config.yaml --layout-only > task_layout.html424"""425 )426 427 parser.add_argument(428 'config_file',429 help='Path to YAML configuration file'430 )431 parser.add_argument(432 '--format', '-f',433 choices=['summary', 'html', 'json'],434 default='summary',435 help='Output format (default: summary)'436 )437 parser.add_argument(438 '--layout-only', '-l',439 action='store_true',440 help='Output only the task layout HTML snippet (no wrapper page). This is the HTML that goes inside {{ TASK_LAYOUT }}.'441 )442 parser.add_argument(443 '--verbose', '-v',444 action='store_true',445 help='Enable verbose output'446 )447 448 args = parser.parse_args()449 450 if args.verbose:451 logging.getLogger().setLevel(logging.DEBUG)452 453 try:454 # Load configuration455 config = load_config(args.config_file)456 457 # Validate458 issues = validate_config(config)459 460 # Get schemes461 schemes = get_annotation_schemes(config)462 if not schemes:463 print("WARNING: No annotation schemes found in configuration", file=sys.stderr)464 465 # Detect conflicts466 conflicts = detect_keybinding_conflicts(schemes)467 468 # Generate output469 if args.layout_only:470 # Output just the task layout HTML snippet471 print(generate_layout_html(schemes))472 elif args.format == 'html':473 print(generate_preview_html(schemes))474 elif args.format == 'json':475 print(generate_preview_json(config, schemes, issues))476 else: # summary477 print(generate_preview_summary(config, schemes, issues, conflicts))478 479 # Exit with error code if there are issues (skip for layout-only mode)480 if not args.layout_only:481 error_count = len([i for i in issues if i.startswith('ERROR')])482 sys.exit(1 if error_count > 0 else 0)483 484 except FileNotFoundError as e:485 print(f"Error: {e}", file=sys.stderr)486 sys.exit(1)487 except yaml.YAMLError as e:488 print(f"Error: Invalid YAML in configuration file: {e}", file=sys.stderr)489 sys.exit(1)490 except Exception as e:491 print(f"Error: {e}", file=sys.stderr)492 if args.verbose:493 import traceback494 traceback.print_exc()495 sys.exit(1)496 497 498if __name__ == '__main__':499 main()500 