From ecd77db067ca4c609444a9c61955467a3f5fd57d Mon Sep 17 00:00:00 2001 From: Enniwealth Date: Thu, 30 Jul 2026 05:32:01 +0100 Subject: [PATCH] New Updates closes #491 #517 #521 --- src/commands/ai_accessibility.rs | 488 ++++++++++++++++++ src/commands/ai_model_router.rs | 291 +++++++++++ src/commands/ai_plan.rs | 425 ++++++++++++++++ src/commands/mod.rs | 3 + src/commands/nl.rs | 8 +- src/main.rs | 18 + src/utils/ai_accessibility.rs | 772 +++++++++++++++++++++++++++++ src/utils/ai_model_router.rs | 692 ++++++++++++++++++++++++++ src/utils/ai_project_planner.rs | 814 +++++++++++++++++++++++++++++++ src/utils/ai_telemetry.rs | 10 + src/utils/mod.rs | 3 + src/utils/ollama.rs | 28 ++ 12 files changed, 3549 insertions(+), 3 deletions(-) create mode 100644 src/commands/ai_accessibility.rs create mode 100644 src/commands/ai_model_router.rs create mode 100644 src/commands/ai_plan.rs create mode 100644 src/utils/ai_accessibility.rs create mode 100644 src/utils/ai_model_router.rs create mode 100644 src/utils/ai_project_planner.rs diff --git a/src/commands/ai_accessibility.rs b/src/commands/ai_accessibility.rs new file mode 100644 index 00000000..5987c632 --- /dev/null +++ b/src/commands/ai_accessibility.rs @@ -0,0 +1,488 @@ +//! CLI for AI Accessibility Features (issue #521). + +use crate::utils::ai_accessibility as a11y; +use crate::utils::ollama; +use crate::utils::print as p; +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; +use std::io::{self, Read}; + +#[derive(Subcommand)] +pub enum AiAccessibilityCommands { + /// Show current accessibility configuration + Status { + #[arg(long)] + json: bool, + }, + + /// Configure accessibility settings + Configure(ConfigureArgs), + + /// Simplify text for easier reading + Simplify(SimplifyArgs), + + /// Format text for screen readers + ScreenReader(ScreenReaderArgs), + + /// List or execute voice commands + #[command(subcommand)] + Voice(VoiceCommands), + + /// List keyboard shortcuts + Shortcuts { + #[arg(long)] + json: bool, + + /// Filter by category + #[arg(long)] + category: Option, + }, + + /// Toggle or set high contrast mode + HighContrast { + /// Explicitly enable or disable + #[arg(long)] + enable: Option, + }, + + /// Toggle screen reader mode + ScreenReaderMode { + #[arg(long)] + enable: Option, + }, + + /// Toggle simplified text mode + SimplifiedText { + #[arg(long)] + enable: Option, + }, + + /// Check text for WCAG compliance + WcagCheck(WcagCheckArgs), + + /// Format text according to active accessibility settings + Format(FormatArgs), +} + +#[derive(Args)] +pub struct ConfigureArgs { + #[arg(long)] + pub screen_reader: Option, + + #[arg(long)] + pub simplified_text: Option, + + #[arg(long)] + pub high_contrast: Option, + + #[arg(long)] + pub voice_commands: Option, + + #[arg(long)] + pub keyboard_shortcuts: Option, + + #[arg(long)] + pub reduce_motion: Option, + + #[arg(long)] + pub announce_progress: Option, + + #[arg(long)] + pub verbose_descriptions: Option, + + /// Font size: small, medium, large, extra_large + #[arg(long)] + pub font_size: Option, +} + +#[derive(Args)] +pub struct SimplifyArgs { + /// Text to simplify (reads from stdin if omitted) + pub text: Option, + + #[arg(long, default_value_t = false)] + pub use_ai: bool, + + #[arg(long, default_value = ollama::DEFAULT_MODEL)] + pub model: String, +} + +#[derive(Args)] +pub struct ScreenReaderArgs { + /// Text to format (reads from stdin if omitted) + pub text: Option, + + /// Section title + #[arg(long, default_value = "Output")] + pub title: String, +} + +#[derive(Subcommand)] +pub enum VoiceCommands { + /// List all available voice commands + List { + #[arg(long)] + json: bool, + + #[arg(long)] + category: Option, + }, + + /// Match a spoken phrase to a command + Match { + /// Spoken phrase to match + phrase: String, + + #[arg(long)] + json: bool, + }, +} + +#[derive(Args)] +pub struct WcagCheckArgs { + /// Text to check (reads from stdin if omitted) + pub text: Option, + + /// WCAG level: a, aa, aaa + #[arg(long, default_value = "aa")] + pub level: String, + + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct FormatArgs { + /// Text to format (reads from stdin if omitted) + pub text: Option, +} + +pub async fn handle(cmd: AiAccessibilityCommands) -> Result<()> { + match cmd { + AiAccessibilityCommands::Status { json } => handle_status(json), + AiAccessibilityCommands::Configure(args) => handle_configure(args), + AiAccessibilityCommands::Simplify(args) => handle_simplify(args).await, + AiAccessibilityCommands::ScreenReader(args) => handle_screen_reader(args), + AiAccessibilityCommands::Voice(voice) => handle_voice(voice), + AiAccessibilityCommands::Shortcuts { json, category } => { + handle_shortcuts(json, category.as_deref()) + } + AiAccessibilityCommands::HighContrast { enable } => handle_toggle("high_contrast", enable), + AiAccessibilityCommands::ScreenReaderMode { enable } => { + handle_toggle("screen_reader", enable) + } + AiAccessibilityCommands::SimplifiedText { enable } => { + handle_toggle("simplified_text", enable) + } + AiAccessibilityCommands::WcagCheck(args) => handle_wcag_check(args), + AiAccessibilityCommands::Format(args) => handle_format(args), + } +} + +fn handle_status(json: bool) -> Result<()> { + let cfg = a11y::load_config()?; + + if json { + println!("{}", serde_json::to_string_pretty(&cfg)?); + return Ok(()); + } + + p::header("Accessibility Configuration"); + p::separator(); + p::kv("Screen reader mode", &cfg.screen_reader_mode.to_string()); + p::kv("Simplified text mode", &cfg.simplified_text_mode.to_string()); + p::kv("High contrast mode", &cfg.high_contrast_mode.to_string()); + p::kv("Voice commands", &cfg.voice_commands_enabled.to_string()); + p::kv("Keyboard shortcuts", &cfg.keyboard_shortcuts_enabled.to_string()); + p::kv("Reduce motion", &cfg.reduce_motion.to_string()); + p::kv("Announce progress", &cfg.announce_progress.to_string()); + p::kv("Verbose descriptions", &cfg.verbose_descriptions.to_string()); + p::kv("Font size", &format!("{:?}", cfg.font_size)); + p::separator(); + Ok(()) +} + +fn handle_configure(args: ConfigureArgs) -> Result<()> { + let cfg = a11y::update_config(|c| { + if let Some(v) = args.screen_reader { + c.screen_reader_mode = v; + } + if let Some(v) = args.simplified_text { + c.simplified_text_mode = v; + } + if let Some(v) = args.high_contrast { + c.high_contrast_mode = v; + } + if let Some(v) = args.voice_commands { + c.voice_commands_enabled = v; + } + if let Some(v) = args.keyboard_shortcuts { + c.keyboard_shortcuts_enabled = v; + } + if let Some(v) = args.reduce_motion { + c.reduce_motion = v; + } + if let Some(v) = args.announce_progress { + c.announce_progress = v; + } + if let Some(v) = args.verbose_descriptions { + c.verbose_descriptions = v; + } + if let Some(ref size) = args.font_size { + c.font_size = parse_font_size(size).unwrap_or(c.font_size); + } + })?; + + p::success("Accessibility settings updated."); + if !args.screen_reader.is_none() + || !args.simplified_text.is_none() + || !args.high_contrast.is_none() + { + p::kv("Screen reader", &cfg.screen_reader_mode.to_string()); + p::kv("Simplified text", &cfg.simplified_text_mode.to_string()); + p::kv("High contrast", &cfg.high_contrast_mode.to_string()); + } + Ok(()) +} + +fn parse_font_size(s: &str) -> Result { + match s.to_lowercase().as_str() { + "small" => Ok(a11y::FontSize::Small), + "medium" => Ok(a11y::FontSize::Medium), + "large" => Ok(a11y::FontSize::Large), + "extra_large" | "extra-large" | "xlarge" => Ok(a11y::FontSize::ExtraLarge), + other => anyhow::bail!( + "Unknown font size '{}'. Use: small, medium, large, extra_large", + other + ), + } +} + +async fn handle_simplify(args: SimplifyArgs) -> Result<()> { + let text = read_text_input(args.text.as_deref())?; + + p::header("Text Simplification"); + p::separator(); + + let simplified = if args.use_ai { + if !ollama::is_ollama_running().await { + p::warn("Ollama not running — using local simplification."); + a11y::simplify_text(&text, false, &args.model).await? + } else { + let spinner = p::spinner("Simplifying with AI…"); + let result = a11y::simplify_text(&text, true, &args.model).await?; + spinner.finish_and_clear(); + result + } + } else { + a11y::simplify_text_local(&text) + }; + + println!("{}", simplified); + p::separator(); + Ok(()) +} + +fn handle_screen_reader(args: ScreenReaderArgs) -> Result<()> { + let text = read_text_input(args.text.as_deref())?; + let cfg = a11y::load_config()?; + let mut cfg = cfg; + cfg.screen_reader_mode = true; + + let formatted = a11y::screen_reader_format(&text, &cfg); + let output = a11y::format_for_screen_reader(&args.title, &[("Content".into(), formatted)]); + + p::header("Screen Reader Output"); + p::separator(); + println!("{}", output.content); + p::separator(); + Ok(()) +} + +fn handle_voice(cmd: VoiceCommands) -> Result<()> { + match cmd { + VoiceCommands::List { json, category } => { + let filter_cat = category.clone(); + let commands = if let Some(ref cat) = filter_cat { + a11y::voice_commands() + .into_iter() + .filter(|c| c.category == *cat) + .collect() + } else { + a11y::voice_commands() + }; + + if json { + println!("{}", serde_json::to_string_pretty(&commands)?); + return Ok(()); + } + + p::header("Voice Commands"); + p::separator(); + let by_cat = a11y::voice_commands_by_category(); + for (cat, cmds) in &by_cat { + if filter_cat.as_ref().is_some_and(|c| c != cat) { + continue; + } + println!(); + p::info(&format!("Category: {}", cat)); + for cmd in cmds { + println!(" \"{}\" → {}", cmd.phrase, cmd.action); + if !cmd.aliases.is_empty() { + println!(" Aliases: {}", cmd.aliases.join(", ")); + } + } + } + p::separator(); + Ok(()) + } + VoiceCommands::Match { phrase, json } => { + let m = a11y::match_voice_command(&phrase) + .with_context(|| format!("No voice command matched: \"{}\"", phrase))?; + + if json { + println!("{}", serde_json::to_string_pretty(&m)?); + return Ok(()); + } + + p::header("Voice Command Match"); + p::separator(); + p::kv("Matched phrase", &m.matched_phrase); + p::kv("Confidence", &format!("{:.0}%", m.confidence * 100.0)); + p::kv("Action", &m.command.action); + p::kv("Description", &m.command.description); + p::separator(); + Ok(()) + } + } +} + +fn handle_shortcuts(json: bool, category: Option<&str>) -> Result<()> { + let shortcuts: Vec<_> = if let Some(cat) = category { + a11y::keyboard_shortcuts() + .into_iter() + .filter(|s| s.category == cat) + .collect() + } else { + a11y::keyboard_shortcuts() + }; + + if json { + println!("{}", serde_json::to_string_pretty(&shortcuts)?); + return Ok(()); + } + + p::header("Keyboard Shortcuts"); + p::separator(); + let headers = &["Keys", "Action", "Description", "Category"]; + let rows: Vec> = shortcuts + .iter() + .map(|s| { + vec![ + s.keys.clone(), + s.action.clone(), + s.description.clone(), + s.category.clone(), + ] + }) + .collect(); + p::table(headers, &rows); + p::separator(); + Ok(()) +} + +fn handle_toggle(setting: &str, enable: Option) -> Result<()> { + let cfg = a11y::update_config(|c| { + let new_val = enable.unwrap_or(match setting { + "high_contrast" => !c.high_contrast_mode, + "screen_reader" => !c.screen_reader_mode, + "simplified_text" => !c.simplified_text_mode, + _ => false, + }); + + match setting { + "high_contrast" => c.high_contrast_mode = new_val, + "screen_reader" => c.screen_reader_mode = new_val, + "simplified_text" => c.simplified_text_mode = new_val, + _ => {} + } + })?; + + let label = setting.replace('_', " "); + let state = match setting { + "high_contrast" => cfg.high_contrast_mode, + "screen_reader" => cfg.screen_reader_mode, + "simplified_text" => cfg.simplified_text_mode, + _ => false, + }; + + p::success(&format!("{} mode: {}", label, if state { "enabled" } else { "disabled" })); + Ok(()) +} + +fn handle_wcag_check(args: WcagCheckArgs) -> Result<()> { + let text = read_text_input(args.text.as_deref())?; + let level = parse_wcag_level(&args.level)?; + let result = a11y::check_wcag_compliance(&text, level); + + if args.json { + println!("{}", serde_json::to_string_pretty(&result)?); + return Ok(()); + } + + p::header("WCAG Compliance Check"); + p::separator(); + p::kv("Level", &format!("{:?}", result.level)); + p::kv("Score", &format!("{}%", result.score_pct)); + p::kv("Passed", &result.passed.to_string()); + + if !result.violations.is_empty() { + println!(); + p::warn("Violations:"); + for v in &result.violations { + println!(" [{}] {} — {}", v.criterion, v.description, v.fix); + } + } + + if !result.recommendations.is_empty() { + println!(); + p::info("Recommendations:"); + for r in &result.recommendations { + println!(" • {}", r); + } + } + + p::separator(); + Ok(()) +} + +fn handle_format(args: FormatArgs) -> Result<()> { + let text = read_text_input(args.text.as_deref())?; + let cfg = a11y::load_config()?; + let formatted = a11y::format_output(&text, &cfg); + println!("{}", formatted); + Ok(()) +} + +fn parse_wcag_level(s: &str) -> Result { + match s.to_lowercase().as_str() { + "a" => Ok(a11y::WcagLevel::A), + "aa" => Ok(a11y::WcagLevel::Aa), + "aaa" => Ok(a11y::WcagLevel::Aaa), + other => anyhow::bail!("Unknown WCAG level '{}'. Use: a, aa, aaa", other), + } +} + +fn read_text_input(arg: Option<&str>) -> Result { + if let Some(text) = arg { + return Ok(text.to_string()); + } + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .context("Failed to read from stdin")?; + if buf.trim().is_empty() { + anyhow::bail!("No text provided. Pass text as an argument or pipe via stdin."); + } + Ok(buf) +} diff --git a/src/commands/ai_model_router.rs b/src/commands/ai_model_router.rs new file mode 100644 index 00000000..5dbb7093 --- /dev/null +++ b/src/commands/ai_model_router.rs @@ -0,0 +1,291 @@ +//! CLI for intelligent AI model selection and routing (issue #491). + +use crate::utils::ai_model_router as router; +use crate::utils::print as p; +use anyhow::Result; +use clap::{Args, Subcommand}; + +#[derive(Subcommand)] +pub enum AiModelRouterCommands { + /// Classify a task by complexity and category + Classify(ClassifyArgs), + + /// Select the optimal model for a task + Select(SelectArgs), + + /// Show or update routing preferences + #[command(subcommand)] + Preferences(PreferencesCommands), + + /// Show model performance metrics from telemetry + Stats(StatsArgs), + + /// Record a learned preference for a task category + Learn(LearnArgs), +} + +#[derive(Args)] +pub struct ClassifyArgs { + /// Task prompt or description to classify + pub prompt: String, + + /// Optional explicit category hint + #[arg(long)] + pub category: Option, + + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct SelectArgs { + /// Task prompt or description + pub prompt: String, + + /// Optional explicit category hint + #[arg(long)] + pub category: Option, + + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Subcommand)] +pub enum PreferencesCommands { + /// Show current routing preferences + Show { + #[arg(long)] + json: bool, + }, + /// Update routing preferences + Set(SetPreferencesArgs), +} + +#[derive(Args)] +pub struct SetPreferencesArgs { + /// Optimize for cost (prefer cheaper models) + #[arg(long)] + pub cost_sensitive: Option, + + /// Prefer local Ollama when available + #[arg(long)] + pub prefer_local: Option, + + /// Prefer faster models over higher quality + #[arg(long)] + pub prefer_speed: Option, + + /// Preferred provider: openai, anthropic, ollama + #[arg(long)] + pub provider: Option, + + /// Maximum cost tier (0=free/local, 1=cheap, 2=mid, 3=premium) + #[arg(long)] + pub max_cost_tier: Option, +} + +#[derive(Args)] +pub struct StatsArgs { + /// Only include records from the last N days + #[arg(long)] + pub days: Option, + + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct LearnArgs { + /// Task category to remember preference for + pub category: String, + + /// Preferred model name + pub model: String, +} + +pub async fn handle(cmd: AiModelRouterCommands) -> Result<()> { + match cmd { + AiModelRouterCommands::Classify(args) => handle_classify(args), + AiModelRouterCommands::Select(args) => handle_select(args).await, + AiModelRouterCommands::Preferences(prefs) => handle_preferences(prefs), + AiModelRouterCommands::Stats(args) => handle_stats(args), + AiModelRouterCommands::Learn(args) => handle_learn(args), + } +} + +fn handle_classify(args: ClassifyArgs) -> Result<()> { + let category = args + .category + .as_deref() + .map(router::parse_category) + .transpose()?; + + let classification = router::classify_task(&args.prompt, category); + + if args.json { + println!("{}", serde_json::to_string_pretty(&classification)?); + return Ok(()); + } + + p::header("Task Classification"); + p::separator(); + p::kv("Complexity", &classification.complexity.to_string()); + p::kv("Category", &classification.category.to_string()); + p::kv("Est. tokens", &classification.estimated_tokens.to_string()); + p::kv("Requires reasoning", &classification.requires_reasoning.to_string()); + p::kv("Requires code", &classification.requires_code.to_string()); + p::kv("Confidence", &format!("{:.0}%", classification.confidence * 100.0)); + if !classification.signals.is_empty() { + p::kv("Signals", &classification.signals.join(", ")); + } + p::separator(); + Ok(()) +} + +async fn handle_select(args: SelectArgs) -> Result<()> { + let category = args + .category + .as_deref() + .map(router::parse_category) + .transpose()?; + + let decision = router::route_task(&args.prompt, category, None).await?; + + if args.json { + println!("{}", serde_json::to_string_pretty(&decision)?); + return Ok(()); + } + + p::header("Model Routing Decision"); + p::separator(); + p::kv("Provider", &format!("{:?}", decision.provider)); + p::kv("Model", &decision.model); + p::kv("Complexity", &decision.complexity.to_string()); + p::kv("Category", &decision.category.to_string()); + p::kv("Reason", &decision.reason); + if let Some(cost) = decision.estimated_cost_usd { + p::kv("Est. cost", &format!("${:.4}", cost)); + } + if !decision.fallback_chain.is_empty() { + println!(); + p::info("Fallback chain:"); + for (i, (provider, model)) in decision.fallback_chain.iter().enumerate() { + println!(" {}. {:?} / {}", i + 1, provider, model); + } + } + p::separator(); + Ok(()) +} + +fn handle_preferences(cmd: PreferencesCommands) -> Result<()> { + match cmd { + PreferencesCommands::Show { json } => { + let prefs = router::load_preferences()?; + if json { + println!("{}", serde_json::to_string_pretty(&prefs)?); + } else { + p::header("Model Routing Preferences"); + p::separator(); + p::kv("Cost sensitive", &prefs.cost_sensitive.to_string()); + p::kv("Prefer local", &prefs.prefer_local.to_string()); + p::kv("Prefer speed", &prefs.prefer_speed.to_string()); + p::kv( + "Preferred provider", + &prefs + .preferred_provider + .as_ref() + .map(|p| format!("{:?}", p)) + .unwrap_or_else(|| "none".into()), + ); + p::kv("Max cost tier", &prefs.max_cost_tier.to_string()); + p::separator(); + } + Ok(()) + } + PreferencesCommands::Set(args) => { + let mut prefs = router::load_preferences()?; + if let Some(v) = args.cost_sensitive { + prefs.cost_sensitive = v; + } + if let Some(v) = args.prefer_local { + prefs.prefer_local = v; + } + if let Some(v) = args.prefer_speed { + prefs.prefer_speed = v; + } + if let Some(ref provider) = args.provider { + prefs.preferred_provider = Some(parse_provider(provider)?); + } + if let Some(tier) = args.max_cost_tier { + prefs.max_cost_tier = tier; + } + router::save_preferences(&prefs)?; + p::success("Routing preferences updated."); + Ok(()) + } + } +} + +fn parse_provider(s: &str) -> Result { + use crate::utils::ai::AIProvider; + match s.to_lowercase().as_str() { + "openai" => Ok(AIProvider::OpenAI), + "anthropic" | "claude" => Ok(AIProvider::Anthropic), + "ollama" | "local" => Ok(AIProvider::Ollama), + other => anyhow::bail!( + "Unknown provider '{}'. Use: openai, anthropic, ollama", + other + ), + } +} + +fn handle_stats(args: StatsArgs) -> Result<()> { + let stats = router::model_performance_stats(args.days)?; + + if args.json { + println!("{}", serde_json::to_string_pretty(&stats)?); + return Ok(()); + } + + p::header("Model Performance Metrics"); + p::separator(); + + if stats.is_empty() { + p::info("No model performance data recorded yet."); + p::info("Enable AI telemetry with: starforge ai-telemetry enable"); + } else { + let headers = &["Provider", "Model", "Feature", "Calls", "Success %", "Avg ms", "Avg tokens"]; + let rows: Vec> = stats + .iter() + .take(20) + .map(|s| { + vec![ + s.provider.clone(), + s.model.clone(), + s.feature.clone(), + s.total_calls.to_string(), + format!("{:.1}", s.success_rate * 100.0), + s.avg_latency_ms.to_string(), + s.avg_tokens.to_string(), + ] + }) + .collect(); + p::table(headers, &rows); + } + + p::separator(); + Ok(()) +} + +fn handle_learn(args: LearnArgs) -> Result<()> { + router::parse_category(&args.category)?; + router::record_category_preference(&args.category, &args.model)?; + p::success(&format!( + "Learned preference: {} → {}", + args.category, args.model + )); + Ok(()) +} diff --git a/src/commands/ai_plan.rs b/src/commands/ai_plan.rs new file mode 100644 index 00000000..9b12f4d2 --- /dev/null +++ b/src/commands/ai_plan.rs @@ -0,0 +1,425 @@ +//! CLI for AI Project Planning Assistant (issue #517). + +use crate::utils::ai_project_planner as planner; +use crate::utils::ollama; +use crate::utils::print as p; +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; +use std::path::PathBuf; + +#[derive(Subcommand)] +pub enum AiPlanCommands { + /// Analyze project requirements from a description + Analyze(AnalyzeArgs), + + /// Suggest contract architectures + Architecture(ArchitectureArgs), + + /// Generate detailed task breakdown + Breakdown(BreakdownArgs), + + /// Estimate project timeline with milestones + Timeline(TimelineArgs), + + /// Plan team resources and allocation + Resources(ResourcesArgs), + + /// Identify project risks and mitigations + Risks(RisksArgs), + + /// Generate a complete project plan + Generate(GenerateArgs), + + /// List saved project plans + List, + + /// Show a saved plan + Show(ShowArgs), +} + +#[derive(Args)] +pub struct AnalyzeArgs { + /// Project description or requirements + pub description: String, + + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct ArchitectureArgs { + pub description: String, + + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct BreakdownArgs { + pub description: String, + + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct TimelineArgs { + pub description: String, + + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct ResourcesArgs { + pub description: String, + + /// Team size override + #[arg(long)] + pub team_size: Option, + + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct RisksArgs { + pub description: String, + + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct GenerateArgs { + /// Project name + pub name: String, + + /// Project description / requirements + pub description: String, + + /// Enhance plan with AI (requires Ollama) + #[arg(long, default_value_t = false)] + pub use_ai: bool, + + /// Ollama model for AI enhancement + #[arg(long, default_value = ollama::DEFAULT_MODEL)] + pub model: String, + + /// Save plan to disk + #[arg(long, default_value_t = true)] + pub save: bool, + + #[arg(long)] + pub json: bool, + + /// Write plan to file + #[arg(long, short)] + pub out: Option, +} + +#[derive(Args)] +pub struct ShowArgs { + /// Path to saved plan JSON + pub path: PathBuf, + + #[arg(long)] + pub json: bool, +} + +pub async fn handle(cmd: AiPlanCommands) -> Result<()> { + match cmd { + AiPlanCommands::Analyze(args) => handle_analyze(args), + AiPlanCommands::Architecture(args) => handle_architecture(args), + AiPlanCommands::Breakdown(args) => handle_breakdown(args), + AiPlanCommands::Timeline(args) => handle_timeline(args), + AiPlanCommands::Resources(args) => handle_resources(args), + AiPlanCommands::Risks(args) => handle_risks(args), + AiPlanCommands::Generate(args) => handle_generate(args).await, + AiPlanCommands::List => handle_list(), + AiPlanCommands::Show(args) => handle_show(args), + } +} + +fn handle_analyze(args: AnalyzeArgs) -> Result<()> { + let reqs = planner::analyze_requirements(&args.description); + output_or_print(&reqs, args.json, "Requirement Analysis", |r| { + p::kv("Summary", &r.summary); + println!(); + p::info("Functional requirements:"); + for req in &r.functional_requirements { + println!(" • {}", req); + } + println!(); + p::info("Non-functional requirements:"); + for req in &r.non_functional_requirements { + println!(" • {}", req); + } + println!(); + p::info("Constraints:"); + for c in &r.constraints { + println!(" • {}", c); + } + println!(); + p::info("Success criteria:"); + for s in &r.success_criteria { + println!(" • {}", s); + } + }) +} + +fn handle_architecture(args: ArchitectureArgs) -> Result<()> { + let archs = planner::suggest_architectures(&args.description); + output_or_print(&archs, args.json, "Architecture Suggestions", |archs| { + for arch in archs { + let marker = if arch.recommended { " ★ recommended" } else { "" }; + println!(); + println!(" {}{}", arch.name, marker); + println!(" {}", arch.description); + println!(" Storage: {}", arch.storage_strategy); + println!(" Auth: {}", arch.auth_model); + println!(" Upgrade: {}", arch.upgrade_strategy); + if !arch.pros.is_empty() { + println!(" Pros:"); + for p_item in &arch.pros { + println!(" + {}", p_item); + } + } + if !arch.cons.is_empty() { + println!(" Cons:"); + for c in &arch.cons { + println!(" - {}", c); + } + } + } + }) +} + +fn handle_breakdown(args: BreakdownArgs) -> Result<()> { + let phases = planner::default_phases(&args.description); + let tasks = planner::breakdown_tasks(&args.description, &phases); + output_or_print(&tasks, args.json, "Task Breakdown", |tasks| { + let headers = &["ID", "Title", "Phase", "Priority", "Effort", "Role"]; + let rows: Vec> = tasks + .iter() + .map(|t| { + vec![ + t.id.clone(), + t.title.clone(), + t.phase.clone(), + format!("{:?}", t.priority), + t.effort_points.to_string(), + t.assignee_role.clone(), + ] + }) + .collect(); + p::table(headers, &rows); + }) +} + +fn handle_timeline(args: TimelineArgs) -> Result<()> { + let phases = planner::default_phases(&args.description); + let tasks = planner::breakdown_tasks(&args.description, &phases); + let timeline = planner::estimate_timeline(&tasks, &phases); + output_or_print(&timeline, args.json, "Timeline Estimate", |t| { + p::kv("Total days", &t.total_days.to_string()); + p::kv("Buffer days", &t.buffer_days.to_string()); + p::kv("Start", &t.start_date.format("%Y-%m-%d").to_string()); + p::kv("Target completion", &t.target_completion.format("%Y-%m-%d").to_string()); + println!(); + p::info("Milestones:"); + for m in &t.milestones { + println!( + " • {} — {} ({})", + m.name, + m.date.format("%Y-%m-%d"), + m.deliverables.join(", ") + ); + } + if !t.critical_path.is_empty() { + println!(); + p::info("Critical path:"); + println!(" {}", t.critical_path.join(" → ")); + } + }) +} + +fn handle_resources(args: ResourcesArgs) -> Result<()> { + let phases = planner::default_phases(&args.description); + let tasks = planner::breakdown_tasks(&args.description, &phases); + let resources = planner::plan_resources(&tasks, args.team_size); + output_or_print(&resources, args.json, "Resource Plan", |r| { + p::kv("Team size", &r.team_size.to_string()); + println!(); + p::info("Roles:"); + for role in &r.roles { + println!(" • {} (×{})", role.role, role.count); + for resp in &role.responsibilities { + println!(" - {}", resp); + } + } + println!(); + p::info("Skills required:"); + for s in &r.skills_required { + println!(" • {}", s); + } + println!(); + p::info("Tooling:"); + for t in &r.tooling { + println!(" • {}", t); + } + }) +} + +fn handle_risks(args: RisksArgs) -> Result<()> { + let risks = planner::identify_risks(&args.description); + output_or_print(&risks, args.json, "Risk Assessment", |risks| { + let headers = &["ID", "Title", "Category", "Severity", "Likelihood"]; + let rows: Vec> = risks + .iter() + .map(|r| { + vec![ + r.id.clone(), + r.title.clone(), + format!("{:?}", r.category), + format!("{:?}", r.severity), + format!("{:?}", r.likelihood), + ] + }) + .collect(); + p::table(headers, &rows); + println!(); + for r in risks { + println!(" {} — {}", r.id, r.title); + println!(" Mitigation: {}", r.mitigation); + println!(" Contingency: {}", r.contingency); + println!(); + } + }) +} + +async fn handle_generate(args: GenerateArgs) -> Result<()> { + p::header(&format!("Project Plan — {}", args.name)); + p::separator(); + + let mut plan = planner::generate_plan(&args.name, &args.description); + + if args.use_ai { + if ollama::is_ollama_running().await { + let spinner = p::spinner("Enhancing plan with AI…"); + planner::enhance_plan_with_ai(&mut plan, &args.model) + .await + .context("AI enhancement failed")?; + spinner.finish_and_clear(); + p::success("AI enhancement complete."); + } else { + p::warn("Ollama not running — skipping AI enhancement."); + } + } + + if args.save { + let path = planner::save_plan(&plan)?; + p::success(&format!("Plan saved → {}", path.display())); + } + + if let Some(out) = &args.out { + std::fs::write(out, serde_json::to_string_pretty(&plan)?)?; + p::success(&format!("Plan written → {}", out.display())); + } + + if args.json { + println!("{}", serde_json::to_string_pretty(&plan)?); + return Ok(()); + } + + print_plan_summary(&plan); + p::separator(); + Ok(()) +} + +fn handle_list() -> Result<()> { + let plans = planner::list_saved_plans()?; + p::header("Saved Project Plans"); + p::separator(); + + if plans.is_empty() { + p::info("No saved plans found."); + p::info("Generate one with: starforge ai-plan generate \"\""); + } else { + for path in &plans { + println!(" • {}", path.display()); + } + p::success(&format!("{} plan(s) found.", plans.len())); + } + + p::separator(); + Ok(()) +} + +fn handle_show(args: ShowArgs) -> Result<()> { + let plan = planner::load_plan(&args.path)?; + + if args.json { + println!("{}", serde_json::to_string_pretty(&plan)?); + return Ok(()); + } + + p::header(&format!("Project Plan — {}", plan.project_name)); + p::separator(); + print_plan_summary(&plan); + + if let Some(summary) = &plan.ai_summary { + println!(); + p::info("AI Recommendations"); + println!("{}", summary); + } + + p::separator(); + Ok(()) +} + +fn print_plan_summary(plan: &planner::ProjectPlan) { + p::kv("Generated", &plan.generated_at.format("%Y-%m-%d %H:%M UTC").to_string()); + p::kv("Tasks", &plan.tasks.len().to_string()); + p::kv("Phases", &plan.phases.len().to_string()); + p::kv("Risks", &plan.risks.len().to_string()); + p::kv("Timeline", &format!("{} days", plan.timeline.total_days)); + p::kv("Team size", &plan.resources.team_size.to_string()); + + let recommended = plan.architectures.iter().find(|a| a.recommended); + if let Some(arch) = recommended { + p::kv("Recommended architecture", &arch.name); + } + + println!(); + p::info("Phases:"); + for phase in &plan.phases { + println!( + " {}. {} ({} days) — {}", + phase.order, phase.name, phase.estimated_days, phase.description + ); + } + + println!(); + p::info("Top risks:"); + for risk in plan.risks.iter().take(3) { + println!(" • [{}] {} — {}", risk.id, risk.title, risk.mitigation); + } +} + +fn output_or_print( + data: &T, + json: bool, + title: &str, + print_fn: impl FnOnce(&T), +) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(data)?); + } else { + p::header(title); + p::separator(); + print_fn(data); + p::separator(); + } + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b30847e0..bcf7bd23 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod ai; +pub mod ai_accessibility; pub mod ai_audit; pub mod ai_cache_cmd; pub mod ai_chat; @@ -9,8 +10,10 @@ pub mod ai_deployment_test; pub mod ai_error; pub mod ai_feedback; pub mod ai_ide; +pub mod ai_model_router; pub mod ai_navigate; pub mod ai_profile; +pub mod ai_plan; pub mod ai_property_test; pub mod ai_quality_gate; pub mod ai_recommend; diff --git a/src/commands/nl.rs b/src/commands/nl.rs index 62c91410..1c9ccc57 100644 --- a/src/commands/nl.rs +++ b/src/commands/nl.rs @@ -15,6 +15,7 @@ use clap::Args; use colored::*; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; +use tokio::process::Command as TokioCommand; // ── CLI Arguments ────────────────────────────────────────────────────────── @@ -120,6 +121,8 @@ pub struct ExtractedEntities { pub contract_id: Option, pub network: Option, pub function_name: Option, + pub file_path: Option, + pub amount: Option, pub keywords: Vec, } @@ -382,7 +385,7 @@ fn extract_entities(input: &str) -> ExtractedEntities { if matches!(words[i], "call" | "run" | "execute" | "invoke") { if i + 1 < words.len() { let func = words[i + 1] - .trim_matches(|c: char| !c.is_alphanumeric() && *c != '_'); + .trim_matches(|c: char| !c.is_alphanumeric() && c != '_'); if !func.is_empty() { entities.function_name = Some(func.to_string()); break; @@ -853,9 +856,8 @@ pub async fn handle(args: NlArgs) -> Result<()> { p::info("Press Enter to continue or Ctrl+C to cancel."); println!(); - use tokio::process::Command as TokioCommand; -use std::io::{self, Write}; print!(" {} ", "?".yellow().bold()); + use std::io::{self, Write}; io::stdout().flush()?; let mut buffer = String::new(); io::stdin().read_line(&mut buffer)?; diff --git a/src/main.rs b/src/main.rs index 7475ae6d..fd28f754 100644 --- a/src/main.rs +++ b/src/main.rs @@ -234,6 +234,18 @@ enum Commands { #[command(subcommand)] AiRecommend(commands::ai_recommend::AiRecommendCommands), + /// Intelligent AI model selection and routing based on task complexity and preferences + #[command(subcommand, name = "ai-route")] + AiRoute(commands::ai_model_router::AiModelRouterCommands), + + /// AI project planning assistant — requirements, architecture, timeline, risks + #[command(subcommand, name = "ai-plan")] + AiPlan(commands::ai_plan::AiPlanCommands), + + /// AI accessibility features — screen reader, voice commands, text simplification + #[command(subcommand, name = "ai-accessibility")] + AiAccessibility(commands::ai_accessibility::AiAccessibilityCommands), + /// AI contract function suggestions (context-aware function suggestions based on contract type) #[command(subcommand)] AiContractSuggest(commands::ai_contract_suggest::AiContractSuggestCommands), @@ -392,6 +404,9 @@ async fn main() { Commands::AiFeedback(_) => "ai-feedback", Commands::AiSearch(_) => "ai-search", Commands::AiRecommend(_) => "ai-recommend", + Commands::AiRoute(_) => "ai-route", + Commands::AiPlan(_) => "ai-plan", + Commands::AiAccessibility(_) => "ai-accessibility", Commands::AiContractSuggest(_) => "ai-contract-suggest", Commands::Schedule(_) => "schedule", Commands::Simulate(_) => "simulate", @@ -489,6 +504,9 @@ async fn main() { Commands::AiFeedback(cmd) => commands::ai_feedback::handle(cmd).await, Commands::AiSearch(cmd) => commands::ai_search::handle(cmd).await, Commands::AiRecommend(cmd) => commands::ai_recommend::handle(cmd).await, + Commands::AiRoute(cmd) => commands::ai_model_router::handle(cmd).await, + Commands::AiPlan(cmd) => commands::ai_plan::handle(cmd).await, + Commands::AiAccessibility(cmd) => commands::ai_accessibility::handle(cmd).await, Commands::AiContractSuggest(cmd) => commands::ai_contract_suggest::handle(cmd).await, Commands::Schedule(cmd) => commands::schedule::handle(cmd).await, Commands::Simulate(cmd) => commands::simulate::handle(cmd).await, diff --git a/src/utils/ai_accessibility.rs b/src/utils/ai_accessibility.rs new file mode 100644 index 00000000..1704449f --- /dev/null +++ b/src/utils/ai_accessibility.rs @@ -0,0 +1,772 @@ +//! AI Accessibility Features (issue #521). +//! +//! Makes StarForge usable for developers with disabilities through screen +//! reader optimization, voice command support, text simplification, visual +//! assistance, keyboard navigation, and a customizable interface. + +use crate::utils::config; +use crate::utils::ollama::{self, GenerateOptions}; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +// ─── Configuration ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccessibilityConfig { + pub screen_reader_mode: bool, + pub simplified_text_mode: bool, + pub high_contrast_mode: bool, + pub voice_commands_enabled: bool, + pub keyboard_shortcuts_enabled: bool, + pub reduce_motion: bool, + pub font_size: FontSize, + pub announce_progress: bool, + pub verbose_descriptions: bool, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FontSize { + Small, + Medium, + Large, + ExtraLarge, +} + +impl Default for AccessibilityConfig { + fn default() -> Self { + Self { + screen_reader_mode: false, + simplified_text_mode: false, + high_contrast_mode: false, + voice_commands_enabled: false, + keyboard_shortcuts_enabled: true, + reduce_motion: false, + font_size: FontSize::Medium, + announce_progress: true, + verbose_descriptions: false, + updated_at: Utc::now(), + } + } +} + +// ─── Voice commands ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoiceCommand { + pub phrase: String, + pub aliases: Vec, + pub action: String, + pub description: String, + pub category: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoiceCommandMatch { + pub command: VoiceCommand, + pub confidence: f32, + pub matched_phrase: String, +} + +// ─── Keyboard shortcuts ───────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyboardShortcut { + pub keys: String, + pub action: String, + pub description: String, + pub category: String, +} + +// ─── Screen reader output ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScreenReaderOutput { + pub heading: String, + pub content: String, + pub landmarks: Vec, + pub live_region: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Landmark { + pub role: String, + pub label: String, + pub content: String, +} + +// ─── WCAG compliance ───────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WcagCheckResult { + pub level: WcagLevel, + pub passed: bool, + pub score_pct: u8, + pub violations: Vec, + pub recommendations: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WcagLevel { + A, + Aa, + Aaa, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WcagViolation { + pub criterion: String, + pub description: String, + pub severity: String, + pub fix: String, +} + +fn config_path() -> Result { + Ok(config::get_data_dir()?.join("accessibility_config.json")) +} + +pub fn load_config() -> Result { + let path = config_path()?; + if !path.exists() { + return Ok(AccessibilityConfig::default()); + } + Ok(serde_json::from_str(&fs::read_to_string(&path)?)?) +} + +pub fn save_config(cfg: &AccessibilityConfig) -> Result<()> { + let path = config_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, serde_json::to_string_pretty(cfg)?)?; + Ok(()) +} + +pub fn update_config(mutator: F) -> Result +where + F: FnOnce(&mut AccessibilityConfig), +{ + let mut cfg = load_config()?; + mutator(&mut cfg); + cfg.updated_at = Utc::now(); + save_config(&cfg)?; + Ok(cfg) +} + +/// Built-in voice commands covering all major StarForge operations. +pub fn voice_commands() -> Vec { + vec![ + VoiceCommand { + phrase: "deploy contract".into(), + aliases: vec!["deploy".into(), "publish contract".into()], + action: "starforge deploy".into(), + description: "Deploy a compiled Soroban contract".into(), + category: "deployment".into(), + }, + VoiceCommand { + phrase: "run tests".into(), + aliases: vec!["test contract".into(), "execute tests".into()], + action: "starforge test".into(), + description: "Run contract tests".into(), + category: "testing".into(), + }, + VoiceCommand { + phrase: "audit contract".into(), + aliases: vec!["security audit".into(), "check security".into()], + action: "starforge ai audit".into(), + description: "Run AI security audit on a contract".into(), + category: "security".into(), + }, + VoiceCommand { + phrase: "explain contract".into(), + aliases: vec!["what does this do".into(), "describe contract".into()], + action: "starforge ai explain".into(), + description: "Explain what a contract does in plain English".into(), + category: "ai".into(), + }, + VoiceCommand { + phrase: "create project".into(), + aliases: vec!["new project".into(), "scaffold project".into()], + action: "starforge new".into(), + description: "Generate a new Soroban project".into(), + category: "project".into(), + }, + VoiceCommand { + phrase: "plan project".into(), + aliases: vec!["create roadmap".into(), "project plan".into()], + action: "starforge ai-plan generate".into(), + description: "Generate an AI project plan".into(), + category: "planning".into(), + }, + VoiceCommand { + phrase: "show wallet".into(), + aliases: vec!["wallet balance".into(), "my wallet".into()], + action: "starforge wallet list".into(), + description: "List configured wallets".into(), + category: "wallet".into(), + }, + VoiceCommand { + phrase: "switch network".into(), + aliases: vec!["change network".into(), "use testnet".into()], + action: "starforge network".into(), + description: "View or switch the active network".into(), + category: "network".into(), + }, + VoiceCommand { + phrase: "optimize gas".into(), + aliases: vec!["gas optimization".into(), "reduce gas".into()], + action: "starforge ai optimise".into(), + description: "Get gas optimization suggestions".into(), + category: "optimization".into(), + }, + VoiceCommand { + phrase: "generate tests".into(), + aliases: vec!["create tests".into(), "test generation".into()], + action: "starforge ai test".into(), + description: "Generate a test suite for a contract".into(), + category: "testing".into(), + }, + VoiceCommand { + phrase: "show help".into(), + aliases: vec!["help me".into(), "what can you do".into()], + action: "starforge help".into(), + description: "Show contextual help".into(), + category: "navigation".into(), + }, + VoiceCommand { + phrase: "simplify text".into(), + aliases: vec!["easy read".into(), "plain language".into()], + action: "starforge ai-accessibility simplify".into(), + description: "Simplify text for easier reading".into(), + category: "accessibility".into(), + }, + VoiceCommand { + phrase: "toggle high contrast".into(), + aliases: vec!["high contrast".into(), "contrast mode".into()], + action: "starforge ai-accessibility high-contrast".into(), + description: "Toggle high contrast visual mode".into(), + category: "accessibility".into(), + }, + VoiceCommand { + phrase: "ask ai".into(), + aliases: vec!["question".into(), "ai assistant".into()], + action: "starforge ai ask".into(), + description: "Ask the AI assistant a question".into(), + category: "ai".into(), + }, + ] +} + +/// Match a spoken phrase to the best voice command. +pub fn match_voice_command(input: &str) -> Option { + let normalized = normalize_phrase(input); + let commands = voice_commands(); + + for cmd in &commands { + let phrases: Vec<&str> = std::iter::once(cmd.phrase.as_str()) + .chain(cmd.aliases.iter().map(String::as_str)) + .collect(); + + for phrase in phrases { + let norm_phrase = normalize_phrase(phrase); + if normalized == norm_phrase { + return Some(VoiceCommandMatch { + command: cmd.clone(), + confidence: 1.0, + matched_phrase: phrase.to_string(), + }); + } + if normalized.contains(&norm_phrase) || norm_phrase.contains(&normalized) { + return Some(VoiceCommandMatch { + command: cmd.clone(), + confidence: 0.85, + matched_phrase: phrase.to_string(), + }); + } + } + } + + // Fuzzy word overlap + let input_words: Vec<&str> = normalized.split_whitespace().collect(); + let mut best: Option<(VoiceCommandMatch, f32)> = None; + + for cmd in &commands { + for phrase in std::iter::once(&cmd.phrase).chain(&cmd.aliases) { + let normalized_phrase = normalize_phrase(phrase); + let phrase_words: Vec<&str> = normalized_phrase.split_whitespace().collect(); + let overlap = input_words + .iter() + .filter(|w| phrase_words.contains(w)) + .count(); + if overlap > 0 { + let confidence = overlap as f32 / phrase_words.len().max(input_words.len()) as f32; + if confidence >= 0.5 { + let m = VoiceCommandMatch { + command: cmd.clone(), + confidence, + matched_phrase: phrase.clone(), + }; + if best.as_ref().is_none_or(|(_, c)| confidence > *c) { + best = Some((m, confidence)); + } + } + } + } + } + + best.map(|(m, _)| m) +} + +fn normalize_phrase(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || c.is_whitespace()) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +/// Built-in keyboard shortcuts for StarForge operations. +pub fn keyboard_shortcuts() -> Vec { + vec![ + KeyboardShortcut { + keys: "Ctrl+H".into(), + action: "starforge help".into(), + description: "Show contextual help".into(), + category: "navigation".into(), + }, + KeyboardShortcut { + keys: "Ctrl+D".into(), + action: "starforge deploy".into(), + description: "Deploy contract".into(), + category: "deployment".into(), + }, + KeyboardShortcut { + keys: "Ctrl+T".into(), + action: "starforge test".into(), + description: "Run tests".into(), + category: "testing".into(), + }, + KeyboardShortcut { + keys: "Ctrl+A".into(), + action: "starforge ai audit".into(), + description: "AI security audit".into(), + category: "security".into(), + }, + KeyboardShortcut { + keys: "Ctrl+E".into(), + action: "starforge ai explain".into(), + description: "Explain contract".into(), + category: "ai".into(), + }, + KeyboardShortcut { + keys: "Ctrl+N".into(), + action: "starforge new".into(), + description: "New project".into(), + category: "project".into(), + }, + KeyboardShortcut { + keys: "Ctrl+W".into(), + action: "starforge wallet list".into(), + description: "List wallets".into(), + category: "wallet".into(), + }, + KeyboardShortcut { + keys: "Ctrl+Shift+P".into(), + action: "starforge ai-plan generate".into(), + description: "Generate project plan".into(), + category: "planning".into(), + }, + KeyboardShortcut { + keys: "Ctrl+Shift+A".into(), + action: "starforge ai-accessibility status".into(), + description: "Accessibility status".into(), + category: "accessibility".into(), + }, + KeyboardShortcut { + keys: "Ctrl+Shift+S".into(), + action: "starforge ai-accessibility simplify".into(), + description: "Simplify selected text".into(), + category: "accessibility".into(), + }, + KeyboardShortcut { + keys: "Ctrl+Shift+C".into(), + action: "starforge ai-accessibility high-contrast".into(), + description: "Toggle high contrast mode".into(), + category: "accessibility".into(), + }, + KeyboardShortcut { + keys: "Ctrl+Shift+R".into(), + action: "starforge ai-route select".into(), + description: "Select optimal AI model".into(), + category: "ai".into(), + }, + KeyboardShortcut { + keys: "Ctrl+?".into(), + action: "starforge ai-accessibility shortcuts".into(), + description: "Show keyboard shortcuts".into(), + category: "accessibility".into(), + }, + ] +} + +/// Format CLI output for screen readers with semantic landmarks. +pub fn format_for_screen_reader(title: &str, sections: &[(String, String)]) -> ScreenReaderOutput { + let mut landmarks = Vec::new(); + let mut content_parts = Vec::new(); + + content_parts.push(format!("Section: {}", title)); + + for (label, text) in sections { + landmarks.push(Landmark { + role: "region".into(), + label: label.clone(), + content: text.clone(), + }); + content_parts.push(format!("{}: {}", label, text)); + } + + ScreenReaderOutput { + heading: title.to_string(), + content: content_parts.join(". "), + landmarks, + live_region: None, + } +} + +/// Apply screen reader formatting to plain text output. +pub fn screen_reader_format(text: &str, cfg: &AccessibilityConfig) -> String { + if !cfg.screen_reader_mode && !cfg.verbose_descriptions { + return text.to_string(); + } + + let lines: Vec<&str> = text.lines().collect(); + let mut output = String::new(); + + if cfg.verbose_descriptions { + output.push_str(&format!( + "Document with {} lines. ", + lines.len() + )); + } + + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed.starts_with("✓") || trimmed.starts_with("Success") { + output.push_str(&format!("Success notification, line {}: {}. ", i + 1, trimmed)); + } else if trimmed.starts_with("✗") || trimmed.starts_with("Error") { + output.push_str(&format!("Error notification, line {}: {}. ", i + 1, trimmed)); + } else if trimmed.starts_with("⚠") || trimmed.starts_with("Warning") { + output.push_str(&format!("Warning, line {}: {}. ", i + 1, trimmed)); + } else if trimmed.starts_with("→") { + output.push_str(&format!("Information, line {}: {}. ", i + 1, trimmed)); + } else if trimmed.contains(':') && !trimmed.starts_with(' ') { + let parts: Vec<&str> = trimmed.splitn(2, ':').collect(); + if parts.len() == 2 { + output.push_str(&format!( + "Field {} value {}. ", + parts[0].trim(), + parts[1].trim() + )); + continue; + } + output.push_str(&format!("Line {}: {}. ", i + 1, trimmed)); + } else { + output.push_str(&format!("Line {}: {}. ", i + 1, trimmed)); + } + } + + output +} + +/// Simplify text locally using rule-based plain language transforms. +pub fn simplify_text_local(text: &str) -> String { + let replacements: &[(&str, &str)] = &[ + ("utilize", "use"), + ("implement", "build"), + ("facilitate", "help"), + ("subsequently", "then"), + ("prior to", "before"), + ("in order to", "to"), + ("due to the fact that", "because"), + ("at this point in time", "now"), + ("with regard to", "about"), + ("in the event that", "if"), + ("smart contract", "contract program"), + ("deployment", "putting online"), + ("initialization", "setup"), + ("authorization", "permission check"), + ("configuration", "settings"), + ("optimization", "making faster"), + ("verification", "checking"), + ("comprehensive", "full"), + ("functionality", "features"), + ("approximately", "about"), + ("demonstrate", "show"), + ("execute", "run"), + ("retrieve", "get"), + ("terminate", "stop"), + ("commence", "start"), + ]; + + let mut result = text.to_string(); + for (from, to) in replacements { + result = result.replace(from, to); + let capitalized = format!("{}{}", from.chars().next().unwrap().to_uppercase(), &from[1..]); + let to_cap = format!("{}{}", to.chars().next().unwrap().to_uppercase(), &to[1..]); + result = result.replace(&capitalized, &to_cap); + } + + // Break long sentences at conjunctions + result = result.replace("; ", ". "); + result = result.replace(" furthermore, ", ". Also, "); + result = result.replace(" however, ", ". But, "); + + result +} + +/// Simplify text using AI (Ollama) for complex content. +pub async fn simplify_text_ai(text: &str, model: &str) -> Result { + let prompt = ollama::prompts::text_simplification_prompt(text); + let opts = GenerateOptions { + temperature: Some(0.2), + num_predict: Some(2048), + num_ctx: Some(4096), + }; + + let response = ollama::generate(model, &prompt, Some(opts)) + .await + .context("AI text simplification failed")?; + + Ok(response.response.trim().to_string()) +} + +/// Simplify text using local rules, optionally enhanced by AI. +pub async fn simplify_text(text: &str, use_ai: bool, model: &str) -> Result { + let local = simplify_text_local(text); + if use_ai && ollama::is_ollama_running().await { + simplify_text_ai(&local, model).await + } else { + Ok(local) + } +} + +/// Apply high contrast formatting markers to text output. +pub fn apply_high_contrast(text: &str) -> String { + text.lines() + .map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with("✓") || trimmed.to_lowercase().contains("success") { + format!("[SUCCESS] {}", trimmed) + } else if trimmed.starts_with("✗") || trimmed.to_lowercase().contains("error") { + format!("[ERROR] {}", trimmed) + } else if trimmed.starts_with("⚠") || trimmed.to_lowercase().contains("warning") { + format!("[WARNING] {}", trimmed) + } else if trimmed.starts_with("→") { + format!("[INFO] {}", trimmed) + } else if trimmed.contains("---") || trimmed.contains("===") { + format!("[SEPARATOR] {}", trimmed) + } else { + format!("[TEXT] {}", trimmed) + } + }) + .collect::>() + .join("\n") +} + +/// Check text/output for WCAG compliance issues. +pub fn check_wcag_compliance(text: &str, level: WcagLevel) -> WcagCheckResult { + let mut violations = Vec::new(); + let mut recommendations = Vec::new(); + let mut checks_passed = 0u32; + let total_checks = 8u32; + + // 1.3.1 Info and Relationships — structure + if !text.contains('\n') && text.len() > 500 { + violations.push(WcagViolation { + criterion: "1.3.1".into(), + description: "Long unstructured text block without headings".into(), + severity: "moderate".into(), + fix: "Add section headings and line breaks".into(), + }); + } else { + checks_passed += 1; + } + + // 1.4.1 Use of Color — don't rely on color alone + if text.contains("red") && !text.contains("[ERROR]") { + recommendations.push("Pair color references with text labels like [ERROR]".into()); + } + checks_passed += 1; + + // 1.4.3 Contrast — check for low-contrast indicators + if text.contains("dimmed") || text.contains("gray") { + violations.push(WcagViolation { + criterion: "1.4.3".into(), + description: "Dimmed or gray text may have insufficient contrast".into(), + severity: "moderate".into(), + fix: "Enable high contrast mode".into(), + }); + } else { + checks_passed += 1; + } + + // 2.1.1 Keyboard — shortcuts available + checks_passed += 1; + + // 2.4.2 Page Titled — has identifiable heading + if text.lines().next().is_none_or(|l| l.trim().is_empty()) { + violations.push(WcagViolation { + criterion: "2.4.2".into(), + description: "Output lacks a descriptive heading".into(), + severity: "minor".into(), + fix: "Add a title or header line".into(), + }); + } else { + checks_passed += 1; + } + + // 3.1.5 Reading Level — sentence length + let long_sentences = text + .split('.') + .filter(|s| s.split_whitespace().count() > 30) + .count(); + if long_sentences > 2 { + violations.push(WcagViolation { + criterion: "3.1.5".into(), + description: format!("{} sentences exceed 30 words", long_sentences), + severity: "moderate".into(), + fix: "Use simplified text mode".into(), + }); + recommendations.push("Run starforge ai-accessibility simplify on output".into()); + } else { + checks_passed += 1; + } + + // 3.3.2 Labels or Instructions + if text.contains(':') { + checks_passed += 1; + } else { + recommendations.push("Use labeled key-value pairs for structured data".into()); + checks_passed += 1; + } + + // 4.1.2 Name, Role, Value — semantic markers + if !text.contains('[') && text.len() > 200 { + recommendations.push("Enable screen reader mode for semantic landmarks".into()); + } + checks_passed += 1; + + let max_violations = match level { + WcagLevel::A => 3, + WcagLevel::Aa => 1, + WcagLevel::Aaa => 0, + }; + + let passed = violations.len() <= max_violations; + let score_pct = ((checks_passed as f64 / total_checks as f64) * 100.0) as u8; + + WcagCheckResult { + level, + passed, + score_pct, + violations, + recommendations, + } +} + +/// Format any CLI output according to active accessibility settings. +pub fn format_output(text: &str, cfg: &AccessibilityConfig) -> String { + let mut result = text.to_string(); + + if cfg.simplified_text_mode { + result = simplify_text_local(&result); + } + + if cfg.screen_reader_mode || cfg.verbose_descriptions { + result = screen_reader_format(&result, cfg); + } + + if cfg.high_contrast_mode { + result = apply_high_contrast(&result); + } + + result +} + +/// Group voice commands by category for display. +pub fn voice_commands_by_category() -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for cmd in voice_commands() { + map.entry(cmd.category.clone()) + .or_default() + .push(cmd); + } + map +} + +/// Group keyboard shortcuts by category. +pub fn shortcuts_by_category() -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for shortcut in keyboard_shortcuts() { + map.entry(shortcut.category.clone()) + .or_default() + .push(shortcut); + } + map +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn voice_command_exact_match() { + let m = match_voice_command("deploy contract").unwrap(); + assert_eq!(m.command.action, "starforge deploy"); + assert_eq!(m.confidence, 1.0); + } + + #[test] + fn voice_command_alias_match() { + let m = match_voice_command("security audit").unwrap(); + assert!(m.command.action.contains("audit")); + } + + #[test] + fn simplify_text_replaces_jargon() { + let result = simplify_text_local("We will utilize comprehensive deployment"); + assert!(result.contains("use")); + assert!(result.contains("full")); + } + + #[test] + fn high_contrast_adds_markers() { + let result = apply_high_contrast("✓ Success\n✗ Error"); + assert!(result.contains("[SUCCESS]")); + assert!(result.contains("[ERROR]")); + } + + #[test] + fn wcag_check_detects_long_sentences() { + let long = "word ".repeat(35); + let result = check_wcag_compliance(&long, WcagLevel::Aa); + assert!(!result.violations.is_empty() || !result.recommendations.is_empty()); + } + + #[test] + fn keyboard_shortcuts_not_empty() { + assert!(!keyboard_shortcuts().is_empty()); + } +} diff --git a/src/utils/ai_model_router.rs b/src/utils/ai_model_router.rs new file mode 100644 index 00000000..9774e563 --- /dev/null +++ b/src/utils/ai_model_router.rs @@ -0,0 +1,692 @@ +//! Intelligent AI model selection and routing (issue #491). +//! +//! Classifies tasks by complexity and category, maps them to the optimal +//! provider/model based on cost, speed, and capability requirements, learns +//! user preferences over time, and tracks routing performance via telemetry. + +use crate::utils::ai::{AIProvider, AIServiceConfig, ProviderConfig}; +use crate::utils::ai_telemetry; +use crate::utils::config; +use crate::utils::ollama; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +// ─── Task classification ───────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskComplexity { + Simple, + Moderate, + Complex, + Expert, +} + +impl std::fmt::Display for TaskComplexity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TaskComplexity::Simple => write!(f, "simple"), + TaskComplexity::Moderate => write!(f, "moderate"), + TaskComplexity::Complex => write!(f, "complex"), + TaskComplexity::Expert => write!(f, "expert"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskCategory { + General, + CodeGeneration, + CodeAnalysis, + SecurityAudit, + Planning, + Accessibility, + Documentation, + Testing, + Optimization, +} + +impl std::fmt::Display for TaskCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TaskCategory::General => write!(f, "general"), + TaskCategory::CodeGeneration => write!(f, "code_generation"), + TaskCategory::CodeAnalysis => write!(f, "code_analysis"), + TaskCategory::SecurityAudit => write!(f, "security_audit"), + TaskCategory::Planning => write!(f, "planning"), + TaskCategory::Accessibility => write!(f, "accessibility"), + TaskCategory::Documentation => write!(f, "documentation"), + TaskCategory::Testing => write!(f, "testing"), + TaskCategory::Optimization => write!(f, "optimization"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskClassification { + pub complexity: TaskComplexity, + pub category: TaskCategory, + pub estimated_tokens: u32, + pub requires_reasoning: bool, + pub requires_code: bool, + pub confidence: f32, + pub signals: Vec, +} + +// ─── Model capabilities ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelCapability { + pub provider: AIProvider, + pub model: String, + pub max_complexity: TaskComplexity, + pub supports_code: bool, + pub cost_tier: u8, + pub speed_tier: u8, + pub quality_tier: u8, + pub is_local: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoutingPreferences { + pub cost_sensitive: bool, + pub prefer_local: bool, + pub prefer_speed: bool, + pub preferred_provider: Option, + pub max_cost_tier: u8, + pub updated_at: DateTime, +} + +impl Default for RoutingPreferences { + fn default() -> Self { + Self { + cost_sensitive: false, + prefer_local: false, + prefer_speed: false, + preferred_provider: None, + max_cost_tier: 3, + updated_at: Utc::now(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoutingDecision { + pub provider: AIProvider, + pub model: String, + pub complexity: TaskComplexity, + pub category: TaskCategory, + pub reason: String, + pub estimated_cost_usd: Option, + pub fallback_chain: Vec<(AIProvider, String)>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelPerformanceRecord { + pub provider: String, + pub model: String, + pub feature: String, + pub success_rate: f64, + pub avg_latency_ms: u64, + pub avg_tokens: u64, + pub total_calls: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct PreferenceStore { + preferences: RoutingPreferences, + category_overrides: HashMap, + feature_model_history: HashMap>, +} + +fn default_model_catalog() -> Vec { + vec![ + ModelCapability { + provider: AIProvider::OpenAI, + model: "gpt-3.5-turbo".into(), + max_complexity: TaskComplexity::Moderate, + supports_code: true, + cost_tier: 1, + speed_tier: 3, + quality_tier: 2, + is_local: false, + }, + ModelCapability { + provider: AIProvider::OpenAI, + model: "gpt-4o-mini".into(), + max_complexity: TaskComplexity::Complex, + supports_code: true, + cost_tier: 2, + speed_tier: 3, + quality_tier: 3, + is_local: false, + }, + ModelCapability { + provider: AIProvider::OpenAI, + model: "gpt-4o".into(), + max_complexity: TaskComplexity::Expert, + supports_code: true, + cost_tier: 3, + speed_tier: 2, + quality_tier: 4, + is_local: false, + }, + ModelCapability { + provider: AIProvider::Anthropic, + model: "claude-3-5-haiku".into(), + max_complexity: TaskComplexity::Moderate, + supports_code: true, + cost_tier: 1, + speed_tier: 3, + quality_tier: 2, + is_local: false, + }, + ModelCapability { + provider: AIProvider::Anthropic, + model: "claude-3-5-sonnet".into(), + max_complexity: TaskComplexity::Complex, + supports_code: true, + cost_tier: 2, + speed_tier: 2, + quality_tier: 4, + is_local: false, + }, + ModelCapability { + provider: AIProvider::Anthropic, + model: "claude-opus-4-1".into(), + max_complexity: TaskComplexity::Expert, + supports_code: true, + cost_tier: 3, + speed_tier: 1, + quality_tier: 5, + is_local: false, + }, + ModelCapability { + provider: AIProvider::Ollama, + model: ollama::DEFAULT_MODEL.into(), + max_complexity: TaskComplexity::Complex, + supports_code: true, + cost_tier: 0, + speed_tier: 2, + quality_tier: 3, + is_local: true, + }, + ] +} + +fn preferences_path() -> Result { + Ok(config::get_data_dir()?.join("ai_model_preferences.json")) +} + +pub fn load_preferences() -> Result { + let path = preferences_path()?; + if !path.exists() { + return Ok(RoutingPreferences::default()); + } + let store: PreferenceStore = + serde_json::from_str(&fs::read_to_string(&path)?).unwrap_or_default(); + Ok(store.preferences) +} + +pub fn save_preferences(prefs: &RoutingPreferences) -> Result<()> { + let path = preferences_path()?; + let mut store = if path.exists() { + serde_json::from_str(&fs::read_to_string(&path)?).unwrap_or_default() + } else { + PreferenceStore::default() + }; + store.preferences = prefs.clone(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, serde_json::to_string_pretty(&store)?)?; + Ok(()) +} + +pub fn record_category_preference(category: &str, model: &str) -> Result<()> { + let path = preferences_path()?; + let mut store: PreferenceStore = if path.exists() { + serde_json::from_str(&fs::read_to_string(&path)?).unwrap_or_default() + } else { + PreferenceStore::default() + }; + store + .category_overrides + .insert(category.to_string(), model.to_string()); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, serde_json::to_string_pretty(&store)?)?; + Ok(()) +} + +/// Classify a task from its prompt text and optional explicit category. +pub fn classify_task(prompt: &str, category_hint: Option) -> TaskClassification { + let lower = prompt.to_lowercase(); + let word_count = prompt.split_whitespace().count(); + let line_count = prompt.lines().count(); + let has_code = prompt.contains("```") || lower.contains("fn ") || lower.contains("pub struct"); + let mut signals = Vec::new(); + + let category = category_hint.unwrap_or_else(|| infer_category(&lower, has_code, &mut signals)); + + let requires_code = matches!( + category, + TaskCategory::CodeGeneration + | TaskCategory::CodeAnalysis + | TaskCategory::SecurityAudit + | TaskCategory::Testing + | TaskCategory::Optimization + ) || has_code; + + let requires_reasoning = lower.contains("architect") + || lower.contains("design") + || lower.contains("plan") + || lower.contains("roadmap") + || lower.contains("why") + || lower.contains("compare") + || lower.contains("trade-off") + || lower.contains("risk") + || matches!(category, TaskCategory::Planning | TaskCategory::SecurityAudit); + + if requires_reasoning { + signals.push("reasoning_keywords".into()); + } + if has_code { + signals.push("contains_code".into()); + } + + let complexity = if word_count > 800 || line_count > 60 || requires_reasoning && word_count > 300 + { + signals.push("high_token_count".into()); + TaskComplexity::Expert + } else if word_count > 300 || line_count > 25 || requires_reasoning { + TaskComplexity::Complex + } else if word_count > 80 || requires_code { + TaskComplexity::Moderate + } else { + TaskComplexity::Simple + }; + + let estimated_tokens = (word_count as u32 * 2).max(256).min(8192); + let confidence = if category_hint.is_some() { + 0.95 + } else if signals.len() >= 2 { + 0.85 + } else { + 0.7 + }; + + TaskClassification { + complexity, + category, + estimated_tokens, + requires_reasoning, + requires_code, + confidence, + signals, + } +} + +fn infer_category(lower: &str, has_code: bool, signals: &mut Vec) -> TaskCategory { + if lower.contains("accessibility") + || lower.contains("screen reader") + || lower.contains("wcag") + || lower.contains("voice command") + { + signals.push("accessibility_keywords".into()); + TaskCategory::Accessibility + } else if lower.contains("plan") + || lower.contains("roadmap") + || lower.contains("timeline") + || lower.contains("milestone") + || lower.contains("sprint") + { + signals.push("planning_keywords".into()); + TaskCategory::Planning + } else if lower.contains("audit") + || lower.contains("vulnerabilit") + || lower.contains("exploit") + || lower.contains("security") + { + signals.push("security_keywords".into()); + TaskCategory::SecurityAudit + } else if lower.contains("test") || lower.contains("coverage") { + TaskCategory::Testing + } else if lower.contains("optimiz") || lower.contains("gas") || lower.contains("performance") { + TaskCategory::Optimization + } else if lower.contains("document") || lower.contains("readme") || lower.contains("explain") { + TaskCategory::Documentation + } else if lower.contains("generate") || lower.contains("implement") || lower.contains("write") + { + if has_code { + signals.push("code_generation".into()); + TaskCategory::CodeGeneration + } else { + TaskCategory::General + } + } else if has_code || lower.contains("analyze") || lower.contains("review") { + TaskCategory::CodeAnalysis + } else { + TaskCategory::General + } +} + +fn complexity_rank(c: TaskComplexity) -> u8 { + match c { + TaskComplexity::Simple => 1, + TaskComplexity::Moderate => 2, + TaskComplexity::Complex => 3, + TaskComplexity::Expert => 4, + } +} + +fn complexity_sufficient(model_max: TaskComplexity, required: TaskComplexity) -> bool { + complexity_rank(model_max) >= complexity_rank(required) +} + +/// Select the optimal model for a classified task. +pub async fn route_task( + prompt: &str, + category_hint: Option, + prefs: Option, +) -> Result { + let classification = classify_task(prompt, category_hint); + let prefs = prefs.unwrap_or_else(|| load_preferences().unwrap_or_default()); + let catalog = default_model_catalog(); + let ollama_available = ollama::is_ollama_running().await; + + let path = preferences_path().ok(); + let category_override = path + .as_ref() + .and_then(|p| { + if p.exists() { + serde_json::from_str::(&fs::read_to_string(p).ok()?).ok() + } else { + None + } + }) + .and_then(|s| { + s.category_overrides + .get(&classification.category.to_string()) + .cloned() + }); + + let mut candidates: Vec<&ModelCapability> = catalog + .iter() + .filter(|m| complexity_sufficient(m.max_complexity, classification.complexity)) + .filter(|m| !classification.requires_code || m.supports_code) + .filter(|m| !m.is_local || ollama_available) + .filter(|m| m.cost_tier <= prefs.max_cost_tier) + .collect(); + + if let Some(ref provider) = prefs.preferred_provider { + candidates.retain(|m| &m.provider == provider || m.is_local); + if candidates.is_empty() { + candidates = catalog + .iter() + .filter(|m| complexity_sufficient(m.max_complexity, classification.complexity)) + .filter(|m| !m.is_local || ollama_available) + .collect(); + } + } + + if prefs.prefer_local && ollama_available { + if let Some(local) = candidates.iter().find(|m| m.is_local) { + return Ok(build_decision(local, &classification, "Local Ollama preferred by user")); + } + } + + if let Some(override_model) = category_override { + if let Some(m) = catalog.iter().find(|m| m.model == override_model) { + return Ok(build_decision( + m, + &classification, + "Learned category preference", + )); + } + } + + candidates.sort_by(|a, b| { + let score_a = model_score(a, &classification, &prefs); + let score_b = model_score(b, &classification, &prefs); + score_b.cmp(&score_a) + }); + + let best = candidates.first().context("No suitable model found for task")?; + + let reason = match (classification.complexity, classification.category) { + (TaskComplexity::Simple, _) if prefs.cost_sensitive => { + "Simple task — optimizing for cost" + } + (_, TaskCategory::CodeGeneration) => "Code generation — code-specialized model", + (_, TaskCategory::SecurityAudit) => "Security audit — high-capability model", + (TaskComplexity::Expert, _) => "Expert complexity — capable model selected", + (TaskComplexity::Simple, _) => "Simple task — fast/cheap model", + _ => "Balanced cost-performance routing", + }; + + Ok(build_decision(best, &classification, reason)) +} + +fn model_score(m: &ModelCapability, task: &TaskClassification, prefs: &RoutingPreferences) -> i32 { + let mut score = (m.quality_tier as i32) * 10; + + if prefs.cost_sensitive { + score += (4 - m.cost_tier as i32) * 15; + } else { + score += (m.cost_tier as i32) * 3; + } + + if prefs.prefer_speed { + score += (m.speed_tier as i32) * 12; + } + + if m.is_local && prefs.prefer_local { + score += 30; + } + + if task.requires_code && m.supports_code { + score += 10; + } + + if task.requires_reasoning && m.quality_tier >= 3 { + score += 15; + } + + if m.model.contains("codellama") && task.requires_code { + score += 20; + } + + score +} + +fn build_decision( + model: &ModelCapability, + classification: &TaskClassification, + reason: &str, +) -> RoutingDecision { + let catalog = default_model_catalog(); + let fallback_chain: Vec<(AIProvider, String)> = catalog + .iter() + .filter(|m| m.provider != model.provider || m.model != model.model) + .filter(|m| complexity_sufficient(m.max_complexity, classification.complexity)) + .take(3) + .map(|m| (m.provider.clone(), m.model.clone())) + .collect(); + + let estimated_cost = if model.is_local { + None + } else { + ai_telemetry::estimate_cost( + &provider_name(&model.provider), + &model.model, + classification.estimated_tokens as u64, + (classification.estimated_tokens / 2) as u64, + ) + }; + + RoutingDecision { + provider: model.provider.clone(), + model: model.model.clone(), + complexity: classification.complexity, + category: classification.category, + reason: reason.to_string(), + estimated_cost_usd: estimated_cost, + fallback_chain, + } +} + +fn provider_name(p: &AIProvider) -> &'static str { + match p { + AIProvider::OpenAI => "openai", + AIProvider::Anthropic => "anthropic", + AIProvider::Ollama => "ollama", + } +} + +/// Build an AIServiceConfig from a routing decision for immediate use. +pub fn config_from_decision(decision: &RoutingDecision) -> AIServiceConfig { + let provider_config = ProviderConfig { + api_key: std::env::var(match decision.provider { + AIProvider::OpenAI => "OPENAI_API_KEY", + AIProvider::Anthropic => "ANTHROPIC_API_KEY", + AIProvider::Ollama => "OLLAMA_API_KEY", + }) + .ok(), + base_url: match decision.provider { + AIProvider::OpenAI => "https://api.openai.com".into(), + AIProvider::Anthropic => "https://api.anthropic.com".into(), + AIProvider::Ollama => ollama::OLLAMA_BASE_URL.into(), + }, + model: decision.model.clone(), + max_tokens: 4096, + timeout_secs: 60, + }; + + let mut providers = HashMap::new(); + providers.insert(decision.provider.clone(), provider_config); + + let fallback_order = std::iter::once(decision.provider.clone()) + .chain( + decision + .fallback_chain + .iter() + .map(|(p, _)| p.clone()), + ) + .collect(); + + AIServiceConfig { + default_provider: decision.provider.clone(), + providers, + fallback_order, + circuit_breaker_threshold: 3, + circuit_breaker_timeout_secs: 60, + } +} + +/// Aggregate model performance from local AI telemetry records. +pub fn model_performance_stats(days: Option) -> Result> { + let records = ai_telemetry::load_records(days)?; + let mut by_model: HashMap<(String, String, String), (u64, u64, u64, u64)> = HashMap::new(); + + for r in &records { + let key = (r.provider.clone(), r.model.clone(), r.feature.clone()); + let entry = by_model.entry(key).or_insert((0, 0, 0, 0)); + entry.0 += 1; + if r.success { + entry.1 += 1; + } + entry.2 += r.latency_ms; + let tokens = r.tokens_in.unwrap_or(0) + r.tokens_out.unwrap_or(0); + entry.3 += tokens; + } + + let mut stats: Vec = by_model + .into_iter() + .map(|((provider, model, feature), (total, success, latency, tokens))| { + ModelPerformanceRecord { + provider, + model, + feature, + success_rate: if total > 0 { + success as f64 / total as f64 + } else { + 0.0 + }, + avg_latency_ms: if total > 0 { latency / total } else { 0 }, + avg_tokens: if total > 0 { tokens / total } else { 0 }, + total_calls: total, + } + }) + .collect(); + + stats.sort_by(|a, b| b.total_calls.cmp(&a.total_calls)); + Ok(stats) +} + +pub fn parse_category(s: &str) -> Result { + match s.to_lowercase().as_str() { + "general" => Ok(TaskCategory::General), + "code_generation" | "code-generation" | "codegen" => Ok(TaskCategory::CodeGeneration), + "code_analysis" | "code-analysis" | "analysis" => Ok(TaskCategory::CodeAnalysis), + "security_audit" | "security-audit" | "security" | "audit" => { + Ok(TaskCategory::SecurityAudit) + } + "planning" | "plan" => Ok(TaskCategory::Planning), + "accessibility" | "a11y" => Ok(TaskCategory::Accessibility), + "documentation" | "docs" => Ok(TaskCategory::Documentation), + "testing" | "test" => Ok(TaskCategory::Testing), + "optimization" | "optimisation" | "optimize" => Ok(TaskCategory::Optimization), + other => anyhow::bail!( + "Unknown category '{}'. Use: general, code_generation, code_analysis, security_audit, planning, accessibility, documentation, testing, optimization", + other + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_simple_question() { + let c = classify_task("What is Soroban?", None); + assert_eq!(c.complexity, TaskComplexity::Simple); + } + + #[test] + fn classify_code_generation() { + let c = classify_task("Generate a token contract with mint and burn", None); + assert_eq!(c.category, TaskCategory::CodeGeneration); + } + + #[test] + fn classify_planning_task() { + let c = classify_task("Create a development roadmap with milestones", None); + assert_eq!(c.category, TaskCategory::Planning); + } + + #[test] + fn classify_security_audit() { + let c = classify_task("Audit this contract for vulnerabilities", None); + assert_eq!(c.category, TaskCategory::SecurityAudit); + } + + #[test] + fn complexity_sufficient_check() { + assert!(complexity_sufficient( + TaskComplexity::Complex, + TaskComplexity::Moderate + )); + assert!(!complexity_sufficient( + TaskComplexity::Simple, + TaskComplexity::Expert + )); + } +} diff --git a/src/utils/ai_project_planner.rs b/src/utils/ai_project_planner.rs new file mode 100644 index 00000000..12e7cdd3 --- /dev/null +++ b/src/utils/ai_project_planner.rs @@ -0,0 +1,814 @@ +//! AI Project Planning Assistant (issue #517). +//! +//! Helps plan Soroban projects with requirement analysis, architecture +//! suggestions, task breakdown, timeline estimation, resource planning, +//! and risk identification. + +use crate::utils::config; +use crate::utils::ollama::{self, GenerateOptions}; +use anyhow::{Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +// ─── Core plan structures ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectRequirements { + pub summary: String, + pub functional_requirements: Vec, + pub non_functional_requirements: Vec, + pub constraints: Vec, + pub stakeholders: Vec, + pub success_criteria: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArchitectureSuggestion { + pub name: String, + pub description: String, + pub contract_modules: Vec, + pub storage_strategy: String, + pub auth_model: String, + pub upgrade_strategy: String, + pub pros: Vec, + pub cons: Vec, + pub recommended: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractModule { + pub name: String, + pub responsibility: String, + pub interfaces: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DevelopmentPhase { + pub name: String, + pub order: u32, + pub description: String, + pub deliverables: Vec, + pub dependencies: Vec, + pub estimated_days: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskItem { + pub id: String, + pub title: String, + pub description: String, + pub phase: String, + pub priority: TaskPriority, + pub effort_points: u32, + pub assignee_role: String, + pub dependencies: Vec, + pub acceptance_criteria: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TaskPriority { + Low, + Medium, + High, + Critical, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineEstimate { + pub total_days: u32, + pub start_date: DateTime, + pub target_completion: DateTime, + pub milestones: Vec, + pub critical_path: Vec, + pub buffer_days: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Milestone { + pub name: String, + pub date: DateTime, + pub deliverables: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourcePlan { + pub team_size: u32, + pub roles: Vec, + pub allocation: Vec, + pub skills_required: Vec, + pub tooling: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TeamRole { + pub role: String, + pub count: u32, + pub responsibilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceAllocation { + pub role: String, + pub phase: String, + pub allocation_pct: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectRisk { + pub id: String, + pub title: String, + pub description: String, + pub category: RiskCategory, + pub severity: RiskSeverity, + pub likelihood: RiskLikelihood, + pub mitigation: String, + pub contingency: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RiskCategory { + Technical, + Security, + Schedule, + Resource, + Compliance, + Operational, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RiskSeverity { + Low, + Medium, + High, + Critical, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RiskLikelihood { + Unlikely, + Possible, + Likely, + AlmostCertain, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestingStrategy { + pub unit_tests: String, + pub integration_tests: String, + pub property_tests: String, + pub security_tests: String, + pub testnet_validation: String, + pub coverage_target_pct: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeploymentPlan { + pub environments: Vec, + pub pre_deploy_checks: Vec, + pub deployment_steps: Vec, + pub rollback_procedure: String, + pub monitoring_setup: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectPlan { + pub project_name: String, + pub generated_at: DateTime, + pub requirements: ProjectRequirements, + pub architectures: Vec, + pub phases: Vec, + pub tasks: Vec, + pub timeline: TimelineEstimate, + pub resources: ResourcePlan, + pub risks: Vec, + pub testing_strategy: TestingStrategy, + pub deployment_plan: DeploymentPlan, + pub ai_summary: Option, +} + +// ─── Static analysis (no LLM) ──────────────────────────────────────────────── + +pub fn analyze_requirements(description: &str) -> ProjectRequirements { + let lower = description.to_lowercase(); + let mut functional = Vec::new(); + let mut non_functional = Vec::new(); + let mut constraints = Vec::new(); + + if lower.contains("token") || lower.contains("mint") || lower.contains("transfer") { + functional.push("Token lifecycle management (mint, transfer, burn)".into()); + functional.push("Balance tracking and authorization".into()); + } + if lower.contains("nft") || lower.contains("metadata") { + functional.push("NFT minting with metadata storage".into()); + functional.push("Ownership transfer and enumeration".into()); + } + if lower.contains("governance") || lower.contains("vote") || lower.contains("dao") { + functional.push("Proposal creation and voting mechanism".into()); + functional.push("Quorum and execution thresholds".into()); + } + if lower.contains("escrow") || lower.contains("payment") { + functional.push("Conditional fund release".into()); + functional.push("Dispute resolution hooks".into()); + } + if lower.contains("staking") || lower.contains("reward") { + functional.push("Stake/unstake with reward accrual".into()); + functional.push("Reward distribution logic".into()); + } + if lower.contains("allowlist") || lower.contains("access control") { + functional.push("Role-based or allowlist access control".into()); + } + + if functional.is_empty() { + functional.push("Core contract business logic as described".into()); + functional.push("State initialization and admin functions".into()); + } + + if lower.contains("gas") || lower.contains("optim") { + non_functional.push("Gas-efficient storage and computation".into()); + } + if lower.contains("upgrade") { + non_functional.push("Upgradeable contract architecture".into()); + } + non_functional.push("Comprehensive test coverage (>80%)".into()); + non_functional.push("Security audit before mainnet deployment".into()); + + if lower.contains("testnet") { + constraints.push("Initial deployment on Stellar testnet".into()); + } + if lower.contains("mainnet") { + constraints.push("Production mainnet deployment required".into()); + } + constraints.push("Soroban SDK compatibility".into()); + constraints.push("Stellar network fee budget constraints".into()); + + ProjectRequirements { + summary: summarize_description(description), + functional_requirements: functional, + non_functional_requirements: non_functional, + constraints, + stakeholders: vec![ + "Smart contract developers".into(), + "Product owner".into(), + "Security reviewer".into(), + ], + success_criteria: vec![ + "All acceptance tests pass on testnet".into(), + "Security audit findings resolved".into(), + "Gas costs within budget".into(), + "Documentation complete".into(), + ], + } +} + +fn summarize_description(description: &str) -> String { + let trimmed = description.trim(); + if trimmed.len() <= 200 { + trimmed.to_string() + } else { + format!("{}…", &trimmed[..197]) + } +} + +pub fn suggest_architectures(description: &str) -> Vec { + let lower = description.to_lowercase(); + let mut architectures = Vec::new(); + + architectures.push(ArchitectureSuggestion { + name: "Monolithic Contract".into(), + description: "Single Soroban contract containing all business logic.".into(), + contract_modules: vec![ContractModule { + name: "main".into(), + responsibility: "All contract logic and storage".into(), + interfaces: vec!["initialize".into(), "execute".into(), "admin".into()], + }], + storage_strategy: "Persistent instance storage with typed keys".into(), + auth_model: "require_auth on privileged functions".into(), + upgrade_strategy: "Immutable or admin-controlled migration".into(), + pros: vec![ + "Simple deployment and testing".into(), + "Lower cross-contract call overhead".into(), + ], + cons: vec![ + "Harder to upgrade individual components".into(), + "Contract size limits may apply".into(), + ], + recommended: !lower.contains("multi") && !lower.contains("modular"), + }); + + if lower.contains("token") + || lower.contains("governance") + || lower.contains("multi") + || lower.contains("modular") + { + architectures.push(ArchitectureSuggestion { + name: "Modular Multi-Contract".into(), + description: "Separate contracts for distinct domains with cross-contract calls.".into(), + contract_modules: vec![ + ContractModule { + name: "core".into(), + responsibility: "Shared types and registry".into(), + interfaces: vec!["register".into(), "lookup".into()], + }, + ContractModule { + name: "logic".into(), + responsibility: "Primary business logic".into(), + interfaces: vec!["execute".into(), "query".into()], + }, + ContractModule { + name: "admin".into(), + responsibility: "Governance and upgrades".into(), + interfaces: vec!["propose".into(), "execute_upgrade".into()], + }, + ], + storage_strategy: "Domain-specific storage per contract".into(), + auth_model: "Cross-contract auth with admin proxy".into(), + upgrade_strategy: "Per-module upgrade with governance timelock".into(), + pros: vec![ + "Independent module upgrades".into(), + "Clear separation of concerns".into(), + "Reusable components".into(), + ], + cons: vec![ + "Higher deployment complexity".into(), + "Cross-contract call gas costs".into(), + ], + recommended: true, + }); + } + + architectures +} + +pub fn breakdown_tasks(description: &str, phases: &[DevelopmentPhase]) -> Vec { + let reqs = analyze_requirements(description); + let mut tasks = Vec::new(); + let mut id = 1; + + let phase_names: Vec = if phases.is_empty() { + default_phases(description) + .iter() + .map(|p| p.name.clone()) + .collect() + } else { + phases.iter().map(|p| p.name.clone()).collect() + }; + + let scaffold_phase = phase_names.first().cloned().unwrap_or_else(|| "Setup".into()); + + tasks.push(TaskItem { + id: format!("T-{id:03}"), + title: "Initialize Soroban project scaffold".into(), + description: "Create project with starforge new, configure Cargo.toml".into(), + phase: scaffold_phase.clone(), + priority: TaskPriority::High, + effort_points: 2, + assignee_role: "Developer".into(), + dependencies: vec![], + acceptance_criteria: vec!["Project compiles".into(), "CI configured".into()], + }); + id += 1; + + for req in &reqs.functional_requirements { + tasks.push(TaskItem { + id: format!("T-{id:03}"), + title: format!("Implement: {}", req), + description: format!("Build and test functionality for: {}", req), + phase: phase_names.get(1).cloned().unwrap_or_else(|| "Development".into()), + priority: TaskPriority::High, + effort_points: 5, + assignee_role: "Developer".into(), + dependencies: vec!["T-001".into()], + acceptance_criteria: vec![ + "Unit tests pass".into(), + "Function documented".into(), + ], + }); + id += 1; + } + + tasks.push(TaskItem { + id: format!("T-{id:03}"), + title: "Write integration tests".into(), + description: "End-to-end tests on local/testnet environment".into(), + phase: phase_names.get(2).cloned().unwrap_or_else(|| "Testing".into()), + priority: TaskPriority::High, + effort_points: 5, + assignee_role: "QA Engineer".into(), + dependencies: vec!["T-002".into()], + acceptance_criteria: vec![">80% coverage".into(), "All scenarios covered".into()], + }); + id += 1; + + let audit_deps = format!("T-{:03}", id - 1); + tasks.push(TaskItem { + id: format!("T-{id:03}"), + title: "Security audit".into(), + description: "Run starforge ai audit and resolve findings".into(), + phase: phase_names.get(2).cloned().unwrap_or_else(|| "Testing".into()), + priority: TaskPriority::Critical, + effort_points: 3, + assignee_role: "Security Reviewer".into(), + dependencies: vec![audit_deps], + acceptance_criteria: vec!["No critical findings".into(), "High findings mitigated".into()], + }); + id += 1; + + let deploy_deps = format!("T-{:03}", id - 1); + tasks.push(TaskItem { + id: format!("T-{id:03}"), + title: "Testnet deployment".into(), + description: "Deploy to Stellar testnet and validate".into(), + phase: phase_names.last().cloned().unwrap_or_else(|| "Deployment".into()), + priority: TaskPriority::High, + effort_points: 3, + assignee_role: "DevOps".into(), + dependencies: vec![deploy_deps], + acceptance_criteria: vec!["Contract deployed".into(), "Smoke tests pass".into()], + }); + + tasks +} + +pub fn default_phases(description: &str) -> Vec { + let lower = description.to_lowercase(); + let complexity_days = if lower.contains("complex") || lower.contains("multi") { + 14 + } else { + 7 + }; + + vec![ + DevelopmentPhase { + name: "Discovery & Design".into(), + order: 1, + description: "Requirements refinement, architecture design, spike work".into(), + deliverables: vec![ + "Requirements document".into(), + "Architecture decision record".into(), + ], + dependencies: vec![], + estimated_days: 3, + }, + DevelopmentPhase { + name: "Core Development".into(), + order: 2, + description: "Contract implementation, unit tests, local validation".into(), + deliverables: vec!["Working contract".into(), "Unit test suite".into()], + dependencies: vec!["Discovery & Design".into()], + estimated_days: complexity_days, + }, + DevelopmentPhase { + name: "Testing & Audit".into(), + order: 3, + description: "Integration testing, security audit, gas optimization".into(), + deliverables: vec![ + "Test report".into(), + "Audit report".into(), + "Optimization report".into(), + ], + dependencies: vec!["Core Development".into()], + estimated_days: 5, + }, + DevelopmentPhase { + name: "Deployment & Launch".into(), + order: 4, + description: "Testnet deployment, monitoring setup, mainnet launch".into(), + deliverables: vec![ + "Deployed contract".into(), + "Runbook".into(), + "Monitoring dashboard".into(), + ], + dependencies: vec!["Testing & Audit".into()], + estimated_days: 3, + }, + ] +} + +pub fn estimate_timeline(tasks: &[TaskItem], phases: &[DevelopmentPhase]) -> TimelineEstimate { + let total_effort: u32 = tasks.iter().map(|t| t.effort_points).sum(); + let phase_days: u32 = phases.iter().map(|p| p.estimated_days).sum(); + let total_days = total_effort.max(phase_days).max(14); + let buffer = (total_days as f64 * 0.2).ceil() as u32; + let start = Utc::now(); + let end = start + Duration::days((total_days + buffer) as i64); + + let milestones = phases + .iter() + .scan(start, |cursor, phase| { + *cursor = *cursor + Duration::days(phase.estimated_days as i64); + Some(Milestone { + name: phase.name.clone(), + date: *cursor, + deliverables: phase.deliverables.clone(), + }) + }) + .collect(); + + let critical_path: Vec = tasks + .iter() + .filter(|t| matches!(t.priority, TaskPriority::Critical | TaskPriority::High)) + .map(|t| t.id.clone()) + .collect(); + + TimelineEstimate { + total_days: total_days + buffer, + start_date: start, + target_completion: end, + milestones, + critical_path, + buffer_days: buffer, + } +} + +pub fn plan_resources(tasks: &[TaskItem], team_size: Option) -> ResourcePlan { + let roles_needed: Vec = tasks + .iter() + .map(|t| t.assignee_role.clone()) + .collect::>() + .into_iter() + .collect(); + + let team_size = team_size.unwrap_or(roles_needed.len().max(2) as u32); + + let roles: Vec = roles_needed + .iter() + .map(|r| TeamRole { + role: r.clone(), + count: 1, + responsibilities: tasks + .iter() + .filter(|t| &t.assignee_role == r) + .map(|t| t.title.clone()) + .take(5) + .collect(), + }) + .collect(); + + let phases: Vec = tasks + .iter() + .map(|t| t.phase.clone()) + .collect::>() + .into_iter() + .collect(); + + let allocation: Vec = roles + .iter() + .flat_map(|role| { + phases.iter().map(|phase| ResourceAllocation { + role: role.role.clone(), + phase: phase.clone(), + allocation_pct: 50, + }) + }) + .collect(); + + ResourcePlan { + team_size, + roles, + allocation, + skills_required: vec![ + "Rust".into(), + "Soroban SDK".into(), + "Smart contract security".into(), + "Stellar ecosystem".into(), + ], + tooling: vec![ + "starforge CLI".into(), + "cargo test".into(), + "Ollama (local AI)".into(), + "Stellar testnet".into(), + ], + } +} + +pub fn identify_risks(description: &str) -> Vec { + let lower = description.to_lowercase(); + let mut risks = vec![ + ProjectRisk { + id: "R-001".into(), + title: "Reentrancy and authorization bugs".into(), + description: "Missing auth checks or reentrancy guards in contract logic".into(), + category: RiskCategory::Security, + severity: RiskSeverity::Critical, + likelihood: RiskLikelihood::Possible, + mitigation: "Use require_auth consistently; follow checks-effects-interactions".into(), + contingency: "Pause contract and deploy patched version".into(), + }, + ProjectRisk { + id: "R-002".into(), + title: "Gas cost overrun".into(), + description: "Contract exceeds gas budget on mainnet".into(), + category: RiskCategory::Technical, + severity: RiskSeverity::High, + likelihood: RiskLikelihood::Possible, + mitigation: "Profile with starforge ai profile; optimize storage access patterns".into(), + contingency: "Refactor hot paths and redeploy".into(), + }, + ProjectRisk { + id: "R-003".into(), + title: "Schedule slip".into(), + description: "Development takes longer than estimated".into(), + category: RiskCategory::Schedule, + severity: RiskSeverity::Medium, + likelihood: RiskLikelihood::Likely, + mitigation: "20% timeline buffer; weekly progress reviews".into(), + contingency: "Descope non-critical features for v1".into(), + }, + ]; + + if lower.contains("upgrade") { + risks.push(ProjectRisk { + id: "R-004".into(), + title: "Upgrade migration failure".into(), + description: "State migration during upgrade corrupts data".into(), + category: RiskCategory::Technical, + severity: RiskSeverity::Critical, + likelihood: RiskLikelihood::Possible, + mitigation: "Test migration on forked testnet state; timelock upgrades".into(), + contingency: "Rollback to previous contract version".into(), + }); + } + + if lower.contains("token") || lower.contains("payment") { + risks.push(ProjectRisk { + id: "R-005".into(), + title: "Fund loss vulnerability".into(), + description: "Logic error allows unauthorized fund extraction".into(), + category: RiskCategory::Security, + severity: RiskSeverity::Critical, + likelihood: RiskLikelihood::Unlikely, + mitigation: "Formal verification; multi-sig admin; audit before launch".into(), + contingency: "Emergency pause and user notification".into(), + }); + } + + if lower.contains("compliance") || lower.contains("kyc") { + risks.push(ProjectRisk { + id: "R-006".into(), + title: "Regulatory compliance gap".into(), + description: "Contract may not meet jurisdictional requirements".into(), + category: RiskCategory::Compliance, + severity: RiskSeverity::High, + likelihood: RiskLikelihood::Possible, + mitigation: "Legal review; implement allowlist/KYC hooks".into(), + contingency: "Geo-restrict via off-chain compliance layer".into(), + }); + } + + risks +} + +pub fn default_testing_strategy() -> TestingStrategy { + TestingStrategy { + unit_tests: "soroban-sdk testutils for all public functions".into(), + integration_tests: "Multi-contract scenarios on local sandbox".into(), + property_tests: "AI property testing with starforge ai-property-test".into(), + security_tests: "starforge ai audit + static analysis".into(), + testnet_validation: "Deploy to testnet; run acceptance test suite".into(), + coverage_target_pct: 85, + } +} + +pub fn default_deployment_plan() -> DeploymentPlan { + DeploymentPlan { + environments: vec!["local".into(), "testnet".into(), "mainnet".into()], + pre_deploy_checks: vec![ + "All tests pass".into(), + "Security audit complete".into(), + "Gas profile within budget".into(), + "WASM hash verified".into(), + ], + deployment_steps: vec![ + "Build optimized WASM".into(), + "Simulate deployment transaction".into(), + "Submit deploy transaction".into(), + "Initialize contract state".into(), + "Verify on-chain deployment".into(), + ], + rollback_procedure: "Keep previous contract ID; redirect clients; migrate state if upgradeable".into(), + monitoring_setup: vec![ + "Contract event monitoring".into(), + "Gas usage alerts".into(), + "Error rate tracking".into(), + ], + } +} + +/// Generate a complete project plan from a natural-language description. +pub fn generate_plan(project_name: &str, description: &str) -> ProjectPlan { + let requirements = analyze_requirements(description); + let architectures = suggest_architectures(description); + let phases = default_phases(description); + let tasks = breakdown_tasks(description, &phases); + let timeline = estimate_timeline(&tasks, &phases); + let resources = plan_resources(&tasks, None); + let risks = identify_risks(description); + + ProjectPlan { + project_name: project_name.to_string(), + generated_at: Utc::now(), + requirements, + architectures, + phases, + tasks, + timeline, + resources, + risks, + testing_strategy: default_testing_strategy(), + deployment_plan: default_deployment_plan(), + ai_summary: None, + } +} + +/// Enhance a plan with AI-generated insights via Ollama. +pub async fn enhance_plan_with_ai(plan: &mut ProjectPlan, model: &str) -> Result<()> { + let plan_json = serde_json::to_string_pretty(plan)?; + let prompt = ollama::prompts::project_planning_prompt(&plan_json); + let opts = GenerateOptions { + temperature: Some(0.3), + num_predict: Some(2048), + num_ctx: Some(8192), + }; + + let response = ollama::generate(model, &prompt, Some(opts)) + .await + .context("AI plan enhancement failed")?; + + plan.ai_summary = Some(response.response.trim().to_string()); + Ok(()) +} + +fn plans_dir() -> Result { + Ok(config::get_data_dir()?.join("project_plans")) +} + +pub fn save_plan(plan: &ProjectPlan) -> Result { + let dir = plans_dir()?; + fs::create_dir_all(&dir)?; + let filename = format!( + "{}_{}.json", + plan.project_name.replace(' ', "_").to_lowercase(), + plan.generated_at.format("%Y%m%d_%H%M%S") + ); + let path = dir.join(filename); + fs::write(&path, serde_json::to_string_pretty(plan)?)?; + Ok(path) +} + +pub fn load_plan(path: &PathBuf) -> Result { + let content = fs::read_to_string(path)?; + Ok(serde_json::from_str(&content)?) +} + +pub fn list_saved_plans() -> Result> { + let dir = plans_dir()?; + if !dir.exists() { + return Ok(vec![]); + } + let mut plans: Vec = fs::read_dir(dir)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|ext| ext == "json")) + .collect(); + plans.sort_by(|a, b| b.cmp(a)); + Ok(plans) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn analyze_token_requirements() { + let reqs = analyze_requirements("Build a SEP-41 token with mint and transfer"); + assert!(reqs + .functional_requirements + .iter() + .any(|r| r.contains("Token"))); + } + + #[test] + fn generate_plan_has_tasks() { + let plan = generate_plan("test-token", "Simple fungible token contract"); + assert!(!plan.tasks.is_empty()); + assert!(!plan.risks.is_empty()); + } + + #[test] + fn timeline_includes_buffer() { + let plan = generate_plan("test", "NFT marketplace"); + assert!(plan.timeline.buffer_days > 0); + } + + #[test] + fn modular_architecture_for_governance() { + let archs = suggest_architectures("DAO governance with voting"); + assert!(archs.iter().any(|a| a.name.contains("Modular"))); + } +} diff --git a/src/utils/ai_telemetry.rs b/src/utils/ai_telemetry.rs index c84dc6df..287fd97e 100644 --- a/src/utils/ai_telemetry.rs +++ b/src/utils/ai_telemetry.rs @@ -103,6 +103,16 @@ fn price_per_1k_tokens(provider: &str, model: &str) -> Option<(f64, f64)> { .map(|(_, input, output)| (*input, *output)) } +/// Estimate USD cost for a call given provider, model, and token counts. +pub fn estimate_cost( + provider: &str, + model: &str, + tokens_in: u64, + tokens_out: u64, +) -> Option { + estimate_cost_usd(provider, model, Some(tokens_in), Some(tokens_out)) +} + fn estimate_cost_usd( provider: &str, model: &str, diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 25db6268..4ccc0c33 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -6,6 +6,7 @@ // AI Service Abstraction Layer (#479) // AI Test Analytics (#570) pub mod ai; +pub mod ai_accessibility; pub mod ai_cache; pub mod ai_context; pub mod ai_conversation; @@ -19,8 +20,10 @@ pub mod ai_error_handler; pub mod ai_feedback; pub mod ai_gas_estimation; pub mod ai_ide_integration; +pub mod ai_model_router; pub mod ai_navigation; pub mod ai_performance_profiler; +pub mod ai_project_planner; pub mod ai_property_testing; pub mod ai_quality_gates; pub mod ai_rate_limiter; diff --git a/src/utils/ollama.rs b/src/utils/ollama.rs index 7364d942..e525dc18 100644 --- a/src/utils/ollama.rs +++ b/src/utils/ollama.rs @@ -534,6 +534,34 @@ Cover:\n\ SYSTEM_CONTEXT, baseline_json, candidate_json ) } + + /// Prompt for AI project planning enhancement. + pub fn project_planning_prompt(plan_json: &str) -> String { + format!( + "{}\ +Review the following Soroban project plan (JSON) and provide actionable \ +recommendations. Cover:\n\ +1. **Requirement gaps** — missing functional or non-functional requirements.\n\ +2. **Architecture review** — validate the recommended architecture for this use case.\n\ +3. **Timeline realism** — flag optimistic estimates and suggest adjustments.\n\ +4. **Risk coverage** — identify risks not yet captured.\n\ +5. **Testing & deployment** — suggest improvements to the testing and deployment strategy.\n\n\ +```json\n{}\n```", + SYSTEM_CONTEXT, plan_json + ) + } + + /// Prompt for plain-language text simplification (accessibility). + pub fn text_simplification_prompt(text: &str) -> String { + format!( + "{}\ +Rewrite the following text in plain, simple language suitable for developers \ +with cognitive disabilities or screen reader users. Use short sentences, common \ +words, and clear structure. Preserve all technical meaning.\n\n\ +{}", + SYSTEM_CONTEXT, text + ) + } } // ─── Cloud fallback ────────────────────────────────────────────────────────────