diff --git a/README.md b/README.md
index b722a4d..4c44b69 100644
--- a/README.md
+++ b/README.md
@@ -155,6 +155,23 @@ Die KI-Workflows helfen bei Formulierung, Ton und Emojis. Die passenden Einstell
+### Schreibstil-Vorlagen
+
+Für den Workflow **Blitztext+** (Text-Verbesserer) gibt es vorgefertigte Schreibstil-Vorlagen, die du unter **Einstellungen → KI-Workflows → „Schreibstil-Vorlage"** auswählst:
+
+| Vorlage | Wirkung |
+| --- | --- |
+| **Standard (Text verbessern)** | Bisheriges Verhalten – sauber formatierter Text, der gewählte **Tonfall** greift. |
+| **E-Mail – formell** | Höfliche E-Mail in der Sie-Form mit klarer Struktur. |
+| **E-Mail – locker** | Freundliche E-Mail in der Du-Form. |
+| **Stichpunkte** | Gliedert den Inhalt in prägnante Stichpunkte. |
+| **Zusammenfassung** | Knappe, sachliche Zusammenfassung der Kernaussagen. |
+| **Persönlich (Du-Form)** | Klarer Text in der persönlichen Du-Form. |
+| **Höflich (Sie-Form)** | Klarer Text in der höflichen Sie-Form. |
+| **Kurz & präzise** | Maximal knapp, ohne Füllwörter und Wiederholungen. |
+
+> Bei **Standard** wird zusätzlich der eingestellte **Tonfall** angewendet. Jede andere Vorlage bringt ihren eigenen Schreibstil mit und ersetzt den Tonfall. Eigennamen/Begriffe bleiben in allen Vorlagen erhalten.
+
---
## Tray-Symbol: Statusfarben
@@ -284,7 +301,7 @@ Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespei
- Der eigentliche Key liegt nicht in `config.json`, sondern in `~/.config/blitztext-linux/secrets.env` oder einer bereits gesetzten Umgebungsvariable.
- **autopaste**: Fügt per `ydotool` ein.
- **audio_device**: Name der Audioquelle.
-- **workflows**: Feintuning von Tonalität, Emojis und dem Dampf-Prompt.
+- **workflows**: Feintuning von Tonalität (`text_improver_tone`), Schreibstil-Vorlage (`writing_preset`), Emojis (`emoji_density`) und dem Dampf-Prompt (`dampf_system_prompt`).
---
diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py
index a8f44cf..c1212bb 100644
--- a/app/blitztext_linux.py
+++ b/app/blitztext_linux.py
@@ -32,6 +32,7 @@
from app.config import Config, VALID_HOTKEY_KEYS
from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS, LLMServiceError
+from app.writing_presets import WRITING_PRESETS, WRITING_PRESET_KEYS, preset_index
from app.hotkey_service import HotkeyWorker
from app.audio_recorder import AudioRecorder, AudioRecorderError
from app.transcribe import transcribe, TranscribeError
@@ -223,6 +224,11 @@ def init_ui(self) -> None:
self.combo_tone.addItems(["formal", "neutral", "locker"])
self.combo_tone.setCurrentText(self.config.text_improver_tone)
+ self.combo_writing_preset = QComboBox()
+ for key in WRITING_PRESET_KEYS:
+ self.combo_writing_preset.addItem(WRITING_PRESETS[key].display_name, key)
+ self.combo_writing_preset.setCurrentIndex(preset_index(self.config.writing_preset))
+
self.combo_emoji = QComboBox()
self.combo_emoji.addItems(["wenig", "mittel", "viel"])
self.combo_emoji.setCurrentText(self.config.emoji_density)
@@ -258,6 +264,8 @@ def init_ui(self) -> None:
form_llm.addRow("", create_help_label("Nur der Name der Umgebungsvariable wird gespeichert. Der Schlüssel selbst wird aus os.environ gelesen."))
form_llm.addRow("Text-Verbesserer Tonfall:", self.combo_tone)
+ form_llm.addRow("Schreibstil-Vorlage:", self.combo_writing_preset)
+ form_llm.addRow("", create_help_label("Vorlage für den Text-Verbesserer (z. B. E-Mail formell, Stichpunkte). Bei 'Standard' greift der Tonfall oben; jede andere Vorlage bestimmt den Schreibstil selbst und ersetzt den Tonfall."))
form_llm.addRow("Emoji-Dichte:", self.combo_emoji)
form_llm.addRow("Dampf-Umschreiber Prompt:", self.edit_dampf_prompt)
@@ -383,6 +391,7 @@ def save_settings(self) -> None:
self.config.openai_api_key_env = self.edit_api_key_env.text().strip()
self.config.text_improver_tone = self.combo_tone.currentText()
+ self.config.writing_preset = self.combo_writing_preset.currentData()
self.config.emoji_density = self.combo_emoji.currentText()
self.config.dampf_system_prompt = self.edit_dampf_prompt.toPlainText().strip()
self.config.custom_terms = self._collect_custom_terms()
@@ -497,6 +506,7 @@ def __init__(self, app: QApplication) -> None:
dampf_system_prompt=self.config.dampf_system_prompt,
custom_terms=self.config.custom_terms,
api_key_env=self.config.openai_api_key_env,
+ writing_preset=self.config.writing_preset,
)
self.audio_recorder = AudioRecorder()
self.paste_service = PasteService(autopaste=self.config.autopaste)
@@ -672,6 +682,7 @@ def show_settings_dialog(self) -> None:
dampf_system_prompt=self.config.dampf_system_prompt,
custom_terms=self.config.custom_terms,
api_key_env=self.config.openai_api_key_env,
+ writing_preset=self.config.writing_preset,
)
self.update_menu_availability()
diff --git a/app/config.py b/app/config.py
index 1828b78..0f2c0d2 100644
--- a/app/config.py
+++ b/app/config.py
@@ -14,6 +14,8 @@
from pathlib import Path
from typing import Any
+from app.writing_presets import DEFAULT_PRESET_KEY, WRITING_PRESET_KEYS
+
logger = logging.getLogger("blitztext.config")
DEFAULTS: dict[str, Any] = {
@@ -34,6 +36,7 @@
"emoji_density": "mittel",
"dampf_system_prompt": "",
"custom_terms": [],
+ "writing_preset": DEFAULT_PRESET_KEY,
},
}
@@ -42,6 +45,7 @@
VALID_HOTKEY_MODES = {"toggle", "hold"}
VALID_TONES = {"formal", "neutral", "locker"}
VALID_EMOJI_DENSITIES = {"wenig", "mittel", "viel"}
+VALID_WRITING_PRESETS = set(WRITING_PRESET_KEYS)
VALID_HOTKEY_KEYS = {
"KEY_LEFTALT", "KEY_RIGHTALT", "KEY_RIGHTCTRL", "KEY_LEFTCTRL",
"KEY_F13", "KEY_F14", "KEY_F15", "KEY_F16",
@@ -267,6 +271,16 @@ def dampf_system_prompt(self) -> str:
def dampf_system_prompt(self, value: str) -> None:
self._data["workflows"]["dampf_system_prompt"] = value
+ @property
+ def writing_preset(self) -> str:
+ return self._data["workflows"].get("writing_preset", DEFAULT_PRESET_KEY)
+
+ @writing_preset.setter
+ def writing_preset(self, value: str) -> None:
+ if value not in VALID_WRITING_PRESETS:
+ raise ValueError(f"Ungueltiges Schreib-Preset: {value!r}. Gueltig: {sorted(VALID_WRITING_PRESETS)}")
+ self._data["workflows"]["writing_preset"] = value
+
@property
def custom_terms(self) -> list[str]:
return list(self._data["workflows"].get("custom_terms", []))
@@ -323,6 +337,9 @@ def _validate_and_sanitize(self) -> None:
wf["text_improver_tone"] = "neutral"
if wf.get("emoji_density") not in VALID_EMOJI_DENSITIES:
wf["emoji_density"] = "mittel"
+ preset_value = wf.get("writing_preset")
+ if not isinstance(preset_value, str) or preset_value not in VALID_WRITING_PRESETS:
+ wf["writing_preset"] = DEFAULT_PRESET_KEY
wf["custom_terms"] = _sanitize_terms(wf.get("custom_terms"))
diff --git a/app/llm_service.py b/app/llm_service.py
index 58deb0a..f60fe76 100644
--- a/app/llm_service.py
+++ b/app/llm_service.py
@@ -5,6 +5,7 @@
from typing import Any, Optional
from app.workflows import WorkflowType
+from app.writing_presets import DEFAULT_PRESET_KEY, get_preset
logger = logging.getLogger("blitztext.llm_service")
@@ -51,6 +52,7 @@ def __init__(
dampf_system_prompt: str = "",
custom_terms: Optional[list[str]] = None,
api_key_env: str = "OPENAI_API_KEY",
+ writing_preset: str = DEFAULT_PRESET_KEY,
) -> None:
self.api_key = api_key or ""
self.api_key_env = api_key_env or "OPENAI_API_KEY"
@@ -58,6 +60,7 @@ def __init__(
self.emoji_density = emoji_density
self.dampf_system_prompt = dampf_system_prompt
self.custom_terms = self._sanitize_terms(custom_terms)
+ self.writing_preset = writing_preset or DEFAULT_PRESET_KEY
self._openai_installed = True
self._client_is_fallback_mock = False
@@ -202,7 +205,8 @@ def rewrite(self, workflow: WorkflowType, transcript: str) -> str:
if workflow == WorkflowType.DAMPF_ABLASSEN:
return self.dampf_ablassen(transcript, custom_system_prompt=self.dampf_system_prompt)
if workflow == WorkflowType.TEXT_IMPROVER:
- return self.text_improver(transcript, tone=self.tone)
+ preset = get_preset(self.writing_preset)
+ return self.text_improver(transcript, tone=self.tone, custom_prompt=preset.system_prompt)
if workflow == WorkflowType.EMOJI_TEXT:
return self.emoji_text(transcript, density=self.emoji_density)
raise LLMServiceError(f"Unsupported workflow: {workflow}")
diff --git a/app/writing_presets.py b/app/writing_presets.py
new file mode 100644
index 0000000..36b20f6
--- /dev/null
+++ b/app/writing_presets.py
@@ -0,0 +1,109 @@
+"""Vorgefertigte Schreibstil-Vorlagen (Presets) für den Text-Verbesserer.
+
+Reine Domänendaten – keine Qt- oder OpenAI-Abhängigkeit, damit der Katalog
+isoliert testbar bleibt. Ein Preset liefert einen vollständigen System-Prompt,
+der im Text-Verbesserer-Workflow als ``custom_prompt`` verwendet wird. Das
+Preset ``standard`` hat einen leeren Prompt und bewahrt damit exakt das
+bisherige Verhalten (Standard-Template des Text-Verbesserers).
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+DEFAULT_PRESET_KEY = "standard"
+
+_COMMON_RULES = (
+ " Behalte den Inhalt vollständig und erfinde nichts dazu. Korrigiere "
+ "Grammatik und Zeichensetzung. Gib NUR den fertigen Text zurück, ohne "
+ "Vorbemerkung oder Erklärung."
+)
+
+
+@dataclass(frozen=True)
+class WritingPreset:
+ """Eine auswählbare Schreibstil-Vorlage.
+
+ Attributes:
+ key: Stabiler Bezeichner (in der Config gespeichert).
+ display_name: Anzeigename für die Einstellungen.
+ system_prompt: System-Prompt für den Text-Verbesserer. Leer = Standard.
+ """
+
+ key: str
+ display_name: str
+ system_prompt: str
+
+
+_PRESETS: tuple[WritingPreset, ...] = (
+ WritingPreset(
+ "standard",
+ "Standard (Text verbessern)",
+ "",
+ ),
+ WritingPreset(
+ "email_formal",
+ "E-Mail – formell",
+ "Du erhältst ein gesprochenes Transkript. Formuliere daraus eine "
+ "formelle, höfliche E-Mail in der Sie-Form mit klarer Struktur "
+ "(passende Anrede, Hauptteil, freundlicher Gruß)." + _COMMON_RULES,
+ ),
+ WritingPreset(
+ "email_locker",
+ "E-Mail – locker",
+ "Du erhältst ein gesprochenes Transkript. Formuliere daraus eine "
+ "lockere, freundliche E-Mail in der Du-Form mit natürlichem, "
+ "persönlichem Ton." + _COMMON_RULES,
+ ),
+ WritingPreset(
+ "stichpunkte",
+ "Stichpunkte",
+ "Du erhältst ein gesprochenes Transkript. Gliedere den Inhalt in "
+ "prägnante Stichpunkte (eine Aussage pro Punkt, je mit '- ' "
+ "beginnend)." + _COMMON_RULES,
+ ),
+ WritingPreset(
+ "zusammenfassung",
+ "Zusammenfassung",
+ "Du erhältst ein gesprochenes Transkript. Fasse die Kernaussagen "
+ "knapp und sachlich in wenigen Sätzen zusammen." + _COMMON_RULES,
+ ),
+ WritingPreset(
+ "du_form",
+ "Persönlich (Du-Form)",
+ "Du erhältst ein gesprochenes Transkript. Formuliere es zu einem "
+ "klaren, gut lesbaren Text in der persönlichen Du-Form um." + _COMMON_RULES,
+ ),
+ WritingPreset(
+ "sie_form",
+ "Höflich (Sie-Form)",
+ "Du erhältst ein gesprochenes Transkript. Formuliere es zu einem "
+ "klaren, gut lesbaren Text in der höflichen Sie-Form um." + _COMMON_RULES,
+ ),
+ WritingPreset(
+ "kurz_praezise",
+ "Kurz & präzise",
+ "Du erhältst ein gesprochenes Transkript. Formuliere es maximal kurz "
+ "und präzise um: entferne Füllwörter und Wiederholungen, behalte aber "
+ "alle wesentlichen Informationen." + _COMMON_RULES,
+ ),
+)
+
+WRITING_PRESETS: dict[str, WritingPreset] = {preset.key: preset for preset in _PRESETS}
+WRITING_PRESET_KEYS: tuple[str, ...] = tuple(preset.key for preset in _PRESETS)
+
+
+def get_preset(key: str) -> WritingPreset:
+ """Liefert das Preset zum Schlüssel, mit Fallback auf ``standard``."""
+ return WRITING_PRESETS.get(key, WRITING_PRESETS[DEFAULT_PRESET_KEY])
+
+
+def preset_index(key: str) -> int:
+ """Position des Presets in ``WRITING_PRESET_KEYS`` (für Auswahl-Widgets).
+
+ Unbekannte Schlüssel liefern den Index von ``standard``, sodass die UI
+ immer eine gültige Vorauswahl trifft.
+ """
+ try:
+ return WRITING_PRESET_KEYS.index(key)
+ except ValueError:
+ return WRITING_PRESET_KEYS.index(DEFAULT_PRESET_KEY)
diff --git a/tests/test_config.py b/tests/test_config.py
index 7475247..743a765 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -83,6 +83,57 @@ def test_custom_terms_are_sanitized(self, config):
assert config.custom_terms == ["Blitztext", "OpenRouter"]
+class TestWritingPreset:
+ def test_default_writing_preset_is_standard(self, config):
+ assert config.writing_preset == "standard"
+
+ def test_valid_preset_is_accepted_and_persists(self, config, config_dir):
+ config.writing_preset = "email_formal"
+ assert config.writing_preset == "email_formal"
+ config.save()
+
+ loaded = BlitztextConfig(config_dir=config_dir)
+ assert loaded.writing_preset == "email_formal"
+
+ def test_invalid_preset_is_rejected(self, config):
+ with pytest.raises(ValueError):
+ config.writing_preset = "gibt-es-nicht"
+
+ def test_unknown_preset_in_file_is_coerced_to_standard(self, config_dir):
+ config_dir.mkdir(parents=True, exist_ok=True)
+ (config_dir / "config.json").write_text(
+ json.dumps({"workflows": {"writing_preset": "kaputt"}}),
+ encoding="utf-8",
+ )
+
+ loaded = BlitztextConfig(config_dir=config_dir)
+ assert loaded.writing_preset == "standard"
+
+ def test_missing_preset_key_defaults_to_standard(self, config_dir):
+ config_dir.mkdir(parents=True, exist_ok=True)
+ (config_dir / "config.json").write_text(
+ json.dumps({"workflows": {"text_improver_tone": "formal"}}),
+ encoding="utf-8",
+ )
+
+ loaded = BlitztextConfig(config_dir=config_dir)
+ assert loaded.writing_preset == "standard"
+ assert loaded.text_improver_tone == "formal"
+
+ @pytest.mark.parametrize("bad_value", [[], {}, ["email_formal"], 42, None, True])
+ def test_non_string_preset_value_is_coerced_without_crash(self, config_dir, bad_value):
+ # Manuell editierte config.json mit unhashbarem/falschem Typ darf den
+ # Start nicht mit TypeError abbrechen, sondern auf "standard" zurückfallen.
+ config_dir.mkdir(parents=True, exist_ok=True)
+ (config_dir / "config.json").write_text(
+ json.dumps({"workflows": {"writing_preset": bad_value}}),
+ encoding="utf-8",
+ )
+
+ loaded = BlitztextConfig(config_dir=config_dir)
+ assert loaded.writing_preset == "standard"
+
+
class TestTranscriptionHotkey:
def test_valid_hotkey_is_accepted(self, config):
config.transcription_hotkey = "KEY_F13"
diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py
index c3c50ef..0e4e3fd 100644
--- a/tests/test_llm_service.py
+++ b/tests/test_llm_service.py
@@ -8,6 +8,7 @@
from app.llm_service import LLMService, LLMServiceError
from app.workflows import WorkflowType
+from app.writing_presets import WRITING_PRESETS
DUMMY_API_KEY = "dummy-openai-key"
@@ -97,7 +98,7 @@ class TestRewrite:
def test_rewrite_routes_to_text_improver(self, service):
with patch.object(service, "text_improver", return_value="verbessert") as patched:
result = service.rewrite(WorkflowType.TEXT_IMPROVER, "roh")
- patched.assert_called_once_with("roh", tone=service.tone)
+ patched.assert_called_once_with("roh", tone=service.tone, custom_prompt="")
assert result == "verbessert"
def test_openai_error_is_wrapped(self, service):
@@ -111,3 +112,57 @@ def test_missing_openai_package_raises_clear_error(self, service):
with pytest.raises(LLMServiceError, match="openai-Paket nicht installiert"):
service.rewrite(WorkflowType.TEXT_IMPROVER, "test")
+
+
+class TestWritingPreset:
+ def test_default_writing_preset_is_standard(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client)
+ assert service.writing_preset == "standard"
+
+ def test_standard_preset_keeps_default_text_improver_prompt(self, service, mock_client):
+ service.rewrite(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT)
+ messages = mock_client.chat.completions.create.call_args.kwargs["messages"]
+ system_message = next(m["content"] for m in messages if m["role"] == "system")
+ # Standard verwendet weiterhin das Default-Template (kein Preset-Prompt).
+ assert "Formuliere es zu einem sauberen" in system_message
+ for key, preset in WRITING_PRESETS.items():
+ if key == "standard":
+ continue
+ assert preset.system_prompt not in system_message
+
+ def test_preset_prompt_is_passed_as_system_message(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, writing_preset="email_formal")
+ service.rewrite(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT)
+ messages = mock_client.chat.completions.create.call_args.kwargs["messages"]
+ system_message = next(m["content"] for m in messages if m["role"] == "system")
+ assert WRITING_PRESETS["email_formal"].system_prompt in system_message
+
+ def test_unknown_preset_falls_back_to_standard_behavior(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, writing_preset="gibt-es-nicht")
+ service.rewrite(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT)
+ messages = mock_client.chat.completions.create.call_args.kwargs["messages"]
+ system_message = next(m["content"] for m in messages if m["role"] == "system")
+ assert "Formuliere es zu einem sauberen" in system_message
+
+ def test_custom_terms_still_applied_with_preset(self, mock_client):
+ service = LLMService(
+ api_key=DUMMY_API_KEY,
+ client=mock_client,
+ writing_preset="stichpunkte",
+ custom_terms=CUSTOM_TERMS,
+ )
+ service.rewrite(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT)
+ messages = mock_client.chat.completions.create.call_args.kwargs["messages"]
+ system_message = next(m["content"] for m in messages if m["role"] == "system")
+ assert WRITING_PRESETS["stichpunkte"].system_prompt in system_message
+ assert "muessen exakt so geschrieben werden" in system_message
+ assert ", ".join(CUSTOM_TERMS) in system_message
+
+ def test_transcript_stays_in_user_message_not_system(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, writing_preset="email_formal")
+ service.rewrite(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT)
+ messages = mock_client.chat.completions.create.call_args.kwargs["messages"]
+ system_message = next(m["content"] for m in messages if m["role"] == "system")
+ user_message = next(m["content"] for m in messages if m["role"] == "user")
+ assert RAW_TRANSCRIPT in user_message
+ assert RAW_TRANSCRIPT not in system_message
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
index e86c301..4b823db 100644
--- a/tests/test_settings_dialog.py
+++ b/tests/test_settings_dialog.py
@@ -92,6 +92,80 @@ def test_open_config_handles_open_failure(tmp_path):
m_box.critical.assert_not_called()
+class _Combo:
+ def __init__(self, text="", data=None):
+ self._text = text
+ self._data = data
+
+ def currentText(self):
+ return self._text
+
+ def currentData(self):
+ return self._data
+
+
+class _Edit:
+ def __init__(self, text=""):
+ self._text = text
+
+ def text(self):
+ return self._text
+
+ def toPlainText(self):
+ return self._text
+
+
+class _Check:
+ def __init__(self, checked=True):
+ self._checked = checked
+
+ def isChecked(self):
+ return self._checked
+
+
+def _fake_save_self(config_dir, preset_key):
+ config = BlitztextConfig(config_dir=config_dir)
+ return SimpleNamespace(
+ config=config,
+ combo_model=_Combo("base"),
+ combo_backend=_Combo("openai-whisper"),
+ edit_language=_Edit("de"),
+ edit_audio_device=_Edit("@DEFAULT_SOURCE@"),
+ combo_hotkey_mode=_Combo("hold"),
+ combo_transcription_key=_Combo("KEY_LEFTALT"),
+ edit_api_key_env=_Edit("OPENAI_API_KEY"),
+ combo_tone=_Combo("neutral"),
+ combo_writing_preset=_Combo(text="E-Mail – formell", data=preset_key),
+ combo_emoji=_Combo("mittel"),
+ edit_dampf_prompt=_Edit(""),
+ _collect_custom_terms=lambda: [],
+ check_autopaste=_Check(True),
+ edit_notes_folder=_Edit(""),
+ spin_history_size=_Combo("50"),
+ accept=lambda: None,
+ )
+
+
+def test_save_settings_persists_writing_preset(tmp_path):
+ config_dir = tmp_path / ".config" / "blitztext-linux"
+ fake = _fake_save_self(config_dir, "email_formal")
+
+ SettingsDialog.save_settings(fake)
+
+ assert fake.config.writing_preset == "email_formal"
+ reloaded = BlitztextConfig(config_dir=config_dir)
+ assert reloaded.writing_preset == "email_formal"
+
+
+def test_save_settings_keeps_standard_preset(tmp_path):
+ config_dir = tmp_path / ".config" / "blitztext-linux"
+ fake = _fake_save_self(config_dir, "standard")
+
+ SettingsDialog.save_settings(fake)
+
+ assert fake.config.writing_preset == "standard"
+
+
def test_refresh_api_key_status_shows_env_name_not_secret(monkeypatch):
secret_value = "dummy-openai-key"
monkeypatch.setenv("CUSTOM_OPENAI_KEY", secret_value)
diff --git a/tests/test_writing_presets.py b/tests/test_writing_presets.py
new file mode 100644
index 0000000..0ad0903
--- /dev/null
+++ b/tests/test_writing_presets.py
@@ -0,0 +1,81 @@
+"""Tests für den Schreibstil-Preset-Katalog."""
+from __future__ import annotations
+
+import pytest
+
+from app.writing_presets import (
+ DEFAULT_PRESET_KEY,
+ WRITING_PRESET_KEYS,
+ WRITING_PRESETS,
+ WritingPreset,
+ get_preset,
+ preset_index,
+)
+
+EXPECTED_KEYS = (
+ "standard",
+ "email_formal",
+ "email_locker",
+ "stichpunkte",
+ "zusammenfassung",
+ "du_form",
+ "sie_form",
+ "kurz_praezise",
+)
+
+
+class TestCatalogIntegrity:
+ def test_expected_keys_present_and_ordered(self):
+ assert WRITING_PRESET_KEYS == EXPECTED_KEYS
+
+ def test_dict_matches_key_tuple(self):
+ assert set(WRITING_PRESETS) == set(WRITING_PRESET_KEYS)
+ assert len(WRITING_PRESETS) == len(WRITING_PRESET_KEYS)
+
+ def test_default_key_is_standard(self):
+ assert DEFAULT_PRESET_KEY == "standard"
+
+ def test_standard_prompt_is_empty(self):
+ assert WRITING_PRESETS["standard"].system_prompt == ""
+
+ def test_non_standard_presets_have_prompt(self):
+ for key, preset in WRITING_PRESETS.items():
+ if key == DEFAULT_PRESET_KEY:
+ continue
+ assert preset.system_prompt.strip(), f"{key} hat keinen Prompt"
+
+ def test_every_preset_has_display_name(self):
+ for preset in WRITING_PRESETS.values():
+ assert preset.display_name.strip()
+
+ def test_preset_is_immutable(self):
+ preset = WRITING_PRESETS["standard"]
+ with pytest.raises(Exception):
+ preset.key = "geändert" # type: ignore[misc]
+
+ def test_is_writing_preset_instances(self):
+ assert all(isinstance(p, WritingPreset) for p in WRITING_PRESETS.values())
+
+
+class TestGetPreset:
+ @pytest.mark.parametrize("key", EXPECTED_KEYS)
+ def test_known_keys_return_matching_preset(self, key):
+ assert get_preset(key).key == key
+
+ def test_unknown_key_falls_back_to_standard(self):
+ assert get_preset("gibt-es-nicht").key == DEFAULT_PRESET_KEY
+
+ def test_empty_key_falls_back_to_standard(self):
+ assert get_preset("").key == DEFAULT_PRESET_KEY
+
+
+class TestPresetIndex:
+ @pytest.mark.parametrize("key", EXPECTED_KEYS)
+ def test_known_key_maps_to_its_position(self, key):
+ assert WRITING_PRESET_KEYS[preset_index(key)] == key
+
+ def test_standard_is_first(self):
+ assert preset_index("standard") == 0
+
+ def test_unknown_key_selects_standard_index(self):
+ assert preset_index("gibt-es-nicht") == preset_index("standard")