2149 lines
96 KiB
Python
2149 lines
96 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
translate: Diktat mit Übersetzung und Text-to-Speech
|
||
|
||
Funktionen:
|
||
- Diktieren in gewählter Eingabesprache (Standard: Deutsch)
|
||
- Übersetzung in Ausgabesprache (zwei Texte nebeneinander)
|
||
- Umkehr-Button: Eingabe- und Ausgabesprache tauschen
|
||
- Sound-Button: Übersetzung laut vorlesen (Text-to-Speech)
|
||
- Neu-Button: Beide Textbereiche leeren
|
||
|
||
Voraussetzungen:
|
||
py -3.11 -m pip install openai python-dotenv sounddevice
|
||
Für Vorlesen direkt in der App (kein externes Programm): py -3.11 -m pip install pygame
|
||
|
||
Start:
|
||
py -3.11 translate.py
|
||
"""
|
||
|
||
import html as html_module
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import textwrap
|
||
import tempfile
|
||
import threading
|
||
import wave
|
||
import tkinter as tk
|
||
import tkinter.font as tkfont
|
||
from tkinter import ttk, messagebox, simpledialog, filedialog
|
||
from tkinter.scrolledtext import ScrolledText
|
||
from datetime import datetime
|
||
|
||
from dotenv import load_dotenv
|
||
from openai import OpenAI
|
||
|
||
|
||
def _clipboard_set_persistent(text: str) -> bool:
|
||
"""Text in Zwischenablage schreiben, bleibt nach Schließen der App erhalten (Windows)."""
|
||
if sys.platform != "win32":
|
||
try:
|
||
root = tk._default_root
|
||
if root:
|
||
root.clipboard_clear()
|
||
root.clipboard_append(text)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
try:
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
CF_UNICODETEXT = 13
|
||
GMEM_DDESHARE = 0x2000
|
||
kernel32 = ctypes.WinDLL("kernel32")
|
||
user32 = ctypes.WinDLL("user32")
|
||
user32.OpenClipboard.argtypes = [wintypes.HWND]
|
||
user32.OpenClipboard.restype = wintypes.BOOL
|
||
user32.EmptyClipboard.argtypes = []
|
||
user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE]
|
||
user32.SetClipboardData.restype = wintypes.HANDLE
|
||
kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t]
|
||
kernel32.GlobalAlloc.restype = wintypes.HGLOBAL
|
||
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
|
||
kernel32.GlobalLock.restype = ctypes.c_void_p
|
||
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
|
||
for _ in range(5):
|
||
if user32.OpenClipboard(None):
|
||
break
|
||
import time
|
||
time.sleep(0.03)
|
||
else:
|
||
return False
|
||
try:
|
||
user32.EmptyClipboard()
|
||
data = (text + "\0").encode("utf-16-le")
|
||
h = kernel32.GlobalAlloc(GMEM_DDESHARE, len(data))
|
||
if not h:
|
||
return False
|
||
ptr = kernel32.GlobalLock(h)
|
||
if ptr:
|
||
ctypes.memmove(ptr, data, len(data))
|
||
kernel32.GlobalUnlock(h)
|
||
user32.SetClipboardData(CF_UNICODETEXT, h)
|
||
return True
|
||
finally:
|
||
user32.CloseClipboard()
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
# ========== Textfeld-Schriftgröße mit ▲▼-Pfeilen ==========
|
||
_FONT_SIZE_SETTINGS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "translate_font_sizes.json")
|
||
|
||
def _load_font_sizes():
|
||
try:
|
||
if os.path.isfile(_FONT_SIZE_SETTINGS_FILE):
|
||
with open(_FONT_SIZE_SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
def _save_font_size(key, size):
|
||
data = _load_font_sizes()
|
||
data[key] = size
|
||
try:
|
||
with open(_FONT_SIZE_SETTINGS_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2)
|
||
except Exception:
|
||
pass
|
||
|
||
def add_text_font_size_control(parent_frame, text_widget, initial_size=10, label="Aa", bg_color="#F5FCFF", save_key=None):
|
||
"""▲▼-Pfeile für Textfeld-Schriftgröße (5–20pt), unauffällig im Hintergrund."""
|
||
if save_key:
|
||
saved = _load_font_sizes().get(save_key)
|
||
if saved is not None:
|
||
initial_size = int(saved)
|
||
_size = [max(5, min(20, initial_size))]
|
||
_fg = "#8AAFC0"
|
||
_fg_hover = "#1a4d6d"
|
||
cf = tk.Frame(parent_frame, bg=bg_color, highlightthickness=0, bd=0)
|
||
cf.pack(side="right", padx=4)
|
||
tk.Label(cf, text=label, font=("Segoe UI", 8), bg=bg_color, fg=_fg).pack(side="left", padx=(0, 1))
|
||
size_lbl = tk.Label(cf, text=str(_size[0]), font=("Segoe UI", 8), bg=bg_color, fg=_fg, width=2, anchor="center")
|
||
size_lbl.pack(side="left")
|
||
def _apply(ns):
|
||
ns = max(5, min(20, ns))
|
||
_size[0] = ns
|
||
size_lbl.configure(text=str(ns))
|
||
text_widget.configure(font=("Segoe UI", ns))
|
||
if save_key:
|
||
_save_font_size(save_key, ns)
|
||
text_widget.configure(font=("Segoe UI", _size[0]))
|
||
btn_up = tk.Label(cf, text="\u25B2", font=("Segoe UI", 7), bg=bg_color, fg=_fg, cursor="hand2", bd=0, highlightthickness=0)
|
||
btn_up.pack(side="left", padx=(2, 0))
|
||
btn_down = tk.Label(cf, text="\u25BC", font=("Segoe UI", 7), bg=bg_color, fg=_fg, cursor="hand2", bd=0, highlightthickness=0)
|
||
btn_down.pack(side="left")
|
||
btn_up.bind("<Button-1>", lambda e: _apply(_size[0] + 1))
|
||
btn_down.bind("<Button-1>", lambda e: _apply(_size[0] - 1))
|
||
for w in (btn_up, btn_down):
|
||
w.bind("<Enter>", lambda e, ww=w: ww.configure(fg=_fg_hover))
|
||
w.bind("<Leave>", lambda e, ww=w: ww.configure(fg=_fg))
|
||
return _size
|
||
# ==========================================================
|
||
|
||
|
||
# -----------------------------
|
||
# Audio Recorder
|
||
# -----------------------------
|
||
class AudioRecorder:
|
||
"""Recorder mit sounddevice. Speichert als 16kHz mono WAV."""
|
||
def __init__(self, samplerate=16000, channels=1):
|
||
self.samplerate = samplerate
|
||
self.channels = channels
|
||
self._stream = None
|
||
self._frames = []
|
||
self._recording = False
|
||
|
||
def start(self):
|
||
try:
|
||
import sounddevice as sd
|
||
except ImportError as e:
|
||
raise RuntimeError(
|
||
"Paket 'sounddevice' fehlt. Installieren: py -3.11 -m pip install sounddevice"
|
||
)
|
||
self._frames = []
|
||
self._recording = True
|
||
def callback(indata, frames, time_info, status):
|
||
if self._recording:
|
||
self._frames.append(indata.copy())
|
||
self._stream = sd.InputStream(
|
||
samplerate=self.samplerate, channels=self.channels,
|
||
callback=callback, dtype="float32", blocksize=0,
|
||
)
|
||
self._stream.start()
|
||
|
||
def get_recent_audio_seconds(self, n: float):
|
||
"""Gibt die letzten n Sekunden Audio als float32-Numpy-Array zurück (für Stille-Erkennung)."""
|
||
if not self._frames:
|
||
return None
|
||
import numpy as np
|
||
audio = np.concatenate(self._frames, axis=0)
|
||
samples_needed = int(n * self.samplerate)
|
||
if len(audio) < samples_needed:
|
||
return None
|
||
return audio[-samples_needed:]
|
||
|
||
def stop_and_save_wav(self) -> str:
|
||
if not self._stream:
|
||
raise RuntimeError("Recorder nicht gestartet.")
|
||
self._recording = False
|
||
self._stream.stop()
|
||
self._stream.close()
|
||
self._stream = None
|
||
if not self._frames:
|
||
raise RuntimeError("Keine Audio-Daten.")
|
||
import numpy as np
|
||
audio = np.concatenate(self._frames, axis=0)
|
||
audio = np.clip(audio, -1.0, 1.0)
|
||
pcm16 = (audio * 32767.0).astype(np.int16)
|
||
fd, path = tempfile.mkstemp(suffix=".wav", prefix="LindengutAG_")
|
||
os.close(fd)
|
||
with wave.open(path, "wb") as wf:
|
||
wf.setnchannels(self.channels)
|
||
wf.setsampwidth(2)
|
||
wf.setframerate(self.samplerate)
|
||
wf.writeframes(pcm16.tobytes())
|
||
return path
|
||
|
||
|
||
# -----------------------------
|
||
# Sprachen
|
||
# -----------------------------
|
||
LANGUAGES = {
|
||
"de": "Deutsch",
|
||
"en": "Englisch",
|
||
"fr": "Französisch",
|
||
"es": "Spanisch",
|
||
"it": "Italienisch",
|
||
"pt": "Portugiesisch",
|
||
"nl": "Niederländisch",
|
||
"pl": "Polnisch",
|
||
"ru": "Russisch",
|
||
"ja": "Japanisch",
|
||
"zh": "Chinesisch",
|
||
"ar": "Arabisch",
|
||
"tr": "Türkisch",
|
||
"el": "Griechisch",
|
||
"sv": "Schwedisch",
|
||
"sk": "Slowakisch",
|
||
"uk": "Ukrainisch",
|
||
"sq": "Albanisch",
|
||
}
|
||
# Sortiert nach Sprachname (A–Z)
|
||
LANGUAGES_SORTED = sorted(LANGUAGES.items(), key=lambda x: x[1])
|
||
|
||
# Konfigurationsdatei für letzte Einstellungen
|
||
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "translate_config.json")
|
||
VORLAGEN_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gespraech_vorlagen.json")
|
||
|
||
|
||
def load_text_font_size(key: str, default: int = 10) -> int:
|
||
"""Lädt gespeicherte Schriftgröße aus translate_config.json."""
|
||
try:
|
||
if os.path.exists(CONFIG_PATH):
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
font_sizes = data.get("font_sizes", {})
|
||
return int(font_sizes.get(key, default))
|
||
except Exception:
|
||
pass
|
||
return default
|
||
|
||
def save_text_font_size(key: str, size: int):
|
||
"""Speichert Schriftgröße in translate_config.json."""
|
||
try:
|
||
data = {}
|
||
if os.path.exists(CONFIG_PATH):
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
if "font_sizes" not in data:
|
||
data["font_sizes"] = {}
|
||
data["font_sizes"][key] = int(size)
|
||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def load_gesp_config():
|
||
"""Lädt letzte Sprache, Stille-Sekunden, Sprechgeschwindigkeit und Fenster-Geometrie aus Config."""
|
||
try:
|
||
if os.path.exists(CONFIG_PATH):
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
lang = data.get("gesp_lang", "de")
|
||
silence = max(15, data.get("silence_sec", 20))
|
||
speed = data.get("tts_speed", 1.0)
|
||
geom = data.get("gesp_geometry", "")
|
||
return lang, silence, speed, geom if geom and "x" in geom else ""
|
||
except Exception:
|
||
pass
|
||
return "de", 20, 1.0, ""
|
||
|
||
|
||
def save_gesp_config(gesp_lang: str, silence_sec: int, tts_speed: float = 1.0, gesp_geometry: str = None):
|
||
"""Speichert Sprache, Stille-Sekunden, Sprechgeschwindigkeit und optional Fenster-Geometrie."""
|
||
try:
|
||
data = {}
|
||
if os.path.exists(CONFIG_PATH):
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
data["gesp_lang"] = gesp_lang
|
||
data["silence_sec"] = int(max(15, min(30, silence_sec)))
|
||
data["tts_speed"] = float(tts_speed)
|
||
if gesp_geometry is not None:
|
||
data["gesp_geometry"] = gesp_geometry
|
||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _clamp_geometry(geom: str, min_w: int, min_h: int) -> str:
|
||
"""Begrenzt gespeicherte Geometrie auf Mindestgröße (alle Buttons sichtbar)."""
|
||
if not geom or "x" not in geom:
|
||
return f"{min_w}x{min_h}"
|
||
parts = geom.replace("+", "x").split("x")
|
||
try:
|
||
w = max(min_w, int(parts[0].strip()))
|
||
h = max(min_h, int(parts[1].strip()))
|
||
if len(parts) >= 4:
|
||
return f"{w}x{h}+{parts[2].strip()}+{parts[3].strip()}"
|
||
return f"{w}x{h}"
|
||
except (ValueError, IndexError):
|
||
return f"{min_w}x{min_h}"
|
||
|
||
|
||
def load_main_geometry():
|
||
"""Lädt gespeicherte Hauptfenster-Geometrie (Größe + Position), mindestens Mindestgröße."""
|
||
MAIN_MIN_W, MAIN_MIN_H = 600, 450
|
||
try:
|
||
if os.path.exists(CONFIG_PATH):
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
g = json.load(f).get("main_geometry", "")
|
||
if g and "x" in g:
|
||
return _clamp_geometry(g, MAIN_MIN_W, MAIN_MIN_H)
|
||
except Exception:
|
||
pass
|
||
return f"{MAIN_MIN_W}x{MAIN_MIN_H}"
|
||
|
||
|
||
def save_main_geometry(geom: str):
|
||
try:
|
||
data = {}
|
||
if os.path.exists(CONFIG_PATH):
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
data["main_geometry"] = geom
|
||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def load_main_languages():
|
||
"""Lädt gespeicherte Eingabe- und Ausgabesprache aus Config (beim Start)."""
|
||
try:
|
||
if os.path.exists(CONFIG_PATH):
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
lang_in = (data.get("lang_in") or "de").strip()
|
||
lang_out = (data.get("lang_out") or "en").strip()
|
||
if lang_in in LANGUAGES and lang_out in LANGUAGES:
|
||
return lang_in, lang_out
|
||
except Exception:
|
||
pass
|
||
return "de", "en"
|
||
|
||
|
||
def load_lang_out_usage():
|
||
"""Lädt die Nutzungshäufigkeit der Ausgabesprachen (für Sortierung: häufigste zuoberst)."""
|
||
try:
|
||
if os.path.exists(CONFIG_PATH):
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
return data.get("lang_out_usage") or {}
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
|
||
def save_lang_out_usage(usage: dict):
|
||
"""Speichert die Nutzungshäufigkeit der Ausgabesprachen."""
|
||
try:
|
||
data = {}
|
||
if os.path.exists(CONFIG_PATH):
|
||
try:
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except Exception:
|
||
pass
|
||
data["lang_out_usage"] = {k: int(v) for k, v in usage.items() if k in LANGUAGES and isinstance(v, (int, float))}
|
||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
f.flush()
|
||
if hasattr(f, "fileno"):
|
||
try:
|
||
os.fsync(f.fileno())
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def increment_lang_out_usage(lang_out: str):
|
||
"""Erhöht die Nutzungszahl der gewählten Ausgabesprache um 1."""
|
||
if lang_out not in LANGUAGES:
|
||
return
|
||
usage = load_lang_out_usage()
|
||
usage[lang_out] = usage.get(lang_out, 0) + 1
|
||
save_lang_out_usage(usage)
|
||
|
||
|
||
def get_output_languages_ordered():
|
||
"""Reihenfolge für Ausgabesprache: zuerst die drei vom Benutzer am häufigsten genutzten, danach alphabetisch."""
|
||
usage = load_lang_out_usage()
|
||
# Sortieren: absteigend nach Nutzung, bei gleicher Nutzung alphabetisch nach Sprachname
|
||
return sorted(
|
||
LANGUAGES.items(),
|
||
key=lambda x: (-usage.get(x[0], 0), x[1]),
|
||
)
|
||
|
||
|
||
def save_main_languages(lang_in: str, lang_out: str):
|
||
"""Speichert Eingabe- und Ausgabesprache in Config und zählt Ausgabesprache für „häufigste“ mit."""
|
||
try:
|
||
data = {}
|
||
if os.path.exists(CONFIG_PATH):
|
||
try:
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except Exception:
|
||
pass
|
||
data["lang_in"] = lang_in if lang_in in LANGUAGES else "de"
|
||
data["lang_out"] = lang_out if lang_out in LANGUAGES else "en"
|
||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
f.flush()
|
||
if hasattr(f, "fileno"):
|
||
try:
|
||
os.fsync(f.fileno())
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def main():
|
||
load_dotenv()
|
||
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||
if not api_key:
|
||
messagebox.showerror("Fehler", "OPENAI_API_KEY fehlt. Lege eine .env Datei an.")
|
||
return
|
||
|
||
client = OpenAI(api_key=api_key)
|
||
|
||
try:
|
||
def_font = tkfont.nametofont("TkDefaultFont")
|
||
text_font = (def_font.actual()["family"], def_font.actual()["size"])
|
||
except Exception:
|
||
text_font = ("Segoe UI", 10)
|
||
|
||
MAIN_MIN_W, MAIN_MIN_H = 600, 450
|
||
root = tk.Tk()
|
||
root.title("AZA von Arzt zu Arzt")
|
||
root.minsize(MAIN_MIN_W, MAIN_MIN_H)
|
||
root.geometry(load_main_geometry())
|
||
root.configure(bg="#C5E8F2")
|
||
|
||
# Style für hellblauen Hintergrund
|
||
style = ttk.Style()
|
||
style.configure("TFrame", background="#C5E8F2")
|
||
style.configure("TLabel", background="#C5E8F2")
|
||
style.configure("TButton", background="#7EC8E3")
|
||
|
||
# PanedWindow-Sash (Trennbalken) unsichtbar machen
|
||
style.configure("TPanedwindow", background="#C5E8F2")
|
||
style.map("TPanedwindow", background=[("active", "#C5E8F2"), ("!active", "#C5E8F2")])
|
||
|
||
def on_main_close():
|
||
try:
|
||
save_main_geometry(root.geometry())
|
||
in_val = (lang_in_var.get() or "").strip()
|
||
out_val = (lang_out_var.get() or "").strip()
|
||
lin = in_val.split(" – ")[0].strip() if " – " in in_val else ("de" if in_val not in LANGUAGES else in_val)
|
||
lou = out_val.split(" – ")[0].strip() if " – " in out_val else ("en" if out_val not in LANGUAGES else out_val)
|
||
if lin not in LANGUAGES:
|
||
lin = "de"
|
||
if lou not in LANGUAGES:
|
||
lou = "en"
|
||
save_main_languages(lin, lou)
|
||
except Exception:
|
||
pass
|
||
root.destroy()
|
||
|
||
root.protocol("WM_DELETE_WINDOW", on_main_close)
|
||
|
||
recorder = AudioRecorder()
|
||
is_recording = [False]
|
||
diktat_recorder = [None]
|
||
|
||
# Sprachauswahl
|
||
lang_in_saved, lang_out_saved = load_main_languages()
|
||
lang_container = ttk.Frame(root, padding=12)
|
||
lang_container.pack(fill="x")
|
||
LABEL_WIDTH = 30
|
||
ttk.Label(lang_container, text="Eingabesprache (diktiert):", width=LABEL_WIDTH, anchor="w").grid(row=0, column=0, padx=(0, 8), sticky="w")
|
||
lang_in_var = tk.StringVar(value=lang_in_saved)
|
||
combo_in = ttk.Combobox(
|
||
lang_container, textvariable=lang_in_var,
|
||
values=[f"{k} – {v}" for k, v in LANGUAGES_SORTED],
|
||
state="readonly", width=28,
|
||
)
|
||
combo_in.grid(row=0, column=1, sticky="w", padx=(0, 24))
|
||
combo_in.set(f"{lang_in_saved} – {LANGUAGES.get(lang_in_saved, lang_in_saved)}")
|
||
|
||
ttk.Label(lang_container, text="Ausgabesprache (Übersetzung):", width=LABEL_WIDTH, anchor="w").grid(row=1, column=0, padx=(0, 8), pady=(4, 0), sticky="w")
|
||
lang_out_var = tk.StringVar(value=lang_out_saved)
|
||
output_ordered = get_output_languages_ordered()
|
||
combo_out = ttk.Combobox(
|
||
lang_container, textvariable=lang_out_var,
|
||
values=[f"{k} – {v}" for k, v in output_ordered],
|
||
state="readonly", width=28,
|
||
)
|
||
combo_out.grid(row=1, column=1, sticky="w", padx=(0, 12), pady=(4, 0))
|
||
combo_out.set(f"{lang_out_saved} – {LANGUAGES.get(lang_out_saved, lang_out_saved)}")
|
||
|
||
def save_main_languages_now():
|
||
try:
|
||
in_val = (lang_in_var.get() or "").strip()
|
||
out_val = (lang_out_var.get() or "").strip()
|
||
lin = in_val.split(" – ")[0].strip() if " – " in in_val else ("de" if in_val not in LANGUAGES else in_val)
|
||
lou = out_val.split(" – ")[0].strip() if " – " in out_val else ("en" if out_val not in LANGUAGES else out_val)
|
||
if lin not in LANGUAGES:
|
||
lin = "de"
|
||
if lou not in LANGUAGES:
|
||
lou = "en"
|
||
save_main_languages(lin, lou)
|
||
except Exception:
|
||
pass
|
||
|
||
lang_in_var.trace_add("write", lambda *a: save_main_languages_now())
|
||
lang_out_var.trace_add("write", lambda *a: save_main_languages_now())
|
||
|
||
def _on_lang_selected(event):
|
||
"""Bei Auswahl in der Combobox: Wert aus dem Widget übernehmen und sofort speichern (readonly-Combobox setzt StringVar teils nicht)."""
|
||
w = event.widget
|
||
try:
|
||
val = w.get().strip()
|
||
if val:
|
||
if w == combo_in:
|
||
lang_in_var.set(val)
|
||
else:
|
||
lang_out_var.set(val)
|
||
save_main_languages_now()
|
||
except Exception:
|
||
pass
|
||
|
||
combo_in.bind("<<ComboboxSelected>>", _on_lang_selected)
|
||
combo_out.bind("<<ComboboxSelected>>", _on_lang_selected)
|
||
|
||
def get_lang_codes():
|
||
in_val = lang_in_var.get()
|
||
out_val = lang_out_var.get()
|
||
lang_in = in_val.split(" – ")[0] if " – " in in_val else "de"
|
||
lang_out = out_val.split(" – ")[0] if " – " in out_val else "en"
|
||
return lang_in, lang_out
|
||
|
||
def swap_languages():
|
||
in_val = lang_in_var.get()
|
||
out_val = lang_out_var.get()
|
||
lang_in_var.set(out_val)
|
||
lang_out_var.set(in_val)
|
||
|
||
umkehr_row = ttk.Frame(root, padding=(12, 4, 12, 4))
|
||
umkehr_row.pack(fill="x")
|
||
btn_swap = ttk.Button(umkehr_row, text="⇄ Umkehren", command=swap_languages)
|
||
btn_swap.pack(anchor="center")
|
||
|
||
# Zwei Textbereiche
|
||
paned = ttk.PanedWindow(root, orient="horizontal")
|
||
paned.pack(fill="both", expand=True, padx=12, pady=(0, 12))
|
||
|
||
left_f = ttk.Frame(paned, padding=8)
|
||
right_f = ttk.Frame(paned, padding=8)
|
||
paned.add(left_f, weight=1)
|
||
paned.add(right_f, weight=1)
|
||
|
||
# Linkes Textfeld mit Schriftgrößen-Spinbox
|
||
left_header = ttk.Frame(left_f)
|
||
left_header.pack(fill="x", anchor="w")
|
||
ttk.Label(left_header, text="Diktiert / Original:").pack(side="left")
|
||
|
||
txt_left = tk.Text(left_f, wrap="word", font=text_font, bg="#E1EDF5", height=8)
|
||
txt_left.pack(fill="both", expand=True, pady=(4, 0))
|
||
|
||
add_text_font_size_control(left_header, txt_left, initial_size=10, bg_color="#E1EDF5", save_key="translate_left")
|
||
|
||
copy_left_f = ttk.Frame(left_f)
|
||
copy_left_f.pack(fill="x", pady=(4, 0))
|
||
ttk.Button(copy_left_f, text="Kopiere Original", command=lambda: _clipboard_set_persistent(txt_left.get("1.0", "end").strip()) or None).pack(anchor="center")
|
||
|
||
# Rechtes Textfeld mit Schriftgrößen-Spinbox
|
||
right_header = ttk.Frame(right_f)
|
||
right_header.pack(fill="x", anchor="w")
|
||
ttk.Label(right_header, text="Übersetzung:").pack(side="left")
|
||
|
||
txt_right = tk.Text(right_f, wrap="word", font=text_font, bg="#EBF3FA", height=8)
|
||
txt_right.pack(fill="both", expand=True, pady=(4, 0))
|
||
|
||
add_text_font_size_control(right_header, txt_right, initial_size=10, bg_color="#EBF3FA", save_key="translate_right")
|
||
|
||
copy_right_f = ttk.Frame(right_f)
|
||
copy_right_f.pack(fill="x", pady=(4, 0))
|
||
ttk.Button(copy_right_f, text="Kopiere Übersetzung", command=lambda: _clipboard_set_persistent(txt_right.get("1.0", "end").strip()) or None).pack(anchor="center")
|
||
|
||
# Buttons
|
||
btn_frame = ttk.Frame(root, padding=(12, 0, 12, 6))
|
||
btn_frame.pack(fill="x")
|
||
status_var = tk.StringVar(value="Bereit.")
|
||
status_row = ttk.Frame(btn_frame)
|
||
status_row.pack(fill="x", pady=(0, 4))
|
||
status_style = ttk.Style()
|
||
# Statusbalken Hintergrund anpassen
|
||
status_style.configure("Status.TLabel", foreground="#5A6C7D", font=("Segoe UI", 10), background="#C5E8F2")
|
||
status_label = ttk.Label(status_row, textvariable=status_var, width=45, anchor="w", style="Status.TLabel")
|
||
status_label.pack(side="left")
|
||
btn_row1 = ttk.Frame(btn_frame)
|
||
btn_row1.pack(fill="x", pady=(0, 4))
|
||
btn_row2 = ttk.Frame(btn_frame)
|
||
btn_row2.pack(fill="x", pady=(0, 4))
|
||
main_speed_var = tk.StringVar(value="Normal")
|
||
try:
|
||
if os.path.exists(CONFIG_PATH):
|
||
with open(CONFIG_PATH, encoding="utf-8") as f:
|
||
main_speed_var.set(json.load(f).get("main_tts_speed", "Normal"))
|
||
except Exception:
|
||
pass
|
||
def save_main_speed():
|
||
try:
|
||
data = {}
|
||
if os.path.exists(CONFIG_PATH):
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
data["main_tts_speed"] = main_speed_var.get() if main_speed_var.get() in ("Normal", "Langsam") else "Normal"
|
||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
except Exception:
|
||
pass
|
||
main_speed_var.trace_add("write", lambda *a: save_main_speed())
|
||
|
||
def transcribe_wav(wav_path: str, lang: str) -> str:
|
||
with open(wav_path, "rb") as f:
|
||
resp = client.audio.transcriptions.create(
|
||
model="gpt-4o-mini-transcribe",
|
||
file=f,
|
||
language=lang,
|
||
)
|
||
return getattr(resp, "text", "") or str(resp)
|
||
|
||
def translate_text(text: str, from_lang: str, to_lang: str) -> str:
|
||
if not text or not text.strip():
|
||
return ""
|
||
from_name = LANGUAGES.get(from_lang, from_lang)
|
||
to_name = LANGUAGES.get(to_lang, to_lang)
|
||
resp = client.chat.completions.create(
|
||
model="gpt-4o-mini",
|
||
messages=[
|
||
{"role": "system", "content": f"Übersetze den folgenden Text von {from_name} nach {to_name}. Nur die Übersetzung ausgeben, keine Erklärungen."},
|
||
{"role": "user", "content": text.strip()},
|
||
],
|
||
)
|
||
return (resp.choices[0].message.content or "").strip()
|
||
|
||
_pygame_initialized = [False]
|
||
|
||
def _warmup_tts():
|
||
"""Initialisiert Pygame beim Start – vermeidet Anlaufprobleme bei erster Wiedergabe."""
|
||
try:
|
||
import pygame
|
||
pygame.mixer.init()
|
||
_pygame_initialized[0] = True
|
||
except Exception:
|
||
pass
|
||
|
||
root.after(800, _warmup_tts)
|
||
|
||
def _play_audio_in_app(path: str):
|
||
"""Spielt Audio direkt in dieser Anwendung ab – kein externes Programm."""
|
||
try:
|
||
import pygame
|
||
import time
|
||
if not _pygame_initialized[0]:
|
||
pygame.mixer.init()
|
||
_pygame_initialized[0] = True
|
||
time.sleep(0.15)
|
||
pygame.mixer.music.load(path)
|
||
time.sleep(0.05)
|
||
pygame.mixer.music.play()
|
||
while pygame.mixer.music.get_busy():
|
||
time.sleep(0.1)
|
||
except ImportError:
|
||
messagebox.showerror("Audio", "Für Wiedergabe in der App: py -3.11 -m pip install pygame")
|
||
finally:
|
||
root.after(1000, lambda p=path: _try_remove(p))
|
||
|
||
def speak_text(text: str, lang: str, speed: float = 1.0):
|
||
"""KI-TTS: Spricht Text natürlich in der richtigen Sprache aus."""
|
||
if not text or not text.strip():
|
||
return
|
||
text = text.strip()[:4096]
|
||
try:
|
||
fd, path = tempfile.mkstemp(suffix=".mp3", prefix="tts_")
|
||
os.close(fd)
|
||
with client.audio.speech.with_streaming_response.create(
|
||
model="tts-1-hd",
|
||
voice="nova",
|
||
input=text,
|
||
speed=speed,
|
||
) as resp:
|
||
resp.stream_to_file(path)
|
||
import time
|
||
time.sleep(0.1)
|
||
_play_audio_in_app(path)
|
||
return
|
||
except Exception:
|
||
pass
|
||
# 2. gTTS – spricht in der gewählten Sprache (z.B. Italienisch)
|
||
try:
|
||
from gtts import gTTS
|
||
fd, path = tempfile.mkstemp(suffix=".mp3", prefix="tts_")
|
||
os.close(fd)
|
||
gTTS(text=text[:4000], lang=lang).save(path)
|
||
_play_audio_in_app(path)
|
||
return
|
||
except ImportError:
|
||
messagebox.showerror("TTS", "Für Fallback: py -3.11 -m pip install gTTS pygame")
|
||
except Exception as e:
|
||
messagebox.showerror("TTS Fehler", str(e))
|
||
|
||
def _try_remove(p):
|
||
try:
|
||
if os.path.exists(p):
|
||
os.remove(p)
|
||
except Exception:
|
||
pass
|
||
|
||
def toggle_diktat():
|
||
if is_recording[0]:
|
||
is_recording[0] = False
|
||
btn_diktat.configure(text="⏺ Diktat starten")
|
||
status_var.set("Transkribiere…")
|
||
lang_in, _ = get_lang_codes()
|
||
|
||
def worker():
|
||
try:
|
||
rec = diktat_recorder[0]
|
||
wav_path = rec.stop_and_save_wav()
|
||
diktat_recorder[0] = None
|
||
text = transcribe_wav(wav_path, lang_in)
|
||
try:
|
||
if os.path.exists(wav_path):
|
||
os.remove(wav_path)
|
||
except Exception:
|
||
pass
|
||
lang_in2, lang_out = get_lang_codes()
|
||
increment_lang_out_usage(lang_out)
|
||
translation = translate_text(text, lang_in2, lang_out)
|
||
root.after(0, lambda: _done(text, translation))
|
||
except Exception as e:
|
||
root.after(0, lambda: messagebox.showerror("Fehler", str(e)))
|
||
root.after(0, lambda: status_var.set("Fehler."))
|
||
|
||
def _done(orig, trans):
|
||
if orig:
|
||
txt_left.insert("end", ("\n\n" if txt_left.get("1.0", "end").strip() else "") + orig)
|
||
if trans:
|
||
txt_right.insert("end", ("\n\n" if txt_right.get("1.0", "end").strip() else "") + trans)
|
||
status_var.set("Fertig.")
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
else:
|
||
try:
|
||
if not diktat_recorder[0]:
|
||
diktat_recorder[0] = AudioRecorder()
|
||
diktat_recorder[0].start()
|
||
is_recording[0] = True
|
||
btn_diktat.configure(text="⏹ Diktat stoppen")
|
||
status_var.set("Aufnahme läuft…")
|
||
except Exception as e:
|
||
messagebox.showerror("Aufnahme-Fehler", str(e))
|
||
|
||
diktat_style = ttk.Style()
|
||
diktat_style.configure("Diktat.TButton", font=("Segoe UI", 12), foreground="#3D6B6B", padding=(14, 8))
|
||
btn_diktat = ttk.Button(btn_row1, text="⏺ Diktat starten", command=toggle_diktat, style="Diktat.TButton")
|
||
btn_diktat.pack(side="left", padx=(0, 8))
|
||
|
||
def do_speak():
|
||
text = txt_right.get("1.0", "end").strip()
|
||
if not text:
|
||
messagebox.showinfo("Hinweis", "Kein übersetzter Text zum Vorlesen.")
|
||
return
|
||
_, lang_out = get_lang_codes()
|
||
sp = 0.85 if main_speed_var.get() == "Langsam" else 1.0
|
||
status_var.set("Spricht…")
|
||
def w():
|
||
try:
|
||
speak_text(text, lang_out, sp)
|
||
root.after(0, lambda: status_var.set("Fertig."))
|
||
except Exception as e:
|
||
root.after(0, lambda: messagebox.showerror("TTS Fehler", str(e)))
|
||
root.after(0, lambda: status_var.set("Fehler."))
|
||
threading.Thread(target=w, daemon=True).start()
|
||
|
||
speak_style = ttk.Style()
|
||
speak_style.configure("Vorlesen.TButton", font=("Segoe UI", 12), foreground="#4A6B8A", padding=(14, 8))
|
||
btn_speak = ttk.Button(btn_row2, text="🔊 Vorlesen", command=do_speak, style="Vorlesen.TButton")
|
||
btn_speak.pack(side="left", padx=(0, 8))
|
||
neu_style = ttk.Style()
|
||
neu_style.configure("Neu.TButton", font=("Segoe UI", 12), padding=(14, 8))
|
||
|
||
def do_translate_manual():
|
||
"""Manuelle Übersetzung: linker Text → rechter Text."""
|
||
text = txt_left.get("1.0", "end").strip()
|
||
if not text:
|
||
messagebox.showinfo("Hinweis", "Kein Text zum Übersetzen.")
|
||
return
|
||
lang_in, lang_out = get_lang_codes()
|
||
increment_lang_out_usage(lang_out)
|
||
status_var.set("Übersetze…")
|
||
def w():
|
||
try:
|
||
trans = translate_text(text, lang_in, lang_out)
|
||
root.after(0, lambda: _upd(trans))
|
||
except Exception as e:
|
||
root.after(0, lambda: messagebox.showerror("Fehler", str(e)))
|
||
root.after(0, lambda: status_var.set("Fertig."))
|
||
def _upd(t):
|
||
txt_right.delete("1.0", "end")
|
||
txt_right.insert("1.0", t or "")
|
||
threading.Thread(target=w, daemon=True).start()
|
||
|
||
ttk.Button(btn_row1, text="Übersetzen", command=do_translate_manual).pack(side="left", padx=(0, 8))
|
||
|
||
def do_neu():
|
||
"""Beide Textbereiche leeren für neue Runde."""
|
||
if is_recording[0] and diktat_recorder[0]:
|
||
try:
|
||
diktat_recorder[0].stop_and_save_wav()
|
||
except Exception:
|
||
pass
|
||
diktat_recorder[0] = None
|
||
is_recording[0] = False
|
||
btn_diktat.configure(text="⏺ Diktat starten")
|
||
txt_left.delete("1.0", "end")
|
||
txt_right.delete("1.0", "end")
|
||
status_var.set("Bereit.")
|
||
|
||
btn_row3 = ttk.Frame(btn_frame, padding=(0, 4, 0, 0))
|
||
btn_row3.pack(fill="x")
|
||
ttk.Button(btn_row3, text="Neu", command=do_neu, style="Neu.TButton").pack(side="left", padx=(2, 8))
|
||
|
||
LERNKARTEN_ZIELZEILELLAENGE = 70
|
||
LERNKARTEN_HTML_BG_WEISS = "#FFFFFF"
|
||
LERNKARTEN_HTML_BG_HELLBLAU = "#E0F2F7"
|
||
|
||
def _write_lernkarten_html(html_path, txt_content):
|
||
"""Schreibt aus dem Lernkarten-Text eine HTML-Datei: Original weiss, Übersetzung hellblau."""
|
||
blocks = txt_content.split("\n\n")
|
||
body_parts = ['<meta charset="utf-8"><title>Lernkarten Sätze</title>']
|
||
for blk in blocks:
|
||
blk = blk.strip()
|
||
if not blk:
|
||
continue
|
||
if "\n---\n" in blk:
|
||
g, _, i = blk.partition("\n---\n")
|
||
g, i = g.strip(), i.strip()
|
||
g_esc = html_module.escape(g).replace("\n", "<br>\n")
|
||
i_esc = html_module.escape(i).replace("\n", "<br>\n")
|
||
body_parts.append(f'<div style="background:{LERNKARTEN_HTML_BG_WEISS}; padding:8px; margin:6px 0;">{g_esc}</div>')
|
||
body_parts.append(f'<div style="background:{LERNKARTEN_HTML_BG_HELLBLAU}; padding:8px; margin:6px 0;">{i_esc}</div>')
|
||
else:
|
||
esc = html_module.escape(blk).replace("\n", "<br>\n")
|
||
body_parts.append(f'<p style="margin:8px 0; font-weight:bold;">{esc}</p>')
|
||
full = "<!DOCTYPE html><html><head></head><body>" + "\n".join(body_parts) + "</body></html>"
|
||
try:
|
||
with open(html_path, "w", encoding="utf-8") as f:
|
||
f.write(full)
|
||
except Exception:
|
||
pass
|
||
|
||
def _split_into_sentences(text):
|
||
"""Teilt Text in Sätze (an . ! ? gefolgt von Leerzeichen/Zeilenumbruch)."""
|
||
if not text or not text.strip():
|
||
return []
|
||
t = " ".join(text.split())
|
||
parts = re.split(r"(?<=[.!?])\s+", t)
|
||
return [p.strip() for p in parts if p.strip()]
|
||
|
||
def do_save_to_lernkarten():
|
||
"""Diktiert/Original und Übersetzung satzweise in Lernkarten speichern. Pro Satz: Originalzeile, nächste Zeile Übersetzung, dann Absatz. Zeilen max. 70 Zeichen, Neuestes zuoberst."""
|
||
left_txt = txt_left.get("1.0", "end").strip()
|
||
right_txt = txt_right.get("1.0", "end").strip()
|
||
if not left_txt and not right_txt:
|
||
status_var.set("Nichts zum Speichern.")
|
||
return
|
||
base_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Lernmodus_Export")
|
||
os.makedirs(base_dir, exist_ok=True)
|
||
path = os.path.join(base_dir, "Diktat_Lernkarten.txt")
|
||
try:
|
||
lin, lou = get_lang_codes()
|
||
lang_in_name = LANGUAGES.get(lin, lin).strip()
|
||
lang_out_name = LANGUAGES.get(lou, lou).strip()
|
||
if lang_in_name and lang_out_name:
|
||
lang_in_name = lang_in_name[0].upper() + lang_in_name[1:].lower()
|
||
lang_out_name = lang_out_name[0].upper() + lang_out_name[1:].lower()
|
||
header = f"{lang_in_name} / {lang_out_name}"
|
||
left_sentences = _split_into_sentences(left_txt)
|
||
right_sentences = _split_into_sentences(right_txt)
|
||
if not left_sentences and not right_sentences:
|
||
left_sentences = [" ".join(left_txt.split())] if left_txt else []
|
||
right_sentences = [" ".join(right_txt.split())] if right_txt else []
|
||
n = max(len(left_sentences), len(right_sentences), 1)
|
||
parts = []
|
||
for i in range(n):
|
||
left_s = left_sentences[i].strip() if i < len(left_sentences) else ""
|
||
right_s = right_sentences[i].strip() if i < len(right_sentences) else ""
|
||
if not left_s and not right_s:
|
||
continue
|
||
left_wrapped = "\n".join(textwrap.wrap(left_s, width=LERNKARTEN_ZIELZEILELLAENGE, break_long_words=False)) if left_s else ""
|
||
right_wrapped = "\n".join(textwrap.wrap(right_s, width=LERNKARTEN_ZIELZEILELLAENGE, break_long_words=False)) if right_s else ""
|
||
block = (left_wrapped + "\n---\n" + right_wrapped).strip() if (left_wrapped or right_wrapped) else ""
|
||
if block:
|
||
parts.append((left_wrapped, right_wrapped))
|
||
block_txt = "\n\n".join(left_w + "\n---\n" + right_w for left_w, right_w in parts)
|
||
new_block = "Lernkarten Sätze\n\n" + header + "\n\n" + block_txt
|
||
content = ""
|
||
if os.path.exists(path):
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
content = f.read().rstrip()
|
||
out = (new_block + "\n\n" + content) if content else new_block
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write(out)
|
||
path_html = os.path.join(base_dir, "Diktat_Lernkarten.html")
|
||
_write_lernkarten_html(path_html, out)
|
||
status_var.set("In Lernkarten gespeichert.")
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e))
|
||
status_var.set("Fehler beim Speichern.")
|
||
|
||
ttk.Button(btn_row3, text="In Lernkarten speichern", command=do_save_to_lernkarten).pack(side="left", padx=(12, 8))
|
||
|
||
def open_lernkarten_fenster():
|
||
"""Eigenes Fenster für Lernkarten wie Diskussion mit KI: Anzeige mit weiss (Deutsch) und hellblau (Übersetzung), Speichern/Laden als JSON."""
|
||
win = tk.Toplevel(root)
|
||
win.title("Lernkarten – Sätze")
|
||
win.transient(root)
|
||
win.minsize(520, 400)
|
||
win.geometry("700x500")
|
||
win.configure(bg="#E8F4F8")
|
||
base_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Lernmodus_Export")
|
||
lernkarten_dir = os.path.join(base_dir, "Gespeicherte_Lernkarten")
|
||
os.makedirs(lernkarten_dir, exist_ok=True)
|
||
|
||
entries = [] # [{"de": str, "it": str}, ...]
|
||
current_lernkarten_path = [None]
|
||
current_lernkarten_titel = [None]
|
||
status_lern = tk.StringVar(value="Bereit. Laden oder aus Übersetzung hinzufügen.")
|
||
|
||
chat_frame = ttk.Frame(win, padding=(12, 8))
|
||
chat_frame.pack(fill="both", expand=True)
|
||
lern_header = ttk.Frame(chat_frame)
|
||
lern_header.pack(fill="x", anchor="w")
|
||
ttk.Label(lern_header, text="Lernkarten:").pack(side="left")
|
||
display = ScrolledText(chat_frame, wrap="word", font=("Segoe UI", 11), bg="#F5FCFF", state="disabled", height=20)
|
||
display.tag_configure("lern_weiss", background="#FFFFFF")
|
||
display.tag_configure("lern_hellblau", background="#E0F2F7")
|
||
display.pack(fill="both", expand=True)
|
||
add_text_font_size_control(lern_header, display, initial_size=11, bg_color="#F0F0F0", save_key="translate_lernkarten")
|
||
|
||
def refresh_display():
|
||
display.configure(state="normal")
|
||
display.delete("1.0", "end")
|
||
for e in reversed(entries):
|
||
de, it = e.get("de", "").strip(), e.get("it", "").strip()
|
||
if not de and not it:
|
||
continue
|
||
start = display.index("end")
|
||
display.insert("end", (de or "") + "\n", "lern_weiss")
|
||
display.insert("end", (it or "") + "\n\n", "lern_hellblau")
|
||
display.configure(state="disabled")
|
||
status_lern.set(f"{len(entries)} Einträge." if entries else "Keine Einträge.")
|
||
|
||
def do_lernkarten_speichern():
|
||
if not entries:
|
||
messagebox.showinfo("Lernkarten speichern", "Keine Einträge zum Speichern.")
|
||
return
|
||
status_lern.set("Speichere…")
|
||
now = datetime.now()
|
||
datum = now.strftime("%d.%m.%Y")
|
||
uhrzeit = now.strftime("%H:%M")
|
||
path = current_lernkarten_path[0]
|
||
titel = current_lernkarten_titel[0]
|
||
if path and titel:
|
||
data = {"titel": titel, "datum": datum, "uhrzeit": uhrzeit, "eintraege": entries}
|
||
try:
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
status_lern.set(f"Aktualisiert: {titel}")
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e))
|
||
status_lern.set("Fehler.")
|
||
return
|
||
try:
|
||
r = client.chat.completions.create(
|
||
model="gpt-4o-mini",
|
||
messages=[
|
||
{"role": "system", "content": "Gib nur eine kurze deutsche Überschrift (3–8 Wörter) für diese Lernkarten-Sammlung. Keine Anführungszeichen, eine Zeile."},
|
||
{"role": "user", "content": (entries[0].get("de", "")[:500] + " …") if entries else "Lernkarten"},
|
||
],
|
||
)
|
||
titel = (r.choices[0].message.content or "Lernkarten").strip().strip('"\'')
|
||
if not titel:
|
||
titel = "Lernkarten"
|
||
safe_titel = "".join(c for c in titel[:50] if c.isalnum() or c in " _-") or "Lernkarten"
|
||
fname = f"Lernkarten_{now.strftime('%Y-%m-%d_%H-%M')}_{safe_titel}.json"
|
||
path = os.path.join(lernkarten_dir, fname)
|
||
data = {"titel": titel, "datum": datum, "uhrzeit": uhrzeit, "eintraege": entries}
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
current_lernkarten_path[0] = path
|
||
current_lernkarten_titel[0] = titel
|
||
status_lern.set(f"Gespeichert: {titel}")
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e))
|
||
status_lern.set("Fehler.")
|
||
|
||
def do_lernkarten_laden():
|
||
path = filedialog.askopenfilename(
|
||
title="Lernkarten laden",
|
||
initialdir=lernkarten_dir,
|
||
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
|
||
)
|
||
if not path:
|
||
return
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
entries.clear()
|
||
entries.extend(data.get("eintraege", []))
|
||
current_lernkarten_path[0] = path
|
||
current_lernkarten_titel[0] = data.get("titel", "–")
|
||
refresh_display()
|
||
status_lern.set(f"Geladen: {data.get('titel', '–')} ({data.get('datum', '')} {data.get('uhrzeit', '')})")
|
||
except Exception as e:
|
||
messagebox.showerror("Lernkarten laden", str(e))
|
||
|
||
def do_aus_txt_laden():
|
||
path_txt = os.path.join(base_dir, "Diktat_Lernkarten.txt")
|
||
if not os.path.isfile(path_txt):
|
||
messagebox.showinfo("Lernkarten", "Diktat_Lernkarten.txt nicht gefunden.")
|
||
return
|
||
try:
|
||
with open(path_txt, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e))
|
||
return
|
||
blocks = content.replace("\r", "").split("\n\n")
|
||
new_entries = []
|
||
for blk in blocks:
|
||
blk = blk.strip()
|
||
if "\n---\n" not in blk:
|
||
continue
|
||
g, _, i = blk.partition("\n---\n")
|
||
g, i = g.strip(), i.strip()
|
||
if g or i:
|
||
new_entries.append({"de": g, "it": i})
|
||
entries.clear()
|
||
entries.extend(new_entries)
|
||
current_lernkarten_path[0] = None
|
||
current_lernkarten_titel[0] = None
|
||
refresh_display()
|
||
status_lern.set(f"Aus Diktat_Lernkarten.txt geladen: {len(entries)} Einträge.")
|
||
|
||
def do_aus_uebersetzung():
|
||
left_txt = txt_left.get("1.0", "end").strip()
|
||
right_txt = txt_right.get("1.0", "end").strip()
|
||
if not left_txt and not right_txt:
|
||
messagebox.showinfo("Lernkarten", "Kein Text in den Übersetzungsfeldern.")
|
||
return
|
||
left_s = _split_into_sentences(left_txt)
|
||
right_s = _split_into_sentences(right_txt)
|
||
if not left_s and not right_s:
|
||
left_s = [" ".join(left_txt.split())] if left_txt else []
|
||
right_s = [" ".join(right_txt.split())] if right_txt else []
|
||
n = max(len(left_s), len(right_s), 1)
|
||
for i in range(n):
|
||
de = left_s[i].strip() if i < len(left_s) else ""
|
||
it = right_s[i].strip() if i < len(right_s) else ""
|
||
if de or it:
|
||
entries.append({"de": de, "it": it})
|
||
refresh_display()
|
||
status_lern.set(f"{len(entries)} Einträge (aus Übersetzung hinzugefügt).")
|
||
|
||
def do_verlauf_loeschen():
|
||
entries.clear()
|
||
current_lernkarten_path[0] = None
|
||
current_lernkarten_titel[0] = None
|
||
refresh_display()
|
||
status_lern.set("Verlauf gelöscht.")
|
||
|
||
btn_row = ttk.Frame(win, padding=(12, 8))
|
||
btn_row.pack(fill="x")
|
||
ttk.Button(btn_row, text="Lernkarten speichern", command=do_lernkarten_speichern).pack(side="left", padx=(0, 8))
|
||
ttk.Button(btn_row, text="Lernkarten laden", command=do_lernkarten_laden).pack(side="left", padx=(0, 8))
|
||
ttk.Button(btn_row, text="Aus Diktat_Lernkarten.txt laden", command=do_aus_txt_laden).pack(side="left", padx=(0, 8))
|
||
ttk.Button(btn_row, text="Aus Übersetzung hinzufügen", command=do_aus_uebersetzung).pack(side="left", padx=(0, 8))
|
||
ttk.Button(btn_row, text="Verlauf löschen", command=do_verlauf_loeschen).pack(side="left", padx=(0, 8))
|
||
ttk.Label(win, textvariable=status_lern, font=("Segoe UI", 10)).pack(anchor="w", padx=12, pady=(0, 8))
|
||
refresh_display()
|
||
|
||
tk.Button(btn_row3, text=" Lernkarten ", command=open_lernkarten_fenster,
|
||
font=("Segoe UI", 10, "bold"), bg="#b8e8d8", fg="#1a4d6d",
|
||
activebackground="#98d8c8", activeforeground="#1a4d6d",
|
||
relief="flat", bd=0, padx=14, pady=4, cursor="hand2"
|
||
).pack(side="left", padx=(0, 8))
|
||
|
||
def open_lernkarten_ordner():
|
||
"""Öffnet den Ordner Lernmodus_Export (Lernkarten aus Gespräch und Diktat)."""
|
||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||
lernmodus_dir = os.path.join(base_dir, "Lernmodus_Export")
|
||
os.makedirs(lernmodus_dir, exist_ok=True)
|
||
try:
|
||
if os.name == "nt":
|
||
os.startfile(lernmodus_dir)
|
||
else:
|
||
import subprocess
|
||
subprocess.run(["xdg-open", lernmodus_dir], check=False)
|
||
except Exception:
|
||
import subprocess
|
||
subprocess.run(["explorer", lernmodus_dir] if os.name == "nt" else ["xdg-open", lernmodus_dir], check=False)
|
||
|
||
ttk.Button(btn_row3, text="Ordner Lernkarten", command=open_lernkarten_ordner).pack(side="left", padx=(0, 8))
|
||
|
||
def start_lernkarten_abfrage():
|
||
"""Startet das Lernkarten-Abfrage-Programm."""
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
abfrage_path = os.path.join(script_dir, "lernkarten_abfrage.py")
|
||
if os.path.exists(abfrage_path):
|
||
subprocess.Popen([sys.executable, abfrage_path], cwd=script_dir)
|
||
else:
|
||
messagebox.showinfo("Lernkarten-Abfrage", "lernkarten_abfrage.py nicht gefunden.")
|
||
|
||
ttk.Button(btn_row3, text="📝 Lernkarten üben", command=start_lernkarten_abfrage).pack(side="left", padx=(12, 8))
|
||
|
||
vorlesen_row = ttk.Frame(btn_frame, padding=(0, 8, 0, 0))
|
||
vorlesen_row.pack(fill="x")
|
||
ttk.Label(vorlesen_row, text="Vorlesen:").pack(side="left", padx=(0, 4))
|
||
combo_main_speed = ttk.Combobox(vorlesen_row, textvariable=main_speed_var, values=["Normal", "Langsam"], state="readonly", width=8)
|
||
combo_main_speed.pack(side="left", padx=(0, 8))
|
||
combo_main_speed.set(main_speed_var.get() if main_speed_var.get() in ("Normal", "Langsam") else "Normal")
|
||
|
||
ttk.Button(btn_row2, text="📚 Gespräch / Lernmodus", command=lambda: open_gespraech_window(lehrer_default=True)).pack(side="left", padx=(0, 8))
|
||
|
||
def open_gespraech_window(lehrer_default=True):
|
||
"""Öffnet Gesprächs-Fenster: KI diskutiert mit dir, auto Vorlesen, optional Sprachen lernen."""
|
||
GESP_MIN_W, GESP_MIN_H = 680, 540
|
||
win = tk.Toplevel(root)
|
||
win.title("Gespräch – KI diskutiert mit dir")
|
||
gesp_lang_saved, silence_saved, speed_saved, gesp_geom_saved = load_gesp_config()
|
||
win.minsize(GESP_MIN_W, GESP_MIN_H)
|
||
win.geometry(_clamp_geometry(gesp_geom_saved, GESP_MIN_W, GESP_MIN_H) if gesp_geom_saved else "820x680")
|
||
win.configure(bg="#E8F4F8")
|
||
win.transient(root)
|
||
|
||
# —— Einstellungen (kompakt, übersichtlich) ——
|
||
opts_frame = ttk.LabelFrame(win, text=" Einstellungen ", padding=(10, 8))
|
||
opts_frame.pack(fill="x", padx=10, pady=(8, 4))
|
||
|
||
row1 = ttk.Frame(opts_frame)
|
||
row1.pack(fill="x", pady=(0, 6))
|
||
ttk.Label(row1, text="Spreche/lerne in dieser Sprache:").pack(side="left", padx=(0, 6))
|
||
gesp_lang_var = tk.StringVar(value=gesp_lang_saved)
|
||
combo = ttk.Combobox(
|
||
row1, textvariable=gesp_lang_var,
|
||
values=[f"{k} – {v}" for k, v in LANGUAGES_SORTED],
|
||
state="readonly", width=20,
|
||
)
|
||
combo.pack(side="left", padx=(0, 16))
|
||
combo_str = f"{gesp_lang_saved} – {LANGUAGES.get(gesp_lang_saved, gesp_lang_saved)}"
|
||
combo.set(combo_str if gesp_lang_saved in LANGUAGES else "de – Deutsch")
|
||
ttk.Label(row1, text=" Geschwindigkeit:").pack(side="left", padx=(16, 6))
|
||
speed_map = {"Normal": 1.0, "Langsam": 0.85, "Sehr langsam": 0.7}
|
||
def speed_to_label(v):
|
||
if v <= 0.75:
|
||
return "Sehr langsam"
|
||
if v < 0.95:
|
||
return "Langsam"
|
||
return "Normal"
|
||
speed_var = tk.StringVar(value=speed_to_label(speed_saved))
|
||
combo_speed = ttk.Combobox(
|
||
row1, textvariable=speed_var,
|
||
values=["Normal", "Langsam", "Sehr langsam"],
|
||
state="readonly", width=12,
|
||
)
|
||
combo_speed.pack(side="left", padx=(0, 4))
|
||
combo_speed.set(speed_var.get() if speed_var.get() in speed_map else "Normal")
|
||
|
||
# Toggle: Weitere Einstellungen (Sprachen lernen, Vorlage, Thema, Eigenes Thema)
|
||
lehrer_var = tk.BooleanVar(value=lehrer_default)
|
||
extra_opts_visible = [False]
|
||
extra_opts_frame = ttk.Frame(opts_frame)
|
||
def toggle_extra_opts():
|
||
if extra_opts_visible[0]:
|
||
extra_opts_frame.pack_forget()
|
||
btn_toggle_extra.configure(text="▼ Weitere Einstellungen anzeigen")
|
||
extra_opts_visible[0] = False
|
||
else:
|
||
extra_opts_frame.pack(fill="x", pady=(6, 0))
|
||
btn_toggle_extra.configure(text="▲ Weitere Einstellungen einklappen")
|
||
extra_opts_visible[0] = True
|
||
btn_toggle_extra = ttk.Button(opts_frame, text="▼ Weitere Einstellungen anzeigen", command=toggle_extra_opts)
|
||
btn_toggle_extra.pack(anchor="w", pady=(2, 0))
|
||
|
||
def load_saved_vorlagen():
|
||
try:
|
||
if os.path.exists(VORLAGEN_PATH):
|
||
with open(VORLAGEN_PATH, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
def save_vorlagen(vorlagen_list):
|
||
try:
|
||
with open(VORLAGEN_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(vorlagen_list, f, indent=2, ensure_ascii=False)
|
||
except Exception:
|
||
pass
|
||
|
||
LERN_VORLAGEN_DEFAULT = [
|
||
"Standard (Korrektur + Antwort)",
|
||
"Ich möchte diese Sprache erlernen. KI stellt viele Fragen, damit das Gespräch ständig weiter fließt – nicht nur mein Input.",
|
||
]
|
||
|
||
def get_all_vorlagen():
|
||
saved = load_saved_vorlagen()
|
||
texts = [v.get("text", "") for v in saved if isinstance(v, dict) and v.get("text")]
|
||
return LERN_VORLAGEN_DEFAULT + (texts if texts else [])
|
||
|
||
row2 = ttk.Frame(extra_opts_frame)
|
||
row2.pack(fill="x", pady=(0, 6))
|
||
ttk.Checkbutton(row2, text="Sprachen lernen (Lehrer-Modus)", variable=lehrer_var).pack(side="left", padx=(0, 16))
|
||
ttk.Label(row2, text="Vorlage (wie KI antwortet):").pack(side="left", padx=(0, 6))
|
||
lern_vorlage_var = tk.StringVar(value=LERN_VORLAGEN_DEFAULT[1])
|
||
combo_vorlage = ttk.Combobox(
|
||
row2, textvariable=lern_vorlage_var,
|
||
values=get_all_vorlagen(), width=52,
|
||
)
|
||
combo_vorlage.pack(side="left", padx=(0, 4), fill="x", expand=True)
|
||
combo_vorlage.set(lern_vorlage_var.get())
|
||
|
||
def open_vorlagen_dialog():
|
||
vwin = tk.Toplevel(win)
|
||
vwin.title("Vorlagen verwalten")
|
||
vwin.geometry("520x340")
|
||
vwin.transient(win)
|
||
ttk.Label(vwin, text="Gespeicherte Vorlagen (wie KI antworten soll):").pack(anchor="w", padx=10, pady=(10, 4))
|
||
list_f = ttk.Frame(vwin)
|
||
list_f.pack(fill="both", expand=True, padx=10, pady=4)
|
||
lb = tk.Listbox(list_f, height=8, font=("Segoe UI", 10))
|
||
lb.pack(side="left", fill="both", expand=True)
|
||
sb = ttk.Scrollbar(list_f, command=lb.yview)
|
||
sb.pack(side="right", fill="y")
|
||
lb.configure(yscrollcommand=sb.set)
|
||
saved = load_saved_vorlagen()
|
||
names = [v.get("name", v.get("text", "")[:40]) for v in saved if isinstance(v, dict)]
|
||
for n in names:
|
||
lb.insert("end", n)
|
||
btn_vf = ttk.Frame(vwin, padding=10)
|
||
btn_vf.pack(fill="x")
|
||
|
||
def vorlage_speichern():
|
||
txt = lern_vorlage_var.get().strip()
|
||
if not txt:
|
||
messagebox.showinfo("Vorlage", "Bitte zuerst eine Vorlage eingeben oder auswählen.", parent=vwin)
|
||
return
|
||
name = simpledialog.askstring("Name", "Name für diese Vorlage (z.B. „Stelle viele Fragen“):", parent=vwin)
|
||
if not name or not name.strip():
|
||
return
|
||
name = name.strip()
|
||
lst = load_saved_vorlagen()
|
||
lst.append({"name": name, "text": txt})
|
||
save_vorlagen(lst)
|
||
lb.insert("end", name)
|
||
combo_vorlage["values"] = get_all_vorlagen()
|
||
messagebox.showinfo("Gespeichert", f"Vorlage „{name}“ gespeichert.", parent=vwin)
|
||
|
||
def vorlage_laden():
|
||
sel = lb.curselection()
|
||
if not sel:
|
||
messagebox.showinfo("Vorlage", "Bitte eine Vorlage auswählen.", parent=vwin)
|
||
return
|
||
idx = sel[0]
|
||
lst = load_saved_vorlagen()
|
||
if 0 <= idx < len(lst) and isinstance(lst[idx], dict):
|
||
lern_vorlage_var.set(lst[idx].get("text", ""))
|
||
combo_vorlage.set(lern_vorlage_var.get())
|
||
vwin.destroy()
|
||
|
||
def vorlage_loeschen():
|
||
sel = lb.curselection()
|
||
if not sel:
|
||
return
|
||
idx = sel[0]
|
||
lst = load_saved_vorlagen()
|
||
if 0 <= idx < len(lst):
|
||
del lst[idx]
|
||
save_vorlagen(lst)
|
||
lb.delete(idx)
|
||
combo_vorlage["values"] = get_all_vorlagen()
|
||
|
||
ttk.Button(btn_vf, text="Aktuelle Vorlage speichern", command=vorlage_speichern).pack(side="left", padx=(0, 8))
|
||
ttk.Button(btn_vf, text="Vorlage laden", command=vorlage_laden).pack(side="left", padx=(0, 8))
|
||
ttk.Button(btn_vf, text="Vorlage löschen", command=vorlage_loeschen).pack(side="left", padx=(0, 8))
|
||
|
||
ttk.Button(row2, text="Vorlagen", command=open_vorlagen_dialog).pack(side="left", padx=(4, 0))
|
||
|
||
THEMEN_VORGABEN = [
|
||
"Allgemein (kein bestimmtes Thema)",
|
||
"Gespräch mit Patienten (Verhalten: liegen, stehen, Anweisungen)",
|
||
"Gespräch in den Ferien / Urlaub",
|
||
"Gespräch beim Einkaufen",
|
||
"Am Arzt / beim Arzt",
|
||
"Small Talk / Kennenlernen",
|
||
"Restaurant / Bestellen",
|
||
"Wegbeschreibungen / unterwegs",
|
||
"Familie und Alltag",
|
||
"Beruf / Arbeit",
|
||
]
|
||
|
||
row3 = ttk.Frame(extra_opts_frame)
|
||
row3.pack(fill="x", pady=(0, 4))
|
||
ttk.Label(row3, text="Thema:").pack(side="left", padx=(0, 6))
|
||
thema_var = tk.StringVar(value=THEMEN_VORGABEN[0])
|
||
combo_thema = ttk.Combobox(
|
||
row3, textvariable=thema_var,
|
||
values=THEMEN_VORGABEN, width=48,
|
||
)
|
||
combo_thema.pack(side="left", padx=(0, 4), fill="x", expand=True)
|
||
combo_thema.set(thema_var.get())
|
||
|
||
row4 = ttk.Frame(extra_opts_frame)
|
||
row4.pack(fill="x", pady=(0, 0))
|
||
ttk.Label(row4, text="Eigenes Thema (optional):").pack(side="left", padx=(0, 6))
|
||
entry_thema_custom = tk.Entry(row4, font=("Segoe UI", 10), bg="#FFF8E8", width=50)
|
||
entry_thema_custom.pack(side="left", padx=(0, 4), fill="x", expand=True)
|
||
|
||
def get_tts_speed() -> float:
|
||
return speed_map.get(speed_var.get(), 1.0)
|
||
|
||
chat_f = ttk.Frame(win, padding=10)
|
||
chat_f.pack(fill="both", expand=True)
|
||
gespraech_header = ttk.Frame(chat_f)
|
||
gespraech_header.pack(fill="x", anchor="w")
|
||
ttk.Label(gespraech_header, text="Gespräch:").pack(side="left")
|
||
txt_chat = ScrolledText(chat_f, wrap="word", font=text_font, bg="#F5FCFF", height=8)
|
||
txt_chat.pack(fill="both", expand=True, pady=(4, 8))
|
||
add_text_font_size_control(gespraech_header, txt_chat, initial_size=11, bg_color="#F0F0F0", save_key="translate_gespraech")
|
||
ttk.Label(chat_f, text="Deine Nachricht (oder per Diktat):").pack(anchor="w")
|
||
entry_msg = tk.Text(chat_f, wrap="word", font=text_font, bg="#FFF8E8", height=2)
|
||
entry_msg.pack(fill="x", pady=(4, 0))
|
||
save_trans_var = tk.BooleanVar(value=True)
|
||
|
||
send_row = ttk.Frame(chat_f, padding=(0, 8, 0, 0))
|
||
send_row.pack(fill="x")
|
||
|
||
status_row_gesp = ttk.Frame(win, padding=(10, 8, 10, 0))
|
||
status_row_gesp.pack(fill="x")
|
||
status_gesp = tk.StringVar(value="Bereit.")
|
||
status_style_gesp = ttk.Style()
|
||
status_style_gesp.configure("Orange.TLabel", foreground="#E65100", font=("Segoe UI", 10, "bold"))
|
||
ttk.Label(status_row_gesp, textvariable=status_gesp, width=50, anchor="w", style="Orange.TLabel").pack(side="left")
|
||
|
||
btn_f = ttk.Frame(win, padding=10)
|
||
btn_f.pack(fill="x")
|
||
gesp_recorder = [None]
|
||
gesp_recording = [False]
|
||
gesp_stop_requested = [False]
|
||
gesp_playing = [False]
|
||
gesp_manual_stop = [False]
|
||
last_ai_message = [""]
|
||
|
||
def auto_start_recording():
|
||
"""Startet Diktat automatisch nach KI-Vorlesen – nur wenn nicht manuell pausiert."""
|
||
try:
|
||
if (not gesp_stop_requested[0] and not gesp_manual_stop[0] and
|
||
not gesp_recording[0] and win.winfo_exists()):
|
||
toggle_gesp_diktat()
|
||
except Exception:
|
||
pass
|
||
|
||
def get_gesp_lang():
|
||
try:
|
||
v = gesp_lang_var.get() or ""
|
||
code = v.split(" – ")[0].strip() if " – " in str(v) else "de"
|
||
return code if code in LANGUAGES else "de"
|
||
except Exception:
|
||
return "de"
|
||
|
||
def save_gesp_config_now(include_geometry=False):
|
||
try:
|
||
geom = win.geometry() if include_geometry and win.winfo_exists() else None
|
||
save_gesp_config(get_gesp_lang(), 20, get_tts_speed(), geom)
|
||
except Exception:
|
||
pass
|
||
|
||
gesp_lang_var.trace_add("write", lambda *a: save_gesp_config_now())
|
||
speed_var.trace_add("write", lambda *a: save_gesp_config_now())
|
||
|
||
def on_gesp_close():
|
||
save_gesp_config_now(include_geometry=True)
|
||
win.destroy()
|
||
|
||
win.protocol("WM_DELETE_WINDOW", on_gesp_close)
|
||
|
||
def transcribe_gesp(path: str, lang: str) -> str:
|
||
with open(path, "rb") as f:
|
||
r = client.audio.transcriptions.create(
|
||
model="gpt-4o-mini-transcribe", file=f, language=lang,
|
||
)
|
||
return getattr(r, "text", "") or str(r)
|
||
|
||
def chat_with_ai(user_msg: str, lang: str, is_lehrer: bool, lern_vorlage: str = "", thema: str = "") -> str:
|
||
lang = lang if lang in LANGUAGES else "de"
|
||
lang_name = LANGUAGES.get(lang, lang)
|
||
thema_add = ""
|
||
if thema and thema.strip() and "Allgemein" not in thema:
|
||
thema_add = (
|
||
f" PFLICHT-THEMA: {thema.strip()}. "
|
||
"Du MUSST das Gespräch ZWINGEND in diesem Bereich führen. Leite und steuere das Gespräch aktiv auf dieses Thema. "
|
||
"Bring dem Lernenden Vokabeln und Redewendungen zu genau diesem Bereich bei. Verlasse das Thema nicht."
|
||
)
|
||
lang_rule = (
|
||
f"RIGID RULE: You are a native {lang_name} speaker. You MUST respond ONLY in {lang_name}. "
|
||
f"NEVER use English, German, or any other language – only {lang_name}. "
|
||
f"You NEVER switch to a German perspective or persona. You are NOT 'a German trying to speak {lang_name}' – you ARE {lang_name}. "
|
||
f"Every single word you write MUST be in {lang_name}. No exceptions."
|
||
)
|
||
no_repeat = (
|
||
"FORBIDDEN: NEVER repeat, echo, or write the user's sentence. It is already displayed. "
|
||
"Do NOT quote the user. Do NOT say 'You said' or paraphrase their words. "
|
||
"Write ONLY your response – never the user's words."
|
||
)
|
||
if is_lehrer:
|
||
vorlage_add = ""
|
||
if lern_vorlage and "Standard" not in lern_vorlage:
|
||
vorlage_add = f" ZUSÄTZLICH (Nutzerwunsch): {lern_vorlage.strip()}"
|
||
sys = (
|
||
lang_rule + " " + no_repeat + thema_add + " "
|
||
f"Du bist ein geduldiger {lang_name}-Lehrer (muttersprachlich). "
|
||
"WICHTIG – Reihenfolge deiner Antwort: "
|
||
"(1) ZUERST: Wenn der Nutzer sprachliche Fehler gemacht hat, korrigiere sie – gib NUR die richtige Form, wiederhole seinen Satz NICHT. "
|
||
"(2) DANN: Gib deine inhaltliche Antwort auf das Gesagte. "
|
||
"Beides kurz halten. Niemals den Satz des Nutzers schreiben oder zitieren." + vorlage_add
|
||
)
|
||
else:
|
||
sys = (
|
||
lang_rule + " " + no_repeat + thema_add + " "
|
||
f"Du bist ein freundlicher Gesprächspartner. "
|
||
"Halte deine Antworten ähnlich lang wie die des Nutzers. "
|
||
"Diskutiere lebendig über verschiedene Themen."
|
||
)
|
||
msgs = []
|
||
try:
|
||
hist = txt_chat.get("1.0", "end").strip()
|
||
except Exception:
|
||
hist = ""
|
||
if hist:
|
||
for block in hist.split("\n\n---\n\n"):
|
||
if "Du: " in block and "KI: " in block:
|
||
parts = block.split("KI: ", 1)
|
||
if len(parts) == 2:
|
||
u = parts[0].replace("Du: ", "").strip()
|
||
a = parts[1].strip()
|
||
if u:
|
||
msgs.append({"role": "user", "content": u})
|
||
if a:
|
||
msgs.append({"role": "assistant", "content": a})
|
||
reminder = f"[Schreibe den Satz des Nutzers NIEMALS. Nur deine Antwort. Erst Fehlerkorrektur (falls nötig), dann inhaltliche Antwort. Alles auf {lang_name}.]"
|
||
msgs.append({"role": "user", "content": f"{reminder}\n\n{user_msg}"})
|
||
r = client.chat.completions.create(
|
||
model="gpt-4o-mini",
|
||
messages=[{"role": "system", "content": sys}] + msgs[-20:],
|
||
)
|
||
return (r.choices[0].message.content or "").strip()
|
||
|
||
def _play_gesp_audio(path: str):
|
||
"""Spielt MP3 ab (pygame)."""
|
||
try:
|
||
import pygame
|
||
import time
|
||
pygame.mixer.init()
|
||
pygame.mixer.music.load(path)
|
||
pygame.mixer.music.play()
|
||
gesp_playing[0] = True
|
||
try:
|
||
while pygame.mixer.music.get_busy() and not gesp_stop_requested[0]:
|
||
time.sleep(0.1)
|
||
finally:
|
||
gesp_playing[0] = False
|
||
try:
|
||
pygame.mixer.music.stop()
|
||
pygame.mixer.quit()
|
||
except Exception:
|
||
pass
|
||
except ImportError:
|
||
pass
|
||
try:
|
||
if path and os.path.exists(path):
|
||
os.remove(path)
|
||
except Exception:
|
||
pass
|
||
|
||
def speak_gesp(text: str, lang: str, speed: float = None):
|
||
"""Spricht Text vor – in der GEWÄHLTEN Sprache (nicht Englisch), damit der „Native Speaker“ korrekt spricht."""
|
||
if not text or not text.strip() or gesp_stop_requested[0]:
|
||
return
|
||
t = text.strip()[:4096]
|
||
sp = get_tts_speed() if speed is None else float(speed)
|
||
path = None
|
||
# gTTS hat expliziten lang=-Parameter → garantiert korrekte Sprache (z.B. Italienisch statt Englisch)
|
||
if lang and lang != "en":
|
||
try:
|
||
from gtts import gTTS
|
||
fd, path = tempfile.mkstemp(suffix=".mp3", prefix="tts_")
|
||
os.close(fd)
|
||
gTTS(text=t[:4000], lang=lang).save(path)
|
||
if gesp_stop_requested[0]:
|
||
return
|
||
_play_gesp_audio(path)
|
||
return
|
||
except Exception:
|
||
pass
|
||
# Fallback: OpenAI TTS (kein lang-Parameter; spricht Textsprache)
|
||
try:
|
||
fd, path = tempfile.mkstemp(suffix=".mp3", prefix="tts_")
|
||
os.close(fd)
|
||
with client.audio.speech.with_streaming_response.create(
|
||
model="tts-1-hd", voice="nova", input=t, speed=sp
|
||
) as resp:
|
||
resp.stream_to_file(path)
|
||
if gesp_stop_requested[0]:
|
||
return
|
||
_play_gesp_audio(path)
|
||
except Exception:
|
||
try:
|
||
from gtts import gTTS
|
||
fd, path = tempfile.mkstemp(suffix=".mp3", prefix="tts_")
|
||
os.close(fd)
|
||
gTTS(text=t[:4000], lang=lang if lang else "it").save(path)
|
||
if not gesp_stop_requested[0]:
|
||
_play_gesp_audio(path)
|
||
except Exception:
|
||
pass
|
||
|
||
def safe_after(callback):
|
||
"""Führt Callback auf Main-Thread aus, nur wenn Fenster noch existiert."""
|
||
def _run():
|
||
try:
|
||
if win.winfo_exists():
|
||
callback()
|
||
except Exception:
|
||
pass
|
||
win.after(0, _run)
|
||
|
||
def auto_save_vokabeln(user_msg: str, ai_msg: str, lang: str, thema: str):
|
||
"""Speichert automatisch 2–5 interessante Vokabeln/Kurzsätze aus dem letzten Austausch in Lernkarten."""
|
||
if not user_msg.strip() and not ai_msg.strip():
|
||
return
|
||
try:
|
||
lang_name = LANGUAGES.get(lang, lang)
|
||
sys_extract = (
|
||
f"Extrahiere 2–5 besonders interessante, nützliche Vokabeln oder kurze Sätze aus diesem Austausch. "
|
||
f"Format: Deutsch – {lang_name} (z.B. 'Guten Tag – buongiorno'). "
|
||
f"Nur Zeilen im Format 'Deutsch – {lang_name}', eine pro Zeile. Kein anderer Text."
|
||
)
|
||
r = client.chat.completions.create(
|
||
model="gpt-4o-mini",
|
||
messages=[
|
||
{"role": "system", "content": sys_extract},
|
||
{"role": "user", "content": f"Du: {user_msg}\n\nKI: {ai_msg}"},
|
||
],
|
||
)
|
||
extracted = (r.choices[0].message.content or "").strip()
|
||
if not extracted:
|
||
return
|
||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||
lernmodus_dir = os.path.join(base_dir, "Lernmodus_Export")
|
||
os.makedirs(lernmodus_dir, exist_ok=True)
|
||
safe_name = "".join(c for c in (thema or "Lernmodus")[:40] if c.isalnum() or c in " _-") or "Lernmodus"
|
||
lang_out_name = lang_name.strip()
|
||
if lang_out_name:
|
||
lang_out_name = lang_out_name[0].upper() + lang_out_name[1:].lower()
|
||
gesp_header = f"Deutsch / {lang_out_name}"
|
||
new_entries = []
|
||
for line in extracted.replace("\r", "").split("\n"):
|
||
line = line.strip()
|
||
if " – " in line:
|
||
a, b = line.split(" – ", 1)
|
||
a, b = a.strip(), b.strip()
|
||
if a and b:
|
||
new_entries.append(f"{a} = {b}")
|
||
if not new_entries:
|
||
return
|
||
kumulativ_path = os.path.join(lernmodus_dir, f"{safe_name}_Gesamtliste_Lernkarten.txt")
|
||
content = ""
|
||
if os.path.exists(kumulativ_path):
|
||
with open(kumulativ_path, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
lines_f = content.split("\n")
|
||
out_f = []
|
||
i = 0
|
||
found_gesp = False
|
||
while i < len(lines_f):
|
||
line = lines_f[i]
|
||
st = line.strip()
|
||
is_h = (" / " in st and " = " not in st and st and not st.startswith("===") and not st.startswith("###"))
|
||
if is_h and st.lower() == gesp_header.lower():
|
||
out_f.append(line)
|
||
i += 1
|
||
while i < len(lines_f) and lines_f[i].strip() and " = " in lines_f[i]:
|
||
out_f.append(lines_f[i])
|
||
i += 1
|
||
for e in new_entries:
|
||
out_f.append(e)
|
||
found_gesp = True
|
||
continue
|
||
out_f.append(line)
|
||
i += 1
|
||
if not found_gesp:
|
||
if out_f and out_f[-1].strip():
|
||
out_f.append("")
|
||
out_f.append(gesp_header)
|
||
out_f.extend(new_entries)
|
||
with open(kumulativ_path, "w", encoding="utf-8") as f:
|
||
f.write("\n".join(out_f))
|
||
except Exception:
|
||
pass
|
||
|
||
def append_chat(user: str, ai: str):
|
||
try:
|
||
last_ai_message[0] = (ai or "").strip()
|
||
prev = txt_chat.get("1.0", "end").strip()
|
||
sep = "\n\n---\n\n" if prev else ""
|
||
txt_chat.insert("end", sep + f"Du: {user}\n\nKI: {ai}")
|
||
txt_chat.see("end")
|
||
entry_msg.delete("1.0", "end")
|
||
except Exception:
|
||
pass
|
||
|
||
def do_send():
|
||
user_txt = entry_msg.get("1.0", "end").strip()
|
||
if not user_txt:
|
||
status_gesp.set("Bitte Nachricht eingeben oder diktieren.")
|
||
return
|
||
lang = get_gesp_lang()
|
||
is_lehrer = lehrer_var.get()
|
||
status_gesp.set("KI antwortet…")
|
||
entry_msg.delete("1.0", "end")
|
||
|
||
def worker():
|
||
try:
|
||
if gesp_stop_requested[0]:
|
||
return
|
||
thema_txt = entry_thema_custom.get().strip() or thema_var.get()
|
||
ai_text = chat_with_ai(user_txt, lang, is_lehrer, lern_vorlage_var.get() if is_lehrer else "", thema_txt)
|
||
if gesp_stop_requested[0]:
|
||
return
|
||
safe_after(lambda: append_chat(user_txt, ai_text))
|
||
safe_after(lambda: status_gesp.set("Vorlesen…"))
|
||
auto_save_vokabeln(user_txt, ai_text, lang, thema_txt)
|
||
speak_gesp(ai_text, lang)
|
||
if not gesp_stop_requested[0] and not gesp_manual_stop[0]:
|
||
safe_after(lambda: status_gesp.set("Fertig. Diktat startet gleich…"))
|
||
win.after(1200, auto_start_recording)
|
||
elif gesp_manual_stop[0]:
|
||
safe_after(lambda: status_gesp.set("Pausiert. Start drücken zum Fortsetzen."))
|
||
else:
|
||
safe_after(lambda: status_gesp.set("Gestoppt."))
|
||
except Exception as e:
|
||
if not gesp_stop_requested[0]:
|
||
safe_after(lambda: messagebox.showerror("Fehler", str(e)))
|
||
safe_after(lambda: status_gesp.set("Fehler."))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
ttk.Button(send_row, text="Senden", command=do_send).pack(side="left", padx=(0, 8))
|
||
|
||
def on_gespraech_return(event):
|
||
"""Enter = Senden, Shift+Enter = neue Zeile."""
|
||
if event.state & 0x1: # Shift gedrückt → Zeilenumbruch wie üblich
|
||
return
|
||
do_send()
|
||
return "break"
|
||
|
||
entry_msg.bind("<Return>", on_gespraech_return)
|
||
|
||
def toggle_gesp_diktat():
|
||
if gesp_recording[0]:
|
||
gesp_manual_stop[0] = True
|
||
gesp_recording[0] = False
|
||
btn_gesp_diktat.configure(text="⏺ Diktat")
|
||
status_gesp.set("Transkribiere…" if not gesp_manual_stop[0] else "Pausiert.")
|
||
lang = get_gesp_lang()
|
||
is_lehrer = lehrer_var.get()
|
||
|
||
def worker():
|
||
try:
|
||
if gesp_stop_requested[0]:
|
||
return
|
||
rec = gesp_recorder[0]
|
||
if not rec:
|
||
return
|
||
wav_path = rec.stop_and_save_wav()
|
||
gesp_recorder[0] = None
|
||
if gesp_stop_requested[0]:
|
||
return
|
||
text = transcribe_gesp(wav_path, lang)
|
||
try:
|
||
if os.path.exists(wav_path):
|
||
os.remove(wav_path)
|
||
except Exception:
|
||
pass
|
||
if gesp_stop_requested[0]:
|
||
return
|
||
safe_after(lambda: status_gesp.set("KI antwortet…"))
|
||
thema_txt = entry_thema_custom.get().strip() or thema_var.get()
|
||
ai_text = chat_with_ai(text, lang, is_lehrer, lern_vorlage_var.get() if is_lehrer else "", thema_txt)
|
||
if gesp_stop_requested[0]:
|
||
return
|
||
safe_after(lambda: append_chat(text, ai_text))
|
||
safe_after(lambda: status_gesp.set("Vorlesen…"))
|
||
auto_save_vokabeln(text, ai_text, lang, thema_txt)
|
||
speak_gesp(ai_text, lang)
|
||
if not gesp_stop_requested[0] and not gesp_manual_stop[0]:
|
||
safe_after(lambda: status_gesp.set("Fertig. Diktat startet gleich…"))
|
||
win.after(1200, auto_start_recording)
|
||
elif gesp_manual_stop[0]:
|
||
safe_after(lambda: status_gesp.set("Pausiert. Start drücken zum Fortsetzen."))
|
||
else:
|
||
safe_after(lambda: status_gesp.set("Gestoppt."))
|
||
except Exception as e:
|
||
if not gesp_stop_requested[0]:
|
||
safe_after(lambda: messagebox.showerror("Fehler", str(e)))
|
||
safe_after(lambda: status_gesp.set("Fehler."))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
else:
|
||
try:
|
||
gesp_manual_stop[0] = False
|
||
if not gesp_recorder[0]:
|
||
gesp_recorder[0] = AudioRecorder()
|
||
gesp_recorder[0].start()
|
||
gesp_recording[0] = True
|
||
btn_gesp_diktat.configure(text="⏹ Stopp")
|
||
status_gesp.set("Aufnahme läuft… (Stopp zum Beenden)")
|
||
|
||
except Exception as e:
|
||
messagebox.showerror("Aufnahme-Fehler", str(e))
|
||
|
||
btn_gesp_diktat = ttk.Button(btn_f, text="⏺ Diktat", command=toggle_gesp_diktat)
|
||
btn_gesp_diktat.pack(side="left", padx=(0, 8))
|
||
|
||
def do_stop_all():
|
||
"""Stoppt alles sofort: Aufnahme, Wiedergabe, pausiert Gespräch."""
|
||
gesp_stop_requested[0] = True
|
||
gesp_manual_stop[0] = True
|
||
if gesp_recording[0] and gesp_recorder[0]:
|
||
try:
|
||
gesp_recorder[0].stop_and_save_wav()
|
||
except Exception:
|
||
pass
|
||
gesp_recorder[0] = None
|
||
gesp_recording[0] = False
|
||
btn_gesp_diktat.configure(text="⏺ Diktat")
|
||
try:
|
||
import pygame
|
||
pygame.mixer.music.stop()
|
||
pygame.mixer.quit()
|
||
except Exception:
|
||
pass
|
||
gesp_playing[0] = False
|
||
status_gesp.set("Gestoppt.")
|
||
win.after(800, lambda: gesp_stop_requested.__setitem__(0, False))
|
||
|
||
ttk.Button(btn_f, text="⏹ Stopp alles", command=do_stop_all).pack(side="left", padx=(0, 8))
|
||
|
||
def do_gesp_neu():
|
||
gesp_stop_requested[0] = True
|
||
if gesp_recording[0] and gesp_recorder[0]:
|
||
try:
|
||
gesp_recorder[0].stop_and_save_wav()
|
||
except Exception:
|
||
pass
|
||
gesp_recorder[0] = None
|
||
gesp_recording[0] = False
|
||
btn_gesp_diktat.configure(text="⏺ Diktat")
|
||
try:
|
||
import pygame
|
||
pygame.mixer.music.stop()
|
||
pygame.mixer.quit()
|
||
except Exception:
|
||
pass
|
||
gesp_stop_requested[0] = False
|
||
try:
|
||
txt_chat.delete("1.0", "end")
|
||
entry_msg.delete("1.0", "end")
|
||
except Exception:
|
||
pass
|
||
status_gesp.set("Bereit.")
|
||
|
||
ttk.Button(btn_f, text="Neu", command=do_gesp_neu).pack(side="left", padx=(0, 8))
|
||
|
||
def do_nochmal_vorlesen():
|
||
"""Lässt die letzte KI-Antwort noch einmal vorlesen."""
|
||
txt = last_ai_message[0]
|
||
if not txt:
|
||
status_gesp.set("Nichts zum Vorlesen – zuerst eine KI-Antwort abwarten.")
|
||
return
|
||
status_gesp.set("Nochmal vorlesen…")
|
||
|
||
def worker():
|
||
speak_gesp(txt, get_gesp_lang())
|
||
safe_after(lambda: status_gesp.set("Fertig."))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
ttk.Button(btn_f, text="Nochmal vorlesen", command=do_nochmal_vorlesen).pack(side="left", padx=(0, 8))
|
||
|
||
current_gespraech_path = [None]
|
||
current_gespraech_titel = [None]
|
||
|
||
def do_gespraech_speichern():
|
||
"""Gespräch als JSON speichern. Wenn geladen oder schon gespeichert: gleiche Datei aktualisieren, sonst neue Datei."""
|
||
hist = txt_chat.get("1.0", "end").strip()
|
||
if not hist:
|
||
messagebox.showinfo("Gespräch speichern", "Kein Gespräch zum Speichern.")
|
||
return
|
||
status_gesp.set("Speichere Gespräch…")
|
||
lang = get_gesp_lang()
|
||
lang_name = LANGUAGES.get(lang, lang)
|
||
|
||
def worker():
|
||
try:
|
||
now = datetime.now()
|
||
datum = now.strftime("%d.%m.%Y")
|
||
uhrzeit = now.strftime("%H:%M")
|
||
path = current_gespraech_path[0]
|
||
titel = current_gespraech_titel[0]
|
||
|
||
if path and titel:
|
||
# Weitergeführtes Gespräch: bestehende Datei aktualisieren
|
||
data = {
|
||
"titel": titel,
|
||
"datum": datum,
|
||
"uhrzeit": uhrzeit,
|
||
"sprache": lang_name,
|
||
"sprache_code": lang,
|
||
"chat": hist,
|
||
}
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
safe_after(lambda: status_gesp.set(f"Gespräch aktualisiert: {titel}"))
|
||
return
|
||
# Neue Gespräch: KI für Überschrift, neue Datei
|
||
r = client.chat.completions.create(
|
||
model="gpt-4o-mini",
|
||
messages=[
|
||
{"role": "system", "content": "Gib nur eine kurze deutsche Überschrift (3–8 Wörter) für dieses Gespräch. Keine Anführungszeichen, nur die Überschrift, eine Zeile."},
|
||
{"role": "user", "content": hist[:3000]},
|
||
],
|
||
)
|
||
titel = (r.choices[0].message.content or "Gespräch").strip().strip('"\'')
|
||
if not titel:
|
||
titel = "Gespräch"
|
||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||
gesp_dir = os.path.join(base_dir, "Lernmodus_Export", "Gespeicherte_Gespraeche")
|
||
os.makedirs(gesp_dir, exist_ok=True)
|
||
safe_titel = "".join(c for c in titel[:50] if c.isalnum() or c in " _-") or "Gespraech"
|
||
fname = f"Gespraech_{now.strftime('%Y-%m-%d_%H-%M')}_{safe_titel}.json"
|
||
path = os.path.join(gesp_dir, fname)
|
||
data = {
|
||
"titel": titel,
|
||
"datum": datum,
|
||
"uhrzeit": uhrzeit,
|
||
"sprache": lang_name,
|
||
"sprache_code": lang,
|
||
"chat": hist,
|
||
}
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
current_gespraech_path[0] = path
|
||
current_gespraech_titel[0] = titel
|
||
safe_after(lambda: status_gesp.set(f"Gespräch gespeichert: {titel}"))
|
||
except Exception as e:
|
||
safe_after(lambda: messagebox.showerror("Fehler", str(e)))
|
||
safe_after(lambda: status_gesp.set("Fehler beim Speichern."))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def do_gespraech_laden():
|
||
"""Gespräch aus JSON-Datei laden."""
|
||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||
gesp_dir = os.path.join(base_dir, "Lernmodus_Export", "Gespeicherte_Gespraeche")
|
||
if not os.path.isdir(gesp_dir):
|
||
os.makedirs(gesp_dir, exist_ok=True)
|
||
path = filedialog.askopenfilename(
|
||
title="Gespräch laden",
|
||
initialdir=gesp_dir,
|
||
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
|
||
)
|
||
if not path:
|
||
return
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
chat_text = data.get("chat", "")
|
||
if not chat_text:
|
||
messagebox.showwarning("Gespräch laden", "In der Datei ist kein Gesprächsinhalt.")
|
||
return
|
||
txt_chat.delete("1.0", "end")
|
||
txt_chat.insert("1.0", chat_text)
|
||
titel = data.get("titel", "–")
|
||
datum = data.get("datum", "")
|
||
uhrzeit = data.get("uhrzeit", "")
|
||
current_gespraech_path[0] = path
|
||
current_gespraech_titel[0] = titel
|
||
status_gesp.set(f"Geladen: {titel} ({datum} {uhrzeit})")
|
||
except Exception as e:
|
||
messagebox.showerror("Gespräch laden", str(e))
|
||
|
||
ttk.Button(btn_f, text="Gespräch speichern", command=do_gespraech_speichern).pack(side="left", padx=(0, 8))
|
||
ttk.Button(btn_f, text="Gespräch laden", command=do_gespraech_laden).pack(side="left", padx=(0, 8))
|
||
|
||
def open_export_ordner():
|
||
"""Öffnet den Lernmodus-Export-Ordner im Explorer."""
|
||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||
lernmodus_dir = os.path.join(base_dir, "Lernmodus_Export")
|
||
os.makedirs(lernmodus_dir, exist_ok=True)
|
||
try:
|
||
if os.name == "nt":
|
||
os.startfile(lernmodus_dir)
|
||
else:
|
||
import subprocess
|
||
subprocess.run(["xdg-open", lernmodus_dir], check=False)
|
||
except Exception:
|
||
import subprocess
|
||
subprocess.run(["explorer", lernmodus_dir] if os.name == "nt" else ["xdg-open", lernmodus_dir], check=False)
|
||
|
||
def do_speichern():
|
||
"""Speichert Wortschatz und Korrekturen – als Text oder JSON in Lernmodus-Ordner."""
|
||
hist = txt_chat.get("1.0", "end").strip()
|
||
if not hist:
|
||
messagebox.showinfo("Speichern", "Kein Gespräch zum Speichern.")
|
||
return
|
||
thema = thema_var.get().strip() or "Lernmodus"
|
||
mit_uebersetzung = save_trans_var.get()
|
||
lang = get_gesp_lang()
|
||
lang_name = LANGUAGES.get(lang, lang)
|
||
status_gesp.set("Speichere…")
|
||
|
||
def worker():
|
||
try:
|
||
sys_extract = (
|
||
f"Du bist ein Sprachlehrer. Extrahiere aus dem Gespräch:\n"
|
||
f"1. KORREKTUREN (Priorität!): Jede sprachliche Korrektur als 'falsch → richtig – Übersetzung'.\n"
|
||
f"2. VOKABELN: 5–15 wichtige Wörter/Phrasen.\n\n"
|
||
)
|
||
if mit_uebersetzung:
|
||
sys_extract += (
|
||
"WICHTIG – Reihenfolge: Zuerst MUTTERSPRACHE (Deutsch, links), dann Fremdsprache (rechts). "
|
||
"Format: Deutsch – " + lang_name + " (z.B. 'Guten Tag – buongiorno', nicht umgekehrt).\n\n"
|
||
)
|
||
sys_extract += "Antworte auf " + lang_name + ". Struktur:\n"
|
||
sys_extract += "=== KORREKTUREN ===\nFalsch → Richtig – Deutsche Übersetzung (je Zeile)\n=== VOKABELN ===\nDeutsch – " + lang_name + " (je Zeile, Muttersprache links)\n"
|
||
r = client.chat.completions.create(
|
||
model="gpt-4o-mini",
|
||
messages=[
|
||
{"role": "system", "content": sys_extract},
|
||
{"role": "user", "content": hist},
|
||
],
|
||
)
|
||
extracted = (r.choices[0].message.content or "").strip()
|
||
if not extracted:
|
||
safe_after(lambda: messagebox.showwarning("Speichern", "Nichts zum Speichern extrahiert."))
|
||
return
|
||
now = datetime.now()
|
||
datum = now.strftime("%Y-%m-%d")
|
||
uhrzeit = now.strftime("%H:%M")
|
||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||
lernmodus_dir = os.path.join(base_dir, "Lernmodus_Export")
|
||
os.makedirs(lernmodus_dir, exist_ok=True)
|
||
safe_name = "".join(c for c in thema[:40] if c.isalnum() or c in " _-") or "Lernmodus"
|
||
header_txt = f"{thema}\nDatum: {datum} Uhrzeit: {uhrzeit}\nSprache: {lang_name}\n{'=' * 50}\n\n"
|
||
base_name_heute = f"{safe_name}_{datum}_{now.strftime('%H%M')}"
|
||
fmt = "Lernkarten"
|
||
if fmt == "json":
|
||
data = {
|
||
"thema": thema,
|
||
"datum": datum,
|
||
"uhrzeit": uhrzeit,
|
||
"sprache": lang_name,
|
||
"vokabeln": [],
|
||
"korrekturen": [],
|
||
"volltext": extracted,
|
||
}
|
||
lines = extracted.replace("\r", "").split("\n")
|
||
section = None
|
||
for line in lines:
|
||
if "KORREKTUR" in line.upper():
|
||
section = "korrekturen"
|
||
continue
|
||
if "VOKABEL" in line.upper() or "WORT" in line.upper():
|
||
section = "vokabeln"
|
||
continue
|
||
line = line.strip()
|
||
if line and section and not line.startswith("==="):
|
||
data[section].append(line)
|
||
save_path = os.path.join(lernmodus_dir, base_name_heute + ".json")
|
||
with open(save_path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
kumulativ_path = os.path.join(lernmodus_dir, f"{safe_name}_Gesamtliste.json")
|
||
try:
|
||
old_data = json.load(open(kumulativ_path, encoding="utf-8")) if os.path.exists(kumulativ_path) else {"eintraege": []}
|
||
except Exception:
|
||
old_data = {"eintraege": []}
|
||
old_data.setdefault("eintraege", []).append({"datum": datum, "uhrzeit": uhrzeit, "korrekturen": data["korrekturen"], "vokabeln": data["vokabeln"]})
|
||
with open(kumulativ_path, "w", encoding="utf-8") as f:
|
||
json.dump(old_data, f, indent=2, ensure_ascii=False)
|
||
elif fmt == "Lernkarten":
|
||
lines = extracted.replace("\r", "").split("\n")
|
||
vokabeln, korrekturen = [], []
|
||
section = None
|
||
for line in lines:
|
||
if "KORREKTUR" in line.upper():
|
||
section = "k"
|
||
continue
|
||
if "VOKABEL" in line.upper() or "WORT" in line.upper():
|
||
section = "v"
|
||
continue
|
||
line = line.strip()
|
||
if line and section and not line.startswith("==="):
|
||
if section == "v":
|
||
vokabeln.append(line)
|
||
else:
|
||
korrekturen.append(line)
|
||
save_path = os.path.join(lernmodus_dir, base_name_heute + "_Lernkarten.txt")
|
||
with open(save_path, "w", encoding="utf-8") as f:
|
||
f.write(header_txt)
|
||
f.write("=== VOKABELN (Vorderseite | Rückseite) ===\n\n")
|
||
for v in vokabeln:
|
||
if " – " in v:
|
||
a, b = v.split(" – ", 1)
|
||
# Muttersprache (links) = Vorderseite, Fremdsprache (rechts) = Rückseite
|
||
f.write(f"Vorderseite: {a.strip()}\nRückseite: {b.strip()}\n\n")
|
||
else:
|
||
f.write(f"Vorderseite: {v}\nRückseite: (Übersetzung eintragen)\n\n")
|
||
f.write("=== KORREKTUREN (Falsch | Richtig) ===\n\n")
|
||
for k in korrekturen:
|
||
if "→" in k:
|
||
a, b = k.split("→", 1)
|
||
f.write(f"Falsch: {a.strip()}\nRichtig: {b.strip()}\n\n")
|
||
else:
|
||
f.write(f"{k}\n\n")
|
||
kumulativ_path = os.path.join(lernmodus_dir, f"{safe_name}_Gesamtliste_Lernkarten.txt")
|
||
lang_out_name = LANGUAGES.get(lang, lang).strip()
|
||
if lang_out_name:
|
||
lang_out_name = lang_out_name[0].upper() + lang_out_name[1:].lower()
|
||
gesp_header = f"Deutsch / {lang_out_name}"
|
||
# Format: Muttersprache (links) – Fremdsprache (rechts), Zeilen max. 100 Zeichen, Neuestes zuoberst
|
||
new_entries_raw = [f"{a.strip()} = {b.strip()}" for v in vokabeln if " – " in v for a, b in [v.split(" – ", 1)]]
|
||
new_entries_wrapped = []
|
||
for e in new_entries_raw:
|
||
new_entries_wrapped.extend(textwrap.wrap(e, width=LERNKARTEN_ZIELZEILELLAENGE, break_long_words=False))
|
||
content = ""
|
||
if os.path.exists(kumulativ_path):
|
||
with open(kumulativ_path, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
lines_f = content.split("\n")
|
||
out_f = []
|
||
i = 0
|
||
found_gesp = False
|
||
while i < len(lines_f):
|
||
line = lines_f[i]
|
||
st = line.strip()
|
||
is_h = (" / " in st and " = " not in st and st and not st.startswith("===") and not st.startswith("###"))
|
||
if is_h and st.lower() == gesp_header.lower():
|
||
out_f.append(line)
|
||
i += 1
|
||
old_section_lines = []
|
||
while i < len(lines_f) and lines_f[i].strip() and " = " in lines_f[i]:
|
||
old_section_lines.append(lines_f[i])
|
||
i += 1
|
||
for e in new_entries_wrapped:
|
||
out_f.append(e)
|
||
out_f.extend(old_section_lines)
|
||
found_gesp = True
|
||
continue
|
||
out_f.append(line)
|
||
i += 1
|
||
if not found_gesp:
|
||
if out_f and out_f[-1].strip():
|
||
out_f.append("")
|
||
out_f.append(gesp_header)
|
||
out_f.extend(new_entries_wrapped)
|
||
with open(kumulativ_path, "w", encoding="utf-8") as f:
|
||
f.write("\n".join(out_f))
|
||
else:
|
||
save_path_heute = os.path.join(lernmodus_dir, base_name_heute + ".txt")
|
||
with open(save_path_heute, "w", encoding="utf-8") as f:
|
||
f.write(header_txt)
|
||
f.write(extracted)
|
||
kumulativ_path = os.path.join(lernmodus_dir, f"{safe_name}_Gesamtliste.txt")
|
||
with open(kumulativ_path, "a", encoding="utf-8") as f:
|
||
f.write(f"\n\n### {datum} {uhrzeit} ###\n\n")
|
||
f.write(extracted)
|
||
save_path = save_path_heute
|
||
safe_after(lambda: status_gesp.set("Gespeichert."))
|
||
except Exception as e:
|
||
safe_after(lambda: messagebox.showerror("Fehler", str(e)))
|
||
safe_after(lambda: status_gesp.set("Fehler."))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
export_row = ttk.Frame(win, padding=(10, 0, 10, 10))
|
||
export_row.pack(fill="x")
|
||
ttk.Button(export_row, text="📁 Ordner Lernkarten", command=open_export_ordner).pack(side="left", padx=(0, 8))
|
||
ttk.Button(export_row, text="💾 Gespräch für Lernkarten speichern", command=do_speichern).pack(side="left", padx=(0, 8))
|
||
ttk.Button(export_row, text="📝 Lernkarten üben", command=start_lernkarten_abfrage).pack(side="left", padx=(0, 8))
|
||
|
||
root.mainloop()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# Gleiche Keygen-Lizenz wie basis14 (ein Schlüssel für KG-Diktat + Translate)
|
||
load_dotenv()
|
||
try:
|
||
from keygen_license import show_license_dialog_and_exit_if_invalid
|
||
show_license_dialog_and_exit_if_invalid("KG-Diktat / Translate")
|
||
except ImportError:
|
||
pass
|
||
|
||
main()
|