AI-assistert oppretting: synops-ai genererer orkestreringsscript fra fritekst (oppgave 24.7)

Nytt CLI-verktøy `synops-ai` som leser cli_tool-noder fra PG, bygger
en systemprompt med tilgjengelige verktøy og script-grammatikk, og
bruker LLM til å foreslå orkestreringsscript fra fritekst-beskrivelse.

Tre moduser:
- Synkron: --description "..." → LLM genererer script → JSON output
- System prompt: --generate-system-prompt → skriver auto-generert prompt
- Eventually: --eventually → lagrer som work_item for Claude Code

Maskinrommet: nytt endepunkt POST /intentions/ai_suggest_script som
kaller synops-ai, validerer resultatet med script_compiler, og returnerer
script + kompileringsresultat til frontend.

Frontend: AI-assistent-knapp i OrchestrationTrait med fritekst-input,
generer-knapp, og feilvisning. Generert script settes direkte i editoren.

Migration: synops-ai seeded som cli_tool-node med norske verb-alias.
This commit is contained in:
vegard 2026-03-18 17:47:32 +00:00
parent 91a73329a4
commit d18dfc260f
8 changed files with 3940 additions and 1 deletions

View file

@ -1433,3 +1433,31 @@ export async function setMixerRole(
role, role,
}); });
} }
// =========================================================================
// AI-assistert script-generering (oppgave 24.7)
// =========================================================================
export interface AiSuggestScriptRequest {
description: string;
trigger_event?: string;
trigger_conditions?: Record<string, unknown>;
eventually?: boolean;
collection_id?: string;
}
export interface AiSuggestScriptResponse {
status: string;
script?: string;
compile_result?: CompileScriptResponse;
work_item_id?: string;
message?: string;
}
/** AI-assistert generering av orkestreringsscript fra fritekst-beskrivelse. */
export function aiSuggestScript(
accessToken: string,
req: AiSuggestScriptRequest
): Promise<AiSuggestScriptResponse> {
return post(accessToken, '/intentions/ai_suggest_script', req);
}

View file

