//! Script-kompilator for orkestreringer. //! //! Parser menneskelig scriptspråk og kompilerer til tekniske CLI-kall. //! Brukerens "transkriber lydfilen (stor modell)" blir til //! "synops-transcribe --cas-hash {event.cas_hash} --model large". //! //! Kompilatoren er deterministisk — ingen AI, ingen nettverkskall. //! Matchingen bruker `cli_tool`-noders `aliases` og `args_hints`. //! //! Ref: docs/concepts/orkestrering.md § 4 "To lag" use serde::{Deserialize, Serialize}; use std::collections::HashMap; // ============================================================================= // Verktøyregister — representerer cli_tool-noder fra PG // ============================================================================= /// Et CLI-verktøy hentet fra en `cli_tool`-node i grafen. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolDef { /// Binærnavn (f.eks. "synops-transcribe") pub binary: String, /// Norske alias-verb som brukeren kan skrive (f.eks. ["transkriber", "transkribering"]) pub aliases: Vec, /// Beskrivelse for feilmeldinger pub description: String, /// Mapping fra menneskelige argumenter til CLI-flagg/variabler /// F.eks. "stor modell" → "--model large", "lydfilen" → "{event.cas_hash}" pub args_hints: HashMap, } /// Samling av tilgjengelige verktøy (bygget fra cli_tool-noder i PG). #[derive(Debug, Clone, Default)] pub struct ToolRegistry { pub tools: Vec, } impl ToolRegistry { /// Bygg et register fra cli_tool-noders metadata (JSONB). pub fn from_metadata(rows: &[(serde_json::Value,)]) -> Self { let tools = rows .iter() .filter_map(|(meta,)| { let binary = meta.get("binary")?.as_str()?.to_string(); let aliases: Vec = meta .get("aliases")? .as_array()? .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); let description = meta .get("description") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let args_hints: HashMap = meta .get("args_hints") .and_then(|v| v.as_object()) .map(|obj| { obj.iter() .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) .collect() }) .unwrap_or_default(); Some(ToolDef { binary, aliases, description, args_hints, }) }) .collect(); ToolRegistry { tools } } /// Finn verktøy basert på et verb (case-insensitive). fn find_by_verb(&self, verb: &str) -> Option<&ToolDef> { let lower = verb.to_lowercase(); self.tools.iter().find(|t| { t.aliases.iter().any(|a| a.to_lowercase() == lower) || t.binary.to_lowercase() == lower }) } /// Alle tilgjengelige verb (for feilmeldinger). fn all_aliases(&self) -> Vec<&str> { self.tools .iter() .flat_map(|t| t.aliases.iter().map(|a| a.as_str())) .collect() } /// Fuzzy-match: finn nærmeste alias til et ukjent verb. fn suggest(&self, verb: &str) -> Option { let lower = verb.to_lowercase(); let mut best: Option<(&str, usize)> = None; for alias in self.all_aliases() { let dist = levenshtein(&lower, &alias.to_lowercase()); if dist <= 3 { if best.is_none() || dist < best.unwrap().1 { best = Some((alias, dist)); } } } best.map(|(a, _)| a.to_string()) } } // ============================================================================= // AST — parsert representasjon av menneskelig script // ============================================================================= /// Et parsert steg i scriptet. #[derive(Debug, Clone)] pub struct ParsedStep { /// Linjenummer i kildekoden (1-indeksert) pub line_number: usize, /// Stegnummer (1, 2, 3 ...) pub step_number: usize, /// Verbet brukeren skrev (f.eks. "transkriber") pub verb: String, /// Objektet etter verbet (f.eks. "lydfilen") pub object: String, /// Argumenter i parentes (f.eks. ["stor modell"]) pub args: Vec, /// Fallback ved feil (valgfri) pub fallback: Option>, /// Råteksten slik brukeren skrev den pub raw: String, } /// Parsert script med alle steg. #[derive(Debug, Clone)] pub struct ParsedScript { pub steps: Vec, /// Global feilhåndtering (siste "ved feil:"-linje) pub global_fallback: Option, } // ============================================================================= // Kompileringsresultat // ============================================================================= /// Et kompilert steg klart for utførelse. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CompiledStep { pub step_number: usize, pub binary: String, pub args: Vec, /// Fallback-kommando ved feil pub fallback: Option>, } /// Kompilert script — klart for vaktmesteren. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CompiledScript { pub steps: Vec, pub global_fallback: Option, /// Teknisk lag — lesbar representasjon av kompilert script pub technical: String, } /// En diagnostisk melding fra kompilatoren. #[derive(Debug, Clone, Serialize)] pub struct Diagnostic { pub line: usize, pub severity: Severity, pub message: String, pub suggestion: Option, pub raw_input: String, pub compiled_output: Option, } #[derive(Debug, Clone, Serialize, PartialEq)] pub enum Severity { Ok, Error, } /// Resultat fra kompileringen. #[derive(Debug, Clone, Serialize)] pub struct CompileResult { pub diagnostics: Vec, pub compiled: Option, } impl CompileResult { pub fn has_errors(&self) -> bool { self.diagnostics.iter().any(|d| d.severity == Severity::Error) } /// Formater diagnostikk som Rust-stil rapport. pub fn format_report(&self) -> String { let mut out = String::new(); out.push_str("┌─ Kompilering ─────────────────────────────────────┐\n"); out.push_str("│ │\n"); for d in &self.diagnostics { match d.severity { Severity::Ok => { out.push_str(&format!( "│ ✓ Linje {}: {}\n", d.line, d.raw_input )); if let Some(ref compiled) = d.compiled_output { out.push_str(&format!("│ → {compiled}\n")); } } Severity::Error => { out.push_str(&format!( "│ ✗ Linje {}: {}\n", d.line, d.raw_input )); out.push_str(&format!("│ Feil: {}\n", d.message)); if let Some(ref sug) = d.suggestion { out.push_str(&format!("│ Mente du: \"{sug}\"?\n")); } } } out.push_str("│ │\n"); } let errors = self.diagnostics.iter().filter(|d| d.severity == Severity::Error).count(); if errors == 0 { out.push_str("│ Kompilering OK.\n"); } else { out.push_str(&format!( "│ {} feil. Rett opp og prøv igjen.\n", errors )); } out.push_str("└───────────────────────────────────────────────────┘"); out } } // ============================================================================= // Gyldige trigger-kontekstvariabler // ============================================================================= /// Gyldige variabelprefiks og felter fra TriggerContext. const VALID_EVENT_VARS: &[&str] = &[ "event.node_id", "event.node_kind", "event.edge_type", "event.source_id", "event.target_id", "event.op", "event.cas_hash", "event.communication_id", "event.collection_id", "event.url", "event.created_by", ]; /// Sjekk om en variabelreferanse er gyldig. fn is_valid_variable(var: &str) -> bool { // Stripp { og } let inner = var.trim_matches(|c| c == '{' || c == '}'); VALID_EVENT_VARS.contains(&inner) } // ============================================================================= // Parser — menneskelig script → AST // ============================================================================= /// Parser det menneskelige scriptlaget til en AST. /// /// Forventet format: /// ```text /// 1. transkriber lydfilen (stor modell) /// ved feil: transkriber lydfilen (medium modell) /// 2. oppsummer samtalen /// /// ved feil: opprett oppgave "Pipeline feilet" (bug) /// ``` /// /// NÅR/HVIS-linjer ignoreres (de er allerede i metadata.trigger). pub fn parse(script: &str) -> Result { let mut steps = Vec::new(); let mut global_fallback: Option = None; let lines: Vec<&str> = script.lines().collect(); let mut i = 0; while i < lines.len() { let line = lines[i].trim(); // Skip tomme linjer og NÅR/HVIS (allerede håndtert av trigger-metadata) if line.is_empty() || line.starts_with("NÅR ") || line.starts_with("HVIS ") { i += 1; continue; } // Nummerert steg: "1. verb objekt (args)" if let Some(step) = try_parse_numbered_step(line, i + 1) { let mut step = step; // Sjekk neste linje for innrykket "ved feil:" (steg-fallback). // Kun innrykket fallback (starter med whitespace) er steg-fallback. // Uinnrykket "ved feil:" er global fallback. if i + 1 < lines.len() { let next_raw = lines[i + 1]; let is_indented = next_raw.starts_with(' ') || next_raw.starts_with('\t'); if is_indented { let next = next_raw.trim(); if let Some(fallback) = try_parse_fallback(next, i + 2) { step.fallback = Some(Box::new(fallback)); i += 1; // Hopp over fallback-linjen } } } steps.push(step); i += 1; continue; } // Global "ved feil:" (ikke innrykket under et steg) if let Some(fb) = try_parse_fallback(line, i + 1) { global_fallback = Some(fb); i += 1; continue; } // Ukjent linje — hopp over (kommentarer, blanke, etc.) i += 1; } if steps.is_empty() { return Err("Scriptet inneholder ingen steg. Forventet format: \"1. verb objekt (args)\"".into()); } Ok(ParsedScript { steps, global_fallback, }) } /// Forsøk å parse en nummerert steg-linje. /// Format: ". [(, )]" fn try_parse_numbered_step(line: &str, line_number: usize) -> Option { // Match "N. rest" let dot_pos = line.find('.')?; let num_str = &line[..dot_pos]; let step_number: usize = num_str.trim().parse().ok()?; let rest = line[dot_pos + 1..].trim(); if rest.is_empty() { return None; } let (verb, object, args) = parse_step_content(rest); Some(ParsedStep { line_number, step_number, verb, object, args, fallback: None, raw: line.to_string(), }) } /// Parse en "ved feil:"-linje. fn try_parse_fallback(line: &str, line_number: usize) -> Option { let lower = line.to_lowercase(); let rest = if lower.starts_with("ved feil:") { line["ved feil:".len()..].trim() } else if lower.starts_with("ved_feil:") { line["ved_feil:".len()..].trim() } else { return None; }; if rest.is_empty() { return None; } let (verb, object, args) = parse_step_content(rest); Some(ParsedStep { line_number, step_number: 0, verb, object, args, fallback: None, raw: line.to_string(), }) } /// Parse innholdet av et steg: "verb objekt (arg1, arg2)" /// Returnerer (verb, objekt, [args]). fn parse_step_content(content: &str) -> (String, String, Vec) { // Ekstraher argumenter i parentes let (main, args) = if let Some(paren_start) = content.find('(') { let paren_end = content.rfind(')').unwrap_or(content.len()); let args_str = &content[paren_start + 1..paren_end]; let args: Vec = args_str .split(',') .map(|a| a.trim().to_string()) .filter(|a| !a.is_empty()) .collect(); let main = content[..paren_start].trim(); (main, args) } else { (content.trim(), Vec::new()) }; // Splitt verb og objekt let parts: Vec<&str> = main.splitn(2, ' ').collect(); let verb = parts[0].to_string(); let object = if parts.len() > 1 { parts[1].trim().to_string() } else { String::new() }; (verb, object, args) } // ============================================================================= // Kompilator — AST + ToolRegistry → CompiledScript // ============================================================================= /// Kompilerer et parsert script til tekniske CLI-kall. /// /// Ser opp verktøy i registeret, mapper argumenter via `args_hints`, /// og validerer variabelreferanser. pub fn compile(parsed: &ParsedScript, registry: &ToolRegistry) -> CompileResult { let mut diagnostics = Vec::new(); let mut compiled_steps = Vec::new(); for step in &parsed.steps { match compile_step(step, registry) { Ok((compiled, diag)) => { diagnostics.push(diag); compiled_steps.push(compiled); } Err(diag) => { diagnostics.push(diag); } } } // Kompiler global fallback let global_fallback = parsed.global_fallback.as_ref().and_then(|fb| { match compile_step(fb, registry) { Ok((compiled, diag)) => { diagnostics.push(diag); Some(compiled) } Err(diag) => { diagnostics.push(diag); None } } }); let has_errors = diagnostics.iter().any(|d| d.severity == Severity::Error); let compiled = if has_errors { None } else { let technical = format_technical(&compiled_steps, &global_fallback); Some(CompiledScript { steps: compiled_steps, global_fallback, technical, }) }; CompileResult { diagnostics, compiled, } } /// Prøv å finne verktøy med flerords-verb. /// /// Parseren splitter "generer feed for samlingen" til verb="generer", object="feed for samlingen". /// Hvis "generer" ikke matcher noe alias, prøv "generer feed" (verb + første ord av object), /// og returner resten av object som effektivt objekt. fn find_tool_with_multiword_verb<'a>( step: &ParsedStep, registry: &'a ToolRegistry, ) -> Result<(&'a ToolDef, String), Diagnostic> { // 1. Prøv enkeltord-verb if let Some(tool) = registry.find_by_verb(&step.verb) { return Ok((tool, step.object.clone())); } // 2. Prøv flerords-verb: "verb + N første ord av object" if !step.object.is_empty() { let object_words: Vec<&str> = step.object.split_whitespace().collect(); for take in 1..=object_words.len().min(3) { let candidate = format!("{} {}", step.verb, object_words[..take].join(" ")); if let Some(tool) = registry.find_by_verb(&candidate) { let remaining = object_words[take..].join(" "); return Ok((tool, remaining)); } } } // 3. Ingen match — gi feilmelding Err(Diagnostic { line: step.line_number, severity: Severity::Error, message: format!("\"{}\" matcher ingen verktøy", step.verb), suggestion: registry.suggest(&step.verb), raw_input: step.raw.clone(), compiled_output: None, }) } /// Kompiler ett steg. fn compile_step( step: &ParsedStep, registry: &ToolRegistry, ) -> Result<(CompiledStep, Diagnostic), Diagnostic> { // Spesialtilfelle: "opprett oppgave" → work_item if step.verb.to_lowercase() == "opprett" && step.object.to_lowercase().starts_with("oppgave") { return compile_work_item(step); } // Finn verktøy via verb — prøv først enkeltord, deretter flerords-verb // (f.eks. "generer feed" der verb="generer" og object="feed for samlingen") let (tool, effective_object) = find_tool_with_multiword_verb(step, registry)?; // Bygg argumentliste let mut cli_args = Vec::new(); // Map objektet via args_hints (f.eks. "lydfilen" → "--cas-hash {event.cas_hash}") if !effective_object.is_empty() { if let Some(hint) = tool.args_hints.get(&effective_object.to_lowercase()) { // Hint kan inneholde variabel ({event.xxx}) eller flagg (--flag value) expand_hint(hint, &mut cli_args); } // Objektet som ikke matcher args_hints ignoreres (det er kontekst for brukeren) } // Map argumenter i parentes for arg in &step.args { let lower = arg.to_lowercase(); if let Some(hint) = tool.args_hints.get(&lower) { expand_hint(hint, &mut cli_args); } else { // Prøv å matche mot args_hints med fuzzy match let suggestion = tool .args_hints .keys() .find(|k| levenshtein(&lower, &k.to_lowercase()) <= 2) .cloned(); return Err(Diagnostic { line: step.line_number, severity: Severity::Error, message: format!( "Ukjent argument \"{}\" for {}", arg, tool.binary ), suggestion, raw_input: step.raw.clone(), compiled_output: None, }); } } // Valider at alle variabelreferanser i args er gyldige for a in &cli_args { if a.starts_with('{') && a.ends_with('}') && !is_valid_variable(a) { return Err(Diagnostic { line: step.line_number, severity: Severity::Error, message: format!("Ukjent variabel: {a}"), suggestion: Some(format!( "Gyldige variabler: {}", VALID_EVENT_VARS.join(", ") )), raw_input: step.raw.clone(), compiled_output: None, }); } } // Kompiler fallback rekursivt let fallback = step .fallback .as_ref() .and_then(|fb| match compile_step(fb, registry) { Ok((compiled, _)) => Some(Box::new(compiled)), Err(_) => None, // Fallback-feil er allerede rapportert }); let compiled_output = format_step_cli(&tool.binary, &cli_args); Ok(( CompiledStep { step_number: step.step_number, binary: tool.binary.clone(), args: cli_args, fallback, }, Diagnostic { line: step.line_number, severity: Severity::Ok, message: String::new(), suggestion: None, raw_input: step.raw.clone(), compiled_output: Some(compiled_output), }, )) } /// Kompiler "opprett oppgave" → work_item. fn compile_work_item(step: &ParsedStep) -> Result<(CompiledStep, Diagnostic), Diagnostic> { // Ekstraher tittel: alt etter "oppgave" i objektet, eller i anførselstegn let obj = &step.object; let title = if let Some(start) = obj.find('"') { let end = obj[start + 1..].find('"').map(|i| i + start + 1).unwrap_or(obj.len()); &obj[start + 1..end] } else { // Alt etter "oppgave " obj.strip_prefix("oppgave").map(|s| s.trim()).unwrap_or(obj) }; let mut args = vec![format!("\"{}\"", title)]; // Tags fra parentes-argumenter for arg in &step.args { args.push("--tag".to_string()); args.push(arg.clone()); } let compiled_output = format!("work_item {}", args.join(" ")); Ok(( CompiledStep { step_number: step.step_number, binary: "work_item".to_string(), args, fallback: None, }, Diagnostic { line: step.line_number, severity: Severity::Ok, message: String::new(), suggestion: None, raw_input: step.raw.clone(), compiled_output: Some(compiled_output), }, )) } /// Ekspander en args_hint-verdi til CLI-argumenter. /// F.eks. "--model large" → ["--model", "large"] /// F.eks. "{event.cas_hash}" → ["{event.cas_hash}"] /// F.eks. "--cas-hash {event.cas_hash}" → ["--cas-hash", "{event.cas_hash}"] fn expand_hint(hint: &str, args: &mut Vec) { // Splitt på whitespace, men bevar {variabler} og "strenger" intakt for token in hint.split_whitespace() { args.push(token.to_string()); } } /// Formater et steg som CLI-kommando. fn format_step_cli(binary: &str, args: &[String]) -> String { if args.is_empty() { binary.to_string() } else { format!("{} {}", binary, args.join(" ")) } } /// Formater kompilert script som teknisk lag-tekst. fn format_technical(steps: &[CompiledStep], global_fallback: &Option) -> String { let mut out = String::new(); for step in steps { out.push_str(&format!( "{}. {}\n", step.step_number, format_step_cli(&step.binary, &step.args) )); if let Some(ref fb) = step.fallback { out.push_str(&format!( " VED_FEIL: {}\n", format_step_cli(&fb.binary, &fb.args) )); } } if let Some(fb) = global_fallback { out.push_str(&format!( "\nVED_FEIL: {}\n", format_step_cli(&fb.binary, &fb.args) )); } out } // ============================================================================= // Hent verktøyregister fra PG // ============================================================================= /// Hent alle cli_tool-noder fra PG og bygg et ToolRegistry. pub async fn load_tool_registry(db: &sqlx::PgPool) -> Result { let rows = sqlx::query_as::<_, (serde_json::Value,)>( "SELECT metadata FROM nodes WHERE node_kind = 'cli_tool'", ) .fetch_all(db) .await .map_err(|e| format!("Feil ved henting av cli_tool-noder: {e}"))?; Ok(ToolRegistry::from_metadata(&rows)) } /// Full kompileringspipeline: parse script → hent verktøy → kompiler. /// /// Returnerer kompileringsresultat med diagnostikk og (ved suksess) kompilert script. pub async fn compile_script( db: &sqlx::PgPool, script: &str, ) -> Result { let parsed = parse(script)?; let registry = load_tool_registry(db).await?; Ok(compile(&parsed, ®istry)) } // ============================================================================= // Levenshtein-avstand (enkel implementasjon for fuzzy-matching) // ============================================================================= fn levenshtein(a: &str, b: &str) -> usize { let a_chars: Vec = a.chars().collect(); let b_chars: Vec = b.chars().collect(); let m = a_chars.len(); let n = b_chars.len(); if m == 0 { return n; } if n == 0 { return m; } let mut prev: Vec = (0..=n).collect(); let mut curr = vec![0; n + 1]; for i in 1..=m { curr[0] = i; for j in 1..=n { let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 }; curr[j] = (prev[j] + 1) .min(curr[j - 1] + 1) .min(prev[j - 1] + cost); } std::mem::swap(&mut prev, &mut curr); } prev[n] } // ============================================================================= // Tester // ============================================================================= #[cfg(test)] mod tests { use super::*; use serde_json::json; fn test_registry() -> ToolRegistry { ToolRegistry { tools: vec![ ToolDef { binary: "synops-transcribe".into(), aliases: vec!["transkriber".into(), "transkribering".into()], description: "Whisper-transkribering".into(), args_hints: HashMap::from([ ("lydfilen".into(), "--cas-hash {event.cas_hash}".into()), ("stor modell".into(), "--model large".into()), ("medium modell".into(), "--model medium".into()), ]), }, ToolDef { binary: "synops-summarize".into(), aliases: vec!["oppsummer".into()], description: "AI-oppsummering".into(), args_hints: HashMap::from([( "samtalen".into(), "--communication-id {event.communication_id}".into(), )]), }, ToolDef { binary: "synops-rss".into(), aliases: vec!["oppdater rss-feed".into(), "oppdater".into()], description: "RSS-generering".into(), args_hints: HashMap::from([( "rss-feed".into(), "--collection-id {event.collection_id}".into(), )]), }, ], } } #[test] fn test_parse_basic_script() { let script = r#" NÅR innspilling avsluttet HVIS samling har podcast 1. transkriber lydfilen (stor modell) ved feil: transkriber lydfilen (medium modell) 2. oppsummer samtalen 3. oppdater rss-feed ved feil: opprett oppgave "Pipeline feilet" (bug) "#; let parsed = parse(script).unwrap(); assert_eq!(parsed.steps.len(), 3); assert_eq!(parsed.steps[0].verb, "transkriber"); assert_eq!(parsed.steps[0].object, "lydfilen"); assert_eq!(parsed.steps[0].args, vec!["stor modell"]); assert!(parsed.steps[0].fallback.is_some()); assert_eq!(parsed.steps[1].verb, "oppsummer"); assert_eq!(parsed.steps[2].verb, "oppdater"); assert!(parsed.global_fallback.is_some()); } #[test] fn test_compile_full_script() { let script = r#" 1. transkriber lydfilen (stor modell) ved feil: transkriber lydfilen (medium modell) 2. oppsummer samtalen "#; let parsed = parse(script).unwrap(); let registry = test_registry(); let result = compile(&parsed, ®istry); assert!(!result.has_errors()); let compiled = result.compiled.unwrap(); assert_eq!(compiled.steps.len(), 2); assert_eq!(compiled.steps[0].binary, "synops-transcribe"); assert_eq!( compiled.steps[0].args, vec!["--cas-hash", "{event.cas_hash}", "--model", "large"] ); assert!(compiled.steps[0].fallback.is_some()); let fb = compiled.steps[0].fallback.as_ref().unwrap(); assert_eq!(fb.args, vec!["--cas-hash", "{event.cas_hash}", "--model", "medium"]); } #[test] fn test_compile_unknown_verb() { let script = "1. send epost til deltakerne\n"; let parsed = parse(script).unwrap(); let registry = test_registry(); let result = compile(&parsed, ®istry); assert!(result.has_errors()); assert_eq!(result.diagnostics[0].severity, Severity::Error); assert!(result.diagnostics[0].message.contains("matcher ingen verktøy")); } #[test] fn test_compile_unknown_arg() { let script = "1. transkriber lydfilen (ekstra stor modell)\n"; let parsed = parse(script).unwrap(); let registry = test_registry(); let result = compile(&parsed, ®istry); assert!(result.has_errors()); assert!(result.diagnostics[0].message.contains("Ukjent argument")); } #[test] fn test_compile_work_item() { let script = "1. opprett oppgave \"Pipeline feilet\" (bug)\n"; let parsed = parse(script).unwrap(); let registry = test_registry(); let result = compile(&parsed, ®istry); assert!(!result.has_errors()); let compiled = result.compiled.unwrap(); assert_eq!(compiled.steps[0].binary, "work_item"); assert_eq!(compiled.steps[0].args, vec!["\"Pipeline feilet\"", "--tag", "bug"]); } #[test] fn test_compile_report_format() { let script = "1. transkriber lydfilen (stor modell)\n2. send epost\n"; let parsed = parse(script).unwrap(); let registry = test_registry(); let result = compile(&parsed, ®istry); let report = result.format_report(); assert!(report.contains("✓ Linje")); assert!(report.contains("✗ Linje")); assert!(report.contains("1 feil")); } #[test] fn test_parse_empty_script() { assert!(parse("").is_err()); assert!(parse("NÅR noe skjer\nHVIS noe\n").is_err()); } #[test] fn test_levenshtein_basic() { assert_eq!(levenshtein("transkriber", "transkriber"), 0); assert_eq!(levenshtein("transkiber", "transkriber"), 1); assert_eq!(levenshtein("", "abc"), 3); assert_eq!(levenshtein("abc", ""), 3); } #[test] fn test_fuzzy_suggestion() { let registry = test_registry(); // "transkiber" er nær "transkriber" let suggestion = registry.suggest("transkiber"); assert_eq!(suggestion, Some("transkriber".into())); } #[test] fn test_from_metadata() { let rows = vec![(json!({ "binary": "synops-transcribe", "aliases": ["transkriber"], "description": "Test", "args_hints": {"lydfilen": "--cas-hash {event.cas_hash}"} }),)]; let registry = ToolRegistry::from_metadata(&rows); assert_eq!(registry.tools.len(), 1); assert_eq!(registry.tools[0].binary, "synops-transcribe"); } #[test] fn test_valid_variables() { assert!(is_valid_variable("{event.node_id}")); assert!(is_valid_variable("{event.cas_hash}")); assert!(!is_valid_variable("{event.nonexistent}")); assert!(!is_valid_variable("{foo.bar}")); } #[test] fn test_global_fallback_as_ved_feil() { let script = "1. oppsummer samtalen\nved feil: opprett oppgave \"Feil\" (bug)\n"; let parsed = parse(script).unwrap(); assert!(parsed.global_fallback.is_some()); assert_eq!(parsed.global_fallback.unwrap().verb, "opprett"); } #[test] fn test_multiword_verb() { // "oppdater rss-feed" er et flerords-alias. Parseren splitter til // verb="oppdater", object="rss-feed". compile_step skal finne alias // "oppdater rss-feed" via flerords-matching. let registry = test_registry(); let script = "1. oppdater rss-feed\n"; let parsed = parse(script).unwrap(); let result = compile(&parsed, ®istry); // Bør kompilere OK — "oppdater" matcher direkte som alias assert!(!result.has_errors()); // Test med alias som KUN finnes som flerord let mut multi_registry = ToolRegistry { tools: vec![ToolDef { binary: "synops-rss".into(), aliases: vec!["generer feed".into()], description: "RSS".into(), args_hints: HashMap::from([( "for samlingen".into(), "--collection-id {event.collection_id}".into(), )]), }], }; let script2 = "1. generer feed for samlingen\n"; let parsed2 = parse(script2).unwrap(); let result2 = compile(&parsed2, &multi_registry); assert!( !result2.has_errors(), "Flerords-verb 'generer feed' bør matche: {:?}", result2.diagnostics ); let compiled = result2.compiled.unwrap(); assert_eq!(compiled.steps[0].binary, "synops-rss"); assert_eq!( compiled.steps[0].args, vec!["--collection-id", "{event.collection_id}"] ); } #[test] fn test_compile_clip_url() { let registry = ToolRegistry { tools: vec![ToolDef { binary: "synops-clip".into(), aliases: vec!["clip".into(), "klipp".into(), "hent artikkel".into(), "clip url".into()], description: "Hent og parse webartikler".into(), args_hints: HashMap::from([ ("url".into(), "--url {arg}".into()), ("fra event".into(), "--url {event.url}".into()), ("lagre node".into(), "--write".into()), ("bruker".into(), "--created-by {event.created_by}".into()), ("med timeout".into(), "--timeout {arg}".into()), ("force playwright".into(), "--playwright".into()), ]), }], }; // Test: "clip fra event (lagre node, bruker)" let script = "1. clip fra event (lagre node, bruker)\n"; let parsed = parse(script).unwrap(); let result = compile(&parsed, ®istry); assert!( !result.has_errors(), "clip fra event bør kompilere: {:?}", result.diagnostics ); let compiled = result.compiled.unwrap(); assert_eq!(compiled.steps[0].binary, "synops-clip"); assert_eq!( compiled.steps[0].args, vec!["--url", "{event.url}", "--write", "--created-by", "{event.created_by}"] ); } }