From 5608c8ba434d20f454b070cc795ab066649569b7 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 15 Apr 2026 21:19:56 +0200 Subject: [PATCH] feat: Add reanalyze case functionality Introduce a new endpoint for reanalyzing cases. This feature allows administrators to rebuild analysis input files and enqueue new analysis jobs. The system will then generate a new document version, which will become the displayed document. --- server/src/routes/case_actions.rs | 66 +++++++++ server/src/routes/mod.rs | 4 + server/src/routes/user_web.rs | 14 +- server/templates/case_detail.html | 3 + server/tests/close_case_test.rs | 215 +++++++++++++++++++++++++++++- 5 files changed, 300 insertions(+), 2 deletions(-) diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index a14ad24..07270ec 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -87,6 +87,72 @@ pub async fn handle_close_case( Ok(Redirect::to(&format!("/web/cases/{case_id}"))) } +/// POST /web/cases/{case_id}/reanalyze +/// +/// Admin-only. Build a fresh `analysis_input_v{N+1}.json` from the current +/// transcripts and enqueue a new analyze job. `find_latest_document` picks the +/// currently-highest N; the worker will write `document_v{N+1}.md`, which then +/// wins the "highest version" race and becomes the displayed document. +pub async fn handle_reanalyze_case( + user: AuthenticatedWebUser, + State(config): State>, + State(analyze_tx): State, + AxumPath(case_id): AxumPath, +) -> Result { + uuid::Uuid::parse_str(&case_id) + .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; + + if !user.is_admin() { + return Err(AppError::Forbidden("Admin erforderlich".into())); + } + + if !config.llm_configured() { + return Err(AppError::ServiceUnavailable( + "LLM-Analyse nicht konfiguriert".into(), + )); + } + + let user_root = config.data_path.join(&user.slug); + let case_dir = user_root.join("open").join(&case_id); + if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) { + warn!(slug = %user.slug, case_id = %case_id, "reanalyze: case not found in open/"); + return Err(AppError::NotFound("Case not found".into())); + } + + let current_version = find_latest_document(&case_dir) + .await + .map(|(v, _)| v) + .ok_or_else(|| { + AppError::BadRequest("Kein bestehendes Dokument — nutze 'Fall abschließen'".into()) + })?; + let next_version = current_version + 1; + + let input = build_analysis_input(&case_dir, next_version).await?; + + let input_path = case_dir.join(format!("analysis_input_v{next_version}.json")); + write_input_create_new(&input_path, &input).await?; + + if analyze_tx + .send(AnalyzeJob { + case_dir: case_dir.clone(), + version: next_version, + }) + .is_err() + { + warn!(case_id = %case_id, "analyze send failed (worker gone)"); + } + + info!( + slug = %user.slug, + case_id = %case_id, + version = next_version, + recordings = input.recordings.len(), + "reanalyze requested; analysis enqueued" + ); + + Ok(Redirect::to(&format!("/web/cases/{case_id}"))) +} + /// GET /web/cases/{case_id}/document /// /// Render the latest `document_v{N}.md` for the case. The handler scans the diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 1b39411..679585d 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -24,6 +24,10 @@ pub fn api_router() -> Router { "/web/cases/{case_id}/close", post(case_actions::handle_close_case), ) + .route( + "/web/cases/{case_id}/reanalyze", + post(case_actions::handle_reanalyze_case), + ) .route( "/web/cases/{case_id}/document", get(case_actions::handle_document_view), diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 4da20b8..2a84137 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -43,6 +43,7 @@ struct CaseDetailTemplate { llm_missing: bool, analyzing: bool, has_document: bool, + can_reanalyze: bool, } /// Four mutually exclusive flags derived from filesystem state + config. @@ -54,6 +55,7 @@ struct CaseFlags { analyzing: bool, can_close: bool, llm_missing: bool, + can_reanalyze: bool, } async fn compute_flags( @@ -61,6 +63,7 @@ async fn compute_flags( status: &str, recordings: &[RecordingView], llm_configured: bool, + is_admin: bool, ) -> CaseFlags { let has_document = any_document_exists(case_dir).await; let analyzing = !has_document @@ -79,6 +82,7 @@ async fn compute_flags( analyzing, can_close: transcribed_stage && llm_configured, llm_missing: transcribed_stage && !llm_configured, + can_reanalyze: has_document && is_admin && llm_configured, } } @@ -140,7 +144,14 @@ pub async fn handle_case_detail( .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); - let flags = compute_flags(&case_dir, &status, &recordings, config.llm_configured()).await; + let flags = compute_flags( + &case_dir, + &status, + &recordings, + config.llm_configured(), + user.is_admin(), + ) + .await; let case_id_short = case_id.chars().take(8).collect(); CaseDetailTemplate { @@ -154,6 +165,7 @@ pub async fn handle_case_detail( llm_missing: flags.llm_missing, analyzing: flags.analyzing, has_document: flags.has_document, + can_reanalyze: flags.can_reanalyze, } .render() .map(Html) diff --git a/server/templates/case_detail.html b/server/templates/case_detail.html index 89c9293..91ae6ef 100644 --- a/server/templates/case_detail.html +++ b/server/templates/case_detail.html @@ -45,6 +45,9 @@ header form { margin: 0; }
{% if has_document %} Ergebnis öffnen +{% if can_reanalyze %} +
+{% endif %} {% else if analyzing %} wird analysiert … {% else if can_close %} diff --git a/server/tests/close_case_test.rs b/server/tests/close_case_test.rs index 4af4b08..f8cdc06 100644 --- a/server/tests/close_case_test.rs +++ b/server/tests/close_case_test.rs @@ -35,7 +35,10 @@ fn make_user(slug: &str) -> User { } fn config_with_llm(data_path: PathBuf, llm_url: String) -> Arc { - let users = vec![make_user("dr_a")]; + config_with_llm_users(data_path, llm_url, vec![make_user("dr_a")]) +} + +fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec) -> Arc { let api_keys: HashMap = users .iter() .map(|u| (u.api_key.clone(), u.slug.clone())) @@ -51,6 +54,16 @@ fn config_with_llm(data_path: PathBuf, llm_url: String) -> Arc { }) } +fn make_admin(slug: &str) -> User { + User { + slug: slug.into(), + api_key: format!("key-{slug}"), + web_password: bcrypt::hash("s", 4).unwrap(), + role: "admin".into(), + whisper: Default::default(), + } +} + fn config_without_llm(data_path: PathBuf) -> Arc { let users = vec![make_user("dr_a")]; let api_keys: HashMap = users @@ -410,6 +423,206 @@ async fn document_view_picks_highest_version() { assert!(body_str.contains("Dokument v2")); } +// --------------------------------------------------------------------- +// Reanalyze (admin-only) +// --------------------------------------------------------------------- + +fn reanalyze_request(case_id: &str, cookie: &str) -> Request { + Request::builder() + .method("POST") + .uri(format!("/web/cases/{case_id}/reanalyze")) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap() +} + +#[tokio::test] +async fn reanalyze_by_non_admin_returns_403() { + let config = config_with_llm(unique_tmp("rn-1"), "http://unused".into()); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + std::fs::write(case_dir.join("document_v1.md"), "v1").unwrap(); + seed_recording(&case_dir, "10-00-00", Some("ok")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app.oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn reanalyze_without_document_returns_400() { + let config = config_with_llm_users( + unique_tmp("rn-2"), + "http://unused".into(), + vec![make_admin("dr_a")], + ); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + seed_recording(&case_dir, "10-00-00", Some("ok")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app.oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn reanalyze_foreign_case_returns_404() { + let config = config_with_llm_users( + unique_tmp("rn-3"), + "http://unused".into(), + vec![make_admin("dr_a")], + ); + let foreign_case = "22222222-2222-2222-2222-222222222222"; + let foreign_dir = config.data_path.join("dr_other/open").join(foreign_case); + std::fs::create_dir_all(&foreign_dir).unwrap(); + std::fs::write(foreign_dir.join("document_v1.md"), "v1").unwrap(); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app.oneshot(reanalyze_request(foreign_case, &cookie)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn reanalyze_without_llm_returns_503() { + let users = vec![make_admin("dr_a")]; + let api_keys: HashMap = users + .iter() + .map(|u| (u.api_key.clone(), u.slug.clone())) + .collect(); + let config = Arc::new(Config { + data_path: unique_tmp("rn-4"), + users, + api_keys, + ..Config::test_default() + }); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + std::fs::write(case_dir.join("document_v1.md"), "v1").unwrap(); + seed_recording(&case_dir, "10-00-00", Some("ok")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app.oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn reanalyze_happy_path_writes_v2_input() { + let config = config_with_llm_users( + unique_tmp("rn-5"), + "http://unused".into(), + vec![make_admin("dr_a")], + ); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + std::fs::write(case_dir.join("document_v1.md"), "altes Dokument").unwrap(); + seed_recording(&case_dir, "10-00-00", Some("erste Aufnahme")); + seed_recording(&case_dir, "10-05-00", Some("zweite Aufnahme")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app.oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + + let input_path = case_dir.join("analysis_input_v2.json"); + assert!(input_path.exists(), "analysis_input_v2.json missing"); + let parsed: Value = serde_json::from_str(&std::fs::read_to_string(&input_path).unwrap()).unwrap(); + assert_eq!(parsed["version"], 2); + assert_eq!(parsed["recordings"].as_array().unwrap().len(), 2); + // v1 document is still there until worker writes v2. + assert!(case_dir.join("document_v1.md").exists()); +} + +#[tokio::test] +async fn reanalyze_second_time_returns_409() { + let config = config_with_llm_users( + unique_tmp("rn-6"), + "http://unused".into(), + vec![make_admin("dr_a")], + ); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + std::fs::write(case_dir.join("document_v1.md"), "v1").unwrap(); + seed_recording(&case_dir, "10-00-00", Some("ok")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let first = app.clone().oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); + assert_eq!(first.status(), StatusCode::SEE_OTHER); + + let second = app.oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); + assert_eq!(second.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +async fn reanalyze_worker_writes_document_v2_via_wiremock() { + let tmp = unique_tmp("rn-w"); + let case_dir = tmp.join("dr_a/open/11111111-1111-1111-1111-111111111111"); + std::fs::create_dir_all(&case_dir).unwrap(); + std::fs::write(case_dir.join("document_v1.md"), "alte Version").unwrap(); + + let input = json!({ + "version": 2, + "last_recording_mtime": "2026-04-15T10:00:00Z", + "recordings": [ + { "recorded_at": "2026-04-15T10:00:00Z", "text": "Patient." } + ] + }); + std::fs::write( + case_dir.join("analysis_input_v2.json"), + serde_json::to_vec_pretty(&input).unwrap(), + ) + .unwrap(); + + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "choices": [{ "message": { "content": "neue Fassung" } }] + }))) + .mount(&mock) + .await; + + let config = config_with_llm_users(tmp.clone(), mock.uri(), vec![make_admin("dr_a")]); + let (tx, rx) = analyze::channel(); + let handle = tokio::spawn(analyze::worker::run(rx, config, reqwest::Client::new())); + + tx.send(analyze::AnalyzeJob { + case_dir: case_dir.clone(), + version: 2, + }) + .unwrap(); + + let doc_v2 = case_dir.join("document_v2.md"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while !doc_v2.exists() { + if std::time::Instant::now() > deadline { + panic!("document_v2.md not written within 5s"); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + let content = std::fs::read_to_string(&doc_v2).unwrap(); + assert!(content.contains("neue Fassung")); + // v1 untouched. + assert_eq!( + std::fs::read_to_string(case_dir.join("document_v1.md")).unwrap(), + "alte Version" + ); + + drop(tx); + let _ = tokio::time::timeout(Duration::from_secs(2), handle).await; +} + #[tokio::test] async fn document_view_without_document_returns_404() { let config = config_with_llm(unique_tmp("dv2"), "http://unused".into());