//! Extractor command handlers (stats, candidates, review, promote, auto-promote, versioning) use std::process::ExitCode; use aphoria::{ learning::learning_store_dir, promotion::{compute_metrics_delta, ChangelogEntry, VersionStore}, AphoriaConfig, LocalPatternStore, ShadowStore, }; use crate::cli::ExtractorCommands; use super::utils::truncate_for_display; pub async fn handle_extractor_command( command: ExtractorCommands, config: &AphoriaConfig, ) -> ExitCode { // Open pattern store let store_dir = learning_store_dir(); let store = match LocalPatternStore::new(&store_dir) { Ok(s) => s, Err(e) => { eprintln!("Failed to open pattern store: {e}"); return ExitCode::from(3); } }; match command { ExtractorCommands::Stats => handle_extractor_stats(&store, config), ExtractorCommands::Candidates { verbose } => { handle_extractor_candidates(&store, config, verbose) } ExtractorCommands::Review { limit, auto } => { handle_extractor_review(&store, config, limit, auto).await } ExtractorCommands::Promote { pattern_id, force } => { handle_extractor_promote(&store, config, &pattern_id, force).await } ExtractorCommands::AutoPromote { dry_run, min_confidence, min_projects } => { handle_auto_promote(&store, config, dry_run, min_confidence, min_projects).await } ExtractorCommands::ShadowStatus { verbose } => { super::shadow::handle_shadow_status(config, verbose) } ExtractorCommands::Feedback { test, limit } => { super::shadow::handle_shadow_feedback(config, &test, limit) } ExtractorCommands::Graduate { test, force } => { super::shadow::handle_shadow_graduate(config, &test, force) } ExtractorCommands::Rollback { test, reason } => { super::shadow::handle_shadow_rollback(config, &test, &reason) } ExtractorCommands::AutoCheck => super::shadow::handle_shadow_auto_check(config), ExtractorCommands::Versions { name } => handle_versions(&name, config), ExtractorCommands::Compare { name, version_a, version_b } => { handle_compare(&name, version_a, version_b, config) } ExtractorCommands::RollbackVersion { name, version, reason } => { handle_rollback_version(&name, version, &reason, config) } } } fn handle_extractor_stats(store: &LocalPatternStore, config: &AphoriaConfig) -> ExitCode { use aphoria::PromotionPipeline; let pipeline = match PromotionPipeline::new(store, None, &config.learning.promotion, None) { Ok(p) => p, Err(e) => { eprintln!("Failed to create pipeline: {e}"); return ExitCode::from(3); } }; let stats = pipeline.stats(); println!("Pattern Learning Statistics"); println!("==========================="); println!(); println!("Total patterns: {}", stats.total_patterns); println!("Eligible for promotion: {}", stats.eligible_patterns); println!("Already promoted: {}", stats.promoted_patterns); println!("Pending review: {}", stats.pending_review); println!(); if stats.eligible_patterns > 0 { println!("Eligible pattern averages:"); println!(" Confidence: {:.2}", stats.avg_confidence); println!(" Projects: {:.1}", stats.avg_projects); } println!(); println!("Promotion thresholds (from config):"); println!(" Min projects: {}", config.learning.promotion.min_projects); println!(" Min confidence: {:.2}", config.learning.promotion.min_confidence); println!(" Auto-promote: {}", config.learning.promotion.auto_promote); ExitCode::SUCCESS } fn handle_extractor_candidates( store: &LocalPatternStore, config: &AphoriaConfig, verbose: bool, ) -> ExitCode { use aphoria::PromotionPipeline; let pipeline = match PromotionPipeline::new(store, None, &config.learning.promotion, None) { Ok(p) => p, Err(e) => { eprintln!("Failed to create pipeline: {e}"); return ExitCode::from(3); } }; let candidates = pipeline.get_candidates(); if candidates.is_empty() { println!("No patterns eligible for promotion."); println!(); println!("Patterns become eligible when:"); println!(" - Seen in {}+ projects", config.learning.promotion.min_projects); println!(" - Average confidence >= {:.2}", config.learning.promotion.min_confidence); return ExitCode::SUCCESS; } println!("Patterns eligible for promotion ({} total):\n", candidates.len()); println!("{:<36} {:>8} {:>6} Subject", "Pattern ID", "Projects", "Conf"); println!("{}", "-".repeat(70)); for pattern in &candidates { let subject = if pattern.claim_template.subject_template.len() > 25 { format!("{}...", &pattern.claim_template.subject_template[..22]) } else { pattern.claim_template.subject_template.clone() }; println!( "{:<36} {:>8} {:>6.2} {}", pattern.id, pattern.project_count(), pattern.avg_confidence, subject ); if verbose { println!(" Language: {}", pattern.language); println!(" Example: {}", truncate_for_display(&pattern.example_code, 60)); println!(" Normalized: {}", pattern.normalized_pattern); println!(); } } println!(); println!("To promote a pattern, run:"); println!(" aphoria extractors promote "); println!(); println!("For interactive review:"); println!(" aphoria extractors review"); ExitCode::SUCCESS } async fn handle_extractor_review( store: &LocalPatternStore, config: &AphoriaConfig, limit: Option, auto: bool, ) -> ExitCode { use aphoria::{llm::GeminiClient, InteractiveReviewer, PromotionPipeline}; // Create LLM client let client = match GeminiClient::new(&config.llm) { Ok(Some(c)) => c, Ok(None) => { eprintln!("LLM not configured. Cannot generate regex patterns."); eprintln!(); eprintln!("To configure LLM, add this to your aphoria.toml:"); eprintln!(); eprintln!(" [llm]"); eprintln!(" enabled = true"); eprintln!(" api_key_env = \"GEMINI_API_KEY\""); return ExitCode::from(3); } Err(e) => { eprintln!("Failed to create LLM client: {e}"); return ExitCode::from(3); } }; let output_dir = config.learning.promotion.output_dir.clone(); let pipeline = match PromotionPipeline::new( store, Some(&client), &config.learning.promotion, Some(output_dir), ) { Ok(p) => p, Err(e) => { eprintln!("Failed to create pipeline: {e}"); return ExitCode::from(3); } }; let mut reviewer = InteractiveReviewer::new(&pipeline).with_auto_approve(auto); if let Some(n) = limit { reviewer = reviewer.with_limit(n); } match reviewer.run() { Ok(result) => { println!(); println!("Review session complete:"); println!(" Approved: {}", result.approved); println!(" Rejected: {}", result.rejected); println!(" Regenerated: {}", result.regenerated); println!(" Skipped: {}", result.skipped); if !result.promoted_files.is_empty() { println!(); println!("Promoted extractors written to:"); for path in &result.promoted_files { println!(" {}", path.display()); } } if !result.errors.is_empty() { println!(); println!("Errors:"); for err in &result.errors { println!(" - {}", err); } } ExitCode::SUCCESS } Err(e) => { eprintln!("Review error: {e}"); ExitCode::from(3) } } } async fn handle_extractor_promote( store: &LocalPatternStore, config: &AphoriaConfig, pattern_id: &str, _force: bool, ) -> ExitCode { use aphoria::{llm::GeminiClient, PromotionPipeline}; use uuid::Uuid; // Parse pattern ID let id = match Uuid::parse_str(pattern_id) { Ok(id) => id, Err(_) => { eprintln!("Invalid pattern ID format. Expected UUID."); return ExitCode::from(3); } }; // Create LLM client let client = match GeminiClient::new(&config.llm) { Ok(Some(c)) => c, Ok(None) => { eprintln!("LLM not configured. Cannot generate regex patterns."); return ExitCode::from(3); } Err(e) => { eprintln!("Failed to create LLM client: {e}"); return ExitCode::from(3); } }; let output_dir = config.learning.promotion.output_dir.clone(); let pipeline = match PromotionPipeline::new( store, Some(&client), &config.learning.promotion, Some(output_dir), ) { Ok(p) => p, Err(e) => { eprintln!("Failed to create pipeline: {e}"); return ExitCode::from(3); } }; match pipeline.promote_by_id(&id) { Ok(path) => { println!("Pattern promoted successfully!"); println!(" Extractor written to: {}", path.display()); ExitCode::SUCCESS } Err(e) => { eprintln!("Promotion failed: {e}"); ExitCode::from(3) } } } async fn handle_auto_promote( store: &LocalPatternStore, config: &AphoriaConfig, dry_run: bool, min_confidence: Option, min_projects: Option, ) -> ExitCode { use aphoria::{llm::GeminiClient, PromotionPipeline}; // Build autonomous config with overrides let mut auto_config = config.autonomous.clone(); if let Some(conf) = min_confidence { auto_config.min_confidence = conf; } if let Some(proj) = min_projects { auto_config.min_projects = proj; } // For dry run, temporarily enable autonomous mode if dry_run { auto_config.enabled = true; } // Check if autonomous promotion is enabled if !auto_config.enabled && !dry_run { println!("Autonomous promotion is disabled."); println!(); println!("To enable, add this to your aphoria.toml:"); println!(); println!(" [autonomous]"); println!(" enabled = true"); println!(" min_confidence = 0.95"); println!(" min_projects = 10"); return ExitCode::SUCCESS; } // Create LLM client let client = match GeminiClient::new(&config.llm) { Ok(Some(c)) => c, Ok(None) => { eprintln!("LLM not configured. Cannot generate regex patterns."); eprintln!(); eprintln!("To configure LLM, add this to your aphoria.toml:"); eprintln!(); eprintln!(" [llm]"); eprintln!(" enabled = true"); eprintln!(" api_key_env = \"GEMINI_API_KEY\""); return ExitCode::from(3); } Err(e) => { eprintln!("Failed to create LLM client: {e}"); return ExitCode::from(3); } }; let output_dir = config.learning.promotion.output_dir.clone(); let pipeline = match PromotionPipeline::new( store, Some(&client), &config.learning.promotion, Some(output_dir), ) { Ok(p) => p, Err(e) => { eprintln!("Failed to create pipeline: {e}"); return ExitCode::from(3); } }; if dry_run { // Preview mode: check what would be promoted without making changes println!("Autonomous Promotion Preview (dry run)"); println!("======================================"); println!(); println!("Thresholds:"); println!(" Min confidence: {:.2}", auto_config.min_confidence); println!(" Min projects: {}", auto_config.min_projects); println!(" Zero failures: {}", auto_config.require_zero_failures); println!(" Zero warnings: {}", auto_config.require_zero_warnings); println!(); let candidates = pipeline.get_candidates(); if candidates.is_empty() { println!("No patterns eligible for promotion."); return ExitCode::SUCCESS; } let mut would_promote = 0; let mut needs_review = 0; for pattern in &candidates { // Create a mock candidate to check eligibility match pipeline.generate_candidate(pattern) { Ok(candidate) => { if candidate.should_auto_promote(&auto_config) { would_promote += 1; println!( "[WOULD AUTO-PROMOTE] {} (conf: {:.2}, projects: {})", pattern.id, pattern.avg_confidence, pattern.project_count() ); } else { needs_review += 1; let blockers = candidate.auto_promotion_blockers(&auto_config); println!("[NEEDS REVIEW] {} - {}", pattern.id, blockers.join(", ")); } } Err(e) => { println!("[ERROR] {} - {}", pattern.id, e); } } } println!(); println!("Summary:"); println!(" Would auto-promote: {}", would_promote); println!(" Needs review: {}", needs_review); println!(); println!("To run for real, remove --dry-run flag."); } else { // Real mode: actually promote println!("Running Autonomous Promotion"); println!("============================"); println!(); println!("Thresholds:"); println!(" Min confidence: {:.2}", auto_config.min_confidence); println!(" Min projects: {}", auto_config.min_projects); println!(); match pipeline.smart_auto_promote_all(&auto_config) { Ok(result) => { println!("Results:"); println!(" Auto-promoted: {}", result.auto_promoted); println!(" Requires review: {}", result.requires_review); println!(" Errors: {}", result.errors.len()); if !result.promoted_files.is_empty() { println!(); println!("Promoted extractors written to:"); for path in &result.promoted_files { println!(" {}", path.display()); } } if !result.errors.is_empty() { println!(); println!("Errors:"); for err in &result.errors { println!(" - {}", err); } } // Print audit log location let audit_dir = auto_config.get_audit_dir(); println!(); println!("Audit log: {}/autonomous-decisions.jsonl", audit_dir.display()); } Err(e) => { eprintln!("Auto-promotion failed: {e}"); return ExitCode::from(3); } } } ExitCode::SUCCESS } // ============================================================================ // Version Command Handlers // ============================================================================ /// Handle the `extractors versions` command. fn handle_versions(name: &str, config: &AphoriaConfig) -> ExitCode { let extractors_dir = config.learning.promotion.output_dir.clone(); let version_store = match VersionStore::new(&extractors_dir) { Ok(s) => s, Err(e) => { eprintln!("Failed to open version store: {e}"); return ExitCode::from(3); } }; let changelog = match version_store.read_changelog(name) { Ok(c) => c, Err(e) => { eprintln!("Failed to read changelog for {}: {e}", name); return ExitCode::from(3); } }; if changelog.entries.is_empty() { println!("No version history found for '{}'.", name); println!(); println!("Version history is created when extractors are promoted"); println!("using the versioned promotion system."); return ExitCode::SUCCESS; } println!("Version History: {}", name); println!("Current version: {}", changelog.current_version); println!(); println!("{:<8} {:<12} Changes", "Version", "Date"); println!("{}", "-".repeat(60)); // Show entries newest first for entry in changelog.entries.iter().rev() { let changes = if entry.changes.len() > 40 { format!("{}...", &entry.changes[..37]) } else { entry.changes.clone() }; println!("{:<8} {:<12} {}", entry.version, entry.date, changes); if let Some(ref metrics) = entry.metrics { println!( " {:<12} Matches: {}, FP: {}", "", metrics.matches, metrics.false_positives ); } } println!(); println!("To compare versions:"); println!(" aphoria extractors compare {} -a 1 -b 2", name); println!(); println!("To rollback to a previous version:"); println!(" aphoria extractors rollback-version {} --version 1 --reason \"...\"", name); ExitCode::SUCCESS } /// Handle the `extractors compare` command. fn handle_compare(name: &str, version_a: u32, version_b: u32, config: &AphoriaConfig) -> ExitCode { // Open shadow store for metrics let shadow_dir = config.shadow.get_shadow_dir(); let shadow_store = match ShadowStore::new(&shadow_dir) { Ok(s) => s, Err(e) => { eprintln!("Failed to open shadow store: {e}"); return ExitCode::from(3); } }; println!("Comparison: {} v{} vs v{}", name, version_a, version_b); println!(); match compute_metrics_delta(&shadow_store, name, version_a, version_b) { Ok(Some(delta)) => { println!("{:<20} {}", "Matches", delta.matches); println!("{:<20} {}", "False Positives", delta.false_positives); } Ok(None) => { println!("Insufficient metrics data available for comparison."); println!(); println!("Metrics are collected during shadow mode testing."); println!("Ensure the extractor has been through shadow mode with"); println!("sufficient feedback before comparing versions."); } Err(e) => { eprintln!("Failed to compute metrics: {e}"); return ExitCode::from(3); } } ExitCode::SUCCESS } /// Handle the `extractors rollback-version` command. fn handle_rollback_version( name: &str, version: u32, reason: &str, config: &AphoriaConfig, ) -> ExitCode { let extractors_dir = config.learning.promotion.output_dir.clone(); let version_store = match VersionStore::new(&extractors_dir) { Ok(s) => s, Err(e) => { eprintln!("Failed to open version store: {e}"); return ExitCode::from(3); } }; // Check that the version exists let versions = match version_store.list_versions(name) { Ok(v) => v, Err(e) => { eprintln!("Failed to list versions: {e}"); return ExitCode::from(3); } }; if !versions.contains(&version) { eprintln!("Version {} not found for '{}'.", version, name); if versions.is_empty() { eprintln!("No archived versions available."); } else { eprintln!("Available versions: {:?}", versions); } return ExitCode::from(3); } // Perform the rollback let path = match version_store.restore_version(name, version, &extractors_dir) { Ok(p) => p, Err(e) => { eprintln!("Failed to restore version: {e}"); return ExitCode::from(3); } }; // Record rollback in changelog let new_version = match version_store.next_version(name) { Ok(v) => v, Err(e) => { eprintln!("Warning: Failed to determine new version number: {e}"); 0 } }; let rollback_entry = ChangelogEntry::new(new_version, format!("Rollback to v{}: {}", version, reason)); if let Err(e) = version_store.append_changelog(name, rollback_entry) { eprintln!("Warning: Failed to update changelog: {e}"); } println!("Rolled back {} to v{}", name, version); println!("Restored as: {}", path.display()); println!(); println!("Reason: {}", reason); println!(); println!("A new changelog entry has been created documenting this rollback."); ExitCode::SUCCESS }