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:
Generated
+20
-1
@@ -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]]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
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, "<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("<b>fett</b> und ==wichtig==");
|
||||
assert_eq!(out, "<b>fett</b> 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("<script>"));
|
||||
}
|
||||
|
||||
#[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>"));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user