Player & Titel-Aktualisierung implementiert
This commit is contained in:
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ~/bin/call-recorder.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REC_DIR="$HOME"
|
|
||||||
PIDFILE="/tmp/call-recorder.pid"
|
|
||||||
LOGFILE="/tmp/call-recorder.log"
|
|
||||||
|
|
||||||
start_recording() {
|
|
||||||
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
|
||||||
notify-send "Call Recorder" "Läuft bereits (PID $(cat "$PIDFILE"))"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local sink source outfile
|
|
||||||
sink="$(pactl get-default-sink).monitor"
|
|
||||||
source="$(pactl get-default-source)"
|
|
||||||
outfile="${REC_DIR}/call_$(date +%Y-%m-%d_%H-%M-%S).mp3"
|
|
||||||
|
|
||||||
ffmpeg -nostdin -y \
|
|
||||||
-f pulse -i "$sink" \
|
|
||||||
-f pulse -i "$source" \
|
|
||||||
-filter_complex "[0:a]pan=mono|c0=0.5*c0+0.5*c1[browser];[1:a]pan=mono|c0=c0[mic];[browser][mic]amerge=inputs=2[aout]" \
|
|
||||||
-map "[aout]" -ac 2 -c:a libmp3lame -q:a 2 \
|
|
||||||
"$outfile" \
|
|
||||||
> "$LOGFILE" 2>&1 &
|
|
||||||
|
|
||||||
echo $! > "$PIDFILE"
|
|
||||||
notify-send "Call Recorder" "Aufnahme gestartet: $(basename "$outfile")"
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_recording() {
|
|
||||||
if [[ ! -f "$PIDFILE" ]]; then
|
|
||||||
notify-send "Call Recorder" "Keine laufende Aufnahme."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
local pid
|
|
||||||
pid="$(cat "$PIDFILE")"
|
|
||||||
kill -INT "$pid" 2>/dev/null || true
|
|
||||||
# ffmpeg braucht einen Moment, um den MP3-Frame sauber abzuschließen
|
|
||||||
for _ in {1..20}; do
|
|
||||||
kill -0 "$pid" 2>/dev/null || break
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
rm -f "$PIDFILE"
|
|
||||||
notify-send "Call Recorder" "Aufnahme gestoppt."
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-toggle}" in
|
|
||||||
start) start_recording ;;
|
|
||||||
stop) stop_recording ;;
|
|
||||||
toggle) if [[ -f "$PIDFILE" ]]; then stop_recording; else start_recording; fi ;;
|
|
||||||
*) echo "Usage: $0 {start|stop|toggle}"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import whisperx
|
|
||||||
import gc
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
HF_TOKEN="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"
|
|
||||||
DEVICE = "cuda"
|
|
||||||
COMPUTE_TYPE = "float16"
|
|
||||||
BATCH_SIZE = 4
|
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Verwendung: python transkribiere_datei.py <audiodatei> [...]")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
audio_files = sys.argv[1:]
|
|
||||||
print(f"{len(audio_files)} Datei(en) zu transkribieren\n")
|
|
||||||
|
|
||||||
print("Lade Whisper large-v3...")
|
|
||||||
model = whisperx.load_model("large-v3", DEVICE, compute_type=COMPUTE_TYPE, language="de")
|
|
||||||
|
|
||||||
print("Lade Diarization-Pipeline...")
|
|
||||||
diarize_model = whisperx.diarize.DiarizationPipeline(token=HF_TOKEN, device=DEVICE)
|
|
||||||
|
|
||||||
for audio_path in audio_files:
|
|
||||||
base = os.path.splitext(audio_path)[0]
|
|
||||||
out_path = base + ".txt"
|
|
||||||
fname = os.path.basename(audio_path)
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"Verarbeite: {fname}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
audio = whisperx.load_audio(audio_path)
|
|
||||||
|
|
||||||
print(" 1/4 Transkription...")
|
|
||||||
result = model.transcribe(audio, batch_size=BATCH_SIZE, language="de")
|
|
||||||
|
|
||||||
print(" 2/4 Alignment...")
|
|
||||||
model_a, metadata = whisperx.load_align_model(language_code="de", device=DEVICE)
|
|
||||||
result = whisperx.align(result["segments"], model_a, metadata, audio, DEVICE,
|
|
||||||
return_char_alignments=False)
|
|
||||||
del model_a; gc.collect()
|
|
||||||
|
|
||||||
print(" 3/4 Speaker Diarization...")
|
|
||||||
diarize_segments = diarize_model(audio)
|
|
||||||
result = whisperx.assign_word_speakers(diarize_segments, result)
|
|
||||||
|
|
||||||
print(" 4/4 Speichere Transkript...")
|
|
||||||
with open(out_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(f"Transkript: {fname}\n")
|
|
||||||
f.write("=" * 60 + "\n\n")
|
|
||||||
current_speaker = None
|
|
||||||
for seg in result["segments"]:
|
|
||||||
speaker = seg.get("speaker", "UNBEKANNT")
|
|
||||||
text = seg["text"].strip()
|
|
||||||
start = seg["start"]
|
|
||||||
end = seg["end"]
|
|
||||||
if speaker != current_speaker:
|
|
||||||
f.write(f"\n[{speaker}]\n")
|
|
||||||
current_speaker = speaker
|
|
||||||
f.write(f" [{start:6.1f}s – {end:5.1f}s] {text}\n")
|
|
||||||
|
|
||||||
print(f" -> Gespeichert: {out_path}")
|
|
||||||
|
|
||||||
print("\nAlle Dateien fertig!")
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
source /home/timbfrank/whisperx-env/bin/activate
|
|
||||||
python /home/timbfrank/transkribiere_datei.py "$@"
|
|
||||||
echo ""
|
|
||||||
echo "Fertig! Fenster schließt in 10 Sekunden..."
|
|
||||||
sleep 10
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import whisperx
|
|
||||||
import gc
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
HF_TOKEN = "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"
|
|
||||||
DEVICE = "cuda"
|
|
||||||
COMPUTE_TYPE = "float16"
|
|
||||||
BATCH_SIZE = 4
|
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Verwendung: python transkribiere_datei.py <audiodatei> [...]")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
audio_files = sys.argv[1:]
|
|
||||||
print(f"{len(audio_files)} Datei(en) zu transkribieren\n")
|
|
||||||
|
|
||||||
print("Lade Whisper large-v3...")
|
|
||||||
model = whisperx.load_model("large-v3", DEVICE, compute_type=COMPUTE_TYPE, language="de")
|
|
||||||
|
|
||||||
print("Lade Diarization-Pipeline...")
|
|
||||||
diarize_model = whisperx.diarize.DiarizationPipeline(token=HF_TOKEN, device=DEVICE)
|
|
||||||
|
|
||||||
for audio_path in audio_files:
|
|
||||||
base = os.path.splitext(audio_path)[0]
|
|
||||||
out_path = base + ".txt"
|
|
||||||
fname = os.path.basename(audio_path)
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"Verarbeite: {fname}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
audio = whisperx.load_audio(audio_path)
|
|
||||||
|
|
||||||
print(" 1/4 Transkription...")
|
|
||||||
result = model.transcribe(audio, batch_size=BATCH_SIZE, language="de")
|
|
||||||
|
|
||||||
print(" 2/4 Alignment...")
|
|
||||||
model_a, metadata = whisperx.load_align_model(language_code="de", device=DEVICE)
|
|
||||||
result = whisperx.align(result["segments"], model_a, metadata, audio, DEVICE,
|
|
||||||
return_char_alignments=False)
|
|
||||||
del model_a; gc.collect()
|
|
||||||
|
|
||||||
print(" 3/4 Speaker Diarization...")
|
|
||||||
diarize_segments = diarize_model(audio)
|
|
||||||
result = whisperx.assign_word_speakers(diarize_segments, result)
|
|
||||||
|
|
||||||
print(" 4/4 Speichere Transkript...")
|
|
||||||
with open(out_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(f"Transkript: {fname}\n")
|
|
||||||
f.write("=" * 60 + "\n\n")
|
|
||||||
current_speaker = None
|
|
||||||
for seg in result["segments"]:
|
|
||||||
speaker = seg.get("speaker", "UNBEKANNT")
|
|
||||||
text = seg["text"].strip()
|
|
||||||
start = seg["start"]
|
|
||||||
end = seg["end"]
|
|
||||||
if speaker != current_speaker:
|
|
||||||
f.write(f"\n[{speaker}]\n")
|
|
||||||
current_speaker = speaker
|
|
||||||
f.write(f" [{start:6.1f}s – {end:5.1f}s] {text}\n")
|
|
||||||
|
|
||||||
print(f" -> Gespeichert: {out_path}")
|
|
||||||
|
|
||||||
print("\nAlle Dateien fertig!")
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import whisperx
|
|
||||||
import gc
|
|
||||||
import os
|
|
||||||
|
|
||||||
HF_TOKEN = "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"
|
|
||||||
DEVICE = "cuda"
|
|
||||||
COMPUTE_TYPE = "float16"
|
|
||||||
BATCH_SIZE = 4
|
|
||||||
|
|
||||||
AUDIO_DIR = os.path.expanduser(
|
|
||||||
"~/VRtual X Dropbox/Tim B. Frank/Apps/Tims Hermes/Projekte/battenfeld-cincinnati/2026-06-30_Audionotizen"
|
|
||||||
)
|
|
||||||
OUTPUT_DIR = os.path.expanduser("~/Transkripte/battenfeld-cincinnati")
|
|
||||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
files = sorted([f for f in os.listdir(AUDIO_DIR) if f.endswith(".mp3")])
|
|
||||||
print(f"{len(files)} Dateien gefunden: {files}\n")
|
|
||||||
|
|
||||||
print("Lade Whisper large-v3...")
|
|
||||||
model = whisperx.load_model("large-v3", DEVICE, compute_type=COMPUTE_TYPE, language="de")
|
|
||||||
|
|
||||||
print("Lade Diarization-Pipeline...")
|
|
||||||
diarize_model = whisperx.diarize.DiarizationPipeline(token=HF_TOKEN, device=DEVICE)
|
|
||||||
|
|
||||||
for fname in files:
|
|
||||||
audio_path = os.path.join(AUDIO_DIR, fname)
|
|
||||||
out_name = fname.replace(".mp3", ".txt")
|
|
||||||
out_path = os.path.join(OUTPUT_DIR, out_name)
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"Verarbeite: {fname}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
audio = whisperx.load_audio(audio_path)
|
|
||||||
|
|
||||||
print(" 1/4 Transkription...")
|
|
||||||
result = model.transcribe(audio, batch_size=BATCH_SIZE, language="de")
|
|
||||||
|
|
||||||
print(" 2/4 Alignment...")
|
|
||||||
model_a, metadata = whisperx.load_align_model(language_code="de", device=DEVICE)
|
|
||||||
result = whisperx.align(result["segments"], model_a, metadata, audio, DEVICE,
|
|
||||||
return_char_alignments=False)
|
|
||||||
del model_a; gc.collect()
|
|
||||||
|
|
||||||
print(" 3/4 Speaker Diarization...")
|
|
||||||
diarize_segments = diarize_model(audio)
|
|
||||||
result = whisperx.assign_word_speakers(diarize_segments, result)
|
|
||||||
|
|
||||||
print(" 4/4 Speichere Transkript...")
|
|
||||||
with open(out_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(f"Transkript: {fname}\n")
|
|
||||||
f.write("="*60 + "\n\n")
|
|
||||||
current_speaker = None
|
|
||||||
for seg in result["segments"]:
|
|
||||||
speaker = seg.get("speaker", "UNBEKANNT")
|
|
||||||
text = seg["text"].strip()
|
|
||||||
start = seg["start"]
|
|
||||||
end = seg["end"]
|
|
||||||
if speaker != current_speaker:
|
|
||||||
f.write(f"\n[{speaker}]\n")
|
|
||||||
current_speaker = speaker
|
|
||||||
f.write(f" [{start:6.1f}s – {end:5.1f}s] {text}\n")
|
|
||||||
|
|
||||||
print(f" -> Gespeichert: {out_path}")
|
|
||||||
|
|
||||||
print("\nAlle Dateien fertig!")
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
|
|
||||||
HERMES:
|
|
||||||
Hier der vollständige Inhalt:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Trigger-Keywords:
|
|
||||||
- transkript verarbeiten
|
|
||||||
- gesprächsprotokoll
|
|
||||||
- gespräch aufbereiten
|
|
||||||
- meeting zusammenfassen
|
|
||||||
- kundengespräch
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Pflicht: Fehlende Infos zuerst klären
|
|
||||||
|
|
||||||
Bevor du mit der Verarbeitung beginnst, prüfe ob folgende Infos vorhanden sind. Wenn nicht → explizit nachfragen:
|
|
||||||
|
|
||||||
- Datum des Gesprächs
|
|
||||||
- Gesprächsort / Kanal (Telefon, vor Ort, Video)
|
|
||||||
- Gesprächsteilnehmer (Name + Rolle / Unternehmen) — wer spricht für wen?
|
|
||||||
- Projektzuordnung (welches Projekt oder welche Kampagne?)
|
|
||||||
- Falls Personen im Transkript nicht namentlich zugeordnet sind: Wer ist wer?
|
|
||||||
|
|
||||||
Erst wenn diese Infos vollständig sind, mit dem Template beginnen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Output-Template
|
|
||||||
|
|
||||||
Ausgabe immer in sauberem Markdown. Dateiname-Vorschlag: YYYY-MM-DD_Gesprächsprotokoll_[Kundenname].md
|
|
||||||
|
|
||||||
# Gesprächsprotokoll – [Kundenname / Projekt]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Basisinfos
|
|
||||||
- **Datum:**
|
|
||||||
- **Kanal:** (Telefon / vor Ort / Video)
|
|
||||||
- **Ort / Plattform:**
|
|
||||||
- **Teilnehmer:**
|
|
||||||
- [Name] – [Rolle], [Unternehmen]
|
|
||||||
- [Name] – [Rolle], [Unternehmen]
|
|
||||||
- **Projekt / Kontext:**
|
|
||||||
- **Gesprächstyp:** (Erstgespräch / Follow-up / Demo / Projektgespräch / Abschluss)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gesprächszusammenfassung
|
|
||||||
[Fließtext, maximal zwei Absätze. Die wichtigsten Themen, Stimmung, roter Faden des Gesprächs. Kein Aufzählungs-Stil — echte Prosa.]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Entscheidungen & Fakten
|
|
||||||
|
|
||||||
**Getroffene Entscheidungen:**
|
|
||||||
- [Entscheidung, ggf. mit wer entschieden hat]
|
|
||||||
|
|
||||||
**Genannte Fakten & Rahmenbedingungen:**
|
|
||||||
- [Budget, Timeline, Teamgröße, technische Vorgaben, Vertragsinfos etc.]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ToDos & Nächste Schritte
|
|
||||||
- [ ] [Aufgabe] — [Person / Unternehmen] — [Datum falls genannt]
|
|
||||||
- [ ] [Aufgabe] — [Person / Unternehmen] — [Datum falls genannt]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nebeninformationen *(intern, nicht zur Weitergabe)*
|
|
||||||
- [Interessante Kontextinfos, persönliche Details, Brancheninfos, die zufällig erwähnt wurden — nicht projektbezogen, aber möglicherweise nützlich]
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Verarbeitungshinweise
|
|
||||||
|
|
||||||
1. Transkript-Input: Kann roher Text, korrigiertes Transkript oder Stichpunkte sein.
|
|
||||||
2. Sprache: Immer auf Deutsch ausgeben, auch wenn das Transkript teilweise Englisch enthält.
|
|
||||||
3. Block 2: Kein Marketing-Sprech, kein „stell dir vor" — sachlich, direkt, wie Tim es formulieren würde.
|
|
||||||
4. Block 4: Format [ ] Aufgabe — Person/Unternehmen — Datum konsequent einhalten. Wenn keine Person zuordenbar → [ ] Aufgabe — unklar, klären
|
|
||||||
5. Block 5: Nur wirklich interessante Nebenfakten, keine Füller. Lieber leer lassen als auffüllen.
|
|
||||||
6. Datei speichern: Wenn möglich, als .md-Datei unter einem sinnvollen Pfad ablegen (z.B. Obsidian-Vault oder lokaler Projektordner). Tim entscheidet ob er sie weiterleitet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Typischer Workflow
|
|
||||||
|
|
||||||
1. Tim gibt Transkript-Text (oder Datei) + ggf. Kontextinfos
|
|
||||||
2. Fehlende Pflichtinfos abfragen
|
|
||||||
3. 5-Block-Template befüllen
|
|
||||||
4. Als Markdown-Datei ausgeben (MEDIA-Link wenn lokal gespeichert)
|
|
||||||
5. Tim kann Block 5 für sich behalten und den Rest weitergeben
|
|
||||||
@@ -61,7 +61,9 @@ class CallRecorder:
|
|||||||
"-f", "pulse", "-i", source,
|
"-f", "pulse", "-i", source,
|
||||||
"-filter_complex", filter_complex,
|
"-filter_complex", filter_complex,
|
||||||
"-map", "[aout]", "-ac", "2",
|
"-map", "[aout]", "-ac", "2",
|
||||||
"-c:a", "libmp3lame", "-q:a", "2",
|
# Feste Bitrate statt VBR-Qualitätsstufe: Datenmenge spielt keine Rolle,
|
||||||
|
# 320k ist die maximal mögliche MP3-Bitrate.
|
||||||
|
"-c:a", "libmp3lame", "-b:a", "320k",
|
||||||
str(output_path),
|
str(output_path),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Desktop-Benachrichtigungen über `notify-send` (freedesktop.org Notifications).
|
||||||
|
|
||||||
|
Wird von notarius-rec verwendet: dieser Befehl läuft typischerweise über einen
|
||||||
|
KDE-Tastenkurzbefehl ganz ohne sichtbares Terminal. Ohne eine Benachrichtigung
|
||||||
|
bekäme der Nutzer nie mit, ob Start/Stop tatsächlich geklappt hat oder woran ein
|
||||||
|
Fehlschlag lag (z.B. fehlender Standard-Sink) - stdout/stderr laufen in diesem
|
||||||
|
Kontext ins Leere. Die Notarius-GUI selbst nutzt stattdessen QSystemTrayIcon,
|
||||||
|
da sie ohnehin eine Qt-Event-Loop hat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def notify(title: str, message: str, urgency: str = "normal") -> None:
|
||||||
|
"""Zeigt eine Desktop-Benachrichtigung. Fehlt notify-send, wird nur geloggt -
|
||||||
|
das darf niemals den eigentlichen Aufnahme-Befehl zum Absturz bringen.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["notify-send", "--urgency", urgency, "--app-name", "Notarius", title, message],
|
||||||
|
check=False,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
logger.warning("notify-send nicht verfügbar, Benachrichtigung übersprungen: %s - %s", title, message)
|
||||||
@@ -53,6 +53,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self._recordings_tab.recording_selected.connect(self._on_recording_selected)
|
self._recordings_tab.recording_selected.connect(self._on_recording_selected)
|
||||||
self._transcript_tab.transcription_finished.connect(self._on_transcription_finished)
|
self._transcript_tab.transcription_finished.connect(self._on_transcription_finished)
|
||||||
|
self._transcript_tab.metadata_changed.connect(self._on_metadata_changed)
|
||||||
self._processing_tab.summarization_finished.connect(self._on_summarization_finished)
|
self._processing_tab.summarization_finished.connect(self._on_summarization_finished)
|
||||||
|
|
||||||
self._update_tab_states()
|
self._update_tab_states()
|
||||||
@@ -72,6 +73,13 @@ class MainWindow(QMainWindow):
|
|||||||
self._update_tab_states()
|
self._update_tab_states()
|
||||||
self._processing_tab.set_recording(self._selected_recording)
|
self._processing_tab.set_recording(self._selected_recording)
|
||||||
|
|
||||||
|
def _on_metadata_changed(self) -> None:
|
||||||
|
# Tab 2 hat Titel/Zeitstempel/Sprecher direkt auf dem geteilten Recording-Objekt
|
||||||
|
# aktualisiert - Tab 3/4 müssen nur ihre (read-only) Kopfzeile neu zeichnen,
|
||||||
|
# nicht ihre editierbaren Textfelder neu laden.
|
||||||
|
self._processing_tab.refresh_header()
|
||||||
|
self._summary_tab.refresh_header()
|
||||||
|
|
||||||
def _on_summarization_finished(self) -> None:
|
def _on_summarization_finished(self) -> None:
|
||||||
self._summary_tab.set_recording(self._selected_recording)
|
self._summary_tab.set_recording(self._selected_recording)
|
||||||
self._update_tab_states()
|
self._update_tab_states()
|
||||||
|
|||||||
@@ -65,12 +65,7 @@ class ProcessingTab(QWidget):
|
|||||||
if not has_recording:
|
if not has_recording:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._header_label.setText(
|
self.refresh_header()
|
||||||
f"<b>Titel:</b> {recording.meta.title}<br>"
|
|
||||||
f"<b>Aufzeichnungszeitpunkt:</b> {format_timestamp(recording.meta.created_at)}<br>"
|
|
||||||
f"<b>Länge:</b> {format_duration(recording.meta.duration_seconds)}<br>"
|
|
||||||
f"<b>Sprecher:</b> {', '.join(recording.meta.speakers) or '-'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if recording.prompt_notes_path.exists():
|
if recording.prompt_notes_path.exists():
|
||||||
self._notes_edit.setPlainText(recording.prompt_notes_path.read_text(encoding="utf-8"))
|
self._notes_edit.setPlainText(recording.prompt_notes_path.read_text(encoding="utf-8"))
|
||||||
@@ -81,6 +76,19 @@ class ProcessingTab(QWidget):
|
|||||||
self._summarize_button.setText("Summarize")
|
self._summarize_button.setText("Summarize")
|
||||||
self._summarize_button.setEnabled(True)
|
self._summarize_button.setEnabled(True)
|
||||||
|
|
||||||
|
def refresh_header(self) -> None:
|
||||||
|
"""Aktualisiert nur die Kopfzeile (z.B. nach einer Titeländerung in Tab 2),
|
||||||
|
ohne Notizen/Status anzurühren - vermeidet den Verlust unsaved Änderungen.
|
||||||
|
"""
|
||||||
|
if self._recording is None:
|
||||||
|
return
|
||||||
|
self._header_label.setText(
|
||||||
|
f"<b>Titel:</b> {self._recording.meta.title}<br>"
|
||||||
|
f"<b>Aufzeichnungszeitpunkt:</b> {format_timestamp(self._recording.meta.created_at)}<br>"
|
||||||
|
f"<b>Länge:</b> {format_duration(self._recording.meta.duration_seconds)}<br>"
|
||||||
|
f"<b>Sprecher:</b> {', '.join(self._recording.meta.speakers) or '-'}"
|
||||||
|
)
|
||||||
|
|
||||||
def _on_notes_save_clicked(self) -> None:
|
def _on_notes_save_clicked(self) -> None:
|
||||||
if self._recording is None:
|
if self._recording is None:
|
||||||
return
|
return
|
||||||
@@ -119,6 +127,7 @@ class ProcessingTab(QWidget):
|
|||||||
self._controller.config.claude_model,
|
self._controller.config.claude_model,
|
||||||
transcript_markdown,
|
transcript_markdown,
|
||||||
notes,
|
notes,
|
||||||
|
self._recording.meta.speakers,
|
||||||
self._on_summary_ok,
|
self._on_summary_ok,
|
||||||
self._on_summary_error,
|
self._on_summary_error,
|
||||||
self._on_summary_cancelled,
|
self._on_summary_cancelled,
|
||||||
|
|||||||
@@ -48,18 +48,26 @@ class SummaryTab(QWidget):
|
|||||||
if not has_recording:
|
if not has_recording:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._header_label.setText(
|
self.refresh_header()
|
||||||
f"<b>Titel:</b> {recording.meta.title}<br>"
|
|
||||||
f"<b>Aufzeichnungszeitpunkt:</b> {format_timestamp(recording.meta.created_at)}<br>"
|
|
||||||
f"<b>Länge:</b> {format_duration(recording.meta.duration_seconds)}<br>"
|
|
||||||
f"<b>Sprecher:</b> {', '.join(recording.meta.speakers) or '-'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if recording.has_summary:
|
if recording.has_summary:
|
||||||
self._text_edit.setPlainText(recording.summary_path.read_text(encoding="utf-8"))
|
self._text_edit.setPlainText(recording.summary_path.read_text(encoding="utf-8"))
|
||||||
else:
|
else:
|
||||||
self._text_edit.clear()
|
self._text_edit.clear()
|
||||||
|
|
||||||
|
def refresh_header(self) -> None:
|
||||||
|
"""Aktualisiert nur die Kopfzeile (z.B. nach einer Titeländerung in Tab 2),
|
||||||
|
ohne den Summary-Text anzurühren - vermeidet den Verlust unsaved Änderungen.
|
||||||
|
"""
|
||||||
|
if self._recording is None:
|
||||||
|
return
|
||||||
|
self._header_label.setText(
|
||||||
|
f"<b>Titel:</b> {self._recording.meta.title}<br>"
|
||||||
|
f"<b>Aufzeichnungszeitpunkt:</b> {format_timestamp(self._recording.meta.created_at)}<br>"
|
||||||
|
f"<b>Länge:</b> {format_duration(self._recording.meta.duration_seconds)}<br>"
|
||||||
|
f"<b>Sprecher:</b> {', '.join(self._recording.meta.speakers) or '-'}"
|
||||||
|
)
|
||||||
|
|
||||||
def _on_save_clicked(self) -> None:
|
def _on_save_clicked(self) -> None:
|
||||||
if self._recording is None:
|
if self._recording is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ class TranscriptTab(QWidget):
|
|||||||
# daraufhin erneut, ob Tab 3 aktiviert werden kann.
|
# daraufhin erneut, ob Tab 3 aktiviert werden kann.
|
||||||
transcription_finished = Signal()
|
transcription_finished = Signal()
|
||||||
|
|
||||||
|
# Titel/Zeitstempel/Sprecher wurden geändert - MainWindow aktualisiert daraufhin
|
||||||
|
# nur die (read-only) Kopfzeilen in Tab 3/4, ohne deren Textinhalte anzurühren.
|
||||||
|
metadata_changed = Signal()
|
||||||
|
|
||||||
def __init__(self, controller: AppController, parent: QWidget | None = None) -> None:
|
def __init__(self, controller: AppController, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._controller = controller
|
self._controller = controller
|
||||||
@@ -115,6 +119,7 @@ class TranscriptTab(QWidget):
|
|||||||
self._recording.meta.title = new_title
|
self._recording.meta.title = new_title
|
||||||
self._recording.save_meta()
|
self._recording.save_meta()
|
||||||
self._controller.recordings_changed.emit()
|
self._controller.recordings_changed.emit()
|
||||||
|
self.metadata_changed.emit()
|
||||||
|
|
||||||
def _on_timestamp_changed(self, new_iso: str) -> None:
|
def _on_timestamp_changed(self, new_iso: str) -> None:
|
||||||
if self._recording is None:
|
if self._recording is None:
|
||||||
@@ -122,6 +127,7 @@ class TranscriptTab(QWidget):
|
|||||||
self._recording.meta.created_at = new_iso
|
self._recording.meta.created_at = new_iso
|
||||||
self._recording.save_meta()
|
self._recording.save_meta()
|
||||||
self._controller.recordings_changed.emit()
|
self._controller.recordings_changed.emit()
|
||||||
|
self.metadata_changed.emit()
|
||||||
|
|
||||||
def _on_speakers_edited(self) -> None:
|
def _on_speakers_edited(self) -> None:
|
||||||
if self._recording is None:
|
if self._recording is None:
|
||||||
@@ -129,6 +135,7 @@ class TranscriptTab(QWidget):
|
|||||||
speakers = [s.strip() for s in self._speaker_field.text().split(",") if s.strip()]
|
speakers = [s.strip() for s in self._speaker_field.text().split(",") if s.strip()]
|
||||||
self._recording.meta.speakers = speakers
|
self._recording.meta.speakers = speakers
|
||||||
self._recording.save_meta()
|
self._recording.save_meta()
|
||||||
|
self.metadata_changed.emit()
|
||||||
|
|
||||||
# -- Transkription -------------------------------------------------------------
|
# -- Transkription -------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -8,22 +8,44 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from PySide6.QtGui import QAction, QIcon
|
from PySide6.QtCore import QRectF, Qt
|
||||||
from PySide6.QtWidgets import QApplication, QMenu, QStyle, QSystemTrayIcon
|
from PySide6.QtGui import QAction, QColor, QCursor, QIcon, QPainter, QPixmap
|
||||||
|
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
|
||||||
|
|
||||||
from notarius.gui.controller import AppController
|
from notarius.gui.controller import AppController
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Bewusst schlichte, farbige Punkte statt Systemthemen-Icons: ein Standard-Icon
|
||||||
|
# wie "Lautstärke" oder "Media-Stop" lässt sich im Tray leicht mit anderen
|
||||||
|
# Anwendungen verwechseln. Grün/Rot ist eindeutig und sofort erkennbar.
|
||||||
|
_IDLE_COLOR = QColor("#2ecc71")
|
||||||
|
_RECORDING_COLOR = QColor("#e74c3c")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_dot_icon(color: QColor, size: int = 64) -> QIcon:
|
||||||
|
"""Zeichnet einen einfarbigen, kräftigen Kreis als Tray-Icon."""
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
painter.setBrush(color)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
margin = size * 0.1
|
||||||
|
painter.drawEllipse(QRectF(margin, margin, size - 2 * margin, size - 2 * margin))
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
class TrayIcon(QSystemTrayIcon):
|
class TrayIcon(QSystemTrayIcon):
|
||||||
def __init__(self, controller: AppController, parent=None) -> None:
|
def __init__(self, controller: AppController, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._controller = controller
|
self._controller = controller
|
||||||
|
|
||||||
style = QApplication.instance().style()
|
self._icon_idle = _make_dot_icon(_IDLE_COLOR)
|
||||||
self._icon_idle = style.standardIcon(QStyle.StandardPixmap.SP_MediaVolume)
|
self._icon_recording = _make_dot_icon(_RECORDING_COLOR)
|
||||||
self._icon_recording = style.standardIcon(QStyle.StandardPixmap.SP_MediaStop)
|
|
||||||
self.setIcon(self._icon_idle)
|
self.setIcon(self._icon_idle)
|
||||||
|
|
||||||
self._menu = QMenu()
|
self._menu = QMenu()
|
||||||
@@ -54,11 +76,11 @@ class TrayIcon(QSystemTrayIcon):
|
|||||||
return self._open_action
|
return self._open_action
|
||||||
|
|
||||||
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
|
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
|
||||||
# Linksklick öffnet ebenfalls das Kontextmenü (Trigger = Standard-Klick auf das Icon).
|
# Trigger = Linksklick auf das Icon. self.geometry() liefert unter KDE/Plasma
|
||||||
|
# (StatusNotifierItem-Protokoll) meist ein leeres Rechteck - deshalb an der
|
||||||
|
# tatsächlichen Cursor-Position öffnen statt an einer geratenen Fallback-Position.
|
||||||
if reason == QSystemTrayIcon.ActivationReason.Trigger:
|
if reason == QSystemTrayIcon.ActivationReason.Trigger:
|
||||||
self.contextMenu().popup(
|
self.contextMenu().popup(QCursor.pos())
|
||||||
self.geometry().topLeft() if not self.geometry().isNull() else self.contextMenu().pos()
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_recording_state_changed(self, is_recording: bool) -> None:
|
def _on_recording_state_changed(self, is_recording: bool) -> None:
|
||||||
if is_recording:
|
if is_recording:
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QUrl, Qt
|
from PySide6.QtCore import QUrl, Qt
|
||||||
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
|
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
|
||||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSlider, QWidget
|
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSlider, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _format_ms(milliseconds: int) -> str:
|
def _format_ms(milliseconds: int) -> str:
|
||||||
@@ -24,6 +27,7 @@ class MiniAudioPlayer(QWidget):
|
|||||||
|
|
||||||
self._player = QMediaPlayer(self)
|
self._player = QMediaPlayer(self)
|
||||||
self._audio_output = QAudioOutput(self)
|
self._audio_output = QAudioOutput(self)
|
||||||
|
self._audio_output.setVolume(1.0)
|
||||||
self._player.setAudioOutput(self._audio_output)
|
self._player.setAudioOutput(self._audio_output)
|
||||||
|
|
||||||
self._play_button = QPushButton("▶")
|
self._play_button = QPushButton("▶")
|
||||||
@@ -35,17 +39,28 @@ class MiniAudioPlayer(QWidget):
|
|||||||
|
|
||||||
self._time_label = QLabel("00:00 / 00:00")
|
self._time_label = QLabel("00:00 / 00:00")
|
||||||
|
|
||||||
layout = QHBoxLayout(self)
|
controls_row = QHBoxLayout()
|
||||||
|
controls_row.setContentsMargins(0, 0, 0, 0)
|
||||||
|
controls_row.addWidget(self._play_button)
|
||||||
|
controls_row.addWidget(self._position_slider, stretch=1)
|
||||||
|
controls_row.addWidget(self._time_label)
|
||||||
|
|
||||||
|
self._error_label = QLabel()
|
||||||
|
self._error_label.setStyleSheet("color: #e74c3c;")
|
||||||
|
self._error_label.setVisible(False)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.addWidget(self._play_button)
|
layout.addLayout(controls_row)
|
||||||
layout.addWidget(self._position_slider, stretch=1)
|
layout.addWidget(self._error_label)
|
||||||
layout.addWidget(self._time_label)
|
|
||||||
|
|
||||||
self._player.positionChanged.connect(self._on_position_changed)
|
self._player.positionChanged.connect(self._on_position_changed)
|
||||||
self._player.durationChanged.connect(self._on_duration_changed)
|
self._player.durationChanged.connect(self._on_duration_changed)
|
||||||
self._player.playbackStateChanged.connect(self._on_playback_state_changed)
|
self._player.playbackStateChanged.connect(self._on_playback_state_changed)
|
||||||
|
self._player.errorOccurred.connect(self._on_error_occurred)
|
||||||
|
|
||||||
def load(self, audio_path: Path) -> None:
|
def load(self, audio_path: Path) -> None:
|
||||||
|
self._error_label.setVisible(False)
|
||||||
self._player.stop()
|
self._player.stop()
|
||||||
self._player.setSource(QUrl.fromLocalFile(str(audio_path)))
|
self._player.setSource(QUrl.fromLocalFile(str(audio_path)))
|
||||||
|
|
||||||
@@ -65,3 +80,10 @@ class MiniAudioPlayer(QWidget):
|
|||||||
def _on_duration_changed(self, duration_ms: int) -> None:
|
def _on_duration_changed(self, duration_ms: int) -> None:
|
||||||
self._position_slider.setRange(0, duration_ms)
|
self._position_slider.setRange(0, duration_ms)
|
||||||
self._time_label.setText(f"{_format_ms(self._player.position())} / {_format_ms(duration_ms)}")
|
self._time_label.setText(f"{_format_ms(self._player.position())} / {_format_ms(duration_ms)}")
|
||||||
|
|
||||||
|
def _on_error_occurred(self, error: QMediaPlayer.Error, error_string: str) -> None:
|
||||||
|
# Ohne diese Anzeige würde eine fehlgeschlagene Wiedergabe (z.B. fehlender
|
||||||
|
# Audio-Ausgang, kaputte Datei) nur als stille Untätigkeit wahrgenommen.
|
||||||
|
logger.error("Wiedergabefehler: %s", error_string)
|
||||||
|
self._error_label.setText(f"Wiedergabefehler: {error_string}")
|
||||||
|
self._error_label.setVisible(True)
|
||||||
|
|||||||
+22
-6
@@ -4,16 +4,28 @@ ob die Notarius-GUI läuft - gedacht zum Binden an einen KDE-Tastenkurzbefehl
|
|||||||
`call-recorder.sh toggle` aus reference/.
|
`call-recorder.sh toggle` aus reference/.
|
||||||
|
|
||||||
Importiert bewusst kein PySide6, damit der Aufruf ohne GUI-Overhead sofort reagiert.
|
Importiert bewusst kein PySide6, damit der Aufruf ohne GUI-Overhead sofort reagiert.
|
||||||
|
Zeigt Desktop-Benachrichtigungen (siehe desktop_notify.py), da ein per
|
||||||
|
Tastenkurzbefehl ausgelöster Aufruf kein sichtbares Terminal hat - print()
|
||||||
|
allein würde der Nutzer nie zu Gesicht bekommen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from notarius import recording_session
|
from notarius import recording_session
|
||||||
|
from notarius.desktop_notify import notify
|
||||||
from notarius.logging_setup import configure_logging
|
from notarius.logging_setup import configure_logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _report(message: str, urgency: str = "normal") -> None:
|
||||||
|
print(message)
|
||||||
|
notify("Notarius", message, urgency)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="Notarius-Aufnahme steuern (auch ohne laufende GUI)")
|
parser = argparse.ArgumentParser(description="Notarius-Aufnahme steuern (auch ohne laufende GUI)")
|
||||||
@@ -25,20 +37,24 @@ def main() -> None:
|
|||||||
try:
|
try:
|
||||||
if args.action == "start":
|
if args.action == "start":
|
||||||
recording = recording_session.start()
|
recording = recording_session.start()
|
||||||
print(f"Aufnahme gestartet: {recording.meta.title}")
|
_report(f"Aufnahme gestartet: {recording.meta.title}")
|
||||||
elif args.action == "stop":
|
elif args.action == "stop":
|
||||||
recording = recording_session.stop()
|
recording = recording_session.stop()
|
||||||
if recording is None:
|
if recording is None:
|
||||||
print("Keine laufende Aufnahme.")
|
_report("Keine laufende Aufnahme.")
|
||||||
else:
|
else:
|
||||||
print(f"Aufnahme gestoppt: {recording.meta.title}")
|
_report(f"Aufnahme gestoppt: {recording.meta.title}")
|
||||||
else:
|
else:
|
||||||
state, recording = recording_session.toggle()
|
state, recording = recording_session.toggle()
|
||||||
verb = "gestartet" if state == "started" else "gestoppt"
|
verb = "gestartet" if state == "started" else "gestoppt"
|
||||||
title = f": {recording.meta.title}" if recording else ""
|
title = f": {recording.meta.title}" if recording else ""
|
||||||
print(f"Aufnahme {verb}{title}")
|
_report(f"Aufnahme {verb}{title}")
|
||||||
except recording_session.RecordingSessionError as exc:
|
except Exception as exc: # noqa: BLE001 - jeder Fehler muss dem Nutzer sichtbar gemacht werden
|
||||||
print(f"Fehler: {exc}", file=sys.stderr)
|
# Ohne diesen breiten Fang würde z.B. ein RecorderError (kein Standard-Sink,
|
||||||
|
# ffmpeg nicht startbar) unbehandelt durchschlagen - ohne Terminal verschwindet
|
||||||
|
# der Traceback dann spurlos, der Nutzer merkt nur "es passiert nichts".
|
||||||
|
logger.exception("notarius-rec fehlgeschlagen")
|
||||||
|
_report(f"Fehler: {exc}", urgency="critical")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
Du erstellst aus einem Gesprächstranskript ein strukturiertes Gesprächsprotokoll.
|
Du erstellst aus einem Gesprächstranskript ein strukturiertes Gesprächsprotokoll.
|
||||||
|
|
||||||
Du bekommst zwei Texte:
|
Du bekommst bis zu drei Texte:
|
||||||
1. Das Transkript des Gesprächs (roh, ggf. mit Sprecherlabeln wie SPEAKER_00/SPEAKER_01
|
1. Optional eine Liste "Bekannte Teilnehmer": Namen, die für dieses Gespräch
|
||||||
|
hinterlegt sind, aber NICHT fest den SPEAKER_00/SPEAKER_01-Labeln im Transkript
|
||||||
|
zugeordnet sind. Eine manuelle Zuordnungsfunktion gibt es im Programm noch nicht -
|
||||||
|
bis dahin ist das deine Aufgabe: Versuche anhand des Gesprächsinhalts (Anrede,
|
||||||
|
Selbstvorstellung, wer wen wie nennt, inhaltlicher Kontext) herauszufinden, welches
|
||||||
|
SPEAKER-Label zu welchem Namen gehört, und schreibe im Protokoll durchgängig die
|
||||||
|
echten Namen statt der SPEAKER-Labels. Ist eine eindeutige Zuordnung nicht möglich,
|
||||||
|
rate NICHT - nenne die Teilnehmer trotzdem namentlich im Feld "Teilnehmer" (ohne
|
||||||
|
feste Zuordnung zu einer Aussage) und arbeite im Fließtext weiterhin mit den
|
||||||
|
SPEAKER-Labeln.
|
||||||
|
2. Das Transkript des Gesprächs (roh, ggf. mit Sprecherlabeln wie SPEAKER_00/SPEAKER_01
|
||||||
und Zeitstempeln).
|
und Zeitstempeln).
|
||||||
2. Zusätzliche Hinweise des Nutzers ("Instructions and Notes"): Zuordnung der
|
3. Zusätzliche Hinweise des Nutzers ("Instructions and Notes"): Zuordnung der
|
||||||
Sprecherlabel zu echten Namen, Projektkontext, Kanal, Teilnehmerliste und
|
Sprecherlabel zu echten Namen, Projektkontext, Kanal, Teilnehmerliste und
|
||||||
sonstige Metadaten, die nicht aus dem Transkript allein hervorgehen.
|
sonstige Metadaten, die nicht aus dem Transkript allein hervorgehen. Falls hier
|
||||||
|
eine Zuordnung explizit genannt wird, hat sie Vorrang vor deiner eigenen Vermutung.
|
||||||
|
|
||||||
Dies ist ein einmaliger, nicht-interaktiver Verarbeitungsschritt - du kannst nicht
|
Dies ist ein einmaliger, nicht-interaktiver Verarbeitungsschritt - du kannst nicht
|
||||||
nachfragen. Wenn eine Pflichtangabe (Datum, Kanal, Teilnehmer) weder im Transkript
|
nachfragen. Wenn eine Pflichtangabe (Datum, Kanal, Teilnehmer) weder im Transkript
|
||||||
@@ -67,3 +78,5 @@ Verarbeitungshinweise:
|
|||||||
konsequent einhalten. Wenn keine Person zuordenbar → `[ ] Aufgabe — unklar, klären`.
|
konsequent einhalten. Wenn keine Person zuordenbar → `[ ] Aufgabe — unklar, klären`.
|
||||||
4. Block "Nebeninformationen": Nur wirklich interessante Nebenfakten, keine Füller.
|
4. Block "Nebeninformationen": Nur wirklich interessante Nebenfakten, keine Füller.
|
||||||
Lieber leer lassen als auffüllen.
|
Lieber leer lassen als auffüllen.
|
||||||
|
5. Sprecherzuordnung: Siehe Punkt 1 oben - echte Namen verwenden, wo eindeutig
|
||||||
|
zuordenbar; sonst Teilnehmer trotzdem namentlich auflisten, ohne zu raten.
|
||||||
|
|||||||
@@ -37,16 +37,19 @@ def stream_summary(
|
|||||||
model: str,
|
model: str,
|
||||||
transcript_markdown: str,
|
transcript_markdown: str,
|
||||||
instructions_and_notes: str,
|
instructions_and_notes: str,
|
||||||
|
speakers: list[str],
|
||||||
cancel_event: threading.Event,
|
cancel_event: threading.Event,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Ruft die Anthropic-API streamend auf und baut den Volltext der Antwort zusammen.
|
"""Ruft die Anthropic-API streamend auf und baut den Volltext der Antwort zusammen.
|
||||||
|
|
||||||
|
`speakers` sind die in Tab 2 hinterlegten Namen (noch ohne feste Zuordnung zu
|
||||||
|
den SPEAKER_00/SPEAKER_01-Labeln) - das Modell versucht, sie selbst zuzuordnen.
|
||||||
`cancel_event` wird nach jedem empfangenen Chunk geprüft, damit der
|
`cancel_event` wird nach jedem empfangenen Chunk geprüft, damit der
|
||||||
Stop-Button in Tab 3 die laufende Verarbeitung zeitnah abbrechen kann.
|
Stop-Button in Tab 3 die laufende Verarbeitung zeitnah abbrechen kann.
|
||||||
"""
|
"""
|
||||||
client = anthropic.Anthropic(api_key=api_key)
|
client = anthropic.Anthropic(api_key=api_key)
|
||||||
system_prompt = load_summary_system_prompt()
|
system_prompt = load_summary_system_prompt()
|
||||||
user_message = build_user_message(transcript_markdown, instructions_and_notes)
|
user_message = build_user_message(transcript_markdown, instructions_and_notes, speakers)
|
||||||
|
|
||||||
chunks: list[str] = []
|
chunks: list[str] = []
|
||||||
with client.messages.stream(
|
with client.messages.stream(
|
||||||
|
|||||||
@@ -14,13 +14,33 @@ def load_summary_system_prompt() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_user_message(transcript_markdown: str, instructions_and_notes: str) -> str:
|
def build_user_message(
|
||||||
"""Kombiniert Transkript-Inhalt und Nutzer-Notizen zu einer einzigen User-Message.
|
transcript_markdown: str, instructions_and_notes: str, speakers: list[str]
|
||||||
|
) -> str:
|
||||||
|
"""Kombiniert Transkript-Inhalt, bekannte Sprecher und Nutzer-Notizen zu einer
|
||||||
|
einzigen User-Message.
|
||||||
|
|
||||||
|
Die Sprecherliste (aus Tab 2) enthält noch keine feste Zuordnung zu den
|
||||||
|
SPEAKER_00/SPEAKER_01-Labeln im Transkript - das Programm bietet dafür aktuell
|
||||||
|
keine manuelle Zuordnung an (kommt später). Bis dahin soll das Modell selbst
|
||||||
|
versuchen, die Labels anhand des Gesprächsinhalts den Namen zuzuordnen.
|
||||||
|
|
||||||
Es wird ausschließlich Text übermittelt - keine Audiodaten verlassen den Rechner,
|
Es wird ausschließlich Text übermittelt - keine Audiodaten verlassen den Rechner,
|
||||||
die Transkription läuft ausschließlich lokal (Tab 2 / whisperx).
|
die Transkription läuft ausschließlich lokal (Tab 2 / whisperx).
|
||||||
"""
|
"""
|
||||||
parts = ["## Transkript", "", transcript_markdown.strip()]
|
parts = []
|
||||||
|
|
||||||
|
if speakers:
|
||||||
|
parts += [
|
||||||
|
"## Bekannte Teilnehmer",
|
||||||
|
"",
|
||||||
|
"Diese Namen sind für dieses Gespräch hinterlegt, aber NICHT fest den "
|
||||||
|
"SPEAKER_00/SPEAKER_01-Labeln im Transkript zugeordnet:",
|
||||||
|
", ".join(speakers),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
parts += ["## Transkript", "", transcript_markdown.strip()]
|
||||||
|
|
||||||
notes = instructions_and_notes.strip()
|
notes = instructions_and_notes.strip()
|
||||||
if notes:
|
if notes:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class _SummarizeThread(QThread):
|
|||||||
model: str,
|
model: str,
|
||||||
transcript_markdown: str,
|
transcript_markdown: str,
|
||||||
instructions_and_notes: str,
|
instructions_and_notes: str,
|
||||||
|
speakers: list[str],
|
||||||
cancel_event: threading.Event,
|
cancel_event: threading.Event,
|
||||||
parent=None,
|
parent=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -36,6 +37,7 @@ class _SummarizeThread(QThread):
|
|||||||
self._model = model
|
self._model = model
|
||||||
self._transcript_markdown = transcript_markdown
|
self._transcript_markdown = transcript_markdown
|
||||||
self._instructions_and_notes = instructions_and_notes
|
self._instructions_and_notes = instructions_and_notes
|
||||||
|
self._speakers = speakers
|
||||||
self._cancel_event = cancel_event
|
self._cancel_event = cancel_event
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
@@ -45,6 +47,7 @@ class _SummarizeThread(QThread):
|
|||||||
self._model,
|
self._model,
|
||||||
self._transcript_markdown,
|
self._transcript_markdown,
|
||||||
self._instructions_and_notes,
|
self._instructions_and_notes,
|
||||||
|
self._speakers,
|
||||||
self._cancel_event,
|
self._cancel_event,
|
||||||
)
|
)
|
||||||
except SummarizationCancelled:
|
except SummarizationCancelled:
|
||||||
@@ -73,6 +76,7 @@ class SummarizationWorker:
|
|||||||
model: str,
|
model: str,
|
||||||
transcript_markdown: str,
|
transcript_markdown: str,
|
||||||
instructions_and_notes: str,
|
instructions_and_notes: str,
|
||||||
|
speakers: list[str],
|
||||||
on_finished_ok,
|
on_finished_ok,
|
||||||
on_finished_error,
|
on_finished_error,
|
||||||
on_finished_cancelled,
|
on_finished_cancelled,
|
||||||
@@ -82,7 +86,7 @@ class SummarizationWorker:
|
|||||||
|
|
||||||
self._cancel_event = threading.Event()
|
self._cancel_event = threading.Event()
|
||||||
thread = _SummarizeThread(
|
thread = _SummarizeThread(
|
||||||
api_key, model, transcript_markdown, instructions_and_notes, self._cancel_event
|
api_key, model, transcript_markdown, instructions_and_notes, speakers, self._cancel_event
|
||||||
)
|
)
|
||||||
thread.finished_ok.connect(on_finished_ok)
|
thread.finished_ok.connect(on_finished_ok)
|
||||||
thread.finished_error.connect(on_finished_error)
|
thread.finished_error.connect(on_finished_error)
|
||||||
|
|||||||
Reference in New Issue
Block a user