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.
174 lines
8.7 KiB
HTML
174 lines
8.7 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Doctate — Meine Fälle</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; }
|
|
section { margin: 1.5em 0; }
|
|
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; }
|
|
.empty { color: #888; font-style: italic; }
|
|
|
|
.case-list { list-style: none; padding: 0; margin: 0; }
|
|
.case-row { display: grid; grid-template-columns: auto 1fr auto; gap: 0.6em 1em; align-items: center; padding: 0.7em 1em; border: 1px solid #ccc; border-radius: 4px; margin: 0.5em 0; }
|
|
.case-row.done { border-left: 4px solid #7ed321; }
|
|
.case-row.open { border-left: 4px solid #4a90e2; }
|
|
.case-row .check { align-self: start; padding-top: 0.25em; }
|
|
.case-row .body { min-width: 0; }
|
|
.case-row .body a { color: inherit; text-decoration: none; }
|
|
.case-row .body a:hover .line1 { text-decoration: underline; }
|
|
.case-row .body h3 { margin: 0 0 0.2em 0; font-size: 1em; font-weight: 600; }
|
|
.case-row .body small { color: #666; font-family: monospace; font-size: 0.85em; }
|
|
.case-row .oneliner { color: #333; margin: 0.2em 0 0; }
|
|
.case-row .line1 { font-size: 1em; margin: 0; }
|
|
.case-row .line1 .time { font-family: monospace; font-weight: bold; color: #444; }
|
|
.case-row .line2 { margin: 0.2em 0 0; color: #555; font-size: 0.95em; display: flex; align-items: center; gap: 0.5em; }
|
|
.case-row .uuid { color: #999; font-family: monospace; font-size: 0.7em; margin-top: 0.2em; }
|
|
.status-badge { display: inline-block; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; color: white; }
|
|
.status-badge.open { background: #e24a4a; }
|
|
.status-badge.done { background: #7ed321; }
|
|
.case-row .actions { display: flex; gap: 0.4em; align-items: center; }
|
|
.case-row .actions button { font-size: 0.85em; padding: 0.35em 0.8em; }
|
|
.case-row .actions .delete-btn { padding: 0.3em; 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; }
|
|
.case-row .actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
|
|
|
|
.label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; vertical-align: middle; }
|
|
.label.analyzing { background: #eee; color: #555; }
|
|
.label.done-doc { background: #7ed321; color: white; }
|
|
|
|
.bulk-bar { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; display: flex; gap: 0.6em; flex-wrap: wrap; align-items: center; }
|
|
.bulk-bar button { font-size: 0.9em; padding: 0.5em 1em; }
|
|
.bulk-bar .undo { margin-left: auto; }
|
|
.bulk-bar .undo button { background: #fff3cd; border: 1px solid #d39e00; color: #5a4500; }
|
|
|
|
.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>
|
|
<h1>{{ slug }} — Meine Fälle</h1>
|
|
<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>
|
|
|
|
{% if undo_count > 0 %}
|
|
<form class="bulk-bar" method="post" action="/web/cases/undo-delete" style="background:#fff8e1;">
|
|
<span><strong>{{ undo_count }}</strong> zuletzt gelöschte(r) Fall(/Fälle).</span>
|
|
<button type="submit">Löschung rückgängig</button>
|
|
</form>
|
|
{% endif %}
|
|
|
|
{% if total == 0 %}
|
|
<section><p class="empty">Keine Fälle.</p></section>
|
|
{% else %}
|
|
<form method="post" action="/web/cases/bulk">
|
|
{% for g in groups %}
|
|
<section>
|
|
<h2 class="date-group" data-iso="{{ g.label }}">{{ g.label }} ({{ g.cases.len() }})</h2>
|
|
<ul class="case-list">
|
|
{% for case in g.cases %}
|
|
<li class="case-row {% if case.has_document %}done{% else %}open{% endif %}">
|
|
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"></div>
|
|
<div class="body">
|
|
<a href="/web/cases/{{ case.case_id }}">
|
|
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span>{% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %} — <span class="empty">kein Oneliner</span>{% endmatch %}</div>
|
|
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div>
|
|
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
|
|
</a>
|
|
</div>
|
|
<div class="actions">
|
|
<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze"{% if !case.can_analyze %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
|
|
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only">Reset</button>{% endif %}
|
|
<button type="submit" formaction="/web/cases/{{ case.case_id }}/delete" class="delete-btn" title="Fall entfernen" aria-label="Fall entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" 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>
|
|
</div>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</section>
|
|
{% endfor %}
|
|
<div class="bulk-bar">
|
|
<span>Auswahl:</span>
|
|
<button type="button" onclick="const b=document.querySelectorAll('input[name=case_id]');const all=Array.from(b).every(c=>c.checked);b.forEach(c=>c.checked=!all);this.textContent=all?'Alles auswählen':'Keine auswählen'">Alles auswählen</button>
|
|
<button type="submit" name="action" value="analyze">Analysieren</button>
|
|
{% if is_admin %}<button type="submit" name="action" value="reset" class="admin-only">Reset</button>{% endif %}
|
|
<button type="submit" name="action" value="delete">Entfernen</button>
|
|
</div>
|
|
</form>
|
|
{% endif %}
|
|
<script>
|
|
// Render all UTC timestamps in the browser's local timezone, and relabel
|
|
// date-group headings to Heute/Gestern based on the browser's current
|
|
// UTC date. Server-side renders only UTC so timezone logic belongs here.
|
|
(function () {
|
|
document.querySelectorAll('time[datetime]').forEach(function (el) {
|
|
var d = new Date(el.dateTime);
|
|
if (!isNaN(d.getTime())) {
|
|
el.textContent = d.toLocaleTimeString(undefined, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
});
|
|
}
|
|
});
|
|
|
|
var todayISO = new Date().toISOString().slice(0, 10);
|
|
var yesterday = new Date();
|
|
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
|
var yesterdayISO = yesterday.toISOString().slice(0, 10);
|
|
|
|
document.querySelectorAll('h2.date-group').forEach(function (h) {
|
|
var iso = h.dataset.iso;
|
|
if (!iso) return;
|
|
var suffix = h.textContent.replace(iso, '').trim();
|
|
var label;
|
|
if (iso === todayISO) label = 'Heute';
|
|
else if (iso === yesterdayISO) label = 'Gestern';
|
|
else label = iso;
|
|
h.textContent = label + ' ' + suffix;
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
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: any case event for this user triggers a debounced
|
|
// page reload. Debounce collapses event bursts (e.g. bulk-delete) into
|
|
// one reload. EventSource auto-reconnects on network drops.
|
|
(() => {
|
|
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', () => scheduleReload());
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|