AI-rutingskontroll i admin: 13 kontekster konfigurerbare uten redeploy (oppgave 28.2)

Utvider /admin/ai med full kontroll over hvilken modellalias som brukes
per AI-kontekst. Admin kan bytte modell for orkestrering, bot-chat,
oppsummering, edge-forslag, klassifisering osv. uten å restarte
maskinrommet.

Endringer:
- Migration 028: seeder 7 nye kontekster i ai_job_routing
  (orchestration_script/dream, bot_chat/triage, summarize, suggest_edges, classify)
- Backend: resolve_routing_or_default() i ai_admin.rs — felles oppslag
  mot ai_job_routing med fallback til sidelinja/rutine
- Dispatchers (ai_edges, summarize) bruker nå routing-tabellen i stedet
  for hardkodede env-variabler — endringer trer i kraft umiddelbart
- Frontend: Ruting-tab omskrevet med kategoriserte kontekster
  (Orkestrering, Bot & chat, Analyse, Prosessering), beskrivelser per
  kontekst, og støtte for egendefinerte regler
- Docs: ai_gateway.md §3.4 oppdatert med alle 13 kontekster
This commit is contained in:
vegard 2026-03-18 20:06:50 +00:00
parent 1c59604b07
commit a06b79478a
7 changed files with 257 additions and 59 deletions

View file

@ -78,17 +78,47 @@ Generert config inkluderer alltid `router_settings` og `general_settings` fra fa
### 3.4 Jobbkø-styrt modellvalg
Jobbkøen bruker `ai_job_routing` for å bestemme modellalias per jobbtype:
Jobbkøen bruker `ai_job_routing` for å bestemme modellalias per kontekst.
Admin kan endre mapping i `/admin/ai` uten redeploy — endringer trer i kraft
ved neste jobb som kjøres.
| Jobbtype | Standard alias | Begrunnelse |
**Orkestrering:**
| Kontekst | Standard alias | Begrunnelse |
|---|---|---|
| `ai_text_process` (✨-behandling) | `sidelinja/rutine` | Tekstvasking, høyt volum |
| `orchestration_script` | `sidelinja/rutine` | LLM-kall i orkestreringsskript (SPØR/TRANSFORMER-steg) |
| `orchestration_dream` | `sidelinja/resonering` | Kreativ/utforskende orkestrering — drømmemodus |
**Bot & chat:**
| Kontekst | Standard alias | Begrunnelse |
|---|---|---|
| `bot_chat` | `sidelinja/resonering` | Bot-svar i chat (Claude-agent og andre bots) |
| `bot_triage` | `sidelinja/rutine` | Triagering og klassifisering av innkommende meldinger |
| `agent_respond` | `sidelinja/resonering` | Claude chat-agent svar (legacy — bruk `bot_chat`) |
**Analyse & klassifisering:**
| Kontekst | Standard alias | Begrunnelse |
|---|---|---|
| `suggest_edges` | `sidelinja/rutine` | AI-foreslåtte topics og mentions ved ny node |
| `summarize` | `sidelinja/rutine` | Oppsummering av kommunikasjonsnoder og innhold |
| `classify` | `sidelinja/rutine` | Klassifisering av innhold (node_kind, tags, prioritet) |
**Prosessering:**
| Kontekst | Standard alias | Begrunnelse |
|---|---|---|
| `ai_text_process` | `sidelinja/rutine` | Tekstvasking, høyt volum |
| `whisper_postprocess` | `sidelinja/rutine` | Transkripsjonsvasking, høyt volum |
| `research_clip` | `sidelinja/rutine` | Research-oppsummering, høyt volum |
| `suggest_edges` | `sidelinja/rutine` | AI-foreslåtte topics og mentions, automatisk ved ny node |
| `simple_prompt` | `sidelinja/rutine` | Standard LLM-kall via synops-ai prompt |
| `live_factoid_eval` | `sidelinja/resonering` | Krever presis vurdering under tidspress |
Modellalias lagres som felt på jobben i PG — kan overstyres manuelt per jobb ved behov.
**Teknisk:**
Maskinrommet slår opp modellalias via `ai_admin::resolve_routing_or_default(db, kontekst)`.
Resultatet sendes som miljøvariabel til CLI-verktøy (f.eks. `AI_EDGES_MODEL`, `AI_SUMMARY_MODEL`).
CLI-verktøy som `synops-ai` gjør oppslag direkte i tabellen via `--job-type`-flagget.
### 3.5 Admin-panel (`/admin/ai`)

View file

