From 07f9d9ed26b30ad611a5a86c621fbc1686f52bbb Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 16 Apr 2026 16:55:20 +0200 Subject: [PATCH] Add markdown rendering for LLM output This commit introduces a new module `analyze::render` to handle the rendering of Markdown content produced by the LLM into safe HTML. The LLM output now includes a custom `==text==` highlighting convention to indicate sections that require doctor review due to potential transcription errors or incomplete information. This highlighting is converted into `` tags in the final HTML. To ensure security, all LLM-generated Markdown is first HTML-escaped. This prevents any malicious HTML or script injection from being executed in the browser. Only the custom `` tags are preserved as functional HTML elements. The process is as follows: 1. The raw Markdown from the LLM is processed. 2. All HTML special characters (`<`, `>`, `&`, `"`, `'`) are escaped. 3. The `==text==` highlights are replaced with `text`. 4. The resulting string is parsed as Markdown by `pulldown-cmark`. 5. The parsed Markdown is converted to HTML, which is then safe to inject into the Askama template using the `|safe` filter. The `Cargo.toml` and `Cargo.lock` files have been updated to include the `pulldown-cmark` dependency. The `document.html` template has been modified to use a `div` with the class `doc-content` instead of a `pre` tag, allowing the rendered HTML to be displayed correctly. The `handle_document_view` function now calls the new `md_to_html` rendering function. --- server/Cargo.lock | 21 +++- server/Cargo.toml | 1 + server/src/analyze/mod.rs | 1 + server/src/analyze/prompt.rs | 2 +- server/src/analyze/render.rs | 181 ++++++++++++++++++++++++++++++ server/src/routes/case_actions.rs | 3 +- server/templates/document.html | 8 +- 7 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 server/src/analyze/render.rs 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("