Files
doctate/server/src/routes/bulk.rs
T
Brummel 23ef84d9d8 feat: multi-backend LLM layer with per-request choice on case page
Replace the single [llm] block in settings.toml with a curated catalog
of LLM "backends" (provider + model + sampling params + system prompt)
in server/src/analyze/backend.rs. Two backends ship initially:
gpt_oss_120b (default, reasoning_effort=medium) and llama_3_1_405b
(uses response_format: json_schema to sidestep the <|eot_id|> leak).

The case page now renders one submit button per available backend; the
clicked button's name=backend value rides through AnalysisInput.backend_id
to the worker, which looks it up via find_backend() with default fallback.
A submitted unknown backend id is rejected as 400. .analysis_failed.json
records the failed backend and the failure banner surfaces its label.

The API key now comes from IONOS_API_KEY (env), not settings.toml; the
[llm] section is read into a discarded field so old configs still load.
Backends without a satisfied requires_api_key are filtered from the UI
(any_backend_available()). Three end-to-end tests that mocked the LLM
endpoint via settings.llm.url are #[ignore]'d with a clear note — they
need per-test backend injection (Arc<Vec<LlmBackend>> through the worker)
before they can be re-enabled.
2026-05-03 14:57:21 +02:00

219 lines
7.5 KiB
Rust

