//! Traductor LLM real contra la Claude API (anthropic-version `2023-06-01`). //! //! Implementa [`IntentTranslator`] exactamente como el `MockTranslator`, así que el bucle //! del [`Orchestrator`](crate::Orchestrator) no cambia. Lo que ofrece de extra es que el //! contenido de la intención NL llega a un modelo real (Claude Opus 4.8 por defecto) que //! produce el `.swm` directamente — no se requiere catálogo pre-armado. //! //! Diseño: //! //! - **Síncrono** y mínimo: usamos `ureq` (gated detrás del feature `llm-claude`) porque //! el cliente del bus también es síncrono. Tokio entraría sólo si el resto del crate lo //! pidiera. //! - **HTTP inyectable**: el trait [`HttpClient`] permite tests sin red contra una fake //! que devuelve respuestas pre-armadas y verifica el cuerpo enviado. //! - **Modelo por defecto**: `claude-opus-4-8`, con adaptive thinking activado y //! `effort: "high"`. Cambiar el modelo es modificar el `ClaudeConfig`. //! - **Schema enforcement**: el modelo recibe un system prompt que documenta el shape //! exacto del `Swm` con ejemplos; pedimos respuesta JSON pura. El parser local //! (`serde_json::from_str::`) actúa como gate adicional, y `verify_schema()` //! se llama antes de devolver. Si el modelo divaga, el caller obtiene un //! `TranslateError::Model` con el cuerpo bruto para debugging. //! //! El bucle agéntico (con o sin auto-reparación) reusa el mismo `Orchestrator`. La única //! diferencia es la construcción del traductor: //! //! ```ignore //! let cfg = ClaudeConfig::from_env()?; //! let t = ClaudeTranslator::new(cfg); //! let orch = Orchestrator::new(t, base, apply, compile); //! orch.run("instala grep con regex Perl por defecto")?; //! ``` use crate::translator::{IntentTranslator, SystemContext, TranslateError}; use takana_core::Swm; /// Inyección HTTP. La implementación por defecto ([`UreqHttp`]) usa `ureq` síncrono; /// los tests pasan una fake. pub trait HttpClient: Send + Sync { fn post_json( &self, url: &str, headers: &[(&str, &str)], body: &str, ) -> Result; } #[cfg(feature = "llm-claude")] pub struct UreqHttp; #[cfg(feature = "llm-claude")] impl HttpClient for UreqHttp { fn post_json( &self, url: &str, headers: &[(&str, &str)], body: &str, ) -> Result { let mut req = ureq::post(url); for (k, v) in headers { req = req.set(k, v); } let resp = req .send_string(body) .map_err(|e| TranslateError::Model(format!("http: {e}")))?; resp.into_string() .map_err(|e| TranslateError::Model(format!("body: {e}"))) } } #[derive(Debug, Clone)] pub struct ClaudeConfig { pub api_key: String, pub model: String, pub max_tokens: u32, pub base_url: String, /// Nivel de esfuerzo del modelo (`low|medium|high|max`). Default `high`. pub effort: String, } impl ClaudeConfig { /// Construye desde env vars: `ANTHROPIC_API_KEY` (obligatoria), `TAKANA_LLM_MODEL`, /// `TAKANA_LLM_BASE_URL`, `TAKANA_LLM_EFFORT`. pub fn from_env() -> Result { let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| { TranslateError::Model( "ANTHROPIC_API_KEY no está en el entorno; exporta tu key o usa MockTranslator" .into(), ) })?; Ok(Self { api_key, model: takana_core::env::var("TAKANA_LLM_MODEL") .unwrap_or_else(|| "claude-opus-4-8".into()), max_tokens: 8192, base_url: takana_core::env::var("TAKANA_LLM_BASE_URL") .unwrap_or_else(|| "https://api.anthropic.com".into()), effort: takana_core::env::var("TAKANA_LLM_EFFORT").unwrap_or_else(|| "high".into()), }) } } pub struct ClaudeTranslator { cfg: ClaudeConfig, http: Box, } impl ClaudeTranslator { /// Constructor por defecto: usa [`UreqHttp`] (sólo disponible con feature `llm-claude`). #[cfg(feature = "llm-claude")] pub fn new(cfg: ClaudeConfig) -> Self { Self { cfg, http: Box::new(UreqHttp) } } /// Constructor con HTTP inyectado. Útil para tests y para apuntar a un proxy/mock. pub fn with_http(cfg: ClaudeConfig, http: Box) -> Self { Self { cfg, http } } } const SYSTEM_PROMPT: &str = r#"Eres un traductor que convierte intenciones humanas en lenguaje natural a manifiestos `.swm` (Software Mutación) del sistema hammer. El receptor parseará tu respuesta con serde_json (Rust) y rechazará cualquier output que no sea JSON puro válido. # Shape del Swm (versión 1) ```json { "swm_version": 1, "base": { "distro_version": "", "pins": {} }, "mutations": [ ... ] } ``` # Variantes de mutación 1. config_edit — edita un archivo de texto vía unified diff. {"type":"config_edit","file":"/abs/path","inline_diff":"- old line\n+ new line\n"} - `file` debe ser ruta absoluta. - `inline_diff` usa el formato de diff unified clásico (líneas `-`, `+` y espacio). 2. file_drop — coloca un archivo en el sistema. {"type":"file_drop","path":"/abs/path","content_hash":"b3:","content_b64":""} - `content_hash` es BLAKE3 del contenido descodificado, con el prefijo `b3:`. - SOLO emite `file_drop` si puedes calcular el hash correcto. Si no, salta esta variante. 3. source_patch — compila desde fuente y la inyecta. {"type":"source_patch","repo":"git://...","commit":"","build":{"compiler":"zig-cc","target":"x86_64-linux-musl","link":"static","flags":[]},"target_bin":"/usr/bin/foo"} - Origen git (repo+commit) O tarball (tarball+sha256), exactamente uno; p. ej. {"type":"source_patch","tarball":"https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz","sha256":"","build":{...},"target_bin":"/usr/bin/grep"} - `target_bin` ruta absoluta del binario producido. 4. init_rule — registra un servicio. {"type":"init_rule","action":"start","service":"nginx","command":"/usr/sbin/nginx -g 'daemon off;'"} # Reglas - TODAS las rutas son absolutas (comienzan con `/`). - Copia `distro_version` y `pins` desde el SystemContext que te pase el usuario. - Si la intención es ambigua o requiere información que no tienes (p. ej. el hash de un binario que no puedes computar), prefiere `config_edit` o `source_patch` sobre `file_drop`. - Responde EXCLUSIVAMENTE con el JSON del Swm. Sin texto antes, sin texto después, sin markdown, sin backticks. La primera línea debe ser `{` y la última `}`. "#; impl IntentTranslator for ClaudeTranslator { fn translate(&self, intent: &str, ctx: &SystemContext) -> Result { let body = build_request_body(&self.cfg, intent, ctx)?; let url = format!( "{}/v1/messages", self.cfg.base_url.trim_end_matches('/') ); let auth = format!("{}", self.cfg.api_key); let headers: [(&str, &str); 3] = [ ("x-api-key", auth.as_str()), ("anthropic-version", "2023-06-01"), ("content-type", "application/json"), ]; let raw = self.http.post_json(&url, &headers, &body)?; parse_response(&raw) } } fn build_request_body( cfg: &ClaudeConfig, intent: &str, ctx: &SystemContext, ) -> Result { let ctx_json = serde_json::json!({ "distro_version": ctx.base.distro_version, "pins": ctx.base.pins, "extras": ctx.extras, }); let user_msg = format!( "SystemContext:\n{}\n\nIntención del humano:\n{}\n\nRecuerda: responde sólo con el JSON del Swm.", serde_json::to_string_pretty(&ctx_json).unwrap_or_else(|_| "{}".into()), intent, ); let body = serde_json::json!({ "model": cfg.model, "max_tokens": cfg.max_tokens, "system": SYSTEM_PROMPT, "thinking": { "type": "adaptive" }, "output_config": { "effort": cfg.effort }, "messages": [ { "role": "user", "content": user_msg } ] }); serde_json::to_string(&body).map_err(|e| TranslateError::Model(format!("request body: {e}"))) } fn parse_response(raw: &str) -> Result { let v: serde_json::Value = serde_json::from_str(raw).map_err(|e| { TranslateError::Model(format!("respuesta no es JSON ({e}): {raw}")) })?; // Error envelope de la API: { "type": "error", "error": { "type": "...", "message": "..." } } if v.get("type").and_then(|t| t.as_str()) == Some("error") { let err = v .get("error") .map(|e| e.to_string()) .unwrap_or_else(|| "error sin detalle".into()); return Err(TranslateError::Model(format!("API: {err}"))); } // stop_reason "refusal" ⇒ el modelo rechazó. Lo reportamos como Model. if v.get("stop_reason").and_then(|s| s.as_str()) == Some("refusal") { let det = v .get("stop_details") .map(|d| d.to_string()) .unwrap_or_else(|| "sin stop_details".into()); return Err(TranslateError::Model(format!("refusal: {det}"))); } let text = extract_first_text(&v)?; let cleaned = strip_code_fence(&text); let swm: Swm = serde_json::from_str(cleaned).map_err(|e| { TranslateError::Model(format!( "JSON del modelo no parsea como Swm ({e}). Texto:\n{text}" )) })?; swm.verify_schema() .map_err(|e| TranslateError::Model(format!("schema del Swm: {e}")))?; Ok(swm) } fn extract_first_text(v: &serde_json::Value) -> Result { let content = v .get("content") .and_then(|c| c.as_array()) .ok_or_else(|| TranslateError::Model(format!("'content' ausente o no array: {v}")))?; for block in content { if block.get("type").and_then(|t| t.as_str()) == Some("text") { if let Some(s) = block.get("text").and_then(|t| t.as_str()) { return Ok(s.to_string()); } } } Err(TranslateError::Model(format!( "no encontré un bloque de texto en content: {v}" ))) } /// Limpia el típico envoltorio ```json ... ```. Best-effort: si no encuentra fence, /// devuelve el texto sin cambios. También recorta whitespace de los extremos. fn strip_code_fence(s: &str) -> &str { let trimmed = s.trim(); let inside = trimmed .strip_prefix("```json") .or_else(|| trimmed.strip_prefix("```")) .map(|rest| { rest.trim_start_matches('\n') .trim_end_matches("```") .trim_end_matches('\n') .trim_end() }) .unwrap_or(trimmed); inside.trim() } #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use takana_core::BaseRef; /// Fake HTTP que captura el request y devuelve un body pre-armado. struct FakeHttp { captured: Arc, String)>>>, response: String, } impl HttpClient for FakeHttp { fn post_json( &self, url: &str, headers: &[(&str, &str)], body: &str, ) -> Result { *self.captured.lock().unwrap() = Some(( url.to_string(), headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), body.to_string(), )); Ok(self.response.clone()) } } fn cfg() -> ClaudeConfig { ClaudeConfig { api_key: "sk-test".into(), model: "claude-opus-4-8".into(), max_tokens: 4096, base_url: "https://api.anthropic.com".into(), effort: "high".into(), } } fn empty_ctx() -> SystemContext { SystemContext { base: BaseRef { distro_version: "2026-06-06".into(), pins: BTreeMap::new(), }, extras: serde_json::Value::Null, } } fn ok_response(text: &str) -> String { serde_json::json!({ "id": "msg_test", "type": "message", "role": "assistant", "model": "claude-opus-4-8", "content": [{"type": "text", "text": text}], "stop_reason": "end_turn", "usage": {"input_tokens": 1, "output_tokens": 1} }) .to_string() } fn swm_text() -> String { let s = serde_json::json!({ "swm_version": 1, "base": {"distro_version": "2026-06-06", "pins": {}}, "mutations": [{ "type": "config_edit", "file": "/etc/network.conf", "inline_diff": "- DHCP=yes\n+ STATIC=1\n" }] }); serde_json::to_string(&s).unwrap() } #[test] fn happy_path_emits_v1_messages_post_with_correct_headers() { let captured = Arc::new(Mutex::new(None)); let http = FakeHttp { captured: captured.clone(), response: ok_response(&swm_text()), }; let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); let swm = t.translate("set static IP", &empty_ctx()).unwrap(); assert_eq!(swm.mutations.len(), 1); let (url, headers, body) = captured.lock().unwrap().clone().unwrap(); assert_eq!(url, "https://api.anthropic.com/v1/messages"); assert!(headers.iter().any(|(k, v)| k == "x-api-key" && v == "sk-test")); assert!(headers .iter() .any(|(k, v)| k == "anthropic-version" && v == "2023-06-01")); assert!(headers .iter() .any(|(k, v)| k == "content-type" && v == "application/json")); let parsed: serde_json::Value = serde_json::from_str(&body).unwrap(); assert_eq!(parsed["model"], "claude-opus-4-8"); assert_eq!(parsed["thinking"]["type"], "adaptive"); assert_eq!(parsed["output_config"]["effort"], "high"); let user_msg = parsed["messages"][0]["content"].as_str().unwrap(); assert!(user_msg.contains("set static IP"), "{user_msg}"); assert!(user_msg.contains("2026-06-06"), "{user_msg}"); } #[test] fn unwraps_markdown_code_fence() { let fenced = format!("```json\n{}\n```", swm_text()); let http = FakeHttp { captured: Arc::new(Mutex::new(None)), response: ok_response(&fenced), }; let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); let swm = t.translate("x", &empty_ctx()).unwrap(); assert_eq!(swm.mutations.len(), 1); } #[test] fn refusal_surfaces_as_model_error() { let resp = serde_json::json!({ "id": "msg_x", "type": "message", "content": [], "stop_reason": "refusal", "stop_details": {"category": "cyber", "explanation": "no"}, "usage": {"input_tokens": 1, "output_tokens": 0} }); let http = FakeHttp { captured: Arc::new(Mutex::new(None)), response: resp.to_string(), }; let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); let err = t.translate("x", &empty_ctx()).unwrap_err(); match err { TranslateError::Model(m) => assert!(m.contains("refusal"), "{m}"), other => panic!("esperaba Model, llegó {other:?}"), } } #[test] fn api_error_envelope_is_reported() { let resp = serde_json::json!({ "type": "error", "error": {"type": "invalid_request_error", "message": "bad model"} }); let http = FakeHttp { captured: Arc::new(Mutex::new(None)), response: resp.to_string(), }; let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); let err = t.translate("x", &empty_ctx()).unwrap_err(); match err { TranslateError::Model(m) => assert!(m.contains("invalid_request_error"), "{m}"), other => panic!("esperaba Model, llegó {other:?}"), } } #[test] fn malformed_swm_surfaces_as_model_error() { let resp = ok_response("{\"not\": \"a swm\"}"); let http = FakeHttp { captured: Arc::new(Mutex::new(None)), response: resp, }; let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); let err = t.translate("x", &empty_ctx()).unwrap_err(); match err { TranslateError::Model(m) => assert!(m.contains("Swm"), "{m}"), other => panic!("esperaba Model, llegó {other:?}"), } } #[test] fn schema_verify_catches_bad_swm() { // swm_version inválida: el JSON parsea pero verify_schema rechaza. let bad = serde_json::json!({ "swm_version": 9, "base": {"distro_version": "x", "pins": {}}, "mutations": [] }); let http = FakeHttp { captured: Arc::new(Mutex::new(None)), response: ok_response(&bad.to_string()), }; let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); let err = t.translate("x", &empty_ctx()).unwrap_err(); match err { TranslateError::Model(m) => assert!(m.contains("swm_version"), "{m}"), other => panic!("esperaba Model, llegó {other:?}"), } } #[test] fn strip_code_fence_handles_variants() { assert_eq!(strip_code_fence("```json\n{\"a\":1}\n```"), "{\"a\":1}"); assert_eq!(strip_code_fence("```\n{\"a\":1}\n```"), "{\"a\":1}"); assert_eq!(strip_code_fence("{\"a\":1}"), "{\"a\":1}"); assert_eq!(strip_code_fence(" {\"a\":1}\n\n"), "{\"a\":1}"); } }