diff --git a/server/Cargo.lock b/server/Cargo.lock index 957f6f4..6a1660c 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -339,6 +339,7 @@ dependencies = [ "axum-extra", "bcrypt", "dotenvy", + "pulldown-cmark", "rand 0.8.5", "reqwest", "rpassword", @@ -1150,6 +1151,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quinn" version = "0.11.9" @@ -1202,7 +1221,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/server/Cargo.toml b/server/Cargo.toml index 7355d7b..cac1225 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -25,6 +25,7 @@ axum-extra = { version = "0.12", features = ["cookie", "form"] } time = { version = "0.3.47", features = ["local-offset", "parsing", "formatting", "macros"] } toml_edit = "0.25.11" rpassword = "7.4.0" +pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/server/src/analyze/mod.rs b/server/src/analyze/mod.rs index 8d75b06..2ed90ae 100644 --- a/server/src/analyze/mod.rs +++ b/server/src/analyze/mod.rs @@ -6,6 +6,7 @@ use tokio::sync::mpsc; pub mod llm; pub mod prompt; pub mod recovery; +pub mod render; pub mod worker; /// Single-document-per-case filenames. A re-analysis overwrites the diff --git a/server/src/analyze/prompt.rs b/server/src/analyze/prompt.rs index 44e3d6b..d8aa0ae 100644 --- a/server/src/analyze/prompt.rs +++ b/server/src/analyze/prompt.rs @@ -2,7 +2,7 @@ use super::AnalysisInput; /// System prompt for the consolidation LLM. From docs/projektplan.md:365-369. /// Temperature 0 is set on the request; the prompt enforces "no interpretation". -pub const SYSTEM_PROMPT: &str = "Du bist ein medizinischer Assistent. Fasse die folgenden diktierten Abschnitte zu einem zusammenhängenden Text zusammen. Bereinigen und strukturieren, nichts hinzufügen, nichts interpretieren, keine Diagnose, keine Arztbrief-Struktur. Entferne Redundanzen. Spätere Aufnahmen haben Vorrang — Korrekturen, Nachträge und Widersprüche zugunsten der chronologisch letzten Aussage auflösen. Antworte auf Deutsch. Nur der zusammengefasste Text, keine Einleitung, kein Abschlusssatz. WICHTIG: das Diktat wurde automatisch transkribiert, es kann entsprechende Fehler enthalten, die du korrigieren musst."; +pub const SYSTEM_PROMPT: &str = "Du bist ein medizinischer Assistent. Fasse die folgenden diktierten Abschnitte zu einem zusammenhängenden Text zusammen. Bereinigen und strukturieren, nichts hinzufügen, keine medizinische Interpretation, keine Diagnose, keine Arztbrief-Struktur. Entferne Redundanzen. Spätere Aufnahmen haben Vorrang — Korrekturen, Nachträge und Widersprüche zugunsten der chronologisch letzten Aussage auflösen. Antworte auf Deutsch. Nur der zusammengefasste Text, keine Einleitung, kein Abschlusssatz.\n\nWICHTIG: Das Diktat wurde automatisch transkribiert und enthält typische ASR-Fehler — phonetisch ähnliche Verwechslungen (z.B. \"Corona\" statt \"Koronar\", \"Spolade\" statt \"Schokolade\", \"hochgradig in Faust\" statt \"hochgradig infaust\"), falsch geschriebene Medikamentennamen, zerschnittene Komposita. Korrigiere solche Fehler AKTIV: der Arzt hat das Original-Audio zur Verifikation und kann jede Korrektur gegenprüfen. Bei sicherer Korrektur: schreibe das korrigierte Wort. Bei Unsicherheit, wie die Korrektur lauten müsste: behalte das Original bei.\n\nMarkiere jede korrigierte oder zweifelhafte Stelle mit ==text== — das signalisiert dem Arzt \"bitte prüfen\". Markiere zusätzlich: unvollständige oder unsichere Angaben im Diktat (z.B. \"Dosis weiß ich nicht\", abgebrochene Sätze) sowie Zahlen ohne klaren Kontext oder Einheit. Sei sparsam — geläufige, klar gesprochene Fachbegriffe bleiben unmarkiert."; /// Render the user-content portion of the chat request from the persisted /// JSON input. Recordings with blank text are skipped — they carry no signal diff --git a/server/src/analyze/render.rs b/server/src/analyze/render.rs new file mode 100644 index 0000000..5dc6e52 --- /dev/null +++ b/server/src/analyze/render.rs @@ -0,0 +1,181 @@ +//! Render the LLM's Markdown document to safe HTML. +//! +//! The LLM produces Markdown with a non-standard `==text==` highlight +//! convention for stretches the doctor should double-check (suspected +//! transcription errors, incomplete info, numbers without context). We +//! convert those to `` tags — the only raw HTML we inject — after +//! HTML-escaping the entire LLM output. That way even if the LLM writes +//! `"); + assert_eq!(out, "<script>alert('x')</script>"); + } + + #[test] + fn escape_and_mark_allows_only_our_mark_tags() { + // LLM emits both raw HTML and a ==mark==; only the mark becomes live HTML. + let out = escape_and_mark("fett und ==wichtig=="); + assert_eq!(out, "<b>fett</b> und wichtig"); + } + + #[test] + fn escape_and_mark_preserves_unmatched_opener_as_text() { + let out = escape_and_mark("==offen ohne Ende"); + assert_eq!(out, "==offen ohne Ende"); + } + + #[test] + fn escape_and_mark_does_not_cross_newlines() { + let out = escape_and_mark("==kein\nmark=="); + // A newline between the openers cancels the highlight. + assert_eq!(out, "==kein\nmark=="); + } + + #[test] + fn escape_and_mark_handles_multiple_marks_in_one_line() { + let out = escape_and_mark("==A== und ==B=="); + assert_eq!(out, "A und B"); + } + + #[test] + fn escape_and_mark_handles_unicode_content_in_mark() { + let out = escape_and_mark("==Blutdruck 140/90 über Norm=="); + assert_eq!(out, "Blutdruck 140/90 über Norm"); + } + + #[test] + fn md_to_html_wraps_plain_text_in_paragraph() { + let out = md_to_html("Fließtext ohne Auszeichnung."); + assert_eq!(out.trim(), "

Fließtext ohne Auszeichnung.

"); + } + + #[test] + fn md_to_html_renders_mark_inline() { + let out = md_to_html("Patient klagt über ==Tastanalyse==."); + assert!(out.contains("Tastanalyse")); + assert!(out.contains("

")); + } + + #[test] + fn md_to_html_blocks_raw_html_from_llm() { + // Even if the LLM embeds a "); + assert!(!out.contains("