diff --git a/.gitignore b/.gitignore index d96e2df..eedaf19 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ __pycache__/ /telefon/*.log # Laufzeit-Zustand (aktive Modell-Auswahl aus dem Leitstand-Dropdown), kein Quellcode /telefon/modell.json +# Trainingsdaten-Export (pipe.export) - enthaelt volle Transkripte/Namen +/training/ diff --git a/pipe/bewertung.py b/pipe/bewertung.py new file mode 100644 index 0000000..0902516 --- /dev/null +++ b/pipe/bewertung.py @@ -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 diff --git a/pipe/export.py b/pipe/export.py new file mode 100644 index 0000000..4454ecc --- /dev/null +++ b/pipe/export.py @@ -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() diff --git a/pipe/process_call.py b/pipe/process_call.py index 5bf9844..270cd63 100644 --- a/pipe/process_call.py +++ b/pipe/process_call.py @@ -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 …") diff --git a/pipe/server.py b/pipe/server.py index afc0f29..1c8726d 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -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:

Anruf-Leitstand

- + +
@@ -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){
${(d.stichworte||[]).map(s=>`${esc(s)}`).join('')}
${d.audio?``:''}
Transkript

${esc(d.transkript)}

+
+ + + + + + + +
${knoepfe}
`; } @@ -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='' + +Object.keys(KAT).map(k=>``).join(''); + selDring.innerHTML='' + +['niedrig','normal','hoch','notfall'].map(x=>``).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);