@ -57,6 +57,55 @@
let newRoutingAlias = $state('');
let newRoutingDesc = $state('');
// Kjente AI-kontekster med kategorier og beskrivelser
const KNOWN_CONTEXTS: { category: string; contexts: { job_type: string; description: string }[] }[] = [
{
category: 'Orkestrering',
contexts: [
{ job_type: 'orchestration_script', description: 'LLM-kall i orkestreringsskript (SPØR/TRANSFORMER-steg)' },
{ job_type: 'orchestration_dream', description: 'Kreativ/utforskende orkestrering — drømmemodus' }
]
},
{
category: 'Bot & chat',
contexts: [
{ job_type: 'bot_chat', description: 'Bot-svar i chat (Claude-agent og andre bots)' },
{ job_type: 'bot_triage', description: 'Triagering og klassifisering av innkommende meldinger' },
{ job_type: 'agent_respond', description: 'Claude chat-agent svar (legacy)' }
]
},
{
category: 'Analyse & klassifisering',
contexts: [
{ job_type: 'suggest_edges', description: 'AI-foreslåtte topics og mentions ved ny node' },
{ job_type: 'summarize', description: 'Oppsummering av kommunikasjonsnoder og innhold' },
{ job_type: 'classify', description: 'Klassifisering av innhold (node_kind, tags, prioritet)' }
]
},
{
category: 'Prosessering',
contexts: [
{ job_type: 'ai_text_process', description: 'Tekstvasking og behandling, høyt volum' },
{ job_type: 'whisper_postprocess', description: 'Transkripsjonsvasking etter Whisper' },
{ job_type: 'research_clip', description: 'Research-oppsummering' },
{ job_type: 'simple_prompt', description: 'Standard LLM-kall via synops-ai prompt' },
{ job_type: 'live_factoid_eval', description: 'Faktoid-vurdering under live sending' }
]
}
];
// Alle kjente job_types som flat liste
const allKnownJobTypes = KNOWN_CONTEXTS.flatMap((c) => c.contexts.map((ctx) => ctx.job_type));
function routingForContext(jobType: string): AiJobRouting | undefined {
return data?.routing.find((r) => r.job_type === jobType);
}
function unknownRoutingRules(): AiJobRouting[] {
if (!data) return [];
return data.routing.filter((r) => !allKnownJobTypes.includes(r.job_type));
}
// Poll hvert 10. sekund (AI-config endres sjelden)
$effect(() => {
if (!accessToken) return;
@ -483,54 +532,118 @@
<!-- === TAB: Ruting === -->
{:else if activeTab === 'routing'}
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
<div class="border-b border-gray-100 px-5 py-3">
<h2 class="text-sm font-semibold text-gray-800">Jobbtype → Modellalias</h2>
<div class="space-y-4">
<div class="rounded-lg border border-gray-200 bg-white px-5 py-3 shadow-sm">
<h2 class="text-sm font-semibold text-gray-800">AI-kontekster → Modellalias</h2>
<p class="mt-1 text-xs text-gray-500">
Hvilken modellalias brukes for hvilken jobbtype i jobbkøen.
Hvilken modellalias brukes i hvilken kontekst. Endringer trer i kraft umiddelbart uten redeploy.
</p>
</div>
<div class="divide-y divide-gray-100">
{#each data.routing as routing}
<div class="flex items-center gap-3 px-5 py-3">
<div class="flex-1">
<span class="font-mono text-sm text-gray-800">{routing.job_type}</span>
{#if routing.description}
<span class="ml-2 text-xs text-gray-400">{routing.description}</span>
{/if}
</div>
<select
class="rounded border border-gray-300 px-2 py-1 text-sm"
value={routing.alias}
onchange={(e) => handleUpdateRouting(routing, (e.target as HTMLSelectElement).value)}
disabled={actionLoading === `routing-${routing.job_type}`}
>
{#each data.aliases as alias}
<option value={alias.alias}>{alias.alias}</option>
{/each}
</select>
<button
class="rounded px-2 py-1 text-xs text-red-500 hover:bg-red-50"
onclick={() => handleDeleteRouting(routing.job_type)}
disabled={actionLoading === `delete-routing-${routing.job_type}`}
>
Slett
</button>
{#each KNOWN_CONTEXTS as group}
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
<div class="border-b border-gray-100 px-5 py-2.5">
<h3 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
{group.category}
</h3>
</div>
{/each}
<div class="divide-y divide-gray-100">
{#each group.contexts as ctx}
{@const routing = routingForContext(ctx.job_type)}
<div class="flex items-center gap-3 px-5 py-3">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-mono text-sm text-gray-800">{ctx.job_type}</span>
{#if !routing}
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-400">
ikke konfigurert
</span>
{/if}
</div>
<p class="mt-0.5 text-xs text-gray-400">{ctx.description}</p>
</div>
<select
class="rounded border border-gray-300 px-2 py-1 text-sm {!routing ? 'text-gray-400' : ''}"
value={routing?.alias ?? ''}
onchange={(e) => {
const val = (e.target as HTMLSelectElement).value;
if (val) {
const r = routing ?? { job_type: ctx.job_type, alias: val, description: ctx.description };
handleUpdateRouting(r, val);
}
}}
disabled={actionLoading === `routing-${ctx.job_type}`}
>
{#if !routing}
<option value="">Velg alias...</option>
{/if}
{#each data.aliases as alias}
<option value={alias.alias}>{alias.alias}</option>
{/each}
</select>
{#if routing}
<button
class="rounded px-2 py-1 text-xs text-red-500 hover:bg-red-50"
onclick={() => handleDeleteRouting(ctx.job_type)}
disabled={actionLoading === `delete-routing-${ctx.job_type}`}
>
Fjern
</button>
{/if}
</div>
{/each}
</div>
</section>
{/each}
{#if data.routing.length === 0}
<p class="px-5 py-4 text-sm text-gray-400">Ingen ruting-regler konfigurert.</p>
{/if}
</div>
<!-- Egendefinerte regler (ikke i kjente kontekster) -->
{#if unknownRoutingRules().length > 0}
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
<div class="border-b border-gray-100 px-5 py-2.5">
<h3 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Egendefinerte
</h3>
</div>
<div class="divide-y divide-gray-100">
{#each unknownRoutingRules() as routing}
<div class="flex items-center gap-3 px-5 py-3">
<div class="flex-1">
<span class="font-mono text-sm text-gray-800">{routing.job_type}</span>
{#if routing.description}
<p class="mt-0.5 text-xs text-gray-400">{routing.description}</p>
{/if}
</div>
<select
class="rounded border border-gray-300 px-2 py-1 text-sm"
value={routing.alias}
onchange={(e) => handleUpdateRouting(routing, (e.target as HTMLSelectElement).value)}
disabled={actionLoading === `routing-${routing.job_type}`}
>
{#each data.aliases as alias}
<option value={alias.alias}>{alias.alias}</option>
{/each}
</select>
<button
class="rounded px-2 py-1 text-xs text-red-500 hover:bg-red-50"
onclick={() => handleDeleteRouting(routing.job_type)}
disabled={actionLoading === `delete-routing-${routing.job_type}`}
>
Fjern
</button>
</div>
{/each}
</div>
</section>
{/if}
<!-- Ny ruting -->
<!-- Ny egendefinert ruting -->
{#if showNewRouting}
<div class="border-t border-blue-200 bg-blue-50 px-5 py-3">
<section class="rounded-lg border border-blue-200 bg-blue-50 p-5 shadow-sm">
<h3 class="mb-3 text-sm font-semibold text-gray-800">Ny egendefinert rutingregel</h3>
<div class="flex gap-2">
<input
bind:value={newRoutingJobType}
placeholder="Jobbtype (f.eks. ai_text_process)"
placeholder="Jobbtype (f.eks. min_jobb)"
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
/>
<select
@ -565,18 +678,16 @@
Avbryt
</button>
</div>
</div>
</section>
{:else}
<div class="border-t border-gray-100 px-5 py-3">
<button
class="text-xs text-blue-600 hover:text-blue-800"
onclick={() => (showNewRouting = true)}
>
+ Ny rutingregel
</button>
</div>
<button
class="rounded-lg border border-dashed border-gray-300 px-4 py-2 text-sm text-gray-500 hover:border-gray-400 hover:text-gray-700"
onclick={() => (showNewRouting = true)}
>
+ Ny egendefinert rutingregel
</button>
{/if}
</section>
</div>
<!-- === TAB: Forbruk === -->
{:else if activeTab === 'usage'}

View file

@ -86,6 +86,40 @@ fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string() }))
}
// =============================================================================
// Modelloppslag fra ai_job_routing — brukes av jobbkø-dispatchers
// =============================================================================
/// Slå opp modellalias fra ai_job_routing for en gitt jobbtype/kontekst.
/// Returnerer None hvis ingen regel er konfigurert.
pub async fn resolve_routing(db: &PgPool, job_type: &str) -> Result<Option<String>, sqlx::Error> {
let row: Option<(String,)> =
sqlx::query_as("SELECT alias FROM ai_job_routing WHERE job_type = $1")
.bind(job_type)
.fetch_optional(db)
.await?;
Ok(row.map(|(alias,)| alias))
}
/// Slå opp modellalias med fallback til standard-alias.
/// Brukes av dispatchers som trenger en modell uansett.
pub async fn resolve_routing_or_default(db: &PgPool, job_type: &str) -> String {
match resolve_routing(db, job_type).await {
Ok(Some(alias)) => {
tracing::debug!(job_type, alias = %alias, "Modell fra ai_job_routing");
alias
}
Ok(None) => {
tracing::warn!(job_type, "Ingen rutingregel — bruker sidelinja/rutine");
"sidelinja/rutine".to_string()
}
Err(e) => {
tracing::error!(job_type, error = %e, "Feil ved oppslag i ai_job_routing — bruker fallback");
"sidelinja/rutine".to_string()
}
}
}
// =============================================================================
// GET /admin/ai — oversikt
// =============================================================================

View file

@ -11,6 +11,7 @@
use uuid::Uuid;
use crate::ai_admin;
use crate::cli_dispatch;
use crate::jobs::JobRow;
@ -26,7 +27,7 @@ fn suggest_edges_bin() -> String {
/// LLM-kall, topic-opprettelse, edge-skriving, ressurslogging.
pub async fn handle_suggest_edges(
job: &JobRow,
_db: &sqlx::PgPool,
db: &sqlx::PgPool,
) -> Result<serde_json::Value, String> {
let node_id: Uuid = job
.payload
@ -55,7 +56,10 @@ pub async fn handle_suggest_edges(
cli_dispatch::set_database_url(&mut cmd)?;
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_EDGES_MODEL");
// Modellalias fra ai_job_routing — admin kan endre uten redeploy
let model_alias = ai_admin::resolve_routing_or_default(db, "suggest_edges").await;
cmd.env("AI_EDGES_MODEL", &model_alias);
tracing::info!(
node_id = %node_id,

View file

@ -8,6 +8,7 @@
use uuid::Uuid;
use crate::ai_admin;
use crate::cli_dispatch;
use crate::jobs::JobRow;
@ -28,7 +29,7 @@ fn summarize_bin() -> String {
/// - requested_by: UUID — brukeren som utløste oppsummeringen
pub async fn handle_summarize_communication(
job: &JobRow,
_db: &sqlx::PgPool,
db: &sqlx::PgPool,
) -> Result<serde_json::Value, String> {
let communication_id: Uuid = job
.payload
@ -58,7 +59,10 @@ pub async fn handle_summarize_communication(
cli_dispatch::set_database_url(&mut cmd)?;
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_SUMMARY_MODEL");
// Modellalias fra ai_job_routing — admin kan endre uten redeploy
let model_alias = ai_admin::resolve_routing_or_default(db, "summarize").await;
cmd.env("AI_SUMMARY_MODEL", &model_alias);
tracing::info!(
communication_id = %communication_id,

View file

@ -0,0 +1,16 @@
-- 028_ai_routing_contexts.sql — Utvid ai_job_routing med kontekster for oppgave 28.2
--
-- Legger til de syv AI-kontekstene som admin kan konfigurere i /admin/ai.
-- Eksisterende rader beholdes (ON CONFLICT DO NOTHING).
--
-- Ref: docs/infra/ai_gateway.md §3.4
INSERT INTO ai_job_routing (job_type, alias, description) VALUES
('orchestration_script', 'sidelinja/rutine', 'LLM-kall i orkestreringsskript (SPØR/TRANSFORMER-steg)'),
('orchestration_dream', 'sidelinja/resonering', 'Kreativ/utforskende orkestrering — drømmemodus'),
('bot_chat', 'sidelinja/resonering', 'Bot-svar i chat (Claude-agent og andre bots)'),
('bot_triage', 'sidelinja/rutine', 'Triagering og klassifisering av innkommende meldinger'),
('summarize', 'sidelinja/rutine', 'Oppsummering av kommunikasjonsnoder og innhold'),
('suggest_edges', 'sidelinja/rutine', 'AI-foreslåtte topics og mentions ved ny node'),
('classify', 'sidelinja/rutine', 'Klassifisering av innhold (node_kind, tags, prioritet)')
ON CONFLICT (job_type) DO NOTHING;

View file

@ -373,8 +373,7 @@ modell som brukes til hva.
### synops-ai: lettvekts LLM-kall
- [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`.
- [~] 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.
> Påbegynt: 2026-03-18T19:55
- [x] 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.
### Øvrige manglende verktøy