Implementer Claude som chat-deltaker (Fase A: MVP)
Claude er nå en agent-node i grafen som kan delta i samtaler.
Når en bruker sender melding i en kommunikasjonsnode der Claude
er deltaker, enqueues en agent_respond-jobb som kaller claude CLI
direkte og skriver svaret tilbake til chatten.
Nye filer:
- migrations/007_agent_system.sql: agent_identities, agent_permissions, ai_usage_log
- maskinrommet/src/agent.rs: agent_respond job handler
- scripts/maskinrommet.service: systemd-tjeneste for native kjøring
- scripts/maskinrommet-env.sh: genererer env med Docker container-IPs
Endringer:
- intentions.rs: trigger agent_respond ved melding i agent-chat
- jobs.rs: dispatch agent_respond til agent-handler
- frontend chat: bot-badge (🤖) og amber-farge på agent-meldinger
- LiteLLM config: resonering-modellalias via OpenRouter
Maskinrommet kjører nå direkte på hosten (ikke i Docker) for å
ha tilgang til claude CLI. Caddy peker til host.docker.internal.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
91ccf4b270
commit
33a1b44946
9 changed files with 464 additions and 3 deletions
|
|
@ -20,6 +20,15 @@ model_list:
|
|||
model: "gemini/gemini-flash-latest"
|
||||
api_key: "os.environ/GEMINI_API_KEY"
|
||||
|
||||
- model_name: "resonering"
|
||||
litellm_params:
|
||||
model: "openrouter/anthropic/claude-sonnet-4"
|
||||
api_key: "os.environ/OPENROUTER_API_KEY"
|
||||
- model_name: "resonering"
|
||||
litellm_params:
|
||||
model: "openrouter/google/gemini-2.5-flash"
|
||||
api_key: "os.environ/OPENROUTER_API_KEY"
|
||||
|
||||
router_settings:
|
||||
routing_strategy: "simple-shuffle"
|
||||
num_retries: 2
|
||||
|
|
|
|||
|
|
@ -133,6 +133,13 @@
|
|||
return sender?.title || sender?.nodeKind || 'Ukjent';
|
||||
}
|
||||
|
||||
/** Check if message sender is an agent (bot) */
|
||||
function isAgentMessage(node: Node): boolean {
|
||||
if (!node.createdBy) return false;
|
||||
const sender = nodeStore.get(node.createdBy);
|
||||
return sender?.nodeKind === 'agent';
|
||||
}
|
||||
|
||||
/** Check if this message is from the current user */
|
||||
function isOwnMessage(node: Node): boolean {
|
||||
return !!nodeId && node.createdBy === nodeId;
|
||||
|
|
@ -227,11 +234,12 @@
|
|||
{#each messages as msg (msg.id)}
|
||||
{@const own = isOwnMessage(msg)}
|
||||
{@const audio = isAudioNode(msg)}
|
||||
{@const bot = isAgentMessage(msg)}
|
||||
<div class="flex {own ? 'justify-end' : 'justify-start'}">
|
||||
<div class="max-w-[75%] {own ? (audio ? 'bg-blue-50 border border-blue-200 text-gray-900' : 'bg-blue-600 text-white') : 'bg-white border border-gray-200 text-gray-900'} rounded-2xl px-4 py-2 shadow-sm">
|
||||
<div class="max-w-[75%] {own ? (audio ? 'bg-blue-50 border border-blue-200 text-gray-900' : 'bg-blue-600 text-white') : bot ? 'bg-amber-50 border border-amber-200 text-gray-900' : 'bg-white border border-gray-200 text-gray-900'} rounded-2xl px-4 py-2 shadow-sm">
|
||||
{#if !own}
|
||||
<p class="mb-0.5 text-xs font-medium text-blue-600">
|
||||
{senderName(msg)}
|
||||
<p class="mb-0.5 text-xs font-medium {bot ? 'text-amber-700' : 'text-blue-600'}">
|
||||
{#if bot}<span title="AI-agent">🤖 </span>{/if}{senderName(msg)}
|
||||
</p>
|
||||
{/if}
|
||||
{#if audio}
|
||||
|
|
|
|||
242
maskinrommet/src/agent.rs
Normal file
242
maskinrommet/src/agent.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// Agent — Claude som chat-deltaker.
|
||||
//
|
||||
// Håndterer `agent_respond`-jobber fra jobbkøen.
|
||||
// Kaller `claude -p` direkte som subprocess — maskinrommet kjører
|
||||
// på hosten (ikke i Docker) og har tilgang til claude CLI.
|
||||
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jobs::JobRow;
|
||||
use crate::stdb::StdbClient;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AgentConfig {
|
||||
max_context_messages: i64,
|
||||
max_consecutive_agent_messages: i64,
|
||||
rate_limit_per_hour: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug)]
|
||||
struct MessageRow {
|
||||
#[allow(dead_code)]
|
||||
id: Uuid,
|
||||
content: Option<String>,
|
||||
created_by: Uuid,
|
||||
#[allow(dead_code)]
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug)]
|
||||
struct ParticipantRow {
|
||||
id: Uuid,
|
||||
title: Option<String>,
|
||||
node_kind: String,
|
||||
}
|
||||
|
||||
pub async fn handle_agent_respond(
|
||||
job: &JobRow,
|
||||
db: &PgPool,
|
||||
stdb: &StdbClient,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let communication_id: Uuid = job.payload["communication_id"]
|
||||
.as_str().and_then(|s| s.parse().ok())
|
||||
.ok_or("Mangler communication_id")?;
|
||||
let message_id: Uuid = job.payload["message_id"]
|
||||
.as_str().and_then(|s| s.parse().ok())
|
||||
.ok_or("Mangler message_id")?;
|
||||
let agent_node_id: Uuid = job.payload["agent_node_id"]
|
||||
.as_str().and_then(|s| s.parse().ok())
|
||||
.ok_or("Mangler agent_node_id")?;
|
||||
let sender_node_id: Uuid = job.payload["sender_node_id"]
|
||||
.as_str().and_then(|s| s.parse().ok())
|
||||
.ok_or("Mangler sender_node_id")?;
|
||||
|
||||
tracing::info!(
|
||||
communication_id = %communication_id,
|
||||
message_id = %message_id,
|
||||
agent_node_id = %agent_node_id,
|
||||
sender_node_id = %sender_node_id,
|
||||
"Starter agent_respond"
|
||||
);
|
||||
|
||||
let config = load_agent_config(db, agent_node_id).await?;
|
||||
|
||||
// Kill switch
|
||||
let is_active: bool = sqlx::query_scalar(
|
||||
"SELECT is_active FROM agent_identities WHERE node_id = $1",
|
||||
).bind(agent_node_id).fetch_optional(db).await
|
||||
.map_err(|e| format!("DB-feil: {e}"))?.unwrap_or(false);
|
||||
if !is_active {
|
||||
return Ok(serde_json::json!({"status": "skipped", "reason": "agent_inactive"}));
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
let count: i64 = sqlx::query_scalar::<_, Option<i64>>(
|
||||
"SELECT COUNT(*) FROM ai_usage_log WHERE agent_node_id = $1 AND created_at > now() - interval '1 hour'",
|
||||
).bind(agent_node_id).fetch_one(db).await
|
||||
.map_err(|e| format!("DB-feil: {e}"))?.unwrap_or(0);
|
||||
if count >= config.rate_limit_per_hour {
|
||||
return Ok(serde_json::json!({"status": "skipped", "reason": "rate_limit"}));
|
||||
}
|
||||
|
||||
// Loop-prevensjon
|
||||
let recent: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT n.created_by FROM nodes n JOIN edges e ON e.source_id = n.id WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' ORDER BY n.created_at DESC LIMIT $2",
|
||||
).bind(communication_id).bind(config.max_consecutive_agent_messages)
|
||||
.fetch_all(db).await.map_err(|e| format!("DB-feil: {e}"))?;
|
||||
if !recent.is_empty() && recent.iter().all(|s| *s == agent_node_id) {
|
||||
return Ok(serde_json::json!({"status": "skipped", "reason": "loop_prevention"}));
|
||||
}
|
||||
|
||||
// Hent meldingshistorikk
|
||||
let mut messages = sqlx::query_as::<_, MessageRow>(
|
||||
"SELECT n.id, n.content, n.created_by, n.created_at FROM nodes n JOIN edges e ON e.source_id = n.id WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' AND n.node_kind = 'content' ORDER BY n.created_at DESC LIMIT $2",
|
||||
).bind(communication_id).bind(config.max_context_messages)
|
||||
.fetch_all(db).await.map_err(|e| format!("DB-feil: {e}"))?;
|
||||
messages.reverse();
|
||||
if messages.is_empty() {
|
||||
return Ok(serde_json::json!({"status": "skipped", "reason": "no_messages"}));
|
||||
}
|
||||
|
||||
// Kontekst
|
||||
let comm_title: String = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT title FROM nodes WHERE id = $1",
|
||||
).bind(communication_id).fetch_optional(db).await
|
||||
.map_err(|e| format!("DB-feil: {e}"))?.flatten()
|
||||
.unwrap_or_else(|| "Samtale".to_string());
|
||||
|
||||
let participants = sqlx::query_as::<_, ParticipantRow>(
|
||||
"SELECT n.id, n.title, n.node_kind FROM nodes n JOIN edges e ON e.source_id = n.id WHERE e.target_id = $1 AND e.edge_type IN ('owner', 'member_of')",
|
||||
).bind(communication_id).fetch_all(db).await
|
||||
.map_err(|e| format!("DB-feil: {e}"))?;
|
||||
|
||||
let permission: String = sqlx::query_scalar(
|
||||
"SELECT permission FROM agent_permissions WHERE user_node_id = $1 AND agent_node_id = $2",
|
||||
).bind(sender_node_id).bind(agent_node_id)
|
||||
.fetch_optional(db).await.map_err(|e| format!("DB-feil: {e}"))?
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
|
||||
// Bygg prompt
|
||||
let name_map: std::collections::HashMap<Uuid, String> = participants.iter()
|
||||
.map(|p| (p.id, p.title.clone().unwrap_or_else(|| p.node_kind.clone()))).collect();
|
||||
|
||||
let participant_names: String = participants.iter()
|
||||
.filter(|p| p.id != agent_node_id)
|
||||
.map(|p| p.title.as_deref().unwrap_or("Ukjent"))
|
||||
.collect::<Vec<_>>().join(", ");
|
||||
|
||||
let mut conversation = String::new();
|
||||
for m in &messages {
|
||||
let name = name_map.get(&m.created_by).map(|s| s.as_str()).unwrap_or("Ukjent");
|
||||
let content = m.content.as_deref().unwrap_or("");
|
||||
if m.created_by == agent_node_id {
|
||||
conversation.push_str(&format!("Claude: {content}\n"));
|
||||
} else {
|
||||
conversation.push_str(&format!("{name}: {content}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
let perm_desc = match permission.as_str() {
|
||||
"direct" => "Brukeren har 'direct'-tilgang.",
|
||||
"propose" => "Brukeren har 'propose'-tilgang.",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
let prompt = format!(
|
||||
r#"Du er Claude, en AI-assistent integrert i Synops-plattformen.
|
||||
Du deltar i samtalen "{comm_title}" med {participant_names}.
|
||||
Svar på norsk med mindre brukeren skriver på engelsk.
|
||||
{perm_desc}
|
||||
Svar konsist. Bruk vanlig tekst uten markdown-overskrifter.
|
||||
Svar KUN med meldingsteksten.
|
||||
|
||||
--- Samtalehistorikk ---
|
||||
{conversation}--- Svar ---"#
|
||||
);
|
||||
|
||||
// Kall claude CLI direkte
|
||||
let claude_path = std::env::var("CLAUDE_PATH").unwrap_or_else(|_| "claude".to_string());
|
||||
let project_dir = std::env::var("PROJECT_DIR").unwrap_or_else(|_| "/home/vegard/synops".to_string());
|
||||
|
||||
tracing::info!(prompt_len = prompt.len(), "Kaller claude CLI");
|
||||
|
||||
let output = tokio::process::Command::new(&claude_path)
|
||||
.arg("-p")
|
||||
.arg(&prompt)
|
||||
.arg("--output-format")
|
||||
.arg("json")
|
||||
.arg("--dangerously-skip-permissions")
|
||||
.env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
||||
.current_dir(&project_dir)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Kunne ikke starte claude: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("claude feilet ({}): {}", output.status, &stderr[..stderr.len().min(500)]));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let response_text = match serde_json::from_str::<serde_json::Value>(&stdout) {
|
||||
Ok(json) => json["result"].as_str().unwrap_or("").to_string(),
|
||||
Err(_) => stdout.trim().to_string(),
|
||||
};
|
||||
|
||||
if response_text.is_empty() {
|
||||
return Err("Tom respons fra claude".to_string());
|
||||
}
|
||||
|
||||
tracing::info!(response_len = response_text.len(), "Fikk svar fra claude");
|
||||
|
||||
// Skriv svar
|
||||
let reply_id = Uuid::now_v7();
|
||||
let edge_id = Uuid::now_v7();
|
||||
let agent_str = agent_node_id.to_string();
|
||||
let reply_str = reply_id.to_string();
|
||||
let edge_str = edge_id.to_string();
|
||||
let comm_str = communication_id.to_string();
|
||||
let empty = serde_json::json!({}).to_string();
|
||||
|
||||
stdb.create_node(&reply_str, "content", "", &response_text, "hidden", &empty, &agent_str)
|
||||
.await.map_err(|e| format!("STDB: {e}"))?;
|
||||
stdb.create_edge(&edge_str, &reply_str, &comm_str, "belongs_to", &empty, false, &agent_str)
|
||||
.await.map_err(|e| format!("STDB: {e}"))?;
|
||||
|
||||
let metadata = serde_json::json!({});
|
||||
sqlx::query("INSERT INTO nodes (id, node_kind, content, visibility, metadata, created_by) VALUES ($1, 'content', $2, 'hidden'::visibility, $3, $4)")
|
||||
.bind(reply_id).bind(&response_text).bind(&metadata).bind(agent_node_id)
|
||||
.execute(db).await.map_err(|e| format!("PG: {e}"))?;
|
||||
sqlx::query("INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) VALUES ($1, $2, $3, 'belongs_to', '{}', false, $4)")
|
||||
.bind(edge_id).bind(reply_id).bind(communication_id).bind(agent_node_id)
|
||||
.execute(db).await.map_err(|e| format!("PG: {e}"))?;
|
||||
sqlx::query("INSERT INTO ai_usage_log (agent_node_id, communication_id, job_id, model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type) VALUES ($1, $2, $3, 'claude-code', 'claude-code-cli', 0, 0, 0, 'agent_respond')")
|
||||
.bind(agent_node_id).bind(communication_id).bind(job.id)
|
||||
.execute(db).await.map_err(|e| format!("PG: {e}"))?;
|
||||
|
||||
tracing::info!(reply_node_id = %reply_id, "Agent-svar persistert");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "completed",
|
||||
"reply_node_id": reply_id.to_string(),
|
||||
"response_len": response_text.len()
|
||||
}))
|
||||
}
|
||||
|
||||
async fn load_agent_config(db: &PgPool, agent_node_id: Uuid) -> Result<AgentConfig, String> {
|
||||
let c: serde_json::Value = sqlx::query_scalar("SELECT config FROM agent_identities WHERE node_id = $1")
|
||||
.bind(agent_node_id).fetch_optional(db).await
|
||||
.map_err(|e| format!("DB-feil: {e}"))?.unwrap_or(serde_json::json!({}));
|
||||
Ok(AgentConfig {
|
||||
max_context_messages: c["max_context_messages"].as_i64().unwrap_or(50),
|
||||
max_consecutive_agent_messages: c["max_consecutive_agent_messages"].as_i64().unwrap_or(3),
|
||||
rate_limit_per_hour: c["rate_limit_per_hour"].as_i64().unwrap_or(60),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn find_agent_participant(db: &PgPool, communication_id: Uuid) -> Result<Option<Uuid>, sqlx::Error> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT n.id FROM nodes n JOIN edges e ON e.source_id = n.id JOIN agent_identities ai ON ai.node_id = n.id WHERE e.target_id = $1 AND e.edge_type IN ('owner', 'member_of') AND n.node_kind = 'agent' AND ai.is_active = true LIMIT 1",
|
||||
).bind(communication_id).fetch_optional(db).await
|
||||
}
|
||||
|
|
@ -389,6 +389,51 @@ pub async fn create_node(
|
|||
None
|
||||
};
|
||||
|
||||
// -- Agent-trigger: sjekk om kommunikasjonsnoden har en agent-deltaker --
|
||||
if let Some(ctx_id) = req.context_id {
|
||||
let db_clone = state.db.clone();
|
||||
let user_node_id = user.node_id;
|
||||
let created_node_id = node_id;
|
||||
tokio::spawn(async move {
|
||||
match crate::agent::find_agent_participant(&db_clone, ctx_id).await {
|
||||
Ok(Some(agent_id)) if agent_id != effective_identity => {
|
||||
// Agent funnet, og melding er ikke fra agenten selv
|
||||
let payload = serde_json::json!({
|
||||
"communication_id": ctx_id.to_string(),
|
||||
"message_id": created_node_id.to_string(),
|
||||
"agent_node_id": agent_id.to_string(),
|
||||
"sender_node_id": user_node_id.to_string()
|
||||
});
|
||||
match crate::jobs::enqueue(&db_clone, "agent_respond", payload, None, 8).await {
|
||||
Ok(job_id) => {
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
communication_id = %ctx_id,
|
||||
agent_node_id = %agent_id,
|
||||
"agent_respond-jobb lagt i kø"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
communication_id = %ctx_id,
|
||||
error = %e,
|
||||
"Kunne ikke legge agent_respond-jobb i kø"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // Ingen agent, eller melding fra agenten selv
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
communication_id = %ctx_id,
|
||||
error = %e,
|
||||
"Feil ved agent-deltaker-sjekk"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(CreateNodeResponse { node_id, belongs_to_edge_id }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent;
|
||||
use crate::cas::CasStore;
|
||||
use crate::stdb::StdbClient;
|
||||
use crate::transcribe;
|
||||
|
|
@ -151,6 +152,9 @@ async fn dispatch(
|
|||
"whisper_transcribe" => {
|
||||
transcribe::handle_whisper_job(job, db, stdb, cas, whisper_url).await
|
||||
}
|
||||
"agent_respond" => {
|
||||
agent::handle_agent_respond(job, db, stdb).await
|
||||
}
|
||||
other => Err(format!("Ukjent jobbtype: {other}")),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod agent;
|
||||
mod auth;
|
||||
pub mod cas;
|
||||
mod intentions;
|
||||
|
|
|
|||
107
migrations/007_agent_system.sql
Normal file
107
migrations/007_agent_system.sql
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
-- 007_agent_system.sql — Agentsystem for Claude som chat-deltaker.
|
||||
--
|
||||
-- Nye tabeller:
|
||||
-- agent_identities — kobler agent-noder til config og nøkkel
|
||||
-- agent_permissions — autorisasjonsnivåer (direct/propose)
|
||||
-- ai_usage_log — tokenregnskap for AI-kall
|
||||
--
|
||||
-- Seed: Claude-agentnode med agent_identity + tillatelse for Vegard.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Agent-identiteter
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE agent_identities (
|
||||
node_id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
agent_key TEXT UNIQUE NOT NULL, -- 'claude-main'
|
||||
agent_type TEXT NOT NULL, -- 'claude'
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE agent_identities IS 'Agent-noder som kan delta i samtaler (Claude, fremtidige agenter)';
|
||||
COMMENT ON COLUMN agent_identities.agent_key IS 'Unik nøkkel for oppslag (claude-main)';
|
||||
COMMENT ON COLUMN agent_identities.config IS 'model_alias, system_prompt, max_context_messages, etc.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Agent-tillatelser
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE agent_permissions (
|
||||
user_node_id UUID REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
agent_node_id UUID REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
permission TEXT NOT NULL CHECK (permission IN ('direct', 'propose')),
|
||||
PRIMARY KEY (user_node_id, agent_node_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE agent_permissions IS 'Hvem kan be agenter om hva (direct = implementer, propose = foreslå)';
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. AI-brukslogg
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE ai_usage_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
collection_node_id UUID REFERENCES nodes(id) ON DELETE SET NULL,
|
||||
job_id UUID REFERENCES job_queue(id) ON DELETE SET NULL,
|
||||
agent_node_id UUID REFERENCES nodes(id) ON DELETE SET NULL,
|
||||
communication_id UUID REFERENCES nodes(id) ON DELETE SET NULL,
|
||||
model_alias TEXT NOT NULL,
|
||||
model_actual TEXT,
|
||||
prompt_tokens INT NOT NULL DEFAULT 0,
|
||||
completion_tokens INT NOT NULL DEFAULT 0,
|
||||
total_tokens INT NOT NULL DEFAULT 0,
|
||||
estimated_cost NUMERIC(10, 6),
|
||||
job_type TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ai_usage_created ON ai_usage_log (created_at);
|
||||
CREATE INDEX idx_ai_usage_agent ON ai_usage_log (agent_node_id, created_at);
|
||||
|
||||
COMMENT ON TABLE ai_usage_log IS 'Tokenforbruk per AI-kall — kostnadskontroll og analyse';
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Seed: Claude-agentnode
|
||||
-- =============================================================================
|
||||
|
||||
-- Fast UUID for Claude (forutsigbar, enkel å referere til)
|
||||
INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by)
|
||||
VALUES (
|
||||
'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44',
|
||||
'agent',
|
||||
'Claude',
|
||||
'discoverable',
|
||||
'{"agent_type": "claude", "capabilities": ["chat", "code"]}',
|
||||
'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44' -- self-referencing
|
||||
);
|
||||
|
||||
INSERT INTO agent_identities (node_id, agent_key, agent_type, is_active, config)
|
||||
VALUES (
|
||||
'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44',
|
||||
'claude-main',
|
||||
'claude',
|
||||
true,
|
||||
'{
|
||||
"model_alias": "resonering",
|
||||
"max_context_messages": 50,
|
||||
"max_response_tokens": 2048,
|
||||
"cooldown_seconds": 5,
|
||||
"max_consecutive_agent_messages": 3,
|
||||
"rate_limit_per_hour": 60
|
||||
}'
|
||||
);
|
||||
|
||||
-- Vegard har direct-tilgang (kan bestille endringer)
|
||||
INSERT INTO agent_permissions (user_node_id, agent_node_id, permission)
|
||||
VALUES (
|
||||
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', -- Vegard
|
||||
'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44', -- Claude
|
||||
'direct'
|
||||
);
|
||||
|
||||
-- Gi synops_reader tilgang til nye tabeller
|
||||
GRANT SELECT ON agent_identities TO synops_reader;
|
||||
GRANT SELECT ON agent_permissions TO synops_reader;
|
||||
GRANT SELECT ON ai_usage_log TO synops_reader;
|
||||
27
scripts/maskinrommet-env.sh
Executable file
27
scripts/maskinrommet-env.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env bash
|
||||
# Genererer /tmp/maskinrommet.env med Docker container-IP-er.
|
||||
# Kjøres av systemd ExecStartPre.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ENV_FILE="/srv/synops/.env"
|
||||
read_env() { grep "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2; }
|
||||
container_ip() { docker inspect "$1" --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'; }
|
||||
|
||||
PG_IP=$(container_ip sidelinja-postgres-1)
|
||||
STDB_IP=$(container_ip sidelinja-spacetimedb-1)
|
||||
WHISPER_IP=$(container_ip sidelinja-faster-whisper-1 2>/dev/null || echo "")
|
||||
|
||||
cat > /tmp/maskinrommet.env <<EOF
|
||||
DATABASE_URL=postgres://$(read_env POSTGRES_USER):$(read_env POSTGRES_PASSWORD)@${PG_IP}:5432/synops
|
||||
SPACETIMEDB_URL=http://${STDB_IP}:3000
|
||||
SPACETIMEDB_DATABASE=$(read_env SPACETIMEDB_DATABASE)
|
||||
SPACETIMEDB_TOKEN=$(read_env SPACETIMEDB_TOKEN)
|
||||
AUTHENTIK_ISSUER=$(read_env AUTHENTIK_ISSUER)
|
||||
AUTHENTIK_CLIENT_ID=$(read_env AUTHENTIK_CLIENT_ID)
|
||||
BIND_ADDR=0.0.0.0:3100
|
||||
CAS_ROOT=/srv/synops/media/cas
|
||||
WHISPER_URL=http://${WHISPER_IP:-localhost}:8000
|
||||
PROJECT_DIR=/home/vegard/synops
|
||||
RUST_LOG=maskinrommet=debug,tower_http=debug
|
||||
EOF
|
||||
18
scripts/maskinrommet.service
Normal file
18
scripts/maskinrommet.service
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[Unit]
|
||||
Description=Maskinrommet — Synops Rust API og jobbkø
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=vegard
|
||||
WorkingDirectory=/home/vegard/synops
|
||||
ExecStartPre=/home/vegard/synops/scripts/maskinrommet-env.sh
|
||||
ExecStartPre=/etc/iptables-maskinrommet.sh
|
||||
EnvironmentFile=/tmp/maskinrommet.env
|
||||
ExecStart=/home/vegard/synops/maskinrommet/target/release/maskinrommet
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Loading…
Add table
Reference in a new issue