Skip to main content

ferro_cli/
ai.rs

1//! AI-powered view generation via the Anthropic API.
2//!
3//! Provides:
4//! - `call_anthropic_plain`: Makes a blocking request to the Anthropic Messages API, returns plain text.
5//! - `call_anthropic_structured`: Structured output via Anthropic tool_use, returns JSON string.
6//! - `build_json_view_pass1`: Builds system + user prompts for Pass 1 (plain-text plan).
7//! - `build_json_view_pass2`: Builds system + user prompts for Pass 2 (structured spec).
8//! - `generate_json_view`: Two-pass JSON-UI v2 spec generation (higher-level orchestration).
9
10use console::style;
11use ferro_json_ui::{global_catalog, Spec};
12use regex::Regex;
13use std::fs;
14use std::path::Path;
15
16use crate::commands::generate_routes;
17
18/// Call the Anthropic Messages API with separate system and user prompts.
19///
20/// Reads `ANTHROPIC_API_KEY` from environment. Model defaults to `claude-sonnet-4-5`
21/// but can be overridden via `FERRO_AI_MODEL`.
22///
23/// Uses Anthropic best practices: system prompt with cache_control, temperature 0.2
24/// for deterministic output, and 60-second HTTP timeout.
25///
26/// Alias: also exported as `call_anthropic_plain` (the canonical name in Plan 05 interfaces).
27pub fn call_anthropic_plain(system: &str, user_prompt: &str) -> Result<String, String> {
28    call_anthropic(system, user_prompt)
29}
30
31pub fn call_anthropic(system: &str, user_prompt: &str) -> Result<String, String> {
32    let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| {
33        "ANTHROPIC_API_KEY not set. Export it with:\n  \
34         export ANTHROPIC_API_KEY=sk-ant-...\n\
35         Or use --no-ai for a static template."
36            .to_string()
37    })?;
38
39    let model = std::env::var("FERRO_AI_MODEL").unwrap_or_else(|_| "claude-sonnet-4-5".to_string());
40
41    let body = serde_json::json!({
42        "model": model,
43        "max_tokens": 1024,
44        "temperature": 0.2,
45        "system": [
46            {
47                "type": "text",
48                "text": system,
49                "cache_control": {"type": "ephemeral"}
50            }
51        ],
52        "messages": [
53            {"role": "user", "content": user_prompt}
54        ]
55    });
56
57    let client = reqwest::blocking::Client::builder()
58        .timeout(std::time::Duration::from_secs(60))
59        .build()
60        .map_err(|e| format!("Failed to create HTTP client: {e}"))?;
61
62    let response = client
63        .post("https://api.anthropic.com/v1/messages")
64        .header("x-api-key", &api_key)
65        .header("anthropic-version", "2023-06-01")
66        .header("content-type", "application/json")
67        .json(&body)
68        .send()
69        .map_err(|e| format!("API request failed: {e}"))?;
70
71    let status = response.status();
72    let text = response
73        .text()
74        .map_err(|e| format!("Failed to read response body: {e}"))?;
75
76    if !status.is_success() {
77        return Err(format!("Anthropic API error ({status}): {text}"));
78    }
79
80    let json: serde_json::Value =
81        serde_json::from_str(&text).map_err(|e| format!("Failed to parse response JSON: {e}"))?;
82
83    let response_text = json["content"]
84        .as_array()
85        .and_then(|arr| arr.first())
86        .and_then(|item| item["text"].as_str())
87        .ok_or_else(|| format!("Unexpected response structure: {text}"))?;
88
89    Ok(response_text.to_string())
90}
91
92/// Call the Anthropic Messages API with structured output via tool_use.
93///
94/// Constrains the model output to a JSON Schema by using Anthropic's tool_use
95/// mechanism: `tools: [{ name: "emit_spec", input_schema: schema }]` with
96/// `tool_choice: { type: "tool", name: "emit_spec" }`.
97///
98/// Returns the tool input serialized as a JSON string on success.
99pub fn call_anthropic_structured(
100    system: &str,
101    user_prompt: &str,
102    schema: serde_json::Value,
103) -> Result<String, String> {
104    let api_key =
105        std::env::var("ANTHROPIC_API_KEY").map_err(|_| "ANTHROPIC_API_KEY not set.".to_string())?;
106
107    let model = std::env::var("FERRO_AI_MODEL").unwrap_or_else(|_| "claude-sonnet-4-5".to_string());
108
109    let body = serde_json::json!({
110        "model": model,
111        "max_tokens": 4096,
112        "temperature": 0.2,
113        "system": [
114            {
115                "type": "text",
116                "text": system,
117                "cache_control": {"type": "ephemeral"}
118            }
119        ],
120        "tools": [
121            {
122                "name": "emit_spec",
123                "description": "Emit the complete JSON-UI v2 spec for the requested view.",
124                "input_schema": schema
125            }
126        ],
127        "tool_choice": { "type": "tool", "name": "emit_spec" },
128        "messages": [
129            {"role": "user", "content": user_prompt}
130        ]
131    });
132
133    let client = reqwest::blocking::Client::builder()
134        .timeout(std::time::Duration::from_secs(90))
135        .build()
136        .map_err(|e| format!("Failed to create HTTP client: {e}"))?;
137
138    let response = client
139        .post("https://api.anthropic.com/v1/messages")
140        .header("x-api-key", &api_key)
141        .header("anthropic-version", "2023-06-01")
142        .header("content-type", "application/json")
143        .json(&body)
144        .send()
145        .map_err(|e| format!("API request failed: {e}"))?;
146
147    let status = response.status();
148    let text = response
149        .text()
150        .map_err(|e| format!("Failed to read response body: {e}"))?;
151
152    if !status.is_success() {
153        return Err(format!("Anthropic API error ({status}): {text}"));
154    }
155
156    let json: serde_json::Value =
157        serde_json::from_str(&text).map_err(|e| format!("Failed to parse response JSON: {e}"))?;
158
159    // Extract tool_use block input
160    let tool_input = json["content"]
161        .as_array()
162        .and_then(|arr| arr.iter().find(|item| item["type"] == "tool_use"))
163        .and_then(|item| item.get("input"))
164        .cloned()
165        .ok_or_else(|| format!("No tool_use block in response: {text}"))?;
166
167    serde_json::to_string_pretty(&tool_input)
168        .map_err(|e| format!("Failed to serialize tool input: {e}"))
169}
170
171/// Build Pass 1 prompts for JSON-UI v2 view generation (plain-text component plan).
172///
173/// Returns `(system_prompt, user_prompt)` ready for `call_anthropic_plain`.
174pub fn build_json_view_pass1(name: &str, description: &str) -> (String, String) {
175    let catalog = global_catalog();
176    let catalog_prompt = catalog.prompt();
177
178    let system = format!(
179        "You are a JSON-UI v2 view planner for the Ferro framework.\n\n\
180         {catalog_prompt}\n\n\
181         Given a view name and description, produce a concise plain-text component plan: \
182         which components to use, what data each displays, what actions are present. \
183         Do not emit any JSON or code — only a human-readable plan."
184    );
185
186    let user = format!(
187        "View name: {name}\n\
188         Description: {description}\n\n\
189         Describe the component plan for this view."
190    );
191
192    (system, user)
193}
194
195/// Build Pass 2 prompts for JSON-UI v2 view generation (structured spec).
196///
197/// Returns `(system_prompt, user_prompt)` ready for `call_anthropic_structured`.
198/// Pass 2 receives the plain-text plan from Pass 1 and produces a structured JSON spec.
199pub fn build_json_view_pass2(pass1_result: &str) -> (String, String) {
200    let system = format!(
201        "You are a JSON-UI v2 spec generator for the Ferro framework.\n\n\
202         Component plan from previous step:\n{pass1_result}\n\n\
203         Generate the complete v2 JSON spec matching this plan. \
204         Root element id must be \"root\". \
205         All element ids are unique strings. Use flat elements map — no nesting."
206    );
207
208    let user =
209        "Generate the complete JSON-UI v2 spec for the view described in the component plan."
210            .to_string();
211
212    (system, user)
213}
214
215/// Two-pass AI generation of a JSON-UI v2 spec file.
216///
217/// Pass 1 (describe): asks the model for a plain-text component plan.
218/// Pass 2 (structure): constrains output to the full spec JSON Schema via tool_use.
219///
220/// On validation failure, prints a yellow warning and returns an error so the
221/// caller can fall back to the static template.
222pub fn generate_json_view(name: &str, description: &str, layout: &str) -> Result<String, String> {
223    let catalog = global_catalog();
224    let catalog_prompt = catalog.prompt();
225
226    // Collect project context for user prompt
227    let models = scan_models();
228    let routes = scan_routes();
229
230    let mut project_context = String::new();
231    if !models.is_empty() {
232        project_context.push_str("## Project Models\n");
233        project_context.push_str(&models);
234        project_context.push('\n');
235    }
236    if !routes.is_empty() {
237        project_context.push_str("## Project Routes\n");
238        project_context.push_str(&routes);
239        project_context.push('\n');
240    }
241
242    // --- Pass 1: describe ---
243    let pass1_system = format!(
244        "You are a JSON-UI v2 view planner for the Ferro framework.\n\n\
245         {catalog_prompt}\n\n\
246         Given a view name, description, and project context, produce a concise plain-text \
247         component plan: which components to use, what data each displays, what actions are \
248         present. Do not emit any JSON or code — only a human-readable plan."
249    );
250
251    let pass1_user = format!(
252        "{project_context}\
253         View name: {name}\n\
254         Layout: {layout}\n\
255         Description: {description}\n\n\
256         Describe the component plan for this view."
257    );
258
259    let pass1_result = call_anthropic_plain(&pass1_system, &pass1_user)?;
260
261    // --- Pass 2: structure ---
262    let schema = catalog.json_schema().clone();
263
264    let pass2_system = format!(
265        "You are a JSON-UI v2 spec generator for the Ferro framework.\n\n\
266         Component plan from previous step:\n{pass1_result}\n\n\
267         Generate the complete v2 JSON spec matching this plan. \
268         Use layout \"{layout}\". Root element id must be \"root\". \
269         All element ids are unique strings. Use flat elements map — no nesting."
270    );
271
272    let pass2_user = format!("Generate the complete JSON-UI v2 spec for the \"{name}\" view.");
273
274    let spec_json = call_anthropic_structured(&pass2_system, &pass2_user, schema)?;
275
276    // Validate against catalog
277    match Spec::from_json(&spec_json) {
278        Ok(spec) => {
279            if let Err(errors) = catalog.validate(&spec) {
280                let msgs: Vec<String> = errors.iter().map(|e| format!("  - {e}")).collect();
281                eprintln!(
282                    "{} Generated spec has validation errors:\n{}",
283                    style("Warning:").yellow().bold(),
284                    msgs.join("\n")
285                );
286                return Err("Spec validation failed — using static template.".to_string());
287            }
288        }
289        Err(e) => {
290            eprintln!(
291                "{} Generated spec failed structural parse: {e}",
292                style("Warning:").yellow().bold(),
293            );
294            return Err("Spec parse failed — using static template.".to_string());
295        }
296    }
297
298    Ok(spec_json)
299}
300
301/// Scan `src/models/*.rs` and extract struct fields using regex.
302fn scan_models() -> String {
303    let models_dir = Path::new("src/models");
304    if !models_dir.exists() {
305        return String::new();
306    }
307
308    let struct_re = Regex::new(r"pub\s+struct\s+(\w+)\s*\{").unwrap();
309    let field_re = Regex::new(r"pub\s+(\w+)\s*:\s*([^,\n]+)").unwrap();
310
311    let mut output = String::new();
312
313    let entries: Vec<_> = match fs::read_dir(models_dir) {
314        Ok(entries) => entries.filter_map(|e| e.ok()).collect(),
315        Err(_) => return String::new(),
316    };
317
318    for entry in entries {
319        let path = entry.path();
320        if path.extension().is_none_or(|ext| ext != "rs") {
321            continue;
322        }
323        if path.file_name().is_some_and(|n| n == "mod.rs") {
324            continue;
325        }
326
327        let content = match fs::read_to_string(&path) {
328            Ok(c) => c,
329            Err(_) => continue,
330        };
331
332        // Find struct definitions
333        for struct_cap in struct_re.captures_iter(&content) {
334            let struct_name = &struct_cap[1];
335            let struct_start = struct_cap.get(0).unwrap().end();
336
337            // Find the closing brace for this struct
338            let rest = &content[struct_start..];
339            let mut depth = 1;
340            let mut struct_end = rest.len();
341            for (byte_idx, ch) in rest.char_indices() {
342                match ch {
343                    '{' => depth += 1,
344                    '}' => {
345                        depth -= 1;
346                        if depth == 0 {
347                            struct_end = byte_idx;
348                            break;
349                        }
350                    }
351                    _ => {}
352                }
353            }
354
355            let struct_body = &rest[..struct_end];
356            let fields: Vec<String> = field_re
357                .captures_iter(struct_body)
358                .map(|cap| {
359                    let field_name = cap[1].trim();
360                    let field_type = cap[2].trim().trim_end_matches(',');
361                    format!("{field_name} ({field_type})")
362                })
363                .collect();
364
365            if !fields.is_empty() {
366                output.push_str(&format!("### {}: {}\n", struct_name, fields.join(", ")));
367            }
368        }
369    }
370
371    output
372}
373
374/// Scan `src/routes.rs` and format route definitions.
375fn scan_routes() -> String {
376    let routes_file = Path::new("src/routes.rs");
377    if !routes_file.exists() {
378        return String::new();
379    }
380
381    let content = match fs::read_to_string(routes_file) {
382        Ok(c) => c,
383        Err(_) => return String::new(),
384    };
385
386    let routes = generate_routes::parse_routes_file(&content);
387    let mut output = String::new();
388
389    for route in &routes {
390        let method = match route.method {
391            generate_routes::HttpMethod::Get => "GET",
392            generate_routes::HttpMethod::Post => "POST",
393            generate_routes::HttpMethod::Put => "PUT",
394            generate_routes::HttpMethod::Patch => "PATCH",
395            generate_routes::HttpMethod::Delete => "DELETE",
396        };
397
398        let name_suffix = route
399            .name
400            .as_ref()
401            .map(|n| format!(" (name: \"{n}\")"))
402            .unwrap_or_default();
403
404        output.push_str(&format!(
405            "{} {} -> {}::{}{}\n",
406            method, route.path, route.handler_module, route.handler_fn, name_suffix
407        ));
408    }
409
410    output
411}