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:
parent
021ee46023
commit
cc23e26802
4 changed files with 1057 additions and 17 deletions
|
|
@ -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, ®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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
927
maskinrommet/src/script_compiler.rs
Normal file
927
maskinrommet/src/script_compiler.rs
Normal 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, ®istry))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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, ®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");
|
||||
}
|
||||
}
|
||||
3
tasks.md
3
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`.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue