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:
parent
91a73329a4
commit
d18dfc260f
8 changed files with 3940 additions and 1 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }}>×</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 */
|
||||||
/* ================================================================= */
|
/* ================================================================= */
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
36
migrations/024_cli_tool_synops_ai.sql
Normal file
36
migrations/024_cli_tool_synops_ai.sql
Normal 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
2929
tools/synops-ai/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
tools/synops-ai/Cargo.toml
Normal file
21
tools/synops-ai/Cargo.toml
Normal 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
599
tools/synops-ai/src/main.rs
Normal 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 må matche ett av verktøyenes alias-verb\n\
|
||||||
|
- Argumenter i parentes må 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) på 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue