Files
doctate/server/templates/document.html
T
Brummel 07f9d9ed26 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.
2026-04-16 16:55:20 +02:00

69 lines
2.6 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Doctate — Dokument</title>
<style>
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
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; }
.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; }
.toolbar { display: flex; gap: 0.6em; margin: 1em 0; }
</style>
</head>
<body>
<header>
<div><a class="back-button" href="/web/cases">&larr; Alle Fälle</a></div>
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
</header>
<h1>Dokument</h1>
<div class="meta">{{ case_id }}</div>
<div class="toolbar">
<button id="copy-btn" type="button" class="copy-btn" hidden>In Zwischenablage</button>
</div>
<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>
<script>
(() => {
const btn = document.getElementById('copy-btn');
if (!btn) return;
btn.hidden = false;
const label = btn.textContent;
btn.addEventListener('click', async () => {
const text = document.getElementById('doc-content').innerText;
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch (_) { /* fall through */ }
if (!ok) {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '-1000px';
document.body.appendChild(ta);
ta.select();
try { ok = document.execCommand('copy'); } catch (_) {}
ta.remove();
}
btn.textContent = ok ? 'Kopiert ✓' : 'Fehlgeschlagen';
setTimeout(() => { btn.textContent = label; }, 1500);
});
})();
</script>
</body>
</html>