ferro_cli/commands/ai_explain.rs
1//! `ferro ai:explain <target>` — AI-powered service/route/model explanation.
2//!
3//! Resolves the target in SERVICE → ROUTE → MODEL order (projection-framed is
4//! primary; prose fallback when no projection exists). Assembles a prompt from
5//! introspected facts and produces prose via a raw `CompletionRequest { schema:
6//! None, .. }` call. `--dry-run` prints the assembled prompt without calling
7//! the LLM.
8//!
9//! All resolution and prompt-building logic lives in
10//! `ferro_mcp::tools::ai_explain_core`. This module is a thin CLI wrapper:
11//! tokio runtime bridge, console output, process exit.
12
13// ---------------------------------------------------------------------------
14// Command entry point
15// ---------------------------------------------------------------------------
16
17/// Run the `ferro ai:explain <target>` command.
18///
19/// Thin wrapper over the relocated `ferro_mcp::tools::ai_explain_core` public
20/// items. CLI behavior is unchanged: prose-only output, `--dry-run` prints the
21/// assembled prompt, no LLM call in dry-run mode.
22#[cfg(feature = "projections")]
23pub fn run(target: String, type_override: Option<String>, dry_run: bool) {
24 use console::style;
25 use ferro_ai::client::{Message, Role};
26 use ferro_ai::{AiConfig, CompletionRequest};
27 use ferro_mcp::tools::ai_explain_core::{
28 build_model_prompt, build_route_prompt, build_service_prompt,
29 resolve_max_tokens_with_default, resolve_target, ResolvedTarget,
30 };
31
32 // 1. Fail-fast: require AI provider unless --dry-run (D-06).
33 // In dry-run mode, AI config is NOT checked — the assembled prompt is
34 // printed and the function returns without calling the LLM or requiring
35 // any env vars to be set.
36 let client_result = AiConfig::from_env();
37 if !dry_run {
38 if let Err(ref e) = client_result {
39 eprintln!(
40 "{} AI provider not configured: {e}\n Set FERRO_AI_PROVIDER, FERRO_AI_API_KEY, and FERRO_AI_MODEL.",
41 style("Error:").red().bold()
42 );
43 std::process::exit(1);
44 }
45 }
46
47 // 2. Tokio runtime bridge
48 let rt = match tokio::runtime::Runtime::new() {
49 Ok(r) => r,
50 Err(e) => {
51 eprintln!(
52 "{} Failed to create tokio runtime: {e}",
53 style("Error:").red().bold()
54 );
55 std::process::exit(1);
56 }
57 };
58
59 // 3. Resolve target (service → route → model) via the relocated async core.
60 let resolved = rt.block_on(resolve_target(
61 std::path::Path::new("."),
62 &target,
63 type_override.as_deref(),
64 ));
65
66 match resolved {
67 ResolvedTarget::NotFound(msg) => {
68 eprintln!("{} {msg}", style("Error:").red().bold());
69 std::process::exit(1);
70 }
71 resolved => {
72 // 4. Build prompt using the relocated pub builders.
73 let (system_prompt, user_prompt) = match &resolved {
74 ResolvedTarget::Service(d) => build_service_prompt(d),
75 ResolvedTarget::Route(r) => build_route_prompt(r),
76 ResolvedTarget::Model(m) => build_model_prompt(m),
77 ResolvedTarget::NotFound(_) => unreachable!(),
78 };
79
80 // 5. --dry-run: print assembled prompt and return (no LLM call)
81 if dry_run {
82 println!("{system_prompt}");
83 println!("---");
84 println!("{user_prompt}");
85 return;
86 }
87
88 // 6. Cost guard (default 2048 for ai:explain)
89 let max_tokens = resolve_max_tokens_with_default(2048);
90
91 // 7. Raw prose completion — schema: None (unstructured, no JSON coercion)
92 let client = client_result.expect("already validated above");
93
94 let req = CompletionRequest {
95 system: Some(system_prompt),
96 messages: vec![Message {
97 role: Role::User,
98 content: user_prompt,
99 tool_call_id: None,
100 }],
101 max_tokens,
102 model_override: None,
103 schema: None,
104 tools: None,
105 tool_choice: None,
106 };
107
108 match rt.block_on(client.complete(req)) {
109 Ok(prose) => {
110 println!("{prose}");
111 }
112 Err(e) => {
113 eprintln!(
114 "{} LLM completion failed: {e}",
115 style("Error:").red().bold()
116 );
117 std::process::exit(1);
118 }
119 }
120 }
121 }
122}