Files
doctate/server/src/analyze/render.rs
T
2026-04-19 15:35:10 +02:00

182 lines
6.3 KiB
Rust

//! 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::{Options, Parser, html};
/// 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>"));
}
}