70425939b2
Introduces a new module `auto_trigger` responsible for opportunistically initiating LLM analysis jobs. This mechanism scans case directories for new or updated recordings and transcripts, automatically creating and queuing analysis jobs when appropriate. Key components: - `evaluate_case`: Pure function to determine if a case is ready for analysis based on recording status, transcriptions, document modification times, and existing job/failure markers. - `try_enqueue`: Orchestrates the analysis job creation and queuing process if `evaluate_case` returns `AutoDecision::Enqueue`. - `try_enqueue_all_for_user`: Iterates through all user cases to trigger `try_enqueue` for eligible ones. - `write_failure_marker`/`remove_failure_marker`: Handles persistent recording of analysis failures and their cleanup. This feature aims to reduce manual intervention by automatically analyzing new or changed case data.
173 lines
8.2 KiB
HTML
173 lines
8.2 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Doctate — Fall {{ case_id_short }}</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 { color: #4a90e2; text-decoration: none; }
|
|
h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; }
|
|
.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; }
|
|
.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.6em; font-weight: bold; color: white; vertical-align: middle; }
|
|
.status-badge.open { background: #4a90e2; }
|
|
.status-badge.done { background: #7ed321; }
|
|
.actions { margin: 1em 0 1.5em; display: flex; align-items: center; gap: 0.4em; }
|
|
.actions form { margin: 0; display: inline; }
|
|
.actions button { font-size: 1em; padding: 0.5em 1em; }
|
|
.actions .analyzing { color: #888; font-style: italic; }
|
|
.actions .llm-missing { color: #a66; font-style: italic; }
|
|
.actions .delete-form { margin-left: auto; }
|
|
.actions .delete-btn { padding: 0.4em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; }
|
|
.actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
|
|
.placeholder { color: #888; font-style: italic; margin: 1em 0; }
|
|
.status-panel { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; }
|
|
.recordings-link { display: inline-block; margin: 1em 0; color: #4a90e2; text-decoration: none; font-size: 0.95em; }
|
|
.recordings-link:hover { text-decoration: underline; }
|
|
.doc-content { position: relative; 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; }
|
|
.copy-btn { position: absolute; bottom: 0.5em; right: 0.5em; padding: 0.35em; background: transparent; border: none; border-radius: 4px; cursor: pointer; color: #888; display: inline-flex; align-items: center; justify-content: center; line-height: 0; opacity: 0.45; transition: opacity 0.15s, color 0.15s; }
|
|
.copy-btn:hover { color: #4a90e2; opacity: 1; }
|
|
.copy-btn .icon-check { display: none; }
|
|
.copy-btn.copied { color: #2d8c2d; opacity: 1; }
|
|
.copy-btn.copied .icon-copy { display: none; }
|
|
.copy-btn.copied .icon-check { display: block; }
|
|
|
|
.header-right { display: flex; align-items: center; gap: 0.8em; }
|
|
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
|
|
html.admin-view-off .admin-only { display: none !important; }
|
|
</style>
|
|
<script>
|
|
try {
|
|
if (localStorage.getItem('admin-view') === 'off') {
|
|
document.documentElement.classList.add('admin-view-off');
|
|
}
|
|
} catch (_) {}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div><a class="back" href="/web/cases">← Fälle</a></div>
|
|
<div class="header-right">
|
|
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
|
|
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
|
</div>
|
|
</header>
|
|
<h1>
|
|
{% match oneliner %}
|
|
{% when Some with (t) %}{{ t }}
|
|
{% when None %}Fall
|
|
{% endmatch %}
|
|
{% if has_document %}<span class="status-badge done">ausgewertet</span>{% else %}<span class="status-badge open">offen</span>{% endif %}
|
|
</h1>
|
|
{% if is_admin %}<div class="meta admin-only">{{ case_id }}</div>{% endif %}
|
|
|
|
<div class="actions">
|
|
{% if analyzing %}
|
|
<span class="analyzing">wird analysiert …</span>
|
|
{% else if can_analyze %}
|
|
<form method="post" action="/web/cases/{{ case_id }}/analyze"><button type="submit">{% if has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button></form>
|
|
{% else if llm_missing && !has_document %}
|
|
<span class="llm-missing">LLM-Analyse nicht konfiguriert</span>
|
|
{% endif %}
|
|
{% if is_admin %}
|
|
<form class="admin-only" method="post" action="/web/cases/{{ case_id }}/reset"><button type="submit">Reset</button></form>
|
|
{% endif %}
|
|
<form class="delete-form" method="post" action="/web/cases/{{ case_id }}/delete"><button type="submit" class="delete-btn" title="Fall entfernen" aria-label="Fall entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg></button></form>
|
|
</div>
|
|
|
|
{% match document_html %}
|
|
{% when Some with (html) %}
|
|
<div id="doc-content" class="doc-content">
|
|
<button id="copy-btn" type="button" class="copy-btn" title="In Zwischenablage kopieren" hidden>
|
|
<svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
|
<svg class="icon-check" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
</button>
|
|
<div id="doc-body">{{ html|safe }}</div>
|
|
</div>
|
|
<script>
|
|
(() => {
|
|
const btn = document.getElementById('copy-btn');
|
|
if (!btn) return;
|
|
btn.hidden = false;
|
|
btn.addEventListener('click', async () => {
|
|
const text = document.getElementById('doc-body').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();
|
|
}
|
|
if (ok) {
|
|
btn.classList.add('copied');
|
|
setTimeout(() => btn.classList.remove('copied'), 1500);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% when None %}
|
|
{% if analyzing %}
|
|
<div class="placeholder">Wird analysiert …</div>
|
|
{% else if recordings_count == 0 %}
|
|
<div class="placeholder">Noch keine Aufnahmen vorhanden.</div>
|
|
{% else %}
|
|
<div class="status-panel">
|
|
{{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{% endif %}, davon {{ transcribed_count }} transkribiert.
|
|
</div>
|
|
{% endif %}
|
|
{% endmatch %}
|
|
|
|
<div>
|
|
<a class="recordings-link" href="/web/cases/{{ case_id }}/recordings">→ Aufnahmen anzeigen ({{ recordings_count }})</a>
|
|
</div>
|
|
<script>
|
|
(() => {
|
|
const cb = document.getElementById('admin-view-toggle');
|
|
if (!cb) return;
|
|
cb.checked = !document.documentElement.classList.contains('admin-view-off');
|
|
cb.addEventListener('change', () => {
|
|
const on = cb.checked;
|
|
try { localStorage.setItem('admin-view', on ? 'on' : 'off'); } catch (_) {}
|
|
document.documentElement.classList.toggle('admin-view-off', !on);
|
|
});
|
|
})();
|
|
|
|
// Live-update bus: only events for THIS case trigger a debounced reload.
|
|
(() => {
|
|
const MY_CASE_ID = "{{ case_id }}";
|
|
let timer = null;
|
|
const scheduleReload = () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => location.reload(), 300);
|
|
};
|
|
const es = new EventSource('/web/events');
|
|
// Release the socket-pool slot on navigation. Without this, Chrome
|
|
// keeps the SSE connection "draining" after unload; six rapid nav
|
|
// cycles exhaust the 6-per-origin pool and stall further requests.
|
|
window.addEventListener('pagehide', () => es.close());
|
|
es.addEventListener('case', (msg) => {
|
|
let evt;
|
|
try { evt = JSON.parse(msg.data); } catch (_) { return; }
|
|
if (evt.case_id === MY_CASE_ID) scheduleReload();
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|