synops-ai prompt: direkte LLM-kall via LiteLLM (oppgave 28.1)
Legger til `synops-ai prompt`-subkommando for enkel prompt-inn/tekst-ut bruk av LLM via LiteLLM gateway. Eksisterende script-generering flyttes til `synops-ai script`-subkommando. Prompt-modus: - --prompt (påkrevd): bruker-prompt - --model (valgfri): modellalias, slås opp fra ai_job_routing hvis utelatt - --system (valgfri): systemprompt - --job-type: for modelloppslag og logging (default: simple_prompt) - --temperature: LLM-temperatur (default: 0.7) - Logger tokenbruk i ai_usage_log Seed: simple_prompt → sidelinja/rutine i ai_job_routing.
This commit is contained in:
parent
b89f307fa2
commit
8436f75d87
4 changed files with 262 additions and 155 deletions
|
|
@ -80,7 +80,8 @@ INSERT INTO ai_job_routing (job_type, alias, description) VALUES
|
||||||
('whisper_postprocess', 'sidelinja/rutine', 'Transkripsjonsvasking etter Whisper'),
|
('whisper_postprocess', 'sidelinja/rutine', 'Transkripsjonsvasking etter Whisper'),
|
||||||
('research_clip', 'sidelinja/rutine', 'Research-oppsummering'),
|
('research_clip', 'sidelinja/rutine', 'Research-oppsummering'),
|
||||||
('live_factoid_eval', 'sidelinja/resonering', 'Faktoid-vurdering under live sending — krever presisjon'),
|
('live_factoid_eval', 'sidelinja/resonering', 'Faktoid-vurdering under live sending — krever presisjon'),
|
||||||
('agent_respond', 'sidelinja/resonering', 'Claude chat-agent svar');
|
('agent_respond', 'sidelinja/resonering', 'Claude chat-agent svar'),
|
||||||
|
('simple_prompt', 'sidelinja/rutine', 'Standard LLM-kall via synops-ai prompt');
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 5. Oppdater agent_identities config til ny alias
|
-- 5. Oppdater agent_identities config til ny alias
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -372,8 +372,7 @@ modell som brukes til hva.
|
||||||
|
|
||||||
### synops-ai: lettvekts LLM-kall
|
### synops-ai: lettvekts LLM-kall
|
||||||
|
|
||||||
- [~] 28.1 `synops-ai` CLI: direkte LLM-kall via LiteLLM. Input: `--prompt <tekst> [--model <alias>] [--system <systemprompt>]`. Output: tekst til stdout. Ingen fillesing, ingen verktøy, bare prompt inn/ut. Bruker `ai_job_routing`-tabellen for å bestemme modell hvis `--model` ikke er satt. Logger i `ai_usage_log`.
|
- [x] 28.1 `synops-ai` CLI: direkte LLM-kall via LiteLLM. Input: `--prompt <tekst> [--model <alias>] [--system <systemprompt>]`. Output: tekst til stdout. Ingen fillesing, ingen verktøy, bare prompt inn/ut. Bruker `ai_job_routing`-tabellen for å bestemme modell hvis `--model` ikke er satt. Logger i `ai_usage_log`.
|
||||||
> Påbegynt: 2026-03-18T19:45
|
|
||||||
- [ ] 28.2 AI-rutingskontroll i admin: utvid admin-UI (fase 15.4) med konfigurasjon av hvilken modell som brukes per kontekst. Tabellen `ai_job_routing` mapper `(job_type, context)` → `model_alias`. Kontekster: `orchestration_script`, `orchestration_dream`, `bot_chat`, `bot_triage`, `summarize`, `suggest_edges`, `classify`. Admin kan endre uten redeploy.
|
- [ ] 28.2 AI-rutingskontroll i admin: utvid admin-UI (fase 15.4) med konfigurasjon av hvilken modell som brukes per kontekst. Tabellen `ai_job_routing` mapper `(job_type, context)` → `model_alias`. Kontekster: `orchestration_script`, `orchestration_dream`, `bot_chat`, `bot_triage`, `summarize`, `suggest_edges`, `classify`. Admin kan endre uten redeploy.
|
||||||
- [ ] 28.3 Kostnadstak per bruker/samling: `ai_budget`-felt i metadata for brukere og samlinger. `synops-ai` sjekker budsjett mot `ai_usage_log` aggregat før kall. Ved overskridelse: returner feilmelding, opprett work_item.
|
- [ ] 28.3 Kostnadstak per bruker/samling: `ai_budget`-felt i metadata for brukere og samlinger. `synops-ai` sjekker budsjett mot `ai_usage_log` aggregat før kall. Ved overskridelse: returner feilmelding, opprett work_item.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
|
||||||
| `synops-tasks` | Parse tasks.md og vis oppgavestatus (filtrering på fase/status) | Ferdig |
|
| `synops-tasks` | Parse tasks.md og vis oppgavestatus (filtrering på fase/status) | Ferdig |
|
||||||
| `synops-feature-status` | Sjekk feature-status: spec, oppgaver, commits, feedback | Ferdig |
|
| `synops-feature-status` | Sjekk feature-status: spec, oppgaver, commits, feedback | Ferdig |
|
||||||
| `synops-node` | Hent/vis en node med edges (UUID, --depth, --format json/md) | Ferdig |
|
| `synops-node` | Hent/vis en node med edges (UUID, --depth, --format json/md) | Ferdig |
|
||||||
| `synops-ai` | AI-assistert generering av orkestreringsscript fra fritekst | Ferdig |
|
| `synops-ai` | LLM-verktøy: `prompt` (direkte LLM-kall) + `script` (orkestreringsscript fra fritekst) | Ferdig |
|
||||||
| `synops-clip` | Hent og parse webartikler (Readability + Playwright-fallback, paywall-deteksjon) | Ferdig |
|
| `synops-clip` | Hent og parse webartikler (Readability + Playwright-fallback, paywall-deteksjon) | Ferdig |
|
||||||
| `synops-mail` | Send epost via msmtp (vaktmester@synops.no) | Ferdig (venter SMTP-credentials) |
|
| `synops-mail` | Send epost via msmtp (vaktmester@synops.no) | Ferdig (venter SMTP-credentials) |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,60 @@
|
||||||
// synops-ai — AI-assistert oppretting av orkestreringsscript.
|
// synops-ai — LLM-verktøy for Synops-plattformen.
|
||||||
//
|
//
|
||||||
// Leser cli_tool-noder fra PG, bygger en systemprompt med tilgjengelige
|
// To moduser:
|
||||||
// verktøy og script-grammatikk, og bruker LLM til å generere et
|
// 1. `prompt`: Direkte LLM-kall via LiteLLM — prompt inn, tekst ut.
|
||||||
// orkestreringsscript fra en fritekst-beskrivelse.
|
// 2. `script`: AI-assistert oppretting av orkestreringsscript (oppgave 24.7).
|
||||||
//
|
|
||||||
// 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:
|
// Miljøvariabler:
|
||||||
// DATABASE_URL — PostgreSQL-tilkobling (påkrevd)
|
// DATABASE_URL — PostgreSQL-tilkobling (påkrevd)
|
||||||
// AI_GATEWAY_URL — LiteLLM gateway (default: http://localhost:4000)
|
// AI_GATEWAY_URL — LiteLLM gateway (default: http://localhost:4000)
|
||||||
// LITELLM_MASTER_KEY — API-nøkkel for LiteLLM
|
// 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 clap::{Parser, Subcommand};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::process;
|
use std::process;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// AI-assistert oppretting av orkestreringsscript.
|
/// synops-ai — LLM-verktøy for Synops
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(name = "synops-ai")]
|
||||||
name = "synops-ai",
|
|
||||||
about = "Generer orkestreringsscript fra fritekst-beskrivelse via LLM"
|
|
||||||
)]
|
|
||||||
struct Cli {
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
/// Direkte LLM-kall: prompt inn, tekst ut
|
||||||
|
Prompt(PromptArgs),
|
||||||
|
/// Generer orkestreringsscript fra fritekst-beskrivelse
|
||||||
|
Script(ScriptArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct PromptArgs {
|
||||||
|
/// Bruker-prompt (påkrevd)
|
||||||
|
#[arg(long)]
|
||||||
|
prompt: String,
|
||||||
|
|
||||||
|
/// Modellalias (f.eks. "sidelinja/rutine"). Hvis utelatt, slås opp fra ai_job_routing.
|
||||||
|
#[arg(long)]
|
||||||
|
model: Option<String>,
|
||||||
|
|
||||||
|
/// Systemprompt (valgfri)
|
||||||
|
#[arg(long)]
|
||||||
|
system: Option<String>,
|
||||||
|
|
||||||
|
/// Job-type for modelloppslag og logging (default: "simple_prompt")
|
||||||
|
#[arg(long, default_value = "simple_prompt")]
|
||||||
|
job_type: String,
|
||||||
|
|
||||||
|
/// Temperatur (0.0–2.0, default: 0.7)
|
||||||
|
#[arg(long, default_value = "0.7")]
|
||||||
|
temperature: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct ScriptArgs {
|
||||||
/// Fritekst-beskrivelse av ønsket orkestrering
|
/// Fritekst-beskrivelse av ønsket orkestrering
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
|
@ -37,7 +63,7 @@ struct Cli {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
trigger_event: Option<String>,
|
trigger_event: Option<String>,
|
||||||
|
|
||||||
/// Trigger-betingelser som JSON (f.eks. '{"has_trait":"podcast"}')
|
/// Trigger-betingelser som JSON
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
trigger_conditions: Option<String>,
|
trigger_conditions: Option<String>,
|
||||||
|
|
||||||
|
|
@ -45,7 +71,7 @@ struct Cli {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
generate_system_prompt: bool,
|
generate_system_prompt: bool,
|
||||||
|
|
||||||
/// Eventually-modus: lagre forespørselen som work_item i stedet for synkront LLM-kall
|
/// Eventually-modus: lagre som work_item
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
eventually: bool,
|
eventually: bool,
|
||||||
|
|
||||||
|
|
@ -67,7 +93,7 @@ struct ChatRequest {
|
||||||
temperature: f32,
|
temperature: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Clone)]
|
||||||
struct ChatMessage {
|
struct ChatMessage {
|
||||||
role: String,
|
role: String,
|
||||||
content: String,
|
content: String,
|
||||||
|
|
@ -100,6 +126,174 @@ struct MessageContent {
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
synops_common::logging::init("synops_ai");
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let result = match cli.command {
|
||||||
|
Command::Prompt(args) => run_prompt(args).await,
|
||||||
|
Command::Script(args) => run_script(args).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
eprintln!("Feil: {e}");
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Prompt-modus (oppgave 28.1): direkte LLM-kall
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async fn run_prompt(args: PromptArgs) -> Result<(), String> {
|
||||||
|
let db = synops_common::db::connect().await?;
|
||||||
|
|
||||||
|
// Bestem modell: eksplisitt flag → ai_job_routing → fallback
|
||||||
|
let model_alias = match &args.model {
|
||||||
|
Some(m) => m.clone(),
|
||||||
|
None => resolve_model(&db, &args.job_type).await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
model = %model_alias,
|
||||||
|
job_type = %args.job_type,
|
||||||
|
prompt_len = args.prompt.len(),
|
||||||
|
"Sender prompt til LLM"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bygg meldinger
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
if let Some(ref sys) = args.system {
|
||||||
|
messages.push(ChatMessage {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: sys.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
messages.push(ChatMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: args.prompt.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let (content, usage, actual_model) =
|
||||||
|
call_llm_with(&model_alias, &messages, args.temperature).await?;
|
||||||
|
|
||||||
|
// Skriv svar til stdout
|
||||||
|
println!("{content}");
|
||||||
|
|
||||||
|
// Logg i ai_usage_log
|
||||||
|
let tokens_in = usage.as_ref().map(|u| u.prompt_tokens).unwrap_or(0);
|
||||||
|
let tokens_out = usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0);
|
||||||
|
let actual = actual_model.as_deref().unwrap_or("unknown");
|
||||||
|
|
||||||
|
if let Err(e) = sqlx::query(
|
||||||
|
"INSERT INTO ai_usage_log (model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
)
|
||||||
|
.bind(&model_alias)
|
||||||
|
.bind(actual)
|
||||||
|
.bind(tokens_in as i32)
|
||||||
|
.bind(tokens_out as i32)
|
||||||
|
.bind((tokens_in + tokens_out) as i32)
|
||||||
|
.bind(&args.job_type)
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "Kunne ikke logge AI-bruk i ai_usage_log");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
model_alias = %model_alias,
|
||||||
|
model_actual = %actual,
|
||||||
|
tokens_in,
|
||||||
|
tokens_out,
|
||||||
|
"LLM-kall fullført"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slå opp modellalias fra ai_job_routing. Fallback: sidelinja/rutine.
|
||||||
|
async fn resolve_model(db: &sqlx::PgPool, job_type: &str) -> Result<String, String> {
|
||||||
|
let row: Option<(String,)> =
|
||||||
|
sqlx::query_as("SELECT alias FROM ai_job_routing WHERE job_type = $1")
|
||||||
|
.bind(job_type)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("PG-feil ved oppslag i ai_job_routing: {e}"))?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some((alias,)) => {
|
||||||
|
tracing::info!(job_type, alias = %alias, "Modell funnet i ai_job_routing");
|
||||||
|
Ok(alias)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let fallback = "sidelinja/rutine".to_string();
|
||||||
|
tracing::info!(
|
||||||
|
job_type,
|
||||||
|
fallback = %fallback,
|
||||||
|
"Ingen routing funnet, bruker fallback"
|
||||||
|
);
|
||||||
|
Ok(fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generisk LLM-kall mot LiteLLM gateway.
|
||||||
|
async fn call_llm_with(
|
||||||
|
model: &str,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
temperature: f32,
|
||||||
|
) -> 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 request = ChatRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
messages: messages.to_vec(),
|
||||||
|
temperature,
|
||||||
|
};
|
||||||
|
|
||||||
|
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(120))
|
||||||
|
.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")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok((content, chat_resp.usage, chat_resp.model))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Script-modus (oppgave 24.7): orkestreringsscript-generering
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
// --- cli_tool metadata fra PG ---
|
// --- cli_tool metadata fra PG ---
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
|
|
@ -109,7 +303,6 @@ struct CliToolRow {
|
||||||
metadata: Option<serde_json::Value>,
|
metadata: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Informasjon om et CLI-verktøy hentet fra PG.
|
|
||||||
struct ToolInfo {
|
struct ToolInfo {
|
||||||
binary: String,
|
binary: String,
|
||||||
description: String,
|
description: String,
|
||||||
|
|
@ -151,7 +344,6 @@ fn extract_tool_info(row: &CliToolRow) -> Option<ToolInfo> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bygg systemprompt fra cli_tool-noder og script-grammatikk.
|
|
||||||
fn build_system_prompt(tools: &[ToolInfo]) -> String {
|
fn build_system_prompt(tools: &[ToolInfo]) -> String {
|
||||||
let mut prompt = String::new();
|
let mut prompt = String::new();
|
||||||
|
|
||||||
|
|
@ -166,7 +358,6 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String {
|
||||||
\n",
|
\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tilgjengelige verktøy
|
|
||||||
prompt.push_str("TILGJENGELIGE VERKTØY:\n\n");
|
prompt.push_str("TILGJENGELIGE VERKTØY:\n\n");
|
||||||
for tool in tools {
|
for tool in tools {
|
||||||
prompt.push_str(&format!("- {}: {}\n", tool.binary, tool.description));
|
prompt.push_str(&format!("- {}: {}\n", tool.binary, tool.description));
|
||||||
|
|
@ -182,7 +373,6 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String {
|
||||||
prompt.push('\n');
|
prompt.push('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Script-grammatikk
|
|
||||||
prompt.push_str(
|
prompt.push_str(
|
||||||
"SCRIPT-GRAMMATIKK (menneskelig lag):\n\
|
"SCRIPT-GRAMMATIKK (menneskelig lag):\n\
|
||||||
\n\
|
\n\
|
||||||
|
|
@ -211,7 +401,6 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String {
|
||||||
\n",
|
\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger-events
|
|
||||||
prompt.push_str(
|
prompt.push_str(
|
||||||
"KJENTE TRIGGER-EVENTS:\n\
|
"KJENTE TRIGGER-EVENTS:\n\
|
||||||
- node.created — Ny node opprettet\n\
|
- node.created — Ny node opprettet\n\
|
||||||
|
|
@ -223,7 +412,6 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String {
|
||||||
\n",
|
\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Eksempel
|
|
||||||
prompt.push_str(
|
prompt.push_str(
|
||||||
"EKSEMPEL:\n\
|
"EKSEMPEL:\n\
|
||||||
Beskrivelse: \"Når en innspilling er ferdig, transkriber og oppsummer\"\n\
|
Beskrivelse: \"Når en innspilling er ferdig, transkriber og oppsummer\"\n\
|
||||||
|
|
@ -250,7 +438,6 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String {
|
||||||
\n",
|
\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Instruksjoner
|
|
||||||
prompt.push_str(
|
prompt.push_str(
|
||||||
"INSTRUKSJONER:\n\
|
"INSTRUKSJONER:\n\
|
||||||
1. Les brukerens beskrivelse nøye.\n\
|
1. Les brukerens beskrivelse nøye.\n\
|
||||||
|
|
@ -267,33 +454,17 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String {
|
||||||
prompt
|
prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
async fn run_script(args: ScriptArgs) -> Result<(), String> {
|
||||||
async fn main() {
|
if !args.generate_system_prompt && args.description.is_none() {
|
||||||
synops_common::logging::init("synops_ai");
|
return Err("--description eller --generate-system-prompt er påkrevd".into());
|
||||||
|
|
||||||
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() {
|
if args.eventually && args.requested_by.is_none() {
|
||||||
eprintln!("Feil: --requested-by er påkrevd sammen med --eventually");
|
return Err("--requested-by er påkrevd sammen med --eventually".into());
|
||||||
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?;
|
let db = synops_common::db::connect().await?;
|
||||||
|
|
||||||
// Hent alle cli_tool-noder
|
|
||||||
let rows = sqlx::query_as::<_, CliToolRow>(
|
let rows = sqlx::query_as::<_, CliToolRow>(
|
||||||
"SELECT title, metadata FROM nodes WHERE node_kind = 'cli_tool' ORDER BY title",
|
"SELECT title, metadata FROM nodes WHERE node_kind = 'cli_tool' ORDER BY title",
|
||||||
)
|
)
|
||||||
|
|
@ -306,66 +477,73 @@ async fn run(cli: Cli) -> Result<(), String> {
|
||||||
|
|
||||||
let system_prompt = build_system_prompt(&tools);
|
let system_prompt = build_system_prompt(&tools);
|
||||||
|
|
||||||
// Modus 1: Bare skriv ut systemprompt
|
if args.generate_system_prompt {
|
||||||
if cli.generate_system_prompt {
|
|
||||||
println!("{system_prompt}");
|
println!("{system_prompt}");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let description = cli.description.as_deref().unwrap();
|
let description = args.description.as_deref().unwrap();
|
||||||
|
|
||||||
// Modus 3: Eventually — lagre som work_item
|
if args.eventually {
|
||||||
if cli.eventually {
|
|
||||||
return save_work_item(
|
return save_work_item(
|
||||||
&db,
|
&db,
|
||||||
description,
|
description,
|
||||||
cli.trigger_event.as_deref(),
|
args.trigger_event.as_deref(),
|
||||||
cli.trigger_conditions.as_deref(),
|
args.trigger_conditions.as_deref(),
|
||||||
cli.requested_by.unwrap(),
|
args.requested_by.unwrap(),
|
||||||
cli.collection_id,
|
args.collection_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modus 2: Synkron AI-generering
|
// Synkron script-generering
|
||||||
let user_content = build_user_prompt(
|
let user_content = build_user_prompt(
|
||||||
description,
|
description,
|
||||||
cli.trigger_event.as_deref(),
|
args.trigger_event.as_deref(),
|
||||||
cli.trigger_conditions.as_deref(),
|
args.trigger_conditions.as_deref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
tracing::info!(description_len = description.len(), "Sender til LLM for script-generering");
|
tracing::info!(description_len = description.len(), "Sender til LLM for script-generering");
|
||||||
|
|
||||||
|
let model =
|
||||||
|
std::env::var("AI_SCRIPT_MODEL").unwrap_or_else(|_| "sidelinja/rutine".to_string());
|
||||||
|
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: system_prompt,
|
||||||
|
},
|
||||||
|
ChatMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: user_content,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
let (generated_script, llm_usage, llm_model) =
|
let (generated_script, llm_usage, llm_model) =
|
||||||
call_llm(&system_prompt, &user_content).await?;
|
call_llm_with(&model, &messages, 0.3).await?;
|
||||||
|
|
||||||
tracing::info!(
|
// Strip eventuelle markdown-blokker
|
||||||
script_len = generated_script.len(),
|
let generated_script = strip_markdown_fences(&generated_script);
|
||||||
"Script generert fra LLM"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enkel sjekk om scriptet har nummererte steg
|
tracing::info!(script_len = generated_script.len(), "Script generert fra LLM");
|
||||||
// Full validering skjer i vaktmesteren via script_compiler
|
|
||||||
let has_numbered_steps = generated_script
|
let has_numbered_steps = generated_script.lines().any(|line| {
|
||||||
.lines()
|
let trimmed = line.trim();
|
||||||
.any(|line| {
|
trimmed.len() > 2
|
||||||
let trimmed = line.trim();
|
&& trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
|
||||||
trimmed.len() > 2
|
&& trimmed.contains('.')
|
||||||
&& 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_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 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());
|
let model_id = llm_model.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
// Logg AI-forbruk
|
|
||||||
if let Err(e) = sqlx::query(
|
if let Err(e) = sqlx::query(
|
||||||
"INSERT INTO resource_usage_log (target_node_id, triggered_by, resource_type, detail)
|
"INSERT INTO resource_usage_log (target_node_id, triggered_by, resource_type, detail)
|
||||||
VALUES ($1, $2, $3, $4)",
|
VALUES ($1, $2, $3, $4)",
|
||||||
)
|
)
|
||||||
.bind(cli.collection_id) // target_node_id (kan være null)
|
.bind(args.collection_id)
|
||||||
.bind(cli.requested_by)
|
.bind(args.requested_by)
|
||||||
.bind("ai")
|
.bind("ai")
|
||||||
.bind(serde_json::json!({
|
.bind(serde_json::json!({
|
||||||
"model_id": model_id,
|
"model_id": model_id,
|
||||||
|
|
@ -379,7 +557,6 @@ async fn run(cli: Cli) -> Result<(), String> {
|
||||||
tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk");
|
tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bygg resultat
|
|
||||||
let result = serde_json::json!({
|
let result = serde_json::json!({
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"script": generated_script.trim(),
|
"script": generated_script.trim(),
|
||||||
|
|
@ -399,7 +576,6 @@ async fn run(cli: Cli) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bygg bruker-prompt med beskrivelse og trigger-info.
|
|
||||||
fn build_user_prompt(
|
fn build_user_prompt(
|
||||||
description: &str,
|
description: &str,
|
||||||
trigger_event: Option<&str>,
|
trigger_event: Option<&str>,
|
||||||
|
|
@ -415,75 +591,12 @@ fn build_user_prompt(
|
||||||
prompt
|
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 {
|
fn strip_markdown_fences(text: &str) -> String {
|
||||||
let trimmed = text.trim();
|
let trimmed = text.trim();
|
||||||
if trimmed.starts_with("```") {
|
if trimmed.starts_with("```") {
|
||||||
let lines: Vec<&str> = trimmed.lines().collect();
|
let lines: Vec<&str> = trimmed.lines().collect();
|
||||||
if lines.len() >= 2 {
|
if lines.len() >= 2 {
|
||||||
let start = 1; // Hopp over åpnings-fence
|
let start = 1;
|
||||||
let end = if lines.last().map(|l| l.trim()) == Some("```") {
|
let end = if lines.last().map(|l| l.trim()) == Some("```") {
|
||||||
lines.len() - 1
|
lines.len() - 1
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -495,7 +608,6 @@ fn strip_markdown_fences(text: &str) -> String {
|
||||||
trimmed.to_string()
|
trimmed.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lagre forespørselen som work_item for Claude Code (eventually-modus).
|
|
||||||
async fn save_work_item(
|
async fn save_work_item(
|
||||||
db: &sqlx::PgPool,
|
db: &sqlx::PgPool,
|
||||||
description: &str,
|
description: &str,
|
||||||
|
|
@ -514,7 +626,6 @@ async fn save_work_item(
|
||||||
"trigger_conditions": trigger_conditions,
|
"trigger_conditions": trigger_conditions,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Opprett work_item-node
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
|
INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
|
||||||
|
|
@ -530,7 +641,6 @@ async fn save_work_item(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("PG insert work_item feilet: {e}"))?;
|
.map_err(|e| format!("PG insert work_item feilet: {e}"))?;
|
||||||
|
|
||||||
// Legg til tagged-edge med "script_request"
|
|
||||||
let tag_edge_id = Uuid::now_v7();
|
let tag_edge_id = Uuid::now_v7();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -545,7 +655,6 @@ async fn save_work_item(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("PG insert tagged-edge feilet: {e}"))?;
|
.map_err(|e| format!("PG insert tagged-edge feilet: {e}"))?;
|
||||||
|
|
||||||
// Knytt til samling hvis oppgitt
|
|
||||||
if let Some(coll_id) = collection_id {
|
if let Some(coll_id) = collection_id {
|
||||||
let belongs_edge_id = Uuid::now_v7();
|
let belongs_edge_id = Uuid::now_v7();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|
@ -584,12 +693,10 @@ async fn save_work_item(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trunkér en streng til maks lengde.
|
|
||||||
fn truncate(s: &str, max_len: usize) -> &str {
|
fn truncate(s: &str, max_len: usize) -> &str {
|
||||||
if s.len() <= max_len {
|
if s.len() <= max_len {
|
||||||
s
|
s
|
||||||
} else {
|
} else {
|
||||||
// Finn nærmeste UTF-8-grense
|
|
||||||
let mut end = max_len;
|
let mut end = max_len;
|
||||||
while end > 0 && !s.is_char_boundary(end) {
|
while end > 0 && !s.is_char_boundary(end) {
|
||||||
end -= 1;
|
end -= 1;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue