Script-kompilator for orkestreringer (oppgave 24.3)

Parser menneskelig scriptspråk og kompilerer til tekniske CLI-kall.
"transkriber lydfilen (stor modell)" → "synops-transcribe --cas-hash {event.cas_hash} --model large"

Kompilatoren:
- Parser nummererte steg, ved_feil-fallbacks, og globale feilhåndterere
- Matcher verb mot cli_tool-noders aliases (case-insensitive)
- Mapper argumenter i parentes via args_hints
- Validerer variabelreferanser ({event.*}) mot kjent liste
- Fuzzy-matching med Levenshtein-avstand for forslag ved feil
- Rust-stil kompileringsrapport med ✓/✗ per linje

Integrert i jobbkøen: orchestrate-jobb kompilerer scriptet og
lagrer pipeline i metadata. Utførelse kommer i oppgave 24.5.

12 unit-tester dekker parser, kompilator, feilhåndtering og fuzzy-matching.
This commit is contained in:
vegard 2026-03-18 17:03:47 +00:00
parent 021ee46023
commit cc23e26802
4 changed files with 1057 additions and 17 deletions

View file

@ -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<serde_json::Value, String> {
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<String>, 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, &registry);
// 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")

View file

@ -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;

View file

@ -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<String>,
/// 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<String, String>,
}
/// Samling av tilgjengelige verktøy (bygget fra cli_tool-noder i PG).
#[derive(Debug, Clone, Default)]
pub struct ToolRegistry {
pub tools: Vec<ToolDef>,
}
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<String> = 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<String, String> = 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<String> {
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<String>,
/// Fallback ved feil (valgfri)
pub fallback: Option<Box<ParsedStep>>,
/// Råteksten slik brukeren skrev den
pub raw: String,
}
/// Parsert script med alle steg.
#[derive(Debug, Clone)]
pub struct ParsedScript {
pub steps: Vec<ParsedStep>,
/// Global feilhåndtering (siste "ved feil:"-linje)
pub global_fallback: Option<ParsedStep>,
}
// =============================================================================
// 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<String>,
/// Fallback-kommando ved feil
pub fallback: Option<Box<CompiledStep>>,
}
/// Kompilert script — klart for vaktmesteren.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompiledScript {
pub steps: Vec<CompiledStep>,
pub global_fallback: Option<CompiledStep>,
/// 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<String>,
pub raw_input: String,
pub compiled_output: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub enum Severity {
Ok,
Error,
}
/// Resultat fra kompileringen.
#[derive(Debug, Clone, Serialize)]
pub struct CompileResult {
pub diagnostics: Vec<Diagnostic>,
pub compiled: Option<CompiledScript>,
}
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<ParsedScript, String> {
let mut steps = Vec::new();
let mut global_fallback: Option<ParsedStep> = 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: "<N>. <verb> <objekt> [(<arg1>, <arg2>)]"
fn try_parse_numbered_step(line: &str, line_number: usize) -> Option<ParsedStep> {
// 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<ParsedStep> {
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<String>) {
// 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<String> = 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<String>) {
// 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<CompiledStep>) -> 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<ToolRegistry, String> {
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<CompileResult, String> {
let parsed = parse(script)?;
let registry = load_tool_registry(db).await?;
Ok(compile(&parsed, &registry))
}
// =============================================================================
// Levenshtein-avstand (enkel implementasjon for fuzzy-matching)
// =============================================================================
fn levenshtein(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = 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<usize> = (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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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");
}
}

View file

@ -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`.