@ -6,6 +6,7 @@
compileScript, compileScript,
testOrchestration, testOrchestration,
fetchOrchestrationLog, fetchOrchestrationLog,
aiSuggestScript,
type CompileScriptResponse, type CompileScriptResponse,
type OrchestrationLogEntry type OrchestrationLogEntry
} from '$lib/api'; } from '$lib/api';
@ -77,6 +78,12 @@
let triggerConditions = $state(''); let triggerConditions = $state('');
let executor = $state('script'); let executor = $state('script');
// AI-assist
let showAiAssist = $state(false);
let aiDescription = $state('');
let aiGenerating = $state(false);
let aiError: string | null = $state(null);
// History // History
let logEntries: OrchestrationLogEntry[] = $state([]); let logEntries: OrchestrationLogEntry[] = $state([]);
let showHistory = $state(false); let showHistory = $state(false);
@ -214,6 +221,42 @@
if (showHistory) loadHistory(); if (showHistory) loadHistory();
} }
async function generateWithAi() {
if (!accessToken || !aiDescription.trim()) return;
aiGenerating = true;
aiError = null;
try {
const result = await aiSuggestScript(accessToken, {
description: aiDescription,
trigger_event: triggerEvent !== 'manual' ? triggerEvent : undefined,
collection_id: collection?.id,
});
if (result.script) {
scriptContent = result.script;
aiDescription = '';
showAiAssist = false;
}
if (result.status === 'generated_with_errors' && result.compile_result) {
const errors = result.compile_result.diagnostics
?.filter((d) => d.severity === 'Error')
.map((d) => d.message)
.join('; ');
if (errors) {
aiError = `Script generert med feil: ${errors}`;
}
} else if (result.status === 'deferred') {
aiError = null;
showAiAssist = false;
}
} catch (err) {
aiError = err instanceof Error ? err.message : 'Ukjent feil';
} finally {
aiGenerating = false;
}
}
// ========================================================================= // =========================================================================
// Trigger event options // Trigger event options
// ========================================================================= // =========================================================================
@ -383,6 +426,35 @@
{/if} {/if}
</div> </div>
<!-- AI-assist panel -->
{#if showAiAssist}
<div class="orch-ai-panel">
<div class="orch-ai-header">
<span class="orch-ai-label">AI-assistent</span>
<button class="orch-btn-close" onclick={() => { showAiAssist = false; }}>&times;</button>
</div>
<textarea
class="orch-ai-input"
bind:value={aiDescription}
placeholder="Beskriv hva orkestreringen skal gjøre..."
rows="3"
disabled={aiGenerating}
></textarea>
{#if aiError}
<div class="orch-ai-error">{aiError}</div>
{/if}
<div class="orch-ai-actions">
<button
class="orch-btn orch-btn-ai"
onclick={generateWithAi}
disabled={aiGenerating || !aiDescription.trim()}
>
{aiGenerating ? 'Genererer...' : 'Generer script'}
</button>
</div>
</div>
{/if}
<!-- Actions --> <!-- Actions -->
<div class="orch-actions"> <div class="orch-actions">
<button <button
@ -400,6 +472,13 @@
> >
{testRunning ? 'Kjører...' : 'Test kjøring'} {testRunning ? 'Kjører...' : 'Test kjøring'}
</button> </button>
<button
class="orch-btn orch-btn-ai-toggle"
onclick={() => { showAiAssist = !showAiAssist; }}
title="Generer script fra fritekst-beskrivelse med AI"
>
{showAiAssist ? 'Skjul AI' : 'AI-assistent'}
</button>
<button <button
class="orch-btn orch-btn-secondary" class="orch-btn orch-btn-secondary"
onclick={toggleHistory} onclick={toggleHistory}
@ -903,6 +982,103 @@
word-break: break-word; word-break: break-word;
} }
/* ================================================================= */
/* AI-assist panel */
/* ================================================================= */
.orch-ai-panel {
border-top: 1px solid #e5e7eb;
padding: 8px 12px;
background: #f0f4ff;
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.orch-ai-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.orch-ai-label {
font-size: 11px;
font-weight: 600;
color: #4f46e5;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.orch-btn-close {
border: none;
background: transparent;
font-size: 16px;
color: #6b7280;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.orch-btn-close:hover {
color: #1f2937;
}
.orch-ai-input {
width: 100%;
padding: 6px 8px;
border: 1px solid #c7d2fe;
border-radius: 4px;
font-size: 13px;
font-family: inherit;
resize: vertical;
min-height: 48px;
background: white;
color: #1f2937;
}
.orch-ai-input::placeholder {
color: #a5b4fc;
}
.orch-ai-input:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
.orch-ai-error {
font-size: 11px;
color: #dc2626;
background: #fef2f2;
padding: 4px 8px;
border-radius: 3px;
}
.orch-ai-actions {
display: flex;
gap: 6px;
}
.orch-btn-ai {
background: #4f46e5;
color: white;
border-color: #4f46e5;
}
.orch-btn-ai:hover:not(:disabled) {
background: #4338ca;
}
.orch-btn-ai-toggle {
background: #eef2ff;
color: #4f46e5;
border-color: #c7d2fe;
}
.orch-btn-ai-toggle:hover:not(:disabled) {
background: #e0e7ff;
}
/* ================================================================= */ /* ================================================================= */
/* Responsive */ /* Responsive */
/* ================================================================= */ /* ================================================================= */

View file

@ -4499,6 +4499,155 @@ pub async fn orchestration_log(
Ok(Json(OrchestrationLogResponse { entries })) Ok(Json(OrchestrationLogResponse { entries }))
} }
// =============================================================================
// AI-assistert script-generering (oppgave 24.7)
// =============================================================================
#[derive(Deserialize)]
pub struct AiSuggestScriptRequest {
/// Fritekst-beskrivelse av ønsket orkestrering
pub description: String,
/// Trigger-event (valgfri, f.eks. "communication.ended")
pub trigger_event: Option<String>,
/// Trigger-betingelser som JSON (valgfri)
pub trigger_conditions: Option<serde_json::Value>,
/// Eventually-modus: lagre som work_item i stedet for synkront LLM-kall
#[serde(default)]
pub eventually: bool,
/// Samlings-ID å knytte til (valgfri)
pub collection_id: Option<Uuid>,
}
#[derive(Serialize)]
pub struct AiSuggestScriptResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub script: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compile_result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub work_item_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
/// AI-assistert generering av orkestreringsscript.
///
/// Kaller `synops-ai` CLI-verktøyet for å generere et script fra
/// fritekst-beskrivelse, og validerer resultatet via script-kompilatoren.
/// I eventually-modus lagres forespørselen som work_item for Claude Code.
///
/// Ref: docs/concepts/orkestrering.md § "Nivå 2: AI-assistert oppretting"
pub async fn ai_suggest_script(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<AiSuggestScriptRequest>,
) -> Result<Json<AiSuggestScriptResponse>, (StatusCode, Json<ErrorResponse>)> {
use crate::cli_dispatch;
use crate::script_compiler;
if req.description.trim().is_empty() {
return Err(bad_request("description kan ikke være tom"));
}
// Eventually-modus: kall synops-ai --eventually
if req.eventually {
let mut cmd = tokio::process::Command::new("synops-ai");
cmd.arg("--description").arg(&req.description);
cmd.arg("--eventually");
cmd.arg("--requested-by").arg(user.node_id.to_string());
if let Some(ref event) = req.trigger_event {
cmd.arg("--trigger-event").arg(event);
}
if let Some(ref conditions) = req.trigger_conditions {
cmd.arg("--trigger-conditions").arg(conditions.to_string());
}
if let Some(ref coll_id) = req.collection_id {
cmd.arg("--collection-id").arg(coll_id.to_string());
}
cli_dispatch::set_database_url(&mut cmd)
.map_err(|e| internal_error(&e))?;
cli_dispatch::forward_env(&mut cmd, "AI_GATEWAY_URL");
cli_dispatch::forward_env(&mut cmd, "LITELLM_MASTER_KEY");
cli_dispatch::forward_env(&mut cmd, "AI_SCRIPT_MODEL");
let result = cli_dispatch::run_cli_tool("synops-ai", &mut cmd)
.await
.map_err(|e| internal_error(&format!("synops-ai feilet: {e}")))?;
let work_item_id = result.get("work_item_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
return Ok(Json(AiSuggestScriptResponse {
status: "deferred".to_string(),
script: None,
compile_result: None,
work_item_id,
message: Some("Forespørselen er lagret. Scriptet genereres i bakgrunnen.".to_string()),
}));
}
// Synkron modus: kall synops-ai for generering
let mut cmd = tokio::process::Command::new("synops-ai");
cmd.arg("--description").arg(&req.description);
if let Some(ref event) = req.trigger_event {
cmd.arg("--trigger-event").arg(event);
}
if let Some(ref conditions) = req.trigger_conditions {
cmd.arg("--trigger-conditions").arg(conditions.to_string());
}
if let Some(ref coll_id) = req.collection_id {
cmd.arg("--collection-id").arg(coll_id.to_string());
}
cmd.arg("--requested-by").arg(user.node_id.to_string());
cli_dispatch::set_database_url(&mut cmd)
.map_err(|e| internal_error(&e))?;
cli_dispatch::forward_env(&mut cmd, "AI_GATEWAY_URL");
cli_dispatch::forward_env(&mut cmd, "LITELLM_MASTER_KEY");
cli_dispatch::forward_env(&mut cmd, "AI_SCRIPT_MODEL");
let ai_result = cli_dispatch::run_cli_tool("synops-ai", &mut cmd)
.await
.map_err(|e| internal_error(&format!("synops-ai feilet: {e}")))?;
let generated_script = ai_result
.get("script")
.and_then(|v| v.as_str())
.ok_or_else(|| internal_error("synops-ai returnerte ingen script"))?
.to_string();
// Valider scriptet med kompilatoren
let compile_result = script_compiler::compile_script(&state.db, &generated_script)
.await
.map_err(|e| internal_error(&format!("Kompileringsfeil: {e}")))?;
let compile_json = serde_json::to_value(&compile_result)
.map_err(|e| internal_error(&format!("Serialisering: {e}")))?;
tracing::info!(
user = %user.node_id,
has_errors = compile_result.has_errors(),
"AI-script generert og validert"
);
Ok(Json(AiSuggestScriptResponse {
status: if compile_result.has_errors() {
"generated_with_errors".to_string()
} else {
"completed".to_string()
},
script: Some(generated_script),
compile_result: Some(compile_json),
work_item_id: None,
message: None,
}))
}
// ============================================================================= // =============================================================================
// Tester // Tester
// ============================================================================= // =============================================================================

View file

@ -258,9 +258,10 @@ async fn main() {
.route("/custom-domain/sok", get(custom_domain::serve_custom_domain_search)) .route("/custom-domain/sok", get(custom_domain::serve_custom_domain_search))
.route("/custom-domain/om", get(custom_domain::serve_custom_domain_about)) .route("/custom-domain/om", get(custom_domain::serve_custom_domain_about))
.route("/custom-domain/{article_id}", get(custom_domain::serve_custom_domain_article)) .route("/custom-domain/{article_id}", get(custom_domain::serve_custom_domain_article))
// Orkestrering UI (oppgave 24.6) // Orkestrering UI (oppgave 24.6) + AI-assistert (oppgave 24.7)
.route("/intentions/compile_script", post(intentions::compile_script)) .route("/intentions/compile_script", post(intentions::compile_script))
.route("/intentions/test_orchestration", post(intentions::test_orchestration)) .route("/intentions/test_orchestration", post(intentions::test_orchestration))
.route("/intentions/ai_suggest_script", post(intentions::ai_suggest_script))
.route("/query/orchestration_log", get(intentions::orchestration_log)) .route("/query/orchestration_log", get(intentions::orchestration_log))
// Mixer-kanaler // Mixer-kanaler
.route("/intentions/create_mixer_channel", post(mixer::create_mixer_channel)) .route("/intentions/create_mixer_channel", post(mixer::create_mixer_channel))

View file

@ -0,0 +1,36 @@
-- 024_cli_tool_synops_ai.sql
-- Oppgave 24.7: Seed cli_tool-node for synops-ai.
-- AI-assistert generering av orkestreringsscript fra fritekst-beskrivelse.
-- Ref: docs/concepts/orkestrering.md § "Nivå 2: AI-assistert oppretting"
BEGIN;
-- =============================================================================
-- synops-ai — AI-assistert script-generering
-- =============================================================================
INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by)
VALUES (
'f0000000-c100-4000-b000-000000000015',
'cli_tool',
'synops-ai',
'discoverable',
'{
"binary": "synops-ai",
"aliases": ["generer script", "lag orkestrering", "ai-forslag", "foreslå script"],
"description": "AI-assistert generering av orkestreringsscript fra fritekst-beskrivelse",
"args_hints": {
"beskrivelsen": "--description {input.description}",
"triggeren": "--trigger-event {input.trigger_event}",
"betingelsene": "--trigger-conditions {input.trigger_conditions}",
"i bakgrunnen": "--eventually",
"for brukeren": "--requested-by {event.node_id}",
"for samlingen": "--collection-id {event.collection_id}"
}
}'::jsonb,
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'
)
ON CONFLICT (id) DO UPDATE SET
metadata = EXCLUDED.metadata,
title = EXCLUDED.title;
COMMIT;

