feat: Bewertungssystem (👍/👎) als Trainingsdaten-Export

Jede Karte im Leitstand bekommt Daumen hoch/runter fuer die Kategorisierung.
Bei 👎 optional Korrektur (richtige Kategorie/Dringlichkeit). Gespeichert
als bewertung.json pro Anruf-Ordner (gleiches Muster wie stapel.json,
pipe/bewertung.py).

Export als JSONL (pipe/export.py, neuer "📤 Bewertungen exportieren"-Knopf
+ automatisch beim "Tag archivieren"): ein Datensatz pro bewertetem Anruf
mit Transkript, Modell-Ausgabe, Backend/Modell, Bewertung und Korrektur -
fuer externe Auswertung (z.B. um Kategorisierungs-Fehler systematisch statt
zufaellig zu finden, wie das Notfall-Sicherheitsnetz vom letzten Fund).

Export-Knopf verlangt Bestaetigung ("Transkripte und Namen verlassen den
Rechner") - bewusst kein automatischer Export ohne Hinweis, das steht sonst
im Widerspruch zum On-Prem-Versprechen. training/ ist neu gitignored (volle
Transkripte/Namen drin, siehe README-Hinweis zu Testdaten).

Nebenbei: process_call.py legt jetzt auch backend/modell/dauer_s der
Kategorisierung dauerhaft in meta.json ab (vorher nur im fluechtigen Log) -
noetig, damit der Export nachvollziehbar ist, welches Modell lief.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
This commit is contained in:
Jeuner 2026-08-24 23:47:20 +02:00
parent 7e4505d9b7
commit 90548944a2
5 changed files with 219 additions and 7 deletions

40
pipe/bewertung.py Normal file
View file

@ -0,0 +1,40 @@
"""Bewertung eines Anrufs durch das Praxispersonal (👍/👎 im Leitstand).
Dient gleichzeitig als Trainingsdaten fuer die Modellverbesserung - siehe
pipe.export fuer den Export als JSONL. Gleiches Muster wie stapel.py: eine
kleine JSON-Datei pro Anruf-Ordner, unabhaengig von meta.json (das aendert
sich nach der Ablage nie mehr, die Bewertung schon).
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
DATEI = "bewertung.json"
def lies(ordner: Path) -> dict | None:
"""Bewertung eines Anrufs, oder None wenn (noch) unbewertet."""
pfad = ordner / DATEI
if not pfad.is_file():
return None
try:
return json.loads(pfad.read_text(encoding="utf-8"))
except Exception:
return None
def setze(ordner: Path, bewertung: str, korrektur: dict | None = None) -> bool:
"""Speichert eine Bewertung. False bei ungueltigem Wert."""
if bewertung not in {"gut", "schlecht"}:
return False
try:
(ordner / DATEI).write_text(json.dumps({
"bewertung": bewertung,
"korrektur": korrektur,
"zeitpunkt": datetime.now().isoformat(timespec="seconds"),
}, ensure_ascii=False), encoding="utf-8")
return True
except Exception:
return False

69
pipe/export.py Normal file
View file

@ -0,0 +1,69 @@
"""Exportiert bewertete Anrufe als JSONL-Trainingsdaten (Transkript + Modell-
Ausgabe + Bewertung + ggf. Korrektur) - fuer externe Auswertung, z.B. um
Kategorisierungs-Fehler systematisch statt zufaellig zu finden.
python3 -m pipe.export
Nimmt jeden Anruf mit bewertung.json (unabhaengig vom Datum - kein
inkrementeller Export, einfach alle aktuell vorhandenen Bewertungen). Legt
unter training/ ab, das wie ablage/ nicht ins Git gehoert (siehe .gitignore) -
volle Transkripte + Namen sind bewusst drin (Testphase, siehe README-Hinweis
zu erfundenen Daten), fuer den Produktivbetrieb muss das neu entschieden
werden, siehe Warnhinweis beim Export-Knopf im Leitstand.
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from . import bewertung, config
EXPORT_ORDNER = config.WURZEL / "training"
def sammle() -> list[dict]:
"""Alle aktuell bewerteten Anrufe, als flache Trainingsdatensaetze."""
eintraege = []
if not config.ABLAGE_LOKAL.is_dir():
return eintraege
for meta_pfad in sorted(config.ABLAGE_LOKAL.glob("*/*/meta.json")):
ordner = meta_pfad.parent
b = bewertung.lies(ordner)
if b is None:
continue
try:
daten = json.loads(meta_pfad.read_text(encoding="utf-8"))
except Exception:
continue
eintraege.append({
"anruf_id": str(ordner.relative_to(config.ABLAGE_LOKAL)),
"empfangen": daten.get("empfangen"),
"transkript": daten.get("transkript"),
"modell_ausgabe": daten.get("auswertung"),
"kategorisierung": daten.get("kategorisierung"),
"bewertung": b["bewertung"],
"korrektur": b.get("korrektur"),
"bewertet_am": b.get("zeitpunkt"),
})
return eintraege
def exportiere() -> tuple[Path, int]:
"""Schreibt eine neue JSONL-Datei. Gibt (Pfad, Anzahl) zurueck."""
eintraege = sammle()
EXPORT_ORDNER.mkdir(parents=True, exist_ok=True)
ziel = EXPORT_ORDNER / f"export_{datetime.now():%Y%m%d-%H%M%S}.jsonl"
with ziel.open("w", encoding="utf-8") as f:
for e in eintraege:
f.write(json.dumps(e, ensure_ascii=False) + "\n")
return ziel, len(eintraege)
def main() -> None:
ziel, anzahl = exportiere()
print(f"{anzahl} bewertete Anrufe exportiert nach {ziel}")
if __name__ == "__main__":
main()

View file

@ -86,6 +86,11 @@ def verarbeite(audio: str, anrufer_nummer: str | None = None,
"audio_dauer_s": t.dauer,
"auswertung": auswertung,
"bekannter_kontakt": bekannter_kontakt,
# Backend/Modell mit ablegen (nicht nur ins fluechtige Log) - fuer den
# Bewertungs-Export (pipe.export) muss nachvollziehbar bleiben, welches
# Modell diese Kategorisierung geliefert hat.
"kategorisierung": {"backend": meta["backend"], "modell": meta["modell"],
"dauer_s": meta["dauer_s"], "failover_von": meta.get("failover_von")},
}
print("▸ Lege ab …")

View file

@ -23,7 +23,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
from . import config, dashboard, modellwahl, protokoll, stapel, store
from . import bewertung, config, dashboard, export, modellwahl, protokoll, stapel, store
# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern,
# damit haeufiges Nachfragen den Rechner nicht belastet.
@ -147,14 +147,16 @@ def anrufe() -> list[dict]:
"transkript": d.get("transkript") or "",
"audio": audio,
"kontakt": (d.get("bekannter_kontakt") or {}).get("name"),
"bewertung": (bewertung.lies(config.ABLAGE_LOKAL / ordner) or {}).get("bewertung") if ordner else None,
})
return liste
def archiviere_tag() -> int:
"""Markiert alle 'Erledigt'-Anrufe als archiviert (simuliert - es wird
nichts exportiert oder geloescht, nur vom Board geraeumt). Gibt die Anzahl
zurueck."""
nichts geloescht, nur vom Board geraeumt). Bewertete Anrufe werden dabei
zusaetzlich als Trainingsdaten exportiert, siehe pipe.export. Gibt die
Anzahl archivierter Anrufe zurueck."""
letzter = stapel.erlaubt()[-1]
anzahl = 0
for a in anrufe():
@ -293,7 +295,35 @@ class Handler(BaseHTTPRequestHandler):
if pfad == "/api/archivieren":
anzahl = archiviere_tag()
protokoll.schreibe("archiv", f"{anzahl} Anruf(e) archiviert (simuliert)", anzahl=anzahl)
self._json({"ok": True, "anzahl": anzahl, "simuliert": True})
_, export_anzahl = export.exportiere()
if export_anzahl:
protokoll.schreibe("export", f"{export_anzahl} bewertete Anrufe exportiert")
self._json({"ok": True, "anzahl": anzahl, "simuliert": True, "export_anzahl": export_anzahl})
return
if pfad == "/api/export":
ziel, anzahl = export.exportiere()
protokoll.schreibe("export", f"{anzahl} bewertete Anrufe exportiert nach {ziel.name}")
self._json({"ok": True, "anzahl": anzahl, "datei": ziel.name})
return
if pfad == "/api/bewertung":
try:
laenge = int(self.headers.get("Content-Length") or 0)
wunsch = json.loads(self.rfile.read(min(laenge, 10_000)).decode("utf-8"))
kennung, wert = wunsch["id"], wunsch["bewertung"]
korrektur = wunsch.get("korrektur")
except Exception:
self._json({"ok": False, "fehler": "ungültige Anfrage"})
return
ordner = stapel.ordner_zu(kennung)
if ordner is None:
self._json({"ok": False, "fehler": "Anruf nicht gefunden"})
return
if not bewertung.setze(ordner, wert, korrektur):
self._json({"ok": False, "fehler": f"ungültige Bewertung: {wert}"})
return
protokoll.schreibe("bewertung", f"{ordner.name}{wert}"
+ (f" (Korrektur: {korrektur})" if korrektur else ""))
self._json({"ok": True})
return
if pfad == "/api/modell":
try:
@ -433,6 +463,21 @@ animation:notfallBlinken 1.6s ease-in-out infinite}
padding:6px 12px;font-size:13px;cursor:pointer;font-family:inherit}
.archiv-btn:hover{border-color:var(--muted)}
.archiv-btn:disabled{opacity:.5;cursor:default}
.export-btn{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px;
padding:6px 12px;font-size:13px;cursor:pointer;font-family:inherit;margin-left:8px}
.export-btn:hover{border-color:var(--muted)}
.bewertung{display:flex;align-items:center;gap:6px;margin-top:8px}
.bewertung button{font-size:15px;background:var(--bg);border:1px solid var(--rand);border-radius:7px;
padding:3px 8px;cursor:pointer;line-height:1;opacity:.55}
.bewertung button:hover{opacity:1;border-color:var(--muted)}
.bw-gut.aktiv{opacity:1;background:color-mix(in srgb,var(--gut) 18%,var(--bg));border-color:var(--gut)}
.bw-schlecht.aktiv{opacity:1;background:color-mix(in srgb,var(--schlecht) 18%,var(--bg));border-color:var(--schlecht)}
.bw-korrektur{display:none;align-items:center;gap:5px}
.bw-korrektur.zeigen{display:flex}
.bw-korrektur select{font:inherit;font-size:12px;padding:2px 4px;border-radius:6px;
border:1px solid var(--rand);background:var(--bg);color:var(--fg)}
.bw-korrektur button{font-size:12px;padding:3px 8px;opacity:1;background:var(--gut);
color:#fff;border-color:var(--gut)}
.sicher-btn{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px;
padding:6px 12px;font-size:13px;cursor:pointer;font-family:inherit;margin-right:8px}
.sicher-btn:hover{border-color:var(--muted)}
@ -495,7 +540,8 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:
</style></head><body><div class="wrap">
<header><h1>Anruf-Leitstand</h1>
<button class="sicher-btn" id="sicherBtn" title="Namen, Nummern, Anliegen und Transkripte unscharf - fuer Bildschirmaufnahmen/Screenshots">🙈 Sicher-Modus</button>
<button class="archiv-btn" id="archivBtn" title="Erledigte Anrufe vom Board raeumen (simuliert - nichts wird geloescht oder exportiert)">🗄 Tag archivieren</button>
<button class="archiv-btn" id="archivBtn" title="Erledigte Anrufe vom Board raeumen (simuliert, nichts wird geloescht) - bewertete Anrufe werden dabei als Trainingsdaten exportiert">🗄 Tag archivieren</button>
<button class="export-btn" id="exportBtn" title="Bewertete Anrufe (👍/👎) als JSONL-Trainingsdaten exportieren, unabhaengig vom Archivieren">📤 Bewertungen exportieren</button>
<span class="stand" id="stand"></span>
<select class="modell-select" id="modellSelect" title="Kategorisierungs-Modell fuer neue Anrufe - Aenderung greift sofort, kein Neustart noetig"></select>
</header>
@ -539,6 +585,17 @@ async function verschiebe(id,ziel){
if(!d||!d.ok){takt()} // hat nicht geklappt: echten Stand neu holen
}
// Bewertung dient gleichzeitig als Trainingsdaten (siehe pipe.export) - 👍/👎
// pro Anruf, bei 👎 optional die richtige Kategorie/Dringlichkeit dazu.
async function bewerte(id,wert,korrektur){
const a=ANRUFE.find(x=>x.id===id); if(a)a.bewertung=wert;
const r=await fetch('/api/bewertung',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id,bewertung:wert,korrektur:korrektur||null})});
const d=await r.json().catch(()=>null);
toast(d&&d.ok?`Bewertung gespeichert (${wert==='gut'?'👍':'👎'})`:'Bewertung fehlgeschlagen');
if(a)zeichneBoard();
}
// Nur Ziffern und ein fuehrendes Plus taugen fuer tel:
const waehlbar=n=>String(n||'').replace(/[^\d+]/g,'').replace(/(?!^)\+/g,'');
@ -581,6 +638,15 @@ function karte(d){
<div>${(d.stichworte||[]).map(s=>`<span class="tag">${esc(s)}</span>`).join('')}</div>
${d.audio?`<audio controls preload="none" src="${d.audio}"></audio>`:''}
<details data-id="${esc(d.id)}"${offen}><summary>Transkript</summary><p class="tr">${esc(d.transkript)}</p></details>
<div class="bewertung" data-id="${esc(d.id)}">
<button class="bw-gut${d.bewertung==='gut'?' aktiv':''}" data-wert="gut" title="Kategorisierung war korrekt - dient als Trainingsdaten">👍</button>
<button class="bw-schlecht${d.bewertung==='schlecht'?' aktiv':''}" data-wert="schlecht" title="Kategorisierung war falsch - optional korrigieren">👎</button>
<span class="bw-korrektur">
<select class="bw-kat"></select>
<select class="bw-dring"></select>
<button class="bw-speichern">Speichern</button>
</span>
</div>
<div class="schieber">${knoepfe}</div>
</article>`;
}
@ -604,6 +670,23 @@ function zeichneBoard(){
el.querySelectorAll('details').forEach(d=>{
d.ontoggle=()=>{d.open?offeneTranskripte.add(d.dataset.id):offeneTranskripte.delete(d.dataset.id)};
});
el.querySelectorAll('.bewertung').forEach(bw=>{
const id=bw.dataset.id, korrektur=bw.querySelector('.bw-korrektur');
const selKat=bw.querySelector('.bw-kat'), selDring=bw.querySelector('.bw-dring');
selKat.innerHTML='<option value="">Kategorie unverändert</option>'
+Object.keys(KAT).map(k=>`<option value="${k}">${esc(KAT[k])}</option>`).join('');
selDring.innerHTML='<option value="">Dringlichkeit unverändert</option>'
+['niedrig','normal','hoch','notfall'].map(x=>`<option value="${x}">${x}</option>`).join('');
bw.querySelector('.bw-gut').onclick=e=>{e.stopPropagation();bewerte(id,'gut')};
bw.querySelector('.bw-schlecht').onclick=e=>{e.stopPropagation();korrektur.classList.toggle('zeigen')};
bw.querySelector('.bw-speichern').onclick=e=>{
e.stopPropagation();
const k={};
if(selKat.value)k.kategorie=selKat.value;
if(selDring.value)k.dringlichkeit=selDring.value;
bewerte(id,'schlecht',Object.keys(k).length?k:null);
};
});
el.querySelectorAll('.karte').forEach(k=>{
k.ondragstart=e=>{pausiert=true;k.classList.add('zieht');e.dataTransfer.setData('text/plain',k.dataset.id)};
k.ondragend=()=>{pausiert=false;k.classList.remove('zieht')};
@ -765,8 +848,21 @@ document.getElementById('archivBtn').onclick=async()=>{
const r=await fetch('/api/archivieren',{method:'POST'});
const d=await r.json().catch(()=>null);
btn.disabled=false;
if(d&&d.ok){toast(` ${d.anzahl} Anruf(e) archiviert (simuliert)`);takt()}
else{toast('Archivieren fehlgeschlagen.')}
if(d&&d.ok){
let text=` ${d.anzahl} Anruf(e) archiviert (simuliert)`;
if(d.export_anzahl)text+=` · ${d.export_anzahl} bewertete Anrufe exportiert`;
toast(text);takt();
} else{toast('Archivieren fehlgeschlagen.')}
};
document.getElementById('exportBtn').onclick=async()=>{
const bewertete=ANRUFE.filter(a=>a.bewertung).length;
if(!confirm(`${bewertete} bewertete Anrufe auf dem aktuellen Board gefunden. `
+'Export ist für externe Auswertung gedacht (z.B. Modell-Verbesserung) — '
+'Transkripte und Namen der bewerteten Anrufe verlassen damit den Rechner. Fortfahren?'))return;
const r=await fetch('/api/export',{method:'POST'});
const d=await r.json().catch(()=>null);
toast(d&&d.ok?` ${d.anzahl} Anruf(e) exportiert training/${d.datei}`:'Export fehlgeschlagen');
};
takt();setInterval(takt,3000);