diff --git a/maskinrommet/src/jobs.rs b/maskinrommet/src/jobs.rs index a6e0fdd..98ea1fa 100644 --- a/maskinrommet/src/jobs.rs +++ b/maskinrommet/src/jobs.rs @@ -24,6 +24,7 @@ use crate::maintenance::MaintenanceState; use crate::pg_writes; use crate::publishing::IndexCache; use crate::resources::{self, PriorityRules}; +use crate::script_compiler; use crate::summarize; use crate::transcribe; use crate::tts; @@ -217,27 +218,139 @@ async fn dispatch( "pg_delete_edge" => { pg_writes::handle_delete_edge(job, db, index_cache).await } - // Orchestration: trigger-evaluering har lagt jobben i kø, - // men utførelsen implementeres i oppgave 24.3. - // Foreløpig logger vi og returnerer OK. + // Orchestration: trigger-evaluering har lagt jobben i kø. + // Kompilatoren parser scriptet og validerer det. + // Utførelse av kompilert script kommer i oppgave 24.5. "orchestrate" => { - let orch_id = job.payload.get("orchestration_id") - .and_then(|v| v.as_str()) - .unwrap_or("ukjent"); - tracing::info!( - orchestration_id = %orch_id, - "Orchestrate-jobb mottatt (utførelse kommer i oppgave 24.3)" - ); - Ok(serde_json::json!({ - "status": "pending_implementation", - "orchestration_id": orch_id, - "message": "Orchestration execution not yet implemented (task 24.3)" - })) + handle_orchestrate(job, db).await } other => Err(format!("Ukjent jobbtype: {other}")), } } +/// Handler for `orchestrate`-jobb — kompilerer orchestration-script. +/// +/// Henter orchestration-nodens `content` (menneskelig script), +/// kompilerer det via script_compiler, og lagrer resultatet +/// i nodens `metadata.pipeline`. +/// +/// Utførelse av kompilert pipeline kommer i oppgave 24.5. +async fn handle_orchestrate( + job: &JobRow, + db: &PgPool, +) -> Result { + let orch_id: Uuid = job + .payload + .get("orchestration_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .ok_or("Mangler orchestration_id i payload")?; + + tracing::info!(orchestration_id = %orch_id, "Kompilerer orchestration-script"); + + // Hent orchestration-nodens content og metadata + let row = sqlx::query_as::<_, (Option, serde_json::Value)>( + "SELECT content, metadata FROM nodes WHERE id = $1 AND node_kind = 'orchestration'" + ) + .bind(orch_id) + .fetch_optional(db) + .await + .map_err(|e| format!("Feil ved henting av orchestration-node: {e}"))? + .ok_or_else(|| format!("Orchestration-node {orch_id} finnes ikke"))?; + + let (content, _metadata) = row; + let script = content.ok_or("Orchestration-node mangler content (script)")?; + + if script.trim().is_empty() { + return Err("Orchestration-script er tomt".into()); + } + + // Parse scriptet + let parsed = script_compiler::parse(&script) + .map_err(|e| format!("Parse-feil: {e}"))?; + + // Hent verktøyregister fra cli_tool-noder + let registry = script_compiler::load_tool_registry(db).await?; + + // Kompiler + let result = script_compiler::compile(&parsed, ®istry); + + // Logg rapport + let report = result.format_report(); + tracing::info!( + orchestration_id = %orch_id, + errors = result.has_errors(), + "\n{report}" + ); + + if result.has_errors() { + // Lagre feilrapport i metadata for UI-visning + let diagnostics_json = serde_json::to_value(&result.diagnostics) + .map_err(|e| format!("Serialiseringsfeil: {e}"))?; + + sqlx::query( + r#"UPDATE nodes + SET metadata = jsonb_set( + jsonb_set(metadata, '{compile_errors}', $2), + '{compiled}', 'false' + ) + WHERE id = $1"#, + ) + .bind(orch_id) + .bind(&diagnostics_json) + .execute(db) + .await + .map_err(|e| format!("Feil ved lagring av kompileringsfeil: {e}"))?; + + return Err(format!("Kompilering feilet:\n{report}")); + } + + // Suksess — lagre kompilert pipeline i metadata + let compiled = result.compiled.as_ref().unwrap(); + let pipeline_json = serde_json::to_value(&compiled.steps) + .map_err(|e| format!("Serialiseringsfeil: {e}"))?; + + let global_fb_json = compiled + .global_fallback + .as_ref() + .map(|fb| serde_json::to_value(fb).unwrap_or_default()) + .unwrap_or(serde_json::Value::Null); + + sqlx::query( + r#"UPDATE nodes + SET metadata = jsonb_set( + jsonb_set( + jsonb_set( + metadata - 'compile_errors', + '{pipeline}', $2 + ), + '{compiled}', 'true' + ), + '{global_fallback}', $3 + ) + WHERE id = $1"#, + ) + .bind(orch_id) + .bind(&pipeline_json) + .bind(&global_fb_json) + .execute(db) + .await + .map_err(|e| format!("Feil ved lagring av kompilert pipeline: {e}"))?; + + tracing::info!( + orchestration_id = %orch_id, + steps = compiled.steps.len(), + "Orchestration-script kompilert" + ); + + Ok(serde_json::json!({ + "status": "compiled", + "orchestration_id": orch_id.to_string(), + "steps": compiled.steps.len(), + "technical": compiled.technical, + })) +} + /// Synops-render binary path. fn render_bin() -> String { std::env::var("SYNOPS_RENDER_BIN") diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 0eaf97d..4edc52f 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -26,6 +26,7 @@ pub mod summarize; pub mod ws; pub mod mixer; pub mod orchestration_trigger; +pub mod script_compiler; pub mod tiptap; pub mod transcribe; pub mod tts; diff --git a/maskinrommet/src/script_compiler.rs b/maskinrommet/src/script_compiler.rs new file mode 100644 index 0000000..1c76a22 --- /dev/null +++ b/maskinrommet/src/script_compiler.rs @@ -0,0 +1,927 @@ +//! 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", +]; + +/// 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, + } +} + +/// 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 + let tool = registry.find_by_verb(&step.verb).ok_or_else(|| { + let suggestion = registry.suggest(&step.verb); + Diagnostic { + line: step.line_number, + severity: Severity::Error, + message: format!("\"{}\" matcher ingen verktøy", step.verb), + suggestion, + raw_input: step.raw.clone(), + compiled_output: None, + } + })?; + + // Bygg argumentliste + let mut cli_args = Vec::new(); + + // Map objektet via args_hints (f.eks. "lydfilen" → "--cas-hash {event.cas_hash}") + if !step.object.is_empty() { + if let Some(hint) = tool.args_hints.get(&step.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"); + } +} diff --git a/tasks.md b/tasks.md index c6fc3b6..c692eff 100644 --- a/tasks.md +++ b/tasks.md @@ -318,8 +318,7 @@ automatisk eskalering av intelligens ved feil, kompilering av velprøvde mønstr - [x] 24.1 Orchestration node-type: legg til `orchestration` i maskinrommets node-validering. Metadata-skjema: `trigger` (event + conditions), `executor`, `intelligence`, `effort`, `compiled`, `pipeline`. Valider trigger-events mot kjent liste. - [x] 24.2 Trigger-evaluering i portvokteren: ved node/edge-events, sjekk om noen `orchestration`-noder matcher triggeren. Effektiv lookup (indeksert på `metadata.trigger.event`). Ingen LLM-kall for trigger-matching. -- [~] 24.3 Script-kompilator: parser menneskelig scriptspråk ("transkriber lydfilen (stor modell)") og kompilerer til tekniske CLI-kall. Matcher verb mot `cli_tool`-noders `aliases`, argumenter mot `args_hints`, variabler fra trigger-kontekst. Rust-stil kompileringsfeil med forslag. - > Påbegynt: 2026-03-18T16:55 +- [x] 24.3 Script-kompilator: parser menneskelig scriptspråk ("transkriber lydfilen (stor modell)") og kompilerer til tekniske CLI-kall. Matcher verb mot `cli_tool`-noders `aliases`, argumenter mot `args_hints`, variabler fra trigger-kontekst. Rust-stil kompileringsfeil med forslag. - [ ] 24.4 cli_tool alias-metadata: utvid alle `cli_tool`-noder med `aliases` (norske verb) og `args_hints` (menneskelige argumenter → CLI-flagg). Seed for alle eksisterende verktøy. - [ ] 24.5 Script-executor: vaktmesteren parser kompilert script og eksekverer steg sekvensielt via generisk dispatch. VED_FEIL-håndtering. Logger i `orchestration_log`. - [ ] 24.6 Orchestration UI: editor med tre visninger (Enkel/Teknisk/Kompilert) som tabber. Sanntids kompileringsfeil. Trigger-velger, "Test kjøring"-knapp, kjørehistorikk. Ref: `docs/concepts/orkestrering.md`.