2929
tools/synops-ai/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
[package]
name = "synops-ai"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "synops-ai"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
synops-common = { path = "../synops-common" }

599
tools/synops-ai/src/main.rs Normal file
View file

@ -0,0 +1,599 @@
// synops-ai — AI-assistert oppretting av orkestreringsscript.
//
// Leser cli_tool-noder fra PG, bygger en systemprompt med tilgjengelige
// verktøy og script-grammatikk, og bruker LLM til å generere et
// orkestreringsscript fra en fritekst-beskrivelse.
//
// Tre moduser:
// 1. Generer script: --description "..." [--trigger-event ...]
// 2. System prompt: --generate-system-prompt (skriver prompt til stdout)
// 3. Eventually-modus: --description "..." --eventually (lagrer som work_item)
//
// Miljøvariabler:
// DATABASE_URL — PostgreSQL-tilkobling (påkrevd)
// AI_GATEWAY_URL — LiteLLM gateway (default: http://localhost:4000)
// LITELLM_MASTER_KEY — API-nøkkel for LiteLLM
// AI_SCRIPT_MODEL — Modellalias (default: sidelinja/smart)
//
// Ref: docs/concepts/orkestrering.md § "Nivå 2: AI-assistert oppretting"
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::process;
use uuid::Uuid;
/// AI-assistert oppretting av orkestreringsscript.
#[derive(Parser)]
#[command(
name = "synops-ai",
about = "Generer orkestreringsscript fra fritekst-beskrivelse via LLM"
)]
struct Cli {
/// Fritekst-beskrivelse av ønsket orkestrering
#[arg(long)]
description: Option<String>,
/// Trigger-event for scriptet (f.eks. "communication.ended")
#[arg(long)]
trigger_event: Option<String>,
/// Trigger-betingelser som JSON (f.eks. '{"has_trait":"podcast"}')
#[arg(long)]
trigger_conditions: Option<String>,
/// Kun skriv ut auto-generert systemprompt (ingen LLM-kall)
#[arg(long)]
generate_system_prompt: bool,
/// Eventually-modus: lagre forespørselen som work_item i stedet for synkront LLM-kall
#[arg(long)]
eventually: bool,
/// Bruker-ID som utløste forespørselen (påkrevd for --eventually)
#[arg(long)]
requested_by: Option<Uuid>,
/// Samlings-ID å knytte work_item til
#[arg(long)]
collection_id: Option<Uuid>,
}
// --- LLM request/response (OpenAI-kompatibel) ---
#[derive(Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
temperature: f32,
}
#[derive(Serialize)]
struct ChatMessage {
role: String,
content: String,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
#[serde(default)]
usage: Option<UsageInfo>,
#[serde(default)]
model: Option<String>,
}
#[derive(Deserialize, Clone)]
struct UsageInfo {
#[serde(default)]
prompt_tokens: i64,
#[serde(default)]
completion_tokens: i64,
}
#[derive(Deserialize)]
struct Choice {
message: MessageContent,
}
#[derive(Deserialize)]
struct MessageContent {
content: Option<String>,
}
// --- cli_tool metadata fra PG ---
#[derive(sqlx::FromRow)]
struct CliToolRow {
#[allow(dead_code)]
title: Option<String>,
metadata: Option<serde_json::Value>,
}
/// Informasjon om et CLI-verktøy hentet fra PG.
struct ToolInfo {
binary: String,
description: String,
aliases: Vec<String>,
args_hints: Vec<(String, String)>,
}
fn extract_tool_info(row: &CliToolRow) -> Option<ToolInfo> {
let meta = row.metadata.as_ref()?;
let binary = meta.get("binary")?.as_str()?.to_string();
let description = meta
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let aliases: Vec<String> = meta
.get("aliases")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let args_hints: Vec<(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(ToolInfo {
binary,
description,
aliases,
args_hints,
})
}
/// Bygg systemprompt fra cli_tool-noder og script-grammatikk.
fn build_system_prompt(tools: &[ToolInfo]) -> String {
let mut prompt = String::new();
prompt.push_str(
"Du er en orkestreringsplanlegger for Synops, en norsk redaksjonsplattform.\n\
\n\
Du genererer orkestreringsscript i Synops sitt menneskelige scriptspråk.\n\
Scriptet kompileres automatisk til CLI-kall av vaktmesteren.\n\
\n\
VIKTIG: Skriv scriptet i det menneskelige laget bruk norske verb og \n\
argumenter i parenteser. IKKE skriv CLI-flagg eller teknisk syntax.\n\
\n",
);
// Tilgjengelige verktøy
prompt.push_str("TILGJENGELIGE VERKTØY:\n\n");
for tool in tools {
prompt.push_str(&format!("- {}: {}\n", tool.binary, tool.description));
if !tool.aliases.is_empty() {
prompt.push_str(&format!(" Verb: {}\n", tool.aliases.join(", ")));
}
if !tool.args_hints.is_empty() {
prompt.push_str(" Argumenter (bruk i parenteser):\n");
for (human, _technical) in &tool.args_hints {
prompt.push_str(&format!(" - \"{human}\"\n"));
}
}
prompt.push('\n');
}
// Script-grammatikk
prompt.push_str(
"SCRIPT-GRAMMATIKK (menneskelig lag):\n\
\n\
```\n\
NÅR <event i naturlig språk>\n\
HVIS <betingelse i naturlig språk>\n\
\n\
<N>. <verb> <objekt> [(<argument1>, <argument2>)]\n\
<N+1>. <verb> <objekt>\n\
\n\
ved feil: opprett oppgave \"<tittel>\" (<tag>)\n\
```\n\
\n\
Regler:\n\
- NÅR og HVIS er valgfrie (trigger settes separat i metadata)\n\
- Verb matche ett av verktøyenes alias-verb\n\
- Argumenter i parentes matche verktøyets argumenter nøyaktig\n\
- Hvert steg nummereres sekvensielt (1, 2, 3...)\n\
- Steg-spesifikk feilhåndtering med innrykk:\n\
```\n\
1. transkriber lydfilen (stor modell)\n\
\x20 ved feil: transkriber lydfilen (medium modell)\n\
```\n\
- Global feilhåndtering (uten innrykk) siste linje\n\
- \"opprett oppgave\" er alltid tilgjengelig for feilhåndtering\n\
\n",
);
// Trigger-events
prompt.push_str(
"KJENTE TRIGGER-EVENTS:\n\
- node.created Ny node opprettet\n\
- edge.created Ny edge opprettet\n\
- communication.ended Samtale/innspilling avsluttet\n\
- node.published Node publisert\n\
- scheduled.due Planlagt tidspunkt nådd\n\
- manual Bruker trykker \"Kjør\"\n\
\n",
);
// Eksempel
prompt.push_str(
"EKSEMPEL:\n\
Beskrivelse: \"Når en innspilling er ferdig, transkriber og oppsummer\"\n\
\n\
Resultat:\n\
```\n\
1. transkriber lydfilen (stor modell)\n\
\x20 ved feil: transkriber lydfilen (medium modell)\n\
2. oppsummer samtalen\n\
\n\
ved feil: opprett oppgave \"Pipeline feilet\" (bug)\n\
```\n\
\n\
EKSEMPEL 2:\n\
Beskrivelse: \"Publiser artikkelen og oppdater RSS\"\n\
\n\
Resultat:\n\
```\n\
1. render artikkelen\n\
2. oppdater rss-feed\n\
\n\
ved feil: opprett oppgave \"Publisering feilet\" (bug)\n\
```\n\
\n",
);
// Instruksjoner
prompt.push_str(
"INSTRUKSJONER:\n\
1. Les brukerens beskrivelse nøye.\n\
2. Velg de riktige verktøyene og argumentene.\n\
3. Skriv et gyldig script i det menneskelige laget.\n\
4. Bruk feilhåndtering der det er naturlig (VED_FEIL eller fallback-modell).\n\
5. Svar KUN med scriptet ingen forklaringer, ingen markdown-blokker, \n\
ingen kommentarer. Bare rene script-linjer.\n\
6. Hvis beskrivelsen refererer til funksjonalitet som IKKE finnes i \n\
tilgjengelige verktøy, bruk verbet/objektet likevel kompilatoren \n\
vil rapportere feilen, og brukeren kan opprette en forespørsel.\n",
);
prompt
}
#[tokio::main]
async fn main() {
synops_common::logging::init("synops_ai");
let cli = Cli::parse();
// Validering
if !cli.generate_system_prompt && cli.description.is_none() {
eprintln!("Feil: --description eller --generate-system-prompt er påkrevd");
process::exit(1);
}
if cli.eventually && cli.requested_by.is_none() {
eprintln!("Feil: --requested-by er påkrevd sammen med --eventually");
process::exit(1);
}
if let Err(e) = run(cli).await {
eprintln!("Feil: {e}");
process::exit(1);
}
}
async fn run(cli: Cli) -> Result<(), String> {
let db = synops_common::db::connect().await?;
// Hent alle cli_tool-noder
let rows = sqlx::query_as::<_, CliToolRow>(
"SELECT title, metadata FROM nodes WHERE node_kind = 'cli_tool' ORDER BY title",
)
.fetch_all(&db)
.await
.map_err(|e| format!("PG-feil ved henting av cli_tool-noder: {e}"))?;
let tools: Vec<ToolInfo> = rows.iter().filter_map(extract_tool_info).collect();
tracing::info!(tool_count = tools.len(), "Hentet cli_tool-noder");
let system_prompt = build_system_prompt(&tools);
// Modus 1: Bare skriv ut systemprompt
if cli.generate_system_prompt {
println!("{system_prompt}");
return Ok(());
}
let description = cli.description.as_deref().unwrap();
// Modus 3: Eventually — lagre som work_item
if cli.eventually {
return save_work_item(
&db,
description,
cli.trigger_event.as_deref(),
cli.trigger_conditions.as_deref(),
cli.requested_by.unwrap(),
cli.collection_id,
)
.await;
}
// Modus 2: Synkron AI-generering
let user_content = build_user_prompt(
description,
cli.trigger_event.as_deref(),
cli.trigger_conditions.as_deref(),
);
tracing::info!(description_len = description.len(), "Sender til LLM for script-generering");
let (generated_script, llm_usage, llm_model) =
call_llm(&system_prompt, &user_content).await?;
tracing::info!(
script_len = generated_script.len(),
"Script generert fra LLM"
);
// Enkel sjekk om scriptet har nummererte steg
// Full validering skjer i vaktmesteren via script_compiler
let has_numbered_steps = generated_script
.lines()
.any(|line| {
let trimmed = line.trim();
trimmed.len() > 2
&& trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
&& trimmed.contains('.')
});
let tokens_in = llm_usage.as_ref().map(|u| u.prompt_tokens).unwrap_or(0);
let tokens_out = llm_usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0);
let model_id = llm_model.unwrap_or_else(|| "unknown".to_string());
// Logg AI-forbruk
if let Err(e) = sqlx::query(
"INSERT INTO resource_usage_log (target_node_id, triggered_by, resource_type, detail)
VALUES ($1, $2, $3, $4)",
)
.bind(cli.collection_id) // target_node_id (kan være null)
.bind(cli.requested_by)
.bind("ai")
.bind(serde_json::json!({
"model_id": model_id,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"job_type": "orchestration_script_generation"
}))
.execute(&db)
.await
{
tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk");
}
// Bygg resultat
let result = serde_json::json!({
"status": "completed",
"script": generated_script.trim(),
"has_steps": has_numbered_steps,
"model": model_id,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"tool_count": tools.len(),
});
println!(
"{}",
serde_json::to_string_pretty(&result)
.map_err(|e| format!("JSON-serialisering feilet: {e}"))?
);
Ok(())
}
/// Bygg bruker-prompt med beskrivelse og trigger-info.
fn build_user_prompt(
description: &str,
trigger_event: Option<&str>,
trigger_conditions: Option<&str>,
) -> String {
let mut prompt = format!("Lag et orkestreringsscript for: {description}");
if let Some(event) = trigger_event {
prompt.push_str(&format!("\n\nTrigger-event: {event}"));
}
if let Some(conditions) = trigger_conditions {
prompt.push_str(&format!("\nBetingelser: {conditions}"));
}
prompt
}
/// Kall LiteLLM for script-generering. Returnerer (script, usage, model).
async fn call_llm(
system_prompt: &str,
user_content: &str,
) -> Result<(String, Option<UsageInfo>, Option<String>), String> {
let gateway_url =
std::env::var("AI_GATEWAY_URL").unwrap_or_else(|_| "http://localhost:4000".to_string());
let api_key = std::env::var("LITELLM_MASTER_KEY").unwrap_or_default();
let model =
std::env::var("AI_SCRIPT_MODEL").unwrap_or_else(|_| "sidelinja/rutine".to_string());
let request = ChatRequest {
model,
messages: vec![
ChatMessage {
role: "system".to_string(),
content: system_prompt.to_string(),
},
ChatMessage {
role: "user".to_string(),
content: user_content.to_string(),
},
],
temperature: 0.3,
};
let client = reqwest::Client::new();
let url = format!("{gateway_url}/v1/chat/completions");
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Content-Type", "application/json")
.json(&request)
.timeout(std::time::Duration::from_secs(60))
.send()
.await
.map_err(|e| format!("LiteLLM-kall feilet: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("LiteLLM returnerte {status}: {body}"));
}
let chat_resp: ChatResponse = resp
.json()
.await
.map_err(|e| format!("Kunne ikke parse LiteLLM-respons: {e}"))?;
let content = chat_resp
.choices
.first()
.and_then(|c| c.message.content.as_deref())
.ok_or("LiteLLM returnerte ingen content")?;
// Strip eventuelle markdown-blokker som LLM kan ha lagt til
let cleaned = strip_markdown_fences(content);
Ok((cleaned, chat_resp.usage, chat_resp.model))
}
/// Fjern markdown code fences hvis LLM wrapper scriptet i ```...```
fn strip_markdown_fences(text: &str) -> String {
let trimmed = text.trim();
if trimmed.starts_with("```") {
let lines: Vec<&str> = trimmed.lines().collect();
if lines.len() >= 2 {
let start = 1; // Hopp over åpnings-fence
let end = if lines.last().map(|l| l.trim()) == Some("```") {
lines.len() - 1
} else {
lines.len()
};
return lines[start..end].join("\n");
}
}
trimmed.to_string()
}
/// Lagre forespørselen som work_item for Claude Code (eventually-modus).
async fn save_work_item(
db: &sqlx::PgPool,
description: &str,
trigger_event: Option<&str>,
trigger_conditions: Option<&str>,
requested_by: Uuid,
collection_id: Option<Uuid>,
) -> Result<(), String> {
let work_item_id = Uuid::now_v7();
let title = format!("AI-script: {}", truncate(description, 80));
let metadata = serde_json::json!({
"work_item_type": "script_request",
"description": description,
"trigger_event": trigger_event,
"trigger_conditions": trigger_conditions,
});
// Opprett work_item-node
sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, 'content', $2, $3, 'hidden'::visibility, $4, $5)
"#,
)
.bind(work_item_id)
.bind(&title)
.bind(description)
.bind(&metadata)
.bind(requested_by)
.execute(db)
.await
.map_err(|e| format!("PG insert work_item feilet: {e}"))?;
// Legg til tagged-edge med "script_request"
let tag_edge_id = Uuid::now_v7();
sqlx::query(
r#"
INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $2, 'tagged', '{"tag": "script_request"}'::jsonb, true, $3)
"#,
)
.bind(tag_edge_id)
.bind(work_item_id)
.bind(requested_by)
.execute(db)
.await
.map_err(|e| format!("PG insert tagged-edge feilet: {e}"))?;
// Knytt til samling hvis oppgitt
if let Some(coll_id) = collection_id {
let belongs_edge_id = Uuid::now_v7();
sqlx::query(
r#"
INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'belongs_to', '{}'::jsonb, false, $4)
"#,
)
.bind(belongs_edge_id)
.bind(work_item_id)
.bind(coll_id)
.bind(requested_by)
.execute(db)
.await
.map_err(|e| format!("PG insert belongs_to-edge feilet: {e}"))?;
}
tracing::info!(
work_item_id = %work_item_id,
"Work item opprettet for script-forespørsel"
);
let result = serde_json::json!({
"status": "deferred",
"work_item_id": work_item_id.to_string(),
"title": title,
"message": "Forespørselen er lagret som work_item. Claude Code vil generere scriptet i neste sesjon.",
});
println!(
"{}",
serde_json::to_string_pretty(&result)
.map_err(|e| format!("JSON-serialisering feilet: {e}"))?
);
Ok(())
}
/// Trunkér en streng til maks lengde.
fn truncate(s: &str, max_len: usize) -> &str {
if s.len() <= max_len {
s
} else {
// Finn nærmeste UTF-8-grense
let mut end = max_len;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
}