sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ 89ecd54135a72e486f94df045d191d231b253638
crates/hammer-agent/src/translator.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
Fase 6 — bucle agéntico: hammer-agent (cliente + translator + orchestrator) y hammer ai
1//! Traductor de intención (NL) → `.swm`. Ver `docs/08-ai-integration.md` §5.
2//!
3//! Contrato del trait: dado un texto humano + un `SystemContext` (lo que el orquestador
4//! pudo recabar vía QUERY: pins, herramientas presentes, distro_version, etc.), producir un
5//! `Swm` válido.
6//!
7//! Phase 6 entrega un [`MockTranslator`] que mapea intentos exactos contra un catálogo
8//! YAML de SWMs pre-armados. Eso permite probar el bucle plan→build→try→verify→propose en
9//! tests y en el host del autor sin LLM. La integración con un modelo real (Claude API,
10//! local llama, etc.) se conecta detrás de este mismo trait — el bucle no cambia.
11
12use std::collections::HashMap;
13use std::path::Path;
14
15use hammer_core::{BaseRef, Swm};
16
17#[derive(Debug, thiserror::Error)]
18pub enum TranslateError {
19 #[error("intent no reconocida en el catálogo: '{0}'")]
20 UnknownIntent(String),
21 #[error("io: {0}")]
22 Io(#[from] std::io::Error),
23 #[error("catálogo inválido: {0}")]
24 Catalog(String),
25 #[error("modelo: {0}")]
26 Model(String),
27}
28
29/// Lo que el orquestador le pasa al traductor para contextualizar la intención. Un LLM
30/// usaría esto para decidir, p. ej., "¿el sistema ya tiene grep 3.12? entonces no necesito
31/// recompilar; sólo edito la config". Para el `MockTranslator` es decorativo.
32#[derive(Debug, Clone)]
33pub struct SystemContext {
34 pub base: BaseRef,
35 /// Información libre que el orquestador haya recolectado (rutas y metadatos de archivos
36 /// referenciados por el intent, salida de QUERY, etc.). Estructura JSON arbitraria.
37 pub extras: serde_json::Value,
38}
39
40pub trait IntentTranslator {
41 fn translate(&self, intent: &str, ctx: &SystemContext) -> Result<Swm, TranslateError>;
42}
43
44/// Catálogo intento→.swm leído de un YAML:
45///
46/// ```yaml
47/// version: 1
48/// intents:
49/// - intent: "set static IP 192.168.1.100"
50/// swm_path: "swms/static-ip.swm" # ruta relativa al directorio del catálogo
51/// # OR
52/// swm_inline: { swm_version: 1, ... }
53/// ```
54///
55/// El propósito es tener un "test cassette": el agente humano captura una intención que la
56/// IA real ya resolvió, congela el `.swm` resultante, y a partir de ahí los CI/tests del
57/// bucle agéntico son reproducibles.
58#[derive(Debug, Clone, serde::Deserialize)]
59pub struct IntentCatalog {
60 #[serde(default = "default_version")]
61 pub version: u32,
62 pub intents: Vec<CatalogEntry>,
63}
64
65#[derive(Debug, Clone, serde::Deserialize)]
66pub struct CatalogEntry {
67 pub intent: String,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub swm_path: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub swm_inline: Option<Swm>,
72}
73
74fn default_version() -> u32 {
75 1
76}
77
78impl IntentCatalog {
79 pub fn load_from_path(path: &Path) -> Result<Self, TranslateError> {
80 let text = std::fs::read_to_string(path)?;
81 let cat: IntentCatalog = serde_yaml::from_str(&text)
82 .map_err(|e| TranslateError::Catalog(e.to_string()))?;
83 if cat.version != 1 {
84 return Err(TranslateError::Catalog(format!(
85 "version del catálogo no soportada: {} (esperada 1)",
86 cat.version
87 )));
88 }
89 Ok(cat)
90 }
91
92 /// Resuelve un `intent` exacto contra el catálogo. Las entradas `swm_path` se resuelven
93 /// relativas al `base_dir` (típicamente el directorio del catálogo).
94 pub fn resolve(&self, intent: &str, base_dir: &Path) -> Result<Swm, TranslateError> {
95 let entry = self
96 .intents
97 .iter()
98 .find(|e| e.intent == intent)
99 .ok_or_else(|| TranslateError::UnknownIntent(intent.into()))?;
100 if let Some(s) = &entry.swm_inline {
101 return Ok(s.clone());
102 }
103 if let Some(p) = &entry.swm_path {
104 let full = base_dir.join(p);
105 let text = std::fs::read_to_string(&full)?;
106 return Swm::from_yaml(&text)
107 .map_err(|e| TranslateError::Catalog(format!("swm {}: {e}", full.display())));
108 }
109 Err(TranslateError::Catalog(format!(
110 "entry '{intent}' no declara swm_path ni swm_inline"
111 )))
112 }
113}
114
115/// Traductor offline: matchea la intención exacta contra un mapa precargado.
116#[derive(Debug, Default)]
117pub struct MockTranslator {
118 map: HashMap<String, Swm>,
119}
120
121impl MockTranslator {
122 pub fn empty() -> Self {
123 Self { map: HashMap::new() }
124 }
125
126 pub fn from_catalog(cat: &IntentCatalog, base_dir: &Path) -> Result<Self, TranslateError> {
127 let mut map = HashMap::new();
128 for entry in &cat.intents {
129 let swm = match (&entry.swm_inline, &entry.swm_path) {
130 (Some(s), None) => s.clone(),
131 (None, Some(p)) => {
132 let full = base_dir.join(p);
133 let text = std::fs::read_to_string(&full)?;
134 Swm::from_yaml(&text).map_err(|e| {
135 TranslateError::Catalog(format!("swm {}: {e}", full.display()))
136 })?
137 }
138 _ => {
139 return Err(TranslateError::Catalog(format!(
140 "entry '{}' debe declarar exactamente uno de swm_inline/swm_path",
141 entry.intent
142 )));
143 }
144 };
145 map.insert(entry.intent.clone(), swm);
146 }
147 Ok(Self { map })
148 }
149
150 pub fn with_intent(mut self, intent: impl Into<String>, swm: Swm) -> Self {
151 self.map.insert(intent.into(), swm);
152 self
153 }
154}
155
156impl IntentTranslator for MockTranslator {
157 fn translate(&self, intent: &str, _ctx: &SystemContext) -> Result<Swm, TranslateError> {
158 self.map
159 .get(intent)
160 .cloned()
161 .ok_or_else(|| TranslateError::UnknownIntent(intent.into()))
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use hammer_core::swm::{Base, Mutation};
169 use std::collections::BTreeMap;
170
171 fn empty_ctx() -> SystemContext {
172 SystemContext {
173 base: BaseRef {
174 distro_version: "2026-06-06".into(),
175 pins: BTreeMap::new(),
176 },
177 extras: serde_json::Value::Null,
178 }
179 }
180
181 fn swm_demo() -> Swm {
182 Swm {
183 swm_version: 1,
184 base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
185 mutations: vec![Mutation::ConfigEdit {
186 file: "/etc/network.conf".into(),
187 inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(),
188 }],
189 signature: None,
190 }
191 }
192
193 #[test]
194 fn mock_translator_returns_known_swm() {
195 let t = MockTranslator::empty().with_intent("set static ip", swm_demo());
196 let s = t.translate("set static ip", &empty_ctx()).unwrap();
197 assert_eq!(s.mutations.len(), 1);
198 }
199
200 #[test]
201 fn mock_translator_rejects_unknown() {
202 let t = MockTranslator::empty();
203 let err = t.translate("paint the wall green", &empty_ctx()).unwrap_err();
204 assert!(matches!(err, TranslateError::UnknownIntent(_)));
205 }
206
207 #[test]
208 fn catalog_load_inline() {
209 let d = tempfile::tempdir().unwrap();
210 let cat_path = d.path().join("catalog.yaml");
211 std::fs::write(
212 &cat_path,
213 r#"
214version: 1
215intents:
216 - intent: "set static ip"
217 swm_inline:
218 swm_version: 1
219 base:
220 distro_version: "2026-06-06"
221 mutations:
222 - type: config_edit
223 file: "/etc/network.conf"
224 inline_diff: "- DHCP=yes\n+ STATIC=1\n"
225"#,
226 )
227 .unwrap();
228 let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
229 let t = MockTranslator::from_catalog(&cat, d.path()).unwrap();
230 let s = t.translate("set static ip", &empty_ctx()).unwrap();
231 assert_eq!(s.mutations.len(), 1);
232 }
233
234 #[test]
235 fn catalog_load_from_swm_path() {
236 let d = tempfile::tempdir().unwrap();
237 let swm_path = d.path().join("net.swm");
238 std::fs::write(&swm_path, swm_demo().to_yaml().unwrap()).unwrap();
239 let cat_path = d.path().join("catalog.yaml");
240 std::fs::write(
241 &cat_path,
242 r#"
243version: 1
244intents:
245 - intent: "set static ip"
246 swm_path: "net.swm"
247"#,
248 )
249 .unwrap();
250 let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
251 let t = MockTranslator::from_catalog(&cat, d.path()).unwrap();
252 let s = t.translate("set static ip", &empty_ctx()).unwrap();
253 assert_eq!(s.mutations.len(), 1);
254 }
255
256 #[test]
257 fn catalog_rejects_unsupported_version() {
258 let d = tempfile::tempdir().unwrap();
259 let cat_path = d.path().join("catalog.yaml");
260 std::fs::write(&cat_path, "version: 9\nintents: []\n").unwrap();
261 let err = IntentCatalog::load_from_path(&cat_path).unwrap_err();
262 assert!(matches!(err, TranslateError::Catalog(_)));
263 }
264
265 #[test]
266 fn catalog_rejects_entry_without_swm() {
267 let d = tempfile::tempdir().unwrap();
268 let cat_path = d.path().join("catalog.yaml");
269 std::fs::write(
270 &cat_path,
271 "version: 1\nintents:\n - intent: empty\n",
272 )
273 .unwrap();
274 let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
275 let err = MockTranslator::from_catalog(&cat, d.path()).unwrap_err();
276 assert!(matches!(err, TranslateError::Catalog(_)));
277 }
278}