feat(diarization-ui): add FastAPI UI backend with sqlite storage and Ollama analysis pipeline
This commit is contained in:
@@ -1 +1,3 @@
|
|||||||
API_BASE=http://gx10.aquantico.lan:8093
|
API_BASE=http://gx10.aquantico.lan:8093
|
||||||
|
OLLAMA_BASE_URL=http://gx10.aquantico.lan:11434
|
||||||
|
OLLAMA_MODEL=qwen3.5:9b
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -1,8 +1,12 @@
|
|||||||
FROM docker.io/library/nginx:alpine
|
FROM docker.io/library/python:3.12-slim
|
||||||
|
|
||||||
COPY index.html.template /usr/share/nginx/html/index.html.template
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
PYTHONUNBUFFERED=1
|
||||||
RUN chmod +x /docker-entrypoint.sh
|
|
||||||
|
|
||||||
EXPOSE 80
|
WORKDIR /app
|
||||||
CMD ["/docker-entrypoint.sh"]
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
COPY app.py /app/app.py
|
||||||
|
|
||||||
|
EXPOSE 8094
|
||||||
|
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8094"]
|
||||||
|
|||||||
45
README.md
45
README.md
@@ -1,20 +1,39 @@
|
|||||||
# diarization-ui
|
# diarization-ui
|
||||||
|
|
||||||
Separate UI container for the diarization/transcription API.
|
Eigenes UI-Projekt (separates Repo/Container) für:
|
||||||
|
- Upload Audio
|
||||||
## Run
|
- Aufruf von `transcribe-diarize` API
|
||||||
|
- Speichern in SQLite
|
||||||
```bash
|
- LLM-Auswertung via Ollama (Qwen)
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
UI will be available on `http://127.0.0.1:8094/`.
|
|
||||||
|
|
||||||
By default it calls API at `http://diarization-api:8093`.
|
|
||||||
Set `API_BASE` in `.env` if needed.
|
|
||||||
|
|
||||||
## .env
|
## .env
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_BASE=http://diarization-api:8093
|
API_BASE=http://gx10.aquantico.lan:8093
|
||||||
|
OLLAMA_BASE_URL=http://gx10.aquantico.lan:11434
|
||||||
|
OLLAMA_MODEL=qwen3.5:9b
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Start (Docker/Compose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
UI: `http://127.0.0.1:8094/`
|
||||||
|
|
||||||
|
## Podman (einzelner Container)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman build -t localhost/diarization-ui:latest .
|
||||||
|
podman rm -f diarization-ui || true
|
||||||
|
podman run -d --name diarization-ui -p 18094:8094 \
|
||||||
|
-e API_BASE=http://gx10.aquantico.lan:8093 \
|
||||||
|
-e OLLAMA_BASE_URL=http://gx10.aquantico.lan:11434 \
|
||||||
|
-e OLLAMA_MODEL=qwen3.5:9b \
|
||||||
|
-v diarization_ui_data:/data \
|
||||||
|
localhost/diarization-ui:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
UI dann: `http://127.0.0.1:18094/`
|
||||||
|
|||||||
211
app.py
Normal file
211
app.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
API_BASE = os.getenv("API_BASE", "http://gx10.aquantico.lan:8093").rstrip("/")
|
||||||
|
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://gx10.aquantico.lan:11434").rstrip("/")
|
||||||
|
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3.5:9b")
|
||||||
|
DB_PATH = os.getenv("DB_PATH", "/data/ui.db")
|
||||||
|
|
||||||
|
app = FastAPI(title="Diarization UI + LLM")
|
||||||
|
|
||||||
|
|
||||||
|
def db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
with db() as c:
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS transcripts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
filename TEXT,
|
||||||
|
formatted_text TEXT NOT NULL,
|
||||||
|
raw_json TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS analyses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
transcript_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(transcript_id) REFERENCES transcripts(id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup():
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz():
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"api_base": API_BASE,
|
||||||
|
"ollama_base_url": OLLAMA_BASE_URL,
|
||||||
|
"ollama_model": OLLAMA_MODEL,
|
||||||
|
"db_path": DB_PATH,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index():
|
||||||
|
return """
|
||||||
|
<!doctype html>
|
||||||
|
<html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||||
|
<title>Diarization UI</title>
|
||||||
|
<style>body{font-family:Arial;max-width:1100px;margin:24px auto;padding:0 12px}.row{display:flex;gap:8px;flex-wrap:wrap}button{padding:8px 12px}pre{white-space:pre-wrap;background:#111;color:#0f0;padding:10px;border-radius:8px;min-height:140px}.card{border:1px solid #ddd;border-radius:8px;padding:10px;margin:10px 0}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Upload -> Transcribe + Diarize -> speichern -> LLM Analyse</h2>
|
||||||
|
<div class='row'>
|
||||||
|
<input id='f' type='file' accept='audio/*'>
|
||||||
|
<button onclick='processFile()'>Verarbeiten</button>
|
||||||
|
</div>
|
||||||
|
<p id='status'></p>
|
||||||
|
<pre id='out'></pre>
|
||||||
|
|
||||||
|
<h3>Analyse</h3>
|
||||||
|
<div class='row'>
|
||||||
|
<input id='tid' type='number' placeholder='transcript_id'>
|
||||||
|
<input id='prompt' style='width:500px' placeholder='z.B. Fasse zusammen und extrahiere Aufgaben mit Verantwortlichen.'>
|
||||||
|
<button onclick='analyze()'>Mit Qwen analysieren</button>
|
||||||
|
</div>
|
||||||
|
<pre id='analysis'></pre>
|
||||||
|
|
||||||
|
<h3>Gespeicherte Transkripte</h3>
|
||||||
|
<button onclick='loadTranscripts()'>Neu laden</button>
|
||||||
|
<div id='list'></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function processFile(){
|
||||||
|
const fi=document.getElementById('f');
|
||||||
|
if(!fi.files.length){alert('Datei wählen');return;}
|
||||||
|
const fd=new FormData(); fd.append('file',fi.files[0]);
|
||||||
|
document.getElementById('status').textContent='Läuft...';
|
||||||
|
const r=await fetch('/process',{method:'POST',body:fd});
|
||||||
|
const j=await r.json();
|
||||||
|
document.getElementById('status').textContent = r.ok ? `OK transcript_id=${j.transcript_id}` : `Fehler ${r.status}`;
|
||||||
|
document.getElementById('out').textContent = JSON.stringify(j,null,2);
|
||||||
|
if(j.transcript_id){document.getElementById('tid').value=j.transcript_id;}
|
||||||
|
loadTranscripts();
|
||||||
|
}
|
||||||
|
async function analyze(){
|
||||||
|
const transcript_id=parseInt(document.getElementById('tid').value||'0');
|
||||||
|
const prompt=document.getElementById('prompt').value;
|
||||||
|
const r=await fetch('/analyze',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({transcript_id,prompt})});
|
||||||
|
const j=await r.json();
|
||||||
|
document.getElementById('analysis').textContent = JSON.stringify(j,null,2);
|
||||||
|
}
|
||||||
|
async function loadTranscripts(){
|
||||||
|
const r=await fetch('/transcripts');
|
||||||
|
const j=await r.json();
|
||||||
|
const root=document.getElementById('list');
|
||||||
|
root.innerHTML='';
|
||||||
|
for(const t of j.items){
|
||||||
|
const d=document.createElement('div'); d.className='card';
|
||||||
|
d.innerHTML=`<b>#${t.id}</b> ${t.created_at} ${t.filename||''}<br><pre>${(t.formatted_text||'').slice(0,1200)}</pre>`;
|
||||||
|
root.appendChild(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadTranscripts();
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/process")
|
||||||
|
async def process(file: UploadFile = File(...)):
|
||||||
|
data = await file.read()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(400, "empty file")
|
||||||
|
|
||||||
|
files = {"file": (file.filename or "audio.bin", data, file.content_type or "application/octet-stream")}
|
||||||
|
try:
|
||||||
|
r = requests.post(f"{API_BASE}/transcribe-diarize", files=files, timeout=1800)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"API unreachable: {e}")
|
||||||
|
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise HTTPException(r.status_code, r.text)
|
||||||
|
|
||||||
|
payload = r.json()
|
||||||
|
formatted = payload.get("formatted_text", "")
|
||||||
|
|
||||||
|
with db() as c:
|
||||||
|
cur = c.execute(
|
||||||
|
"INSERT INTO transcripts(created_at, filename, formatted_text, raw_json) VALUES (?,?,?,?)",
|
||||||
|
(datetime.utcnow().isoformat(), file.filename, formatted, json.dumps(payload, ensure_ascii=False)),
|
||||||
|
)
|
||||||
|
transcript_id = cur.lastrowid
|
||||||
|
|
||||||
|
return {"ok": True, "transcript_id": transcript_id, **payload}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/transcripts")
|
||||||
|
def transcripts(limit: int = 20):
|
||||||
|
with db() as c:
|
||||||
|
rows = c.execute(
|
||||||
|
"SELECT id, created_at, filename, formatted_text FROM transcripts ORDER BY id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return {"items": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/analyze")
|
||||||
|
def analyze(transcript_id: int = Form(...), prompt: str = Form(...)):
|
||||||
|
with db() as c:
|
||||||
|
row = c.execute("SELECT formatted_text FROM transcripts WHERE id=?", (transcript_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "transcript not found")
|
||||||
|
|
||||||
|
transcript_text = row[0]
|
||||||
|
llm_prompt = (
|
||||||
|
"Du bist ein Meeting-Analyst. Arbeite auf Deutsch.\n"
|
||||||
|
"Erzeuge präzise Ausgabe für den folgenden Auftrag.\n\n"
|
||||||
|
f"AUFTRAG:\n{prompt}\n\n"
|
||||||
|
f"TRANSKRIPT:\n{transcript_text}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": OLLAMA_MODEL,
|
||||||
|
"prompt": llm_prompt,
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
r = requests.post(f"{OLLAMA_BASE_URL}/api/generate", json=body, timeout=600)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"Ollama unreachable: {e}")
|
||||||
|
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise HTTPException(r.status_code, r.text)
|
||||||
|
|
||||||
|
j = r.json()
|
||||||
|
answer = j.get("response", "")
|
||||||
|
|
||||||
|
with db() as c:
|
||||||
|
cur = c.execute(
|
||||||
|
"INSERT INTO analyses(transcript_id, created_at, prompt, answer) VALUES (?,?,?,?)",
|
||||||
|
(transcript_id, datetime.utcnow().isoformat(), prompt, answer),
|
||||||
|
)
|
||||||
|
analysis_id = cur.lastrowid
|
||||||
|
|
||||||
|
return {"ok": True, "analysis_id": analysis_id, "answer": answer}
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
diarization-ui:
|
diarization-ui:
|
||||||
build:
|
build:
|
||||||
context: ./web-ui
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: diarization-ui
|
container_name: diarization-ui
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8094:80"
|
- "8094:8094"
|
||||||
environment:
|
environment:
|
||||||
- API_BASE=${API_BASE:-http://gx10.aquantico.lan:8093}
|
- API_BASE=${API_BASE:-http://gx10.aquantico.lan:8093}
|
||||||
|
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://gx10.aquantico.lan:11434}
|
||||||
|
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3.5:9b}
|
||||||
|
- DB_PATH=/data/ui.db
|
||||||
|
volumes:
|
||||||
|
- diarization_ui_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
diarization_ui_data:
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
API_BASE="${API_BASE:-http://diarization-api:8093}"
|
|
||||||
sed "s|__API_BASE__|${API_BASE}|g" /usr/share/nginx/html/index.html.template > /usr/share/nginx/html/index.html
|
|
||||||
|
|
||||||
exec nginx -g 'daemon off;'
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Diarization + Whisper UI</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; max-width: 980px; margin: 24px auto; padding: 0 12px; }
|
|
||||||
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
||||||
input[type=file] { padding:6px; }
|
|
||||||
button { padding:8px 14px; cursor:pointer; }
|
|
||||||
pre { white-space: pre-wrap; background:#111; color:#0f0; padding:12px; min-height:220px; border-radius:8px; }
|
|
||||||
.muted { color:#666; font-size:13px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h2>Whisper + Sprechertrennung</h2>
|
|
||||||
<p class="muted">API: <span id="api"></span></p>
|
|
||||||
<div class="row">
|
|
||||||
<input id="f" type="file" accept="audio/*" />
|
|
||||||
<button onclick="go()">Verarbeiten</button>
|
|
||||||
</div>
|
|
||||||
<pre id="out"></pre>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API_BASE = "__API_BASE__";
|
|
||||||
document.getElementById("api").textContent = API_BASE;
|
|
||||||
|
|
||||||
async function go() {
|
|
||||||
const fi = document.getElementById('f');
|
|
||||||
if (!fi.files.length) { alert('Bitte Datei wählen'); return; }
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', fi.files[0]);
|
|
||||||
const out = document.getElementById('out');
|
|
||||||
out.textContent = 'Läuft...';
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${API_BASE}/transcribe-diarize`, { method: 'POST', body: fd });
|
|
||||||
const j = await r.json();
|
|
||||||
if (!r.ok) {
|
|
||||||
out.textContent = `Fehler (${r.status}):\n` + JSON.stringify(j, null, 2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
out.textContent = j.formatted_text || JSON.stringify(j, null, 2);
|
|
||||||
} catch (e) {
|
|
||||||
out.textContent = `Netzwerkfehler: ${e}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
requests==2.32.3
|
||||||
|
python-multipart==0.0.12
|
||||||
Reference in New Issue
Block a user