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 `<mark>` 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 `<mark>` 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 `<mark>text</mark>`.
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.
This commit is contained in:
2026-04-16 16:55:20 +02:00
parent 44ed5e8333
commit 07f9d9ed26
7 changed files with 212 additions and 5 deletions
+20 -1
View File
@@ -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]]
+1
View File
@@ -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"] }
+1
View File
@@ -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
+1 -1
View File
@@ -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
+181
View File
@@ -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 `<mark>` tags — the only raw HTML we inject — after
//! HTML-escaping the entire LLM output. That way even if the LLM writes
//! `<script>`, it becomes visible text, not executable markup.
//!
//! Pipeline:
//! raw markdown → html-escape → ==X== → <mark>X</mark> → pulldown-cmark → html
use pulldown_cmark::{html, Options, Parser};
/// Render analysis Markdown to HTML. Safe to inject into an Askama template
/// with `{{ var|safe }}` because every `<`/`>` from the LLM is pre-escaped
/// and only our whitelisted `<mark>` tags remain as live HTML.
pub fn md_to_html(md: &str) -> String {
let prepared = escape_and_mark(md);
let parser = Parser::new_ext(&prepared, Options::empty());
let mut out = String::with_capacity(prepared.len());
html::push_html(&mut out, parser);
out
}
/// HTML-escape every `<`, `>`, `&`, `"`, `'` in the input, then convert
/// `==text==` highlight markers to `<mark>text</mark>`. Because the escape
/// step runs first, the substitution output is the only live HTML in the
/// resulting string — LLM-emitted angle brackets can never execute.
///
/// Pure, unit-testable. Order matters: escape before substitution, because
/// otherwise `<mark>` itself would be escaped.
pub(crate) fn escape_and_mark(md: &str) -> String {
let escaped = html_escape(md);
substitute_marks(&escaped)
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
other => out.push(other),
}
}
out
}
/// Replace `==text==` with `<mark>text</mark>`. Unmatched openers are left
/// as literal text. No nesting (once we enter a mark, the next `==` closes).
/// `text` may not contain newlines — a paragraph break cancels the mark
/// (prevents runaway highlighting if the LLM forgets the closer).
fn substitute_marks(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len()
&& bytes[i] == b'='
&& bytes[i + 1] == b'='
&& let Some(end) = find_closer(bytes, i + 2)
{
out.push_str("<mark>");
out.push_str(&s[i + 2..end]);
out.push_str("</mark>");
i = end + 2;
continue;
}
// Not a mark opener — push one byte. Safe because we only peek at
// ASCII `=`; any multi-byte char is pushed whole by this single-byte
// copy sequence as long as the loop visits each of its bytes. But
// string slicing requires char boundaries, so we push by char.
let ch_end = next_char_boundary(s, i);
out.push_str(&s[i..ch_end]);
i = ch_end;
}
out
}
/// Find the byte index of the opening `=` of the next `==` closer in
/// `bytes[start..]`, or `None` if none before a newline or end-of-input.
fn find_closer(bytes: &[u8], start: usize) -> Option<usize> {
let mut j = start;
while j + 1 < bytes.len() {
match bytes[j] {
b'\n' => return None,
b'=' if bytes[j + 1] == b'=' => return Some(j),
_ => j += 1,
}
}
None
}
fn next_char_boundary(s: &str, i: usize) -> usize {
let mut j = i + 1;
while j < s.len() && !s.is_char_boundary(j) {
j += 1;
}
j
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_and_mark_converts_highlight() {
let out = escape_and_mark("Patient klagt über ==Tastanalyse== am Po.");
assert_eq!(out, "Patient klagt über <mark>Tastanalyse</mark> am Po.");
}
#[test]
fn escape_and_mark_escapes_llm_html_injection() {
let out = escape_and_mark("<script>alert('x')</script>");
assert_eq!(out, "&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;");
}
#[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("<b>fett</b> und ==wichtig==");
assert_eq!(out, "&lt;b&gt;fett&lt;/b&gt; und <mark>wichtig</mark>");
}
#[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, "<mark>A</mark> und <mark>B</mark>");
}
#[test]
fn escape_and_mark_handles_unicode_content_in_mark() {
let out = escape_and_mark("==Blutdruck 140/90 über Norm==");
assert_eq!(out, "<mark>Blutdruck 140/90 über Norm</mark>");
}
#[test]
fn md_to_html_wraps_plain_text_in_paragraph() {
let out = md_to_html("Fließtext ohne Auszeichnung.");
assert_eq!(out.trim(), "<p>Fließtext ohne Auszeichnung.</p>");
}
#[test]
fn md_to_html_renders_mark_inline() {
let out = md_to_html("Patient klagt über ==Tastanalyse==.");
assert!(out.contains("<mark>Tastanalyse</mark>"));
assert!(out.contains("<p>"));
}
#[test]
fn md_to_html_blocks_raw_html_from_llm() {
// Even if the LLM embeds a <script>, it's rendered as escaped text.
let out = md_to_html("Vorsicht: <script>alert(1)</script>");
assert!(!out.contains("<script>"));
assert!(out.contains("&lt;script&gt;"));
}
#[test]
fn md_to_html_renders_paragraphs_separated_by_blank_line() {
let out = md_to_html("Erster Absatz.\n\nZweiter Absatz.");
assert!(out.contains("<p>Erster Absatz.</p>"));
assert!(out.contains("<p>Zweiter Absatz.</p>"));
}
}
+2 -1
View File
@@ -114,9 +114,10 @@ pub async fn handle_document_view(
}
};
let content = read_document(&case_dir)
let md = read_document(&case_dir)
.await
.ok_or_else(|| AppError::NotFound("Noch kein Dokument erzeugt".into()))?;
let content = crate::analyze::render::md_to_html(&md);
DocumentTemplate { case_id, content }
.render()
+6 -2
View File
@@ -8,7 +8,11 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1
header { display: flex; justify-content: space-between; align-items: center; }
header form { margin: 0; }
.back-button { display: inline-block; padding: 0.5em 1em; background: #4a90e2; color: white; text-decoration: none; border-radius: 4px; font-weight: bold; }
pre { white-space: pre-wrap; font-family: serif; font-size: 1.05em; line-height: 1.5; background: #f6f6f6; padding: 1em; border-radius: 4px; }
.doc-content { font-family: serif; font-size: 1.05em; line-height: 1.55; background: #f6f6f6; padding: 1em 1.2em; border-radius: 4px; }
.doc-content p { margin: 0.8em 0; }
.doc-content p:first-child { margin-top: 0; }
.doc-content p:last-child { margin-bottom: 0; }
.doc-content mark { background: #fff3a8; padding: 0 0.2em; border-radius: 2px; }
.meta { color: #666; font-family: monospace; font-size: 0.9em; margin-bottom: 1em; }
.copy-btn { padding: 0.5em 1em; font-size: 1em; border: 1px solid #4a90e2; background: white; color: #4a90e2; border-radius: 4px; cursor: pointer; font-weight: bold; }
.copy-btn:hover { background: #eaf3fc; }
@@ -25,7 +29,7 @@ pre { white-space: pre-wrap; font-family: serif; font-size: 1.05em; line-height:
<div class="toolbar">
<button id="copy-btn" type="button" class="copy-btn" hidden>In Zwischenablage</button>
</div>
<pre id="doc-content">{{ content }}</pre>
<div id="doc-content" class="doc-content">{{ content|safe }}</div>
<div class="toolbar">
<a class="back-button" href="/web/cases/{{ case_id }}">Aufnahmen</a>
</div>