Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,23 @@ Die KI-Workflows helfen bei Formulierung, Ton und Emojis. Die passenden Einstell
<br><br>
</div>

### 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
Expand Down Expand Up @@ -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`).
</details>

---
Expand Down
11 changes: 11 additions & 0 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
17 changes: 17 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand All @@ -34,6 +36,7 @@
"emoji_density": "mittel",
"dampf_system_prompt": "",
"custom_terms": [],
"writing_preset": DEFAULT_PRESET_KEY,
},
}

Expand All @@ -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",
Expand Down Expand Up @@ -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", []))
Expand Down Expand Up @@ -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"))


Expand Down
6 changes: 5 additions & 1 deletion app/llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -51,13 +52,15 @@ 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"
self.tone = tone
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
Expand Down Expand Up @@ -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}")
Expand Down
109 changes: 109 additions & 0 deletions app/writing_presets.py
Original file line number Diff line number Diff line change
@@ -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)
51 changes: 51 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading