//! Traductor de intención (NL) → `.swm`. Ver `docs/08-ai-integration.md` §5. //! //! Contrato del trait: dado un texto humano + un `SystemContext` (lo que el orquestador //! pudo recabar vía QUERY: pins, herramientas presentes, distro_version, etc.), producir un //! `Swm` válido. //! //! Phase 6 entrega un [`MockTranslator`] que mapea intentos exactos contra un catálogo //! YAML de SWMs pre-armados. Eso permite probar el bucle plan→build→try→verify→propose en //! tests y en el host del autor sin LLM. La integración con un modelo real (Claude API, //! local llama, etc.) se conecta detrás de este mismo trait — el bucle no cambia. use std::collections::HashMap; use std::path::Path; use takana_core::{BaseRef, Swm}; #[derive(Debug, thiserror::Error)] pub enum TranslateError { #[error("intent no reconocida en el catálogo: '{0}'")] UnknownIntent(String), #[error("io: {0}")] Io(#[from] std::io::Error), #[error("catálogo inválido: {0}")] Catalog(String), #[error("modelo: {0}")] Model(String), } /// Lo que el orquestador le pasa al traductor para contextualizar la intención. Un LLM /// usaría esto para decidir, p. ej., "¿el sistema ya tiene grep 3.12? entonces no necesito /// recompilar; sólo edito la config". Para el `MockTranslator` es decorativo. #[derive(Debug, Clone)] pub struct SystemContext { pub base: BaseRef, /// Información libre que el orquestador haya recolectado (rutas y metadatos de archivos /// referenciados por el intent, salida de QUERY, etc.). Estructura JSON arbitraria. pub extras: serde_json::Value, } pub trait IntentTranslator { fn translate(&self, intent: &str, ctx: &SystemContext) -> Result; } /// Catálogo intento→.swm leído de un YAML: /// /// ```yaml /// version: 1 /// intents: /// - intent: "set static IP 192.168.1.100" /// swm_path: "swms/static-ip.swm" # ruta relativa al directorio del catálogo /// # OR /// swm_inline: { swm_version: 1, ... } /// ``` /// /// El propósito es tener un "test cassette": el agente humano captura una intención que la /// IA real ya resolvió, congela el `.swm` resultante, y a partir de ahí los CI/tests del /// bucle agéntico son reproducibles. #[derive(Debug, Clone, serde::Deserialize)] pub struct IntentCatalog { #[serde(default = "default_version")] pub version: u32, pub intents: Vec, } #[derive(Debug, Clone, serde::Deserialize)] pub struct CatalogEntry { pub intent: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub swm_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub swm_inline: Option, } fn default_version() -> u32 { 1 } impl IntentCatalog { pub fn load_from_path(path: &Path) -> Result { let text = std::fs::read_to_string(path)?; let cat: IntentCatalog = serde_yaml::from_str(&text) .map_err(|e| TranslateError::Catalog(e.to_string()))?; if cat.version != 1 { return Err(TranslateError::Catalog(format!( "version del catálogo no soportada: {} (esperada 1)", cat.version ))); } Ok(cat) } /// Resuelve un `intent` exacto contra el catálogo. Las entradas `swm_path` se resuelven /// relativas al `base_dir` (típicamente el directorio del catálogo). pub fn resolve(&self, intent: &str, base_dir: &Path) -> Result { let entry = self .intents .iter() .find(|e| e.intent == intent) .ok_or_else(|| TranslateError::UnknownIntent(intent.into()))?; if let Some(s) = &entry.swm_inline { return Ok(s.clone()); } if let Some(p) = &entry.swm_path { let full = base_dir.join(p); let text = std::fs::read_to_string(&full)?; return Swm::from_yaml(&text) .map_err(|e| TranslateError::Catalog(format!("swm {}: {e}", full.display()))); } Err(TranslateError::Catalog(format!( "entry '{intent}' no declara swm_path ni swm_inline" ))) } } /// Traductor offline: matchea la intención exacta contra un mapa precargado. #[derive(Debug, Default)] pub struct MockTranslator { map: HashMap, } impl MockTranslator { pub fn empty() -> Self { Self { map: HashMap::new() } } pub fn from_catalog(cat: &IntentCatalog, base_dir: &Path) -> Result { let mut map = HashMap::new(); for entry in &cat.intents { let swm = match (&entry.swm_inline, &entry.swm_path) { (Some(s), None) => s.clone(), (None, Some(p)) => { let full = base_dir.join(p); let text = std::fs::read_to_string(&full)?; Swm::from_yaml(&text).map_err(|e| { TranslateError::Catalog(format!("swm {}: {e}", full.display())) })? } _ => { return Err(TranslateError::Catalog(format!( "entry '{}' debe declarar exactamente uno de swm_inline/swm_path", entry.intent ))); } }; map.insert(entry.intent.clone(), swm); } Ok(Self { map }) } pub fn with_intent(mut self, intent: impl Into, swm: Swm) -> Self { self.map.insert(intent.into(), swm); self } } impl IntentTranslator for MockTranslator { fn translate(&self, intent: &str, _ctx: &SystemContext) -> Result { self.map .get(intent) .cloned() .ok_or_else(|| TranslateError::UnknownIntent(intent.into())) } } #[cfg(test)] mod tests { use super::*; use takana_core::swm::{Base, Mutation}; use std::collections::BTreeMap; fn empty_ctx() -> SystemContext { SystemContext { base: BaseRef { distro_version: "2026-06-06".into(), pins: BTreeMap::new(), }, extras: serde_json::Value::Null, } } fn swm_demo() -> Swm { Swm { swm_version: 1, base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() }, mutations: vec![Mutation::ConfigEdit { file: "/etc/network.conf".into(), inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(), }], signature: None, } } #[test] fn mock_translator_returns_known_swm() { let t = MockTranslator::empty().with_intent("set static ip", swm_demo()); let s = t.translate("set static ip", &empty_ctx()).unwrap(); assert_eq!(s.mutations.len(), 1); } #[test] fn mock_translator_rejects_unknown() { let t = MockTranslator::empty(); let err = t.translate("paint the wall green", &empty_ctx()).unwrap_err(); assert!(matches!(err, TranslateError::UnknownIntent(_))); } #[test] fn catalog_load_inline() { let d = tempfile::tempdir().unwrap(); let cat_path = d.path().join("catalog.yaml"); std::fs::write( &cat_path, r#" version: 1 intents: - intent: "set static ip" swm_inline: swm_version: 1 base: distro_version: "2026-06-06" mutations: - type: config_edit file: "/etc/network.conf" inline_diff: "- DHCP=yes\n+ STATIC=1\n" "#, ) .unwrap(); let cat = IntentCatalog::load_from_path(&cat_path).unwrap(); let t = MockTranslator::from_catalog(&cat, d.path()).unwrap(); let s = t.translate("set static ip", &empty_ctx()).unwrap(); assert_eq!(s.mutations.len(), 1); } #[test] fn catalog_load_from_swm_path() { let d = tempfile::tempdir().unwrap(); let swm_path = d.path().join("net.swm"); std::fs::write(&swm_path, swm_demo().to_yaml().unwrap()).unwrap(); let cat_path = d.path().join("catalog.yaml"); std::fs::write( &cat_path, r#" version: 1 intents: - intent: "set static ip" swm_path: "net.swm" "#, ) .unwrap(); let cat = IntentCatalog::load_from_path(&cat_path).unwrap(); let t = MockTranslator::from_catalog(&cat, d.path()).unwrap(); let s = t.translate("set static ip", &empty_ctx()).unwrap(); assert_eq!(s.mutations.len(), 1); } #[test] fn catalog_rejects_unsupported_version() { let d = tempfile::tempdir().unwrap(); let cat_path = d.path().join("catalog.yaml"); std::fs::write(&cat_path, "version: 9\nintents: []\n").unwrap(); let err = IntentCatalog::load_from_path(&cat_path).unwrap_err(); assert!(matches!(err, TranslateError::Catalog(_))); } #[test] fn catalog_rejects_entry_without_swm() { let d = tempfile::tempdir().unwrap(); let cat_path = d.path().join("catalog.yaml"); std::fs::write( &cat_path, "version: 1\nintents:\n - intent: empty\n", ) .unwrap(); let cat = IntentCatalog::load_from_path(&cat_path).unwrap(); let err = MockTranslator::from_catalog(&cat, d.path()).unwrap_err(); assert!(matches!(err, TranslateError::Catalog(_))); } }