use std::str::FromStr;
use std::sync::Arc;
use axum::extract::State;
use axum::response::Redirect;
use doctate_common::BulkAction;
use doctate_common::timestamp::now_rfc3339;
use serde::Deserialize;
use tracing::{info, warn};
use crate::analyze::backend::any_backend_available;
use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE};
use crate::auth::AuthenticatedWebUser;
use crate::config::Config;
use crate::csrf::{CsrfForm, HasCsrfToken};
use crate::error::AppError;
use crate::events::{self, CaseEventKind, EventSender};
use crate::oneliner_locks::OnelinerLocks;
use crate::paths::{CloseMarker, write_close_marker};
use crate::routes::case_actions::{
build_analysis_input, reset_case_artefacts, write_input_create_new,
};
use crate::routes::user_web::locate_case;
#[derive(Debug, Deserialize)]
pub struct BulkForm {
action: String,
/// Repeated `case_id=<uuid>` form fields. Empty for an empty selection
/// (handled as a no-op).
#[serde(default, rename = "case_id")]
case_ids: Vec<String>,
#[serde(default)]
csrf_token: String,
}
impl HasCsrfToken for BulkForm {
fn csrf_token(&self) -> &str {
&self.csrf_token
}
}
/// POST /web/cases/bulk — apply `action` (analyze | close | reset) to
/// every selected case. Errors per case are logged but do not abort the
/// batch: a single bad/missing case must not block the rest.
pub async fn handle_bulk_action(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(analyze_tx): State<AnalyzeSender>,
State(events_tx): State<EventSender>,
State(locks): State<OnelinerLocks>,
CsrfForm(form): CsrfForm<BulkForm>,
) -> Result<Redirect, AppError> {
// Bulk actions are admin-only across the board: the UI hides the
// selection checkboxes and action bar from non-admins, and this
// server-side gate defends against hand-crafted POSTs.
if !user.is_admin() {
warn!(slug = %user.slug, action = %form.action, "bulk: not admin");
return Err(AppError::Forbidden("Nur für Admins".into()));
}
let user_root = config.data_path.join(&user.slug);
// Parse the wire token into the shared enum so the match below is
// exhaustive at compile time. Keep the exact "Unbekannte Aktion"
// error shape the handler returned before the enum existed.
let action = BulkAction::from_str(&form.action).map_err(|err| {
warn!(slug = %user.slug, action = %err.0, "bulk: unknown action");
AppError::BadRequest("Unbekannte Aktion".into())
})?;
match action {
BulkAction::Analyze => {
bulk_analyze(
&analyze_tx,
&events_tx,
&user.slug,
&user_root,
&form.case_ids,
)
.await
}
BulkAction::Close => bulk_close(&events_tx, &user.slug, &user_root, &form.case_ids).await,
BulkAction::Reset => {
bulk_reset(&events_tx, &locks, &user.slug, &user_root, &form.case_ids).await
}
}
Ok(Redirect::to("/web/cases"))
}
async fn bulk_analyze(
analyze_tx: &AnalyzeSender,
events_tx: &EventSender,
slug: &str,
user_root: &std::path::Path,
case_ids: &[String],
) {
if !any_backend_available() {
warn!(slug = %slug, "bulk-analyze: no LLM backend available, skipping all");
return;
}
let mut ok = 0usize;
let mut skipped = 0usize;
for case_id in case_ids {
if uuid::Uuid::parse_str(case_id).is_err() {
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: invalid case_id");
skipped += 1;
continue;
}
let Some(case_dir) = locate_case(user_root, case_id).await else {
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: case not found");
skipped += 1;
continue;
};
// Same order as the single-case handler: drop old document first so
// the UI doesn't keep showing "ausgewertet" during re-analysis.
let document_path = case_dir.join(DOCUMENT_FILE);
match tokio::fs::remove_file(&document_path).await {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-analyze: remove old document failed");
skipped += 1;
continue;
}
}
let input = match build_analysis_input(&case_dir, "").await {
Ok(i) => i,
Err(_) => {
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: build_analysis_input failed");
skipped += 1;
continue;
}
};
let input_path = case_dir.join(ANALYSIS_INPUT_FILE);
if write_input_create_new(&input_path, &input).await.is_err() {
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: write input failed");
skipped += 1;
continue;
}
if analyze_tx
.send(AnalyzeJob {
case_dir: case_dir.clone(),
})
.is_err()
{
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: send failed (worker gone)");
}
events::emit(events_tx, slug, case_id, CaseEventKind::AnalysisQueued);
ok += 1;
}
info!(slug = %slug, ok, skipped, "bulk-analyze completed");
}
async fn bulk_close(
events_tx: &EventSender,
slug: &str,
user_root: &std::path::Path,
case_ids: &[String],
) {
let closed_at = now_rfc3339();
let mut ok = 0usize;
let mut skipped = 0usize;
for case_id in case_ids {
if uuid::Uuid::parse_str(case_id).is_err() {
warn!(slug = %slug, case_id = %case_id, "bulk-close: invalid case_id");
skipped += 1;
continue;
}
let Some(case_dir) = locate_case(user_root, case_id).await else {
warn!(slug = %slug, case_id = %case_id, "bulk-close: case not found");
skipped += 1;
continue;
};
let marker = CloseMarker {
closed_at: closed_at.clone(),
};
if let Err(e) = write_close_marker(&case_dir, &marker).await {
warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-close: write marker failed");
skipped += 1;
continue;
}
events::emit(events_tx, slug, case_id, CaseEventKind::CaseClosed);
ok += 1;
}
info!(slug = %slug, closed_at = %closed_at, ok, skipped, "bulk-close completed");
}
async fn bulk_reset(
events_tx: &EventSender,
locks: &OnelinerLocks,
slug: &str,
user_root: &std::path::Path,
case_ids: &[String],
) {
let mut ok = 0usize;
let mut skipped = 0usize;
for case_id in case_ids {
if uuid::Uuid::parse_str(case_id).is_err() {
warn!(slug = %slug, case_id = %case_id, "bulk-reset: invalid case_id");
skipped += 1;
continue;
}
let Some(case_dir) = locate_case(user_root, case_id).await else {
warn!(slug = %slug, case_id = %case_id, "bulk-reset: case not found");
skipped += 1;
continue;
};
if let Err(e) = reset_case_artefacts(&case_dir, locks).await {
warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-reset: failed");
skipped += 1;
continue;
}
events::emit(events_tx, slug, case_id, CaseEventKind::CaseReset);
ok += 1;
}
info!(slug = %slug, ok, skipped, "bulk-reset completed");
}