feat(diarization-ui): add live debug window for analysis jobs

Streams the Ollama response token-by-token and stores the full LLM
prompt in the jobs table. A Debug button on each analysis job opens
a modal with two tabs: the sent prompt and the live-updating answer
(polls /jobs/{id}/debug-data every 500 ms while streaming, badge
shows "● live"). Response is persisted to llm_response after the job
completes so it stays viewable afterwards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 15:45:36 +02:00
parent c9bcb82a89
commit 405c3e798d

145
app.py
View File

@@ -22,6 +22,8 @@ app = FastAPI(title="Diarization UI")
JOB_DIR = Path(os.getenv("JOB_DIR", "/data/jobs")) JOB_DIR = Path(os.getenv("JOB_DIR", "/data/jobs"))
EXECUTOR = ThreadPoolExecutor(max_workers=2) EXECUTOR = ThreadPoolExecutor(max_workers=2)
JOB_LOCK = threading.Lock() JOB_LOCK = threading.Lock()
_JOB_STREAMS: dict = {}
_JOB_STREAM_LOCK = threading.Lock()
def db(): def db():
@@ -100,6 +102,14 @@ def init_db():
c.execute("ALTER TABLE jobs ADD COLUMN user_prompt TEXT") c.execute("ALTER TABLE jobs ADD COLUMN user_prompt TEXT")
except Exception: except Exception:
pass pass
try:
c.execute("ALTER TABLE jobs ADD COLUMN llm_prompt TEXT")
except Exception:
pass
try:
c.execute("ALTER TABLE jobs ADD COLUMN llm_response TEXT")
except Exception:
pass
# defaults # defaults
c.execute("INSERT OR IGNORE INTO projects(name, created_at) VALUES (?,?)", ("Default", now_iso())) c.execute("INSERT OR IGNORE INTO projects(name, created_at) VALUES (?,?)", ("Default", now_iso()))
@@ -357,13 +367,38 @@ def _process_analysis_job(job_id: int):
+ f"\\nTEXT:\\n{doc['content_md']}\\n" + f"\\nTEXT:\\n{doc['content_md']}\\n"
) )
_job_set(job_id, llm_prompt=llm_prompt)
with _JOB_STREAM_LOCK:
_JOB_STREAMS[job_id] = ""
r = requests.post( r = requests.post(
f"{OLLAMA_BASE_URL}/api/generate", f"{OLLAMA_BASE_URL}/api/generate",
json={"model": OLLAMA_MODEL, "prompt": llm_prompt, "stream": False}, json={"model": OLLAMA_MODEL, "prompt": llm_prompt, "stream": True},
stream=True,
timeout=1200, timeout=1200,
) )
r.raise_for_status() r.raise_for_status()
answer = r.json().get("response", "")
accumulated = ""
ollama_final = {}
chunk_count = 0
for line in r.iter_lines():
if not line:
continue
chunk = json.loads(line)
accumulated += chunk.get("response", "")
with _JOB_STREAM_LOCK:
_JOB_STREAMS[job_id] = accumulated
chunk_count += 1
if chunk_count % 100 == 0:
j_check = _job_get(job_id)
if not j_check or j_check["status"] == "cancelled":
return
if chunk.get("done"):
ollama_final = chunk
break
answer = accumulated
j = _job_get(job_id) j = _job_get(job_id)
if not j or j["status"] == "cancelled": if not j or j["status"] == "cancelled":
@@ -382,15 +417,18 @@ def _process_analysis_job(job_id: int):
answer, answer,
doc["id"], doc["id"],
prm["id"], prm["id"],
json.dumps({"ollama_response": r.json()}, ensure_ascii=False), json.dumps({"ollama_response": ollama_final}, ensure_ascii=False),
now_iso(), now_iso(),
), ),
) )
new_doc_id = cur.lastrowid new_doc_id = cur.lastrowid
_job_set(job_id, status="done", result_document_id=new_doc_id, finished_at=now_iso()) _job_set(job_id, status="done", result_document_id=new_doc_id, finished_at=now_iso(), llm_response=answer)
except Exception as e: except Exception as e:
_job_set(job_id, status="error", error=str(e), finished_at=now_iso()) _job_set(job_id, status="error", error=str(e), finished_at=now_iso())
finally:
with _JOB_STREAM_LOCK:
_JOB_STREAMS.pop(job_id, None)
def enqueue_job(kind: str, **kwargs) -> int: def enqueue_job(kind: str, **kwargs) -> int:
@@ -1258,6 +1296,22 @@ def jobs_delete_form(job_id: int):
return RedirectResponse(url="/jobs", status_code=303) return RedirectResponse(url="/jobs", status_code=303)
@app.get("/jobs/{job_id}/debug-data")
def job_debug_data(job_id: int):
j = _job_get(job_id)
if not j:
raise HTTPException(404, "job not found")
with _JOB_STREAM_LOCK:
live = _JOB_STREAMS.get(job_id)
return {
"status": j["status"],
"kind": j["kind"],
"llm_prompt": j.get("llm_prompt"),
"llm_response": live if live is not None else j.get("llm_response"),
"streaming": live is not None,
}
@app.get("/jobs", response_class=HTMLResponse) @app.get("/jobs", response_class=HTMLResponse)
def jobs_page(queued: Optional[int] = None): def jobs_page(queued: Optional[int] = None):
items = _jobs_payload(200) items = _jobs_payload(200)
@@ -1282,6 +1336,8 @@ def jobs_page(queued: Optional[int] = None):
actions += f"<button class='btn btn-outline-danger btn-sm' onclick='deleteJob({it['id']})'><i class=\'bi bi-trash\'></i> Löschen</button> " actions += f"<button class='btn btn-outline-danger btn-sm' onclick='deleteJob({it['id']})'><i class=\'bi bi-trash\'></i> Löschen</button> "
if it.get("result_document_id"): if it.get("result_document_id"):
actions += f"<a class='btn btn-primary btn-sm' href='/document/{it['result_document_id']}'><i class='bi bi-box-arrow-up-right'></i> Ergebnis</a>" actions += f"<a class='btn btn-primary btn-sm' href='/document/{it['result_document_id']}'><i class='bi bi-box-arrow-up-right'></i> Ergebnis</a>"
if it["kind"] == "analysis":
actions += f" <button class='btn btn-outline-info btn-sm' onclick='openDebug({it['id']})'><i class='bi bi-bug'></i> Debug</button>"
err = f"<div class='alert alert-danger mt-2 mb-0 py-2 small'>{str(it['error']).replace('<','&lt;')}</div>" if it.get("error") else "" err = f"<div class='alert alert-danger mt-2 mb-0 py-2 small'>{str(it['error']).replace('<','&lt;')}</div>" if it.get("error") else ""
cards.append( cards.append(
@@ -1343,6 +1399,8 @@ function renderCard(it) {{
actions += `<button class='btn btn-outline-danger btn-sm' onclick='deleteJob(${{it.id}})'><i class='bi bi-trash'></i> Löschen</button> `; actions += `<button class='btn btn-outline-danger btn-sm' onclick='deleteJob(${{it.id}})'><i class='bi bi-trash'></i> Löschen</button> `;
if(it.result_document_id) if(it.result_document_id)
actions += `<a class='btn btn-primary btn-sm' href='/document/${{it.result_document_id}}'><i class='bi bi-box-arrow-up-right'></i> Ergebnis</a>`; actions += `<a class='btn btn-primary btn-sm' href='/document/${{it.result_document_id}}'><i class='bi bi-box-arrow-up-right'></i> Ergebnis</a>`;
if(it.kind === 'analysis')
actions += ` <button class='btn btn-outline-info btn-sm' onclick='openDebug(${{it.id}})'><i class='bi bi-bug'></i> Debug</button>`;
const err = it.error ? `<div class='alert alert-danger mt-2 mb-0 py-2 small'>${{String(it.error).replace(/</g,'&lt;')}}</div>` : ''; const err = it.error ? `<div class='alert alert-danger mt-2 mb-0 py-2 small'>${{String(it.error).replace(/</g,'&lt;')}}</div>` : '';
const meta = [ const meta = [
it.project_name ? 'Projekt: '+it.project_name : '', it.project_name ? 'Projekt: '+it.project_name : '',
@@ -1447,7 +1505,86 @@ setInterval(() => document.querySelectorAll('.elapsed').forEach(el => {{
}}), 1000); }}), 1000);
updateSelectionCount(); updateSelectionCount();
schedulePoll(3000); schedulePoll(3000);
let _debugJobId = null;
let _debugPollTimer = null;
async function openDebug(jobId) {{
_debugJobId = jobId;
document.getElementById('debug-job-id').textContent = jobId;
document.getElementById('debug-prompt-content').textContent = 'Lade …';
document.getElementById('debug-response-content').textContent = 'Lade …';
bootstrap.Modal.getOrCreateInstance(document.getElementById('debugModal')).show();
await refreshDebug();
}}
async function refreshDebug() {{
if (!_debugJobId) return;
try {{
const r = await fetch('/jobs/' + _debugJobId + '/debug-data');
if (!r.ok) return;
const d = await r.json();
if (d.kind !== 'analysis') {{
document.getElementById('debug-prompt-content').textContent = 'Upload-Job kein LLM-Prompt';
document.getElementById('debug-response-content').textContent = '-';
return;
}}
document.getElementById('debug-prompt-content').textContent = d.llm_prompt || '(kein Prompt gespeichert)';
const respEl = document.getElementById('debug-response-content');
const atBottom = respEl.scrollHeight - respEl.scrollTop <= respEl.clientHeight + 20;
respEl.textContent = d.llm_response || '(noch keine Antwort)';
if (atBottom) respEl.scrollTop = respEl.scrollHeight;
const badge = document.getElementById('debug-streaming-badge');
badge.classList.toggle('d-none', !d.streaming);
clearTimeout(_debugPollTimer);
if (d.streaming || d.status === 'running') {{
_debugPollTimer = setTimeout(refreshDebug, 500);
}}
}} catch(e) {{
clearTimeout(_debugPollTimer);
_debugPollTimer = setTimeout(refreshDebug, 2000);
}}
}}
document.addEventListener('DOMContentLoaded', () => {{
const el = document.getElementById('debugModal');
if (el) el.addEventListener('hide.bs.modal', () => {{
_debugJobId = null;
clearTimeout(_debugPollTimer);
}});
}});
</script> </script>
<div class='modal fade' id='debugModal' tabindex='-1' aria-hidden='true'>
<div class='modal-dialog modal-xl modal-dialog-scrollable'>
<div class='modal-content'>
<div class='modal-header py-2'>
<h5 class='modal-title'><i class='bi bi-bug'></i> Debug · Job #<span id='debug-job-id'></span></h5>
<button type='button' class='btn-close' data-bs-dismiss='modal'></button>
</div>
<div class='modal-body p-0'>
<ul class='nav nav-tabs px-3 pt-2'>
<li class='nav-item'>
<button class='nav-link active' data-bs-toggle='tab' data-bs-target='#debug-prompt-pane' type='button'>
<i class='bi bi-send'></i> Gesendeter Prompt
</button>
</li>
<li class='nav-item'>
<button class='nav-link' data-bs-toggle='tab' data-bs-target='#debug-response-pane' type='button'>
<i class='bi bi-cpu'></i> Antwort
<span id='debug-streaming-badge' class='badge text-bg-primary ms-1 d-none'>● live</span>
</button>
</li>
</ul>
<div class='tab-content p-3'>
<div class='tab-pane fade show active' id='debug-prompt-pane'>
<pre id='debug-prompt-content' style='max-height:65vh;overflow:auto;white-space:pre-wrap;background:#0f172a;color:#c7f9cc;padding:12px;border-radius:10px;font-size:.82rem'></pre>
</div>
<div class='tab-pane fade' id='debug-response-pane'>
<pre id='debug-response-content' style='max-height:65vh;overflow:auto;white-space:pre-wrap;background:#0f172a;color:#c7f9cc;padding:12px;border-radius:10px;font-size:.82rem'></pre>
</div>
</div>
</div>
</div>
</div>
</div>
""" """
return layout("Jobs", body) return layout("Jobs", body)