23ef84d9d8
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.
219 lines
7.5 KiB
Rust
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");
|
|
}
|