From 72be6bc1423a7734aace2a1512a45d1f4d90e064 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Mon, 20 Jul 2026 14:23:17 +0200 Subject: [PATCH 1/6] fix(compose): prevent controls overlapping in compact layout --- app/compose_window.py | 24 +++++++++++--------- tests/test_compose_window.py | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/app/compose_window.py b/app/compose_window.py index be583ef..98329d0 100644 --- a/app/compose_window.py +++ b/app/compose_window.py @@ -12,6 +12,7 @@ QCheckBox, QComboBox, QDialog, + QGridLayout, QHBoxLayout, QLabel, QPlainTextEdit, @@ -208,38 +209,39 @@ def _setup_ui(self) -> None: layout.setContentsMargins(14, 14, 14, 14) layout.setSpacing(10) - header_row = QHBoxLayout() - header_row.setSpacing(8) + header_grid = QGridLayout() + header_grid.setSpacing(8) self.lblWorkflow = QLabel() - header_row.addWidget(self.lblWorkflow) + header_grid.addWidget(self.lblWorkflow, 0, 0) self.cmbWorkflow = QComboBox() self.cmbWorkflow.setMinimumWidth(160) - header_row.addWidget(self.cmbWorkflow, 1) + header_grid.addWidget(self.cmbWorkflow, 0, 1) self.lblPreset = QLabel() - header_row.addWidget(self.lblPreset) + header_grid.addWidget(self.lblPreset, 0, 2) self.cmbPreset = QComboBox() self.cmbPreset.setMinimumWidth(180) - header_row.addWidget(self.cmbPreset, 1) + header_grid.addWidget(self.cmbPreset, 0, 3) self.lblTone = QLabel() - header_row.addWidget(self.lblTone) + header_grid.addWidget(self.lblTone, 1, 0) self.cmbTone = QComboBox() self.cmbTone.setMinimumWidth(130) - header_row.addWidget(self.cmbTone, 1) + header_grid.addWidget(self.cmbTone, 1, 1) self.chkVoiceRouting = QCheckBox() self.chkVoiceRouting.setEnabled(False) self.chkVoiceRouting.setToolTip(t("compose.voice_routing.help")) # Future hook only; phase I-1 must not alter the existing transcription path. - header_row.addWidget(self.chkVoiceRouting) + header_grid.addWidget(self.chkVoiceRouting, 1, 2, 1, 2) - header_row.addStretch(1) - layout.addLayout(header_row) + header_grid.setColumnStretch(1, 1) + header_grid.setColumnStretch(3, 1) + layout.addLayout(header_grid) self.splitMain = QSplitter(Qt.Orientation.Vertical) self.splitMain.setChildrenCollapsible(False) diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py index 6f09be1..b39a941 100644 --- a/tests/test_compose_window.py +++ b/tests/test_compose_window.py @@ -168,6 +168,49 @@ def test_window_opens_without_llm_call(compose_window): assert window.lblStatus.isVisible() is False +@gui_only +def test_compact_window_keeps_controls_separate_and_readable(qapp): + config = Config() + config.compose_signature_text = "Tim" + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), config) + window.show() + window.resize(window.minimumWidth(), window.minimumHeight()) + qapp.processEvents() + + try: + control_groups = [ + [ + ("workflow label", window.lblWorkflow), + ("workflow selector", window.cmbWorkflow), + ("preset label", window.lblPreset), + ("preset selector", window.cmbPreset), + ("tone label", window.lblTone), + ("tone selector", window.cmbTone), + ("voice routing", window.chkVoiceRouting), + ], + [ + ("copy", window.btnCopy), + ("paste", window.btnPaste), + ("signature", window.btnSignature), + ("close", window.btnClose), + ], + ] + for controls in control_groups: + visible_controls = [ + (name, control) for name, control in controls if control.isVisible() + ] + for index, (name, control) in enumerate(visible_controls): + assert control.width() >= control.sizeHint().width(), name + for other_name, other in visible_controls[index + 1 :]: + assert not control.geometry().intersects(other.geometry()), ( + name, + other_name, + ) + finally: + window.close() + qapp.processEvents() + + @gui_only def test_input_reaches_direct_llm_path_and_sets_output(compose_window, qapp): window, llm, _paste = compose_window From 500bac29f010f1b2efdefc6a4147340fe8a3a6c1 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Mon, 20 Jul 2026 15:01:33 +0200 Subject: [PATCH 2/6] feat(compose): route voice recordings to draft --- app/blitztext_linux.py | 49 +++++++++++--- app/compose_window.py | 9 ++- app/i18n.py | 4 +- app/theme.py | 6 ++ tests/test_compose_window.py | 54 +++++++++++++-- tests/test_state_machine.py | 128 +++++++++++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 20 deletions(-) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 0d790ce..b98d40f 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -554,6 +554,7 @@ def __init__( autopaste: bool, paste_service: PasteService, custom_terms: Optional[list[str]] = None, + route_to_compose: bool = False, ) -> None: super().__init__() self.signals = _WorkerSignals() @@ -566,6 +567,7 @@ def __init__( self.autopaste = autopaste self.paste_service = paste_service self.custom_terms = list(custom_terms or []) + self.route_to_compose = route_to_compose def _emit(self, signal_name: str, *args) -> None: try: @@ -587,19 +589,22 @@ def run(self) -> None: if not transcript or not transcript.strip(): raise TranscribeError("Keine Sprache im Audio erkannt.") - # LLM rewrite if it is an LLM workflow. rewrite() meldet einen - # fehlenden API-Key selbst als LLMServiceError mit Env-Var-Hinweis. - if self.workflow in LLM_WORKFLOWS: + # Compose routing always receives the raw recognized text; the + # compose window owns any later rewrite workflow selected there. + # rewrite() reports a missing API key as LLMServiceError itself. + if self.workflow in LLM_WORKFLOWS and not self.route_to_compose: self._emit("status_changed", "rewriting") result_text = self.llm_service.rewrite(self.workflow, transcript) else: result_text = transcript - # Paste - if self.autopaste: - self.paste_service.paste(result_text) - else: - self.paste_service.clipboard_only(result_text) + # Compose routing is delivered by the GUI coordinator. Keeping it + # out of PasteService preserves the focused application's contents. + if not self.route_to_compose: + if self.autopaste: + self.paste_service.paste(result_text) + else: + self.paste_service.clipboard_only(result_text) self._emit("result", result_text) except Exception as e: @@ -633,6 +638,7 @@ def __init__(self, app: QApplication) -> None: self.current_workflow: Optional[WorkflowType] = None self._tray_error_message: Optional[str] = None self._active_workers: list[_TranscribeWorker] = [] + self._recording_routes_to_compose = False # Diktat-/Verlauf-/TTS-Zustand self._dictation_mode = False @@ -962,13 +968,16 @@ def _on_workflow_triggered(self, workflow: WorkflowType) -> None: logger.info("Ignored hotkey trigger %s while busy", workflow) def _start_recording(self, workflow: WorkflowType) -> None: + self._recording_routes_to_compose = False try: self.audio_recorder.start(device=self.config.audio_device) + self._recording_routes_to_compose = self._compose_voice_routing_enabled() self.current_workflow = workflow self._set_state("RECORDING", f"workflow {workflow.value} started") except AudioRecorderError as e: logger.error("Failed to start recording: %s", e) self.show_tray_error(t("error.recording.title"), t("error.recording.start_failed").format(error=e)) + self._recording_routes_to_compose = False self.current_workflow = None self._set_state("IDLE", "recording start failed") @@ -988,6 +997,7 @@ def gui_discard(self) -> None: """Laufende Aufnahme verwerfen, ohne zu transkribieren.""" if self.state == "RECORDING": self.audio_recorder.discard() + self._recording_routes_to_compose = False self.current_workflow = None self._set_state("IDLE", "discarded via gui") @@ -1002,6 +1012,8 @@ def _on_recording_stop(self) -> None: def _stop_recording_and_process(self) -> None: try: + route_to_compose = self._recording_routes_to_compose + self._recording_routes_to_compose = False wav_path = self.audio_recorder.stop() if not wav_path: logger.warning("No audio was recorded") @@ -1027,10 +1039,15 @@ def _stop_recording_and_process(self) -> None: autopaste=self.config.autopaste, paste_service=self.paste_service, custom_terms=self.config.custom_terms, + route_to_compose=route_to_compose, ) worker.signals.status_changed.connect(self._on_worker_status_changed) - worker.signals.result.connect(self._on_worker_result) + worker.signals.result.connect( + lambda result_text, routed=route_to_compose: self._on_worker_result( + result_text, route_to_compose=routed + ) + ) worker.signals.error.connect(self._on_worker_error) worker.signals.finished.connect(self._on_worker_finished) @@ -1040,6 +1057,7 @@ def _stop_recording_and_process(self) -> None: except AudioRecorderError as e: logger.error("Failed to stop recording: %s", e) self.show_tray_error(t("error.recording.title"), t("error.recording.stop_failed").format(error=e)) + self._recording_routes_to_compose = False self.current_workflow = None self._set_state("IDLE", "recording stop failed") @@ -1052,9 +1070,10 @@ def _on_worker_status_changed(self, status: str) -> None: else: self.update_tray_state() - @pyqtSlot(str) - def _on_worker_result(self, result_text: str) -> None: + def _on_worker_result(self, result_text: str, route_to_compose: bool = False) -> None: logger.info("Transcription/Rewrite success. Result length: %d chars", len(result_text)) + if route_to_compose: + self._ensure_compose_window().set_input_text(result_text) self._add_to_history(result_text, is_dictation=self._dictation_mode) if self._dictation_mode: notify_service.notify( @@ -1160,6 +1179,14 @@ def show_compose_window(self, text: str = "") -> None: window.raise_() window.activateWindow() + def _compose_voice_routing_enabled(self) -> bool: + window = self._compose_window + return bool( + window is not None + and window.isVisible() + and window.voice_routing_enabled() + ) + def _on_tts_closed(self, _result: int) -> None: self._tts_window = None diff --git a/app/compose_window.py b/app/compose_window.py index 98329d0..0533480 100644 --- a/app/compose_window.py +++ b/app/compose_window.py @@ -234,9 +234,8 @@ def _setup_ui(self) -> None: header_grid.addWidget(self.cmbTone, 1, 1) self.chkVoiceRouting = QCheckBox() - self.chkVoiceRouting.setEnabled(False) + self.chkVoiceRouting.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.chkVoiceRouting.setToolTip(t("compose.voice_routing.help")) - # Future hook only; phase I-1 must not alter the existing transcription path. header_grid.addWidget(self.chkVoiceRouting, 1, 2, 1, 2) header_grid.setColumnStretch(1, 1) @@ -608,6 +607,10 @@ def set_input_text(self, text: str) -> None: self._hide_status() self._sync_state() + def voice_routing_enabled(self) -> bool: + """Return whether new recordings should target this draft window.""" + return self.chkVoiceRouting.isChecked() + def retranslate_ui(self) -> None: """Refresh visible text to the active UI language.""" current_workflow = self._selected_workflow() @@ -802,6 +805,8 @@ def _on_paste_clicked(self) -> None: self.close() def closeEvent(self, event: QCloseEvent) -> None: + # A hidden modeless window must never remain an implicit routing target. + self.chkVoiceRouting.setChecked(False) if self._busy or (self._worker_thread is not None and self._worker_thread.isRunning()): self._detach_worker_thread() super().closeEvent(event) diff --git a/app/i18n.py b/app/i18n.py index f50ff20..ca70490 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -154,7 +154,7 @@ "tone.neutral": "neutral", "tone.formal": "professionell", "compose.voice_routing.label": "Sprachaufnahme hierher umleiten", - "compose.voice_routing.help": "Sprachaufnahmen vom Hotkey direkt in dieses Entwurfsfenster leiten statt ins Hauptfenster.", + "compose.voice_routing.help": "Neue Sprachaufnahmen in dieses Entwurfsfeld einfügen.", "compose.input.label": "Entwurf", "compose.output.label": "Ergebnis", "compose.button.improve": "Verbessern", @@ -353,7 +353,7 @@ "tone.neutral": "neutral", "tone.formal": "professional", "compose.voice_routing.label": "Route voice input here", - "compose.voice_routing.help": "Route voice recordings from the hotkey directly into this compose window instead of the main window.", + "compose.voice_routing.help": "Insert new voice recordings into this draft field.", "compose.input.label": "Draft", "compose.output.label": "Result", "compose.button.improve": "Improve", diff --git a/app/theme.py b/app/theme.py index 1fc998c..ad6fd4c 100644 --- a/app/theme.py +++ b/app/theme.py @@ -149,8 +149,14 @@ QCheckBox {{ background: transparent; + border: 1px solid transparent; + border-radius: 4px; + padding: 2px; spacing: 8px; }} +QCheckBox:focus {{ + border-color: {BLITZ_500}; +}} QToolTip {{ background-color: {BREEZE_VIEW}; diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py index b39a941..1258bf8 100644 --- a/tests/test_compose_window.py +++ b/tests/test_compose_window.py @@ -328,15 +328,59 @@ def test_no_autopaste_after_llm_success(compose_window, qapp): @gui_only -def test_voice_routing_checkbox_visible_and_disabled(compose_window, qapp): - """Voice-Routing-Checkbox ist vorhanden, sichtbar und deaktiviert (Future-Hook).""" - window, _llm, _paste = compose_window +def test_voice_routing_checkbox_mouse_and_keyboard_toggle(compose_window, qapp): + """Kästchen und Beschriftung sind klickbar; Leertaste schaltet mit Fokus.""" + from PyQt6.QtCore import QPoint, Qt + from PyQt6.QtTest import QTest + from PyQt6.QtWidgets import QCheckBox, QStyle, QStyleOptionButton + window, _llm, _paste = compose_window chk = window.chkVoiceRouting - assert chk is not None + assert isinstance(chk, QCheckBox) + assert chk.isVisible() is True - assert chk.isEnabled() is False + assert chk.isEnabled() is True assert chk.text() == t("compose.voice_routing.label") + assert chk.focusPolicy() == Qt.FocusPolicy.StrongFocus + + option = QStyleOptionButton() + chk.initStyleOption(option) + indicator = chk.style().subElementRect( + QStyle.SubElement.SE_CheckBoxIndicator, option, chk + ) + QTest.mouseClick( + chk, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + indicator.center(), + ) + assert chk.isChecked() is True + + label_pos = QPoint(indicator.right() + 12, chk.rect().center().y()) + QTest.mouseClick( + chk, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + label_pos, + ) + assert chk.isChecked() is False + + chk.setFocus(Qt.FocusReason.TabFocusReason) + qapp.processEvents() + assert chk.hasFocus() is True + QTest.keyClick(chk, Qt.Key.Key_Space, Qt.KeyboardModifier.NoModifier) + assert chk.isChecked() is True + + +@gui_only +def test_closing_compose_window_clears_voice_routing(compose_window, qapp): + window, _llm, _paste = compose_window + window.chkVoiceRouting.setChecked(True) + + window.close() + qapp.processEvents() + + assert window.chkVoiceRouting.isChecked() is False # --------------------------------------------------------------------------- diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index 36f9311..bd84aee 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -129,6 +129,85 @@ def test_autopaste_false_without_override_skips_ydotool(self): assert "ydotool" not in cmd_names +class TestComposeVoiceRoutingWorker: + def test_unrouted_transcription_keeps_standard_autopaste(self, tmp_path): + from app.blitztext_linux import _TranscribeWorker + + wav_path = tmp_path / "recording.wav" + wav_path.write_bytes(b"fake audio") + paste_service = MagicMock() + worker = _TranscribeWorker( + wav_file=wav_path, + model="base", + language="de", + backend="openai-whisper", + workflow=WorkflowType.TRANSCRIPTION, + llm_service=MagicMock(), + autopaste=True, + paste_service=paste_service, + ) + + with patch("app.blitztext_linux.transcribe", return_value="Standardtext"): + worker.run() + + paste_service.paste.assert_called_once_with("Standardtext") + paste_service.clipboard_only.assert_not_called() + + def test_routed_transcription_skips_clipboard_and_autopaste(self, tmp_path): + from app.blitztext_linux import _TranscribeWorker + + wav_path = tmp_path / "recording.wav" + wav_path.write_bytes(b"fake audio") + paste_service = MagicMock() + results = [] + worker = _TranscribeWorker( + wav_file=wav_path, + model="base", + language="de", + backend="openai-whisper", + workflow=WorkflowType.TRANSCRIPTION, + llm_service=MagicMock(), + autopaste=True, + paste_service=paste_service, + route_to_compose=True, + ) + worker.signals.result.connect(results.append) + + with patch("app.blitztext_linux.transcribe", return_value="Gesprochener Text"): + worker.run() + + assert results == ["Gesprochener Text"] + paste_service.paste.assert_not_called() + paste_service.clipboard_only.assert_not_called() + + def test_routed_recording_uses_raw_transcript_instead_of_rewrite(self, tmp_path): + from app.blitztext_linux import _TranscribeWorker + + wav_path = tmp_path / "recording.wav" + wav_path.write_bytes(b"fake audio") + llm_service = MagicMock() + llm_service.rewrite.return_value = "Umgeschriebener Text" + results = [] + worker = _TranscribeWorker( + wav_file=wav_path, + model="base", + language="de", + backend="openai-whisper", + workflow=WorkflowType.TEXT_IMPROVER, + llm_service=llm_service, + autopaste=True, + paste_service=MagicMock(), + route_to_compose=True, + ) + worker.signals.result.connect(results.append) + + with patch("app.blitztext_linux.transcribe", return_value="Rohes Transkript"): + worker.run() + + assert results == ["Rohes Transkript"] + llm_service.rewrite.assert_not_called() + + # --------------------------------------------------------------------------- # Hilfen: minimales Fake-evdev fuer den HotkeyWorker-Event-Loop # --------------------------------------------------------------------------- @@ -291,6 +370,55 @@ def test_second_toggle_possible_after_result(self, gui_app): start_mock.assert_called_once() assert gui_app.state == "RECORDING" + def test_routed_worker_result_reaches_compose_draft(self, gui_app): + window = gui_app._ensure_compose_window() + window.show() + window.chkVoiceRouting.setChecked(True) + + gui_app._on_worker_result("Gesprochener Text", route_to_compose=True) + + assert window.txtInput.toPlainText() == "Gesprochener Text" + assert gui_app.state == "IDLE" + + def test_new_recording_uses_current_compose_routing_state(self, gui_app): + window = gui_app._ensure_compose_window() + window.show() + window.chkVoiceRouting.setChecked(True) + + with patch.object(gui_app.audio_recorder, "start"): + gui_app._start_recording(WorkflowType.TRANSCRIPTION) + + assert gui_app._recording_routes_to_compose is True + + window.close() + with patch.object(gui_app.audio_recorder, "discard"): + gui_app.gui_discard() + with patch.object(gui_app.audio_recorder, "start"): + gui_app._start_recording(WorkflowType.TRANSCRIPTION) + + assert gui_app._recording_routes_to_compose is False + + def test_stop_wires_routed_worker_result_into_compose(self, gui_app): + from pathlib import Path + + window = gui_app._ensure_compose_window() + window.show() + window.chkVoiceRouting.setChecked(True) + pool = MagicMock() + + with patch.object(gui_app.audio_recorder, "start"), \ + patch.object(gui_app.audio_recorder, "stop", return_value=Path("/tmp/voice.wav")), \ + patch("app.blitztext_linux.QThreadPool.globalInstance", return_value=pool): + gui_app._start_recording(WorkflowType.TRANSCRIPTION) + gui_app._stop_recording_and_process() + + worker = pool.start.call_args.args[0] + assert worker.route_to_compose is True + + worker.signals.result.emit("Gerouteter Text") + + assert window.txtInput.toPlainText() == "Gerouteter Text" + @gui_only class TestMainWindowControl: From 8b545c4dfaa4dbb0e8935d647e18315c0525192a Mon Sep 17 00:00:00 2001 From: gummiflip Date: Mon, 20 Jul 2026 15:29:27 +0200 Subject: [PATCH 3/6] feat(ui): expose text editing in main window --- app/i18n.py | 8 +++-- app/main_window.py | 20 ++++++++++-- tests/test_compose_window.py | 10 +++--- tests/test_i18n.py | 3 ++ tests/test_smoke_launch.py | 2 ++ tests/test_state_machine.py | 61 ++++++++++++++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 9 deletions(-) diff --git a/app/i18n.py b/app/i18n.py index ca70490..e969c95 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -170,7 +170,7 @@ "compose.variant.prev": "Vorherige Variante", "compose.variant.next": "Nächste Variante", "compose.variant.none": "Keine Varianten", - "compose.btn.show_prompt": "Prompt", + "compose.btn.show_prompt": "Schreibstil / Prompt bearbeiten", "compose.prompt_preview.title": "Prompt-Vorschau", "compose.prompt_preview.system_label": "System-Prompt:", "compose.prompt_preview.user_label": "Nutzernachricht:", @@ -180,6 +180,8 @@ "mainwindow.button.discard": "↺ Verwerfen", "mainwindow.button.dictation": "✎ Diktat", "mainwindow.button.history": "≡ Verlauf ({count})", + "mainwindow.button.edit_text": "✍ Text bearbeiten", + "mainwindow.tooltip.edit_text": "Text eingeben und mit Schreibstilen oder Prompts bearbeiten (Strg+E)", "mainwindow.tooltip.tts": "Vorlesen", "mainwindow.tooltip.settings": "Einstellungen", "mainwindow.status.ready": "Bereit", @@ -369,7 +371,7 @@ "compose.variant.prev": "Previous variant", "compose.variant.next": "Next variant", "compose.variant.none": "No variants", - "compose.btn.show_prompt": "Prompt", + "compose.btn.show_prompt": "Edit style / prompt", "compose.prompt_preview.title": "Prompt Preview", "compose.prompt_preview.system_label": "System Prompt:", "compose.prompt_preview.user_label": "User Message:", @@ -379,6 +381,8 @@ "mainwindow.button.discard": "↺ Discard", "mainwindow.button.dictation": "✎ Dictation", "mainwindow.button.history": "≡ History ({count})", + "mainwindow.button.edit_text": "✍ Edit text", + "mainwindow.tooltip.edit_text": "Enter text and edit it with writing styles or prompts (Ctrl+E)", "mainwindow.tooltip.tts": "Read aloud", "mainwindow.tooltip.settings": "Settings", "mainwindow.status.ready": "Ready", diff --git a/app/main_window.py b/app/main_window.py index a0a58ca..0ce9d4c 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -1,11 +1,12 @@ """Hauptfenster fuer BlitztextLinux (Glass-Redesign). Grafischer Fallback zum globalen Hotkey: Start/Stopp per Maus-Klick, -Workflow-Auswahl, Verwerfen, Diktat, Verlauf, Vorlesen und Einstellungen. +Workflow-Auswahl, Verwerfen, Diktat, Textbearbeitung, Verlauf, Vorlesen und Einstellungen. Das Design folgt dem Blitztext Design System (Glass-Idiom): runder Amber- Record-„Shutter" als Hero, Status-Punkt + Timer, weiche Pill-Buttons fuer -Verwerfen/Diktat und runde Icon-Buttons fuer Vorlesen/Einstellungen. +Verwerfen/Diktat, eine klare Textbearbeitungs-Aktion und runde Icon-Buttons +fuer Vorlesen/Einstellungen. Das Fenster ist rein praesentational — die gesamte Aufnahme-/State-Logik bleibt im Controller (`BlitztextApp`). Beim Schliessen wird es nur versteckt, @@ -17,7 +18,7 @@ from typing import Optional from PyQt6.QtCore import Qt, QTimer, pyqtSlot, QRectF -from PyQt6.QtGui import QBrush, QCloseEvent, QColor, QPainter, QPen, QRadialGradient +from PyQt6.QtGui import QBrush, QCloseEvent, QColor, QKeySequence, QPainter, QPen, QRadialGradient from PyQt6.QtWidgets import ( QComboBox, QHBoxLayout, @@ -239,6 +240,19 @@ def _setup_ui(self) -> None: sec_row.addWidget(self._btn_dictation) layout.addLayout(sec_row) + # Eigene Zeile: zentrale manuelle Textbearbeitung bleibt auch bei der + # kompakten Fensterbreite vollständig beschriftet. + self._btn_edit_text = QPushButton(t("mainwindow.button.edit_text")) + self._btn_edit_text.setMinimumHeight(30) + self._btn_edit_text.setStyleSheet("border-radius: 15px; font-weight: 600;") + self._btn_edit_text.setToolTip(t("mainwindow.tooltip.edit_text")) + self._btn_edit_text.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self._btn_edit_text.setShortcut(QKeySequence("Ctrl+E")) + self._btn_edit_text.clicked.connect( + lambda _checked=False: self._controller.show_compose_window() + ) + layout.addWidget(self._btn_edit_text) + # Unterzeile: Verlauf (mit Zaehler), Vorlesen, Einstellungen bottom_row = QHBoxLayout() bottom_row.setSpacing(6) diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py index 1258bf8..607a075 100644 --- a/tests/test_compose_window.py +++ b/tests/test_compose_window.py @@ -137,18 +137,20 @@ def _wait_until(qapp, predicate, timeout_ms: int = 2500) -> None: @gui_only @pytest.mark.parametrize( - ("language", "title"), + ("language", "title", "prompt_label"), [ - ("de", "Text verfassen"), - ("en", "Compose Text"), + ("de", "Text verfassen", "Schreibstil / Prompt bearbeiten"), + ("en", "Compose Text", "Edit style / prompt"), ], ) -def test_window_texts_follow_language(qapp, language, title): +def test_window_texts_follow_language(qapp, language, title, prompt_label): set_language(language) window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) try: assert window.windowTitle() == title assert window.btnAction.text() == t("compose.button.improve") + assert window.btnShowPrompt.text() == prompt_label + assert window.btnShowPrompt.toolTip() == t("compose.prompt_preview.tooltip") assert window.btnCopy.text() == t("compose.button.copy") assert window.btnPaste.text() == t("compose.button.insert_close") assert window.btnClose.text() == t("compose.button.close") diff --git a/tests/test_i18n.py b/tests/test_i18n.py index f9f5652..de6de27 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -107,6 +107,9 @@ def test_compose_namespace_seeded(self): "compose.status.processing", "compose.status.error", "compose.status.empty_input", + "compose.btn.show_prompt", + "mainwindow.button.edit_text", + "mainwindow.tooltip.edit_text", } actual_keys = set(TRANSLATIONS["de"].keys()) assert expected_keys.issubset(actual_keys), f"Fehlende Compose-Keys: {expected_keys - actual_keys}" diff --git a/tests/test_smoke_launch.py b/tests/test_smoke_launch.py index d3bcb21..c22a38b 100644 --- a/tests/test_smoke_launch.py +++ b/tests/test_smoke_launch.py @@ -66,6 +66,8 @@ def test_app_boots_idles_and_exits_clean(ui_language, tmp_path): assert win._btn_discard.text() == t("mainwindow.button.discard") assert win._btn_dictation.text() == t("mainwindow.button.dictation") assert win._btn_history.text() == t("mainwindow.button.history").format(count=0) + assert win._btn_edit_text.text() == t("mainwindow.button.edit_text") + assert win._btn_edit_text.toolTip() == t("mainwindow.tooltip.edit_text") assert win._btn_tts.toolTip() == t("mainwindow.tooltip.tts") assert win._btn_settings.toolTip() == t("mainwindow.tooltip.settings") assert win._status_label.text() == t("mainwindow.status.ready") diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index bd84aee..2460c0b 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -458,6 +458,67 @@ def test_main_window_reflects_state(self, gui_app): gui_app._set_state("IDLE", "test") assert win._btn_toggle.text() == "Start" + def test_main_window_text_edit_opens_existing_compose_window(self, gui_app): + win = gui_app._ensure_main_window() + existing = gui_app._ensure_compose_window() + existing.set_input_text("Bestehender Entwurf") + existing.hide() + + win._btn_edit_text.click() + + assert gui_app._compose_window is existing + assert existing.isVisible() is True + assert existing.txtInput.toPlainText() == "Bestehender Entwurf" + + def test_tray_and_main_window_reuse_same_compose_state(self, gui_app): + win = gui_app._ensure_main_window() + + win._btn_edit_text.click() + compose = gui_app._compose_window + compose.set_input_text("Gemeinsamer Zustand") + compose.hide() + gui_app.action_compose.trigger() + + assert gui_app._compose_window is compose + assert compose.isVisible() is True + assert compose.txtInput.toPlainText() == "Gemeinsamer Zustand" + + def test_text_edit_action_is_keyboard_reachable(self, gui_app): + from PyQt6.QtCore import Qt + from PyQt6.QtGui import QKeySequence + from PyQt6.QtTest import QTest + + win = gui_app._ensure_main_window() + win.show() + gui_app.app.processEvents() + + assert win._btn_edit_text.focusPolicy() == Qt.FocusPolicy.StrongFocus + assert win._btn_edit_text.shortcut() == QKeySequence("Ctrl+E") + + win._btn_edit_text.setFocus() + assert win._btn_edit_text.hasFocus() is True + QTest.keyClick(win._btn_edit_text, Qt.Key.Key_Space) + gui_app.app.processEvents() + assert gui_app._compose_window is not None + assert gui_app._compose_window.isVisible() is True + + def test_text_edit_action_fits_compact_main_window(self, gui_app): + win = gui_app._ensure_main_window() + win.show() + gui_app.app.processEvents() + + button_rect = win._btn_edit_text.geometry() + history_rect = win._btn_history.geometry() + text_width = win._btn_edit_text.fontMetrics().horizontalAdvance( + win._btn_edit_text.text() + ) + + assert win.width() == 256 + assert button_rect.left() >= win.contentsRect().left() + assert button_rect.right() <= win.contentsRect().right() + assert button_rect.bottom() < history_rect.top() + assert text_width < button_rect.width() + def test_dictation_mode_syncs_window_and_tray(self, gui_app): win = gui_app._ensure_main_window() gui_app.set_dictation_mode(True) From 0639cb75be3f7f55af20a1bd93c9bee27d913062 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Mon, 20 Jul 2026 16:19:28 +0200 Subject: [PATCH 4/6] refactor(prompts): simplify writing styles and preserve compatibility --- app/blitztext_linux.py | 32 ++++++- app/compose_window.py | 59 +++++------- app/config.py | 27 ++++-- app/i18n.py | 68 ++++++------- app/llm_service.py | 92 ++++++++++++------ app/main_window.py | 4 +- app/writing_presets.py | 168 ++++++++++++++++----------------- tests/test_compose_window.py | 69 +++++++++++--- tests/test_config.py | 102 +++++++++++++++++++- tests/test_i18n.py | 32 +++++++ tests/test_llm_service.py | 164 +++++++++++++++++++++++++++----- tests/test_settings_dialog.py | 45 ++++++++- tests/test_tray_preset_menu.py | 76 +++++++++++++++ tests/test_writing_presets.py | 162 +++++++++++++++---------------- 14 files changed, 775 insertions(+), 325 deletions(-) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index b98d40f..b6a1eff 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -32,7 +32,11 @@ from app.config import Config, DEFAULTS, VALID_HOTKEY_KEYS from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS -from app.writing_presets import WRITING_PRESET_KEYS, get_preset, preset_index +from app.writing_presets import ( + CUSTOM_PRESET_KEY, + WRITING_PRESET_KEYS, + get_preset, +) from app.hotkey_service import HotkeyWorker, hotkey_display_name from app.audio_recorder import AudioRecorder, AudioRecorderError from app.transcribe import transcribe, TranscribeError @@ -286,8 +290,17 @@ def init_ui(self) -> None: self.combo_writing_preset = QComboBox() for key in WRITING_PRESET_KEYS: + if key == CUSTOM_PRESET_KEY: + self.combo_writing_preset.insertSeparator( + self.combo_writing_preset.count() + ) self.combo_writing_preset.addItem(t(f"preset.{key}.name"), key) - self.combo_writing_preset.setCurrentIndex(preset_index(self.config.writing_preset)) + selected_preset = self.combo_writing_preset.findData( + self.config.writing_preset + ) + self.combo_writing_preset.setCurrentIndex( + selected_preset if selected_preset >= 0 else 0 + ) self.edit_compose_custom_preset = QPlainTextEdit() self.edit_compose_custom_preset.setPlainText(self.config.compose_custom_preset_text) @@ -674,8 +687,15 @@ def _build_llm_service(self) -> LLMService: writing_preset=self.config.writing_preset, base_url=base_url, model=self.config.llm_model, + writing_custom_prompt=self.config.compose_custom_preset_text, ) + def _rebuild_llm_service(self) -> None: + """Baut den Service neu und aktualisiert ein bereits offenes Compose.""" + self.llm_service = self._build_llm_service() + if self._compose_window is not None: + self._compose_window.set_llm_service(self.llm_service) + def setup_tray(self) -> None: self.tray_icon = QSystemTrayIcon(self) self._tray_icons = { @@ -741,6 +761,8 @@ def setup_tray(self) -> None: self.preset_action_group.setExclusive(True) self.preset_actions: dict[str, QAction] = {} for key in WRITING_PRESET_KEYS: + if key == CUSTOM_PRESET_KEY: + self.menu_preset.addSeparator() preset_action = QAction(t(f"preset.{key}.name"), self) preset_action.setCheckable(True) preset_action.triggered.connect( @@ -874,7 +896,7 @@ def _on_writing_preset_selected(self, key: str) -> None: return self.config.writing_preset = key self.config.save() - self.llm_service = self._build_llm_service() + self._rebuild_llm_service() self.update_menu_availability() if self._main_window is not None: self._main_window.set_preset(key) @@ -886,7 +908,7 @@ def main_window_preset_changed(self, key: str) -> None: return self.config.writing_preset = key self.config.save() - self.llm_service = self._build_llm_service() + self._rebuild_llm_service() self.update_menu_availability() self._refresh_preset_menu() logger.info("Writing preset changed via main window: %s", key) @@ -921,7 +943,7 @@ def show_settings_dialog(self) -> None: dialog = SettingsDialog(self.config) if dialog.exec() == QDialog.DialogCode.Accepted: # Update LLM Service parameters from saved configuration - self.llm_service = self._build_llm_service() + self._rebuild_llm_service() self._refresh_i18n_texts() self.update_menu_availability() # Preset kann im Dialog geändert worden sein -> Häkchen + Combo angleichen. diff --git a/app/compose_window.py b/app/compose_window.py index 0533480..f844d1d 100644 --- a/app/compose_window.py +++ b/app/compose_window.py @@ -27,7 +27,12 @@ from app.config import Config, VALID_TONES from app.paste_service import PasteService, PasteServiceError from app.workflows import WorkflowType -from app.writing_presets import DEFAULT_PRESET_KEY, WRITING_PRESET_KEYS, preset_index +from app.writing_presets import ( + CUSTOM_PRESET_KEY, + DEFAULT_PRESET_KEY, + WRITING_PRESET_KEYS, + preset_index, +) logger = logging.getLogger("blitztext.compose") @@ -42,10 +47,6 @@ # (z. B. "professionell" für "formal"). COMPOSE_TONE_ORDER: tuple[str, ...] = ("locker", "neutral", "formal") -# Sentinel-Schlüssel für den zusätzlichen Compose-Eintrag „Eigene Vorlage…“. -# Bewusst kein echtes Writing-Preset, damit Hauptfenster/Tray unberührt bleiben. -COMPOSE_CUSTOM_PRESET_KEY = "__compose_custom__" - # In-memory ring buffer of successful generations for the current window # session. Oldest variants are trimmed once the cap is exceeded. MAX_COMPOSE_VARIANTS = 10 @@ -375,9 +376,9 @@ def _populate_preset_combo(self, selected: Optional[str] = None) -> None: self.cmbPreset.blockSignals(True) self.cmbPreset.clear() for key in WRITING_PRESET_KEYS: + if key == CUSTOM_PRESET_KEY: + self.cmbPreset.insertSeparator(self.cmbPreset.count()) self.cmbPreset.addItem(t(f"preset.{key}.name"), key) - # Zusätzlicher, nur im Compose-Fenster sichtbarer Freitext-Eintrag. - self.cmbPreset.addItem(t("compose.preset.custom"), COMPOSE_CUSTOM_PRESET_KEY) target = selected or preset_index(self._llm_service.writing_preset) if isinstance(target, str): index = self.cmbPreset.findData(target) @@ -416,9 +417,6 @@ def _selected_tone(self) -> str: return tone return self._config.text_improver_tone - def _is_custom_preset(self) -> bool: - return self._selected_preset() == COMPOSE_CUSTOM_PRESET_KEY - @pyqtSlot() def _on_selectors_changed(self) -> None: self._update_tone_state() @@ -426,9 +424,9 @@ def _on_selectors_changed(self) -> None: def _update_tone_state(self) -> None: """Sichtbarkeit/Aktivierung des Tonfall-Selektors je nach Workflow+Vorlage. - Tonfall ist nur sinnvoll im Text-Verbesserer (Blitztext+) mit der Vorlage - „Standard“. Bei anderen Vorlagen bestimmt die Vorlage den Stil selbst, der - Selektor bleibt sichtbar, aber ausgegraut. Bei Dampf/Emoji entfällt er. + Tonfall ist im Text-Verbesserer (Blitztext+) für „Standard“ und + „Tonfall ändern“ aktiv. Bei allen anderen Aktionen bleibt der Selektor + sichtbar, aber ausgegraut. Bei Dampf/Emoji entfällt er. """ workflow = self._selected_workflow() is_text_improver = workflow == WorkflowType.TEXT_IMPROVER @@ -436,11 +434,14 @@ def _update_tone_state(self) -> None: self.cmbTone.setVisible(is_text_improver) if not is_text_improver: return - is_standard = self._selected_preset() == DEFAULT_PRESET_KEY - self.cmbTone.setEnabled(is_standard) + uses_tone = self._selected_preset() in { + DEFAULT_PRESET_KEY, + "change_tone", + } + self.cmbTone.setEnabled(uses_tone) self.cmbTone.setToolTip( t("compose.tone.tooltip_active") - if is_standard + if uses_tone else t("compose.tone.tooltip_preset_overrides") ) @@ -599,6 +600,10 @@ def _append_signature(self) -> None: def _on_append_signature_clicked(self) -> None: self._append_signature() + def set_llm_service(self, llm_service: LLMService) -> None: + """Verwendet bei der nächsten Aktion den aktuellen Controller-Service.""" + self._llm_service = llm_service + def set_input_text(self, text: str) -> None: self.txtInput.setPlainText(text) self._variants = [] @@ -651,22 +656,12 @@ def retranslate_ui(self) -> None: def _start_worker(self, text: str) -> None: workflow = self._selected_workflow() tone = self._selected_tone() - custom_prompt: Optional[str] = None - if self._is_custom_preset(): - # Freitext-Vorlage: Basis bleibt „Standard“, der freie System-Prompt - # wird separat durchgereicht (greift nur im Text-Verbesserer). - writing_preset = DEFAULT_PRESET_KEY - custom_prompt = self._config.compose_custom_preset_text - else: - writing_preset = self._selected_preset() - worker = _ComposeWorker( self._llm_service, workflow, text, - writing_preset, + self._selected_preset(), tone=tone, - custom_prompt=custom_prompt, ) self._launch_worker(worker) @@ -734,14 +729,10 @@ def _on_show_prompt_clicked(self) -> None: workflow = self._selected_workflow() tone = self._selected_tone() - custom_prompt: Optional[str] = None - writing_preset = self._selected_preset() - if self._is_custom_preset(): - writing_preset = DEFAULT_PRESET_KEY - custom_prompt = self._config.compose_custom_preset_text - system_prompt = self._llm_service.build_system_prompt( - workflow, writing_preset=writing_preset, tone=tone, custom_prompt=custom_prompt + workflow, + writing_preset=self._selected_preset(), + tone=tone, ) dialog = PromptEditorDialog(system_prompt, text.strip(), parent=self) if dialog.exec() == QDialog.DialogCode.Accepted: diff --git a/app/config.py b/app/config.py index 9ff4c2c..f9f52d5 100644 --- a/app/config.py +++ b/app/config.py @@ -14,7 +14,12 @@ from pathlib import Path from typing import Any -from app.writing_presets import DEFAULT_PRESET_KEY, WRITING_PRESET_KEYS +from app.writing_presets import ( + DEFAULT_PRESET_KEY, + LEGACY_PRESET_MIGRATIONS, + WRITING_PRESET_KEYS, + migrate_preset_selection, +) from app.i18n import LANGUAGES as I18N_LANGUAGES, DEFAULT_LANGUAGE as I18N_DEFAULT_LANGUAGE @@ -391,9 +396,17 @@ def writing_preset(self) -> str: @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 + if not isinstance(value, str) or ( + value not in VALID_WRITING_PRESETS and value not in LEGACY_PRESET_MIGRATIONS + ): + raise ValueError( + f"Ungueltiges Schreib-Preset: {value!r}. " + f"Gueltig: {sorted(VALID_WRITING_PRESETS)}" + ) + canonical, migrated_tone = migrate_preset_selection(value) + self._data["workflows"]["writing_preset"] = canonical + if migrated_tone is not None: + self._data["workflows"]["text_improver_tone"] = migrated_tone @property def custom_terms(self) -> list[str]: @@ -497,8 +510,10 @@ def _validate_and_sanitize(self) -> None: 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 + canonical_preset, migrated_tone = migrate_preset_selection(preset_value) + wf["writing_preset"] = canonical_preset + if migrated_tone is not None: + wf["text_improver_tone"] = migrated_tone wf["custom_terms"] = _sanitize_terms(wf.get("custom_terms")) diff --git a/app/i18n.py b/app/i18n.py index e969c95..8b514fc 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -46,11 +46,11 @@ "settings.base_url.help": "Leer = OpenAI-Standard. Für OpenRouter: https://openrouter.ai/api/v1. Muss mit http:// oder https:// beginnen.", "settings.llm_model.label": "LLM-Modell:", "settings.llm_model.help": "Modellname beim Anbieter, z. B. 'gpt-4o-mini' (OpenAI) oder 'openai/gpt-4o' (OpenRouter).", - "settings.tone.label": "Text-Verbesserer Tonfall:", - "settings.writing_preset.label": "Schreibstil-Vorlage:", - "settings.writing_preset.help": "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.", - "settings.compose_custom_preset.label": "Eigene Vorlage (Compose):", - "settings.compose_custom_preset.help": "Freier System-Prompt für die Vorlage „Eigene Vorlage…“ im Compose-Fenster.", + "settings.tone.label": "Ziel-Tonfall:", + "settings.writing_preset.label": "Text-Aktion:", + "settings.writing_preset.help": "Wähle eine klar abgegrenzte Aktion. Der Ziel-Tonfall gilt für „Standard“ und „Tonfall ändern“.", + "settings.compose_custom_preset.label": "Eigener Prompt:", + "settings.compose_custom_preset.help": "Bestehender freier System-Prompt für die getrennte Aktion „Eigener Prompt“.", "settings.compose_custom_preset.placeholder": "z. B. Formuliere den Text als sachliche Pressemitteilung…", "settings.emoji_density.label": "Emoji-Dichte:", "settings.dampf_prompt.label": "Dampf-Umschreiber Prompt:", @@ -139,17 +139,16 @@ "history.note.merged_filename_prefix": "Diktat-zusammengefuehrt_", "tray.show_window": "Fenster anzeigen", "tray.compose": "Text verfassen…", - "tray.writing_preset": "Schreibstil-Vorlage", + "tray.writing_preset": "Text-Aktion", "tray.dictation_mode": "🎤 Diktat-Modus", "tray.history": "📋 Verlauf…", "tray.tts": "🔊 Vorlesen…", "compose.window_title": "Text verfassen", "compose.workflow.label": "Workflow:", - "compose.preset.label": "Schreibstil:", - "compose.preset.custom": "Eigene Vorlage…", - "compose.tone.label": "Tonfall:", - "compose.tone.tooltip_active": "Tonfall des Text-Verbesserers (nur bei Vorlage „Standard“).", - "compose.tone.tooltip_preset_overrides": "Diese Vorlage bestimmt den Schreibstil selbst — der Tonfall wird ignoriert.", + "compose.preset.label": "Text-Aktion:", + "compose.tone.label": "Ziel-Tonfall:", + "compose.tone.tooltip_active": "Ziel-Tonfall für „Standard“ und „Tonfall ändern“.", + "compose.tone.tooltip_preset_overrides": "Diese Aktion bewahrt ihren eigenen Tonfall; der Ziel-Tonfall wird ignoriert.", "tone.locker": "locker", "tone.neutral": "neutral", "tone.formal": "professionell", @@ -205,14 +204,11 @@ "workflow.text_improver.name": "✨ Blitztext+", "workflow.dampf_ablassen.name": "🔥 Blitztext $%&!", "workflow.emoji_text.name": "😊 Blitztext :)", - "preset.standard.name": "Standard (Text verbessern)", - "preset.email_formal.name": "E-Mail – formell", - "preset.email_locker.name": "E-Mail – locker", - "preset.stichpunkte.name": "Stichpunkte", - "preset.zusammenfassung.name": "Zusammenfassung", - "preset.du_form.name": "Persönlich (Du-Form)", - "preset.sie_form.name": "Höflich (Sie-Form)", - "preset.kurz_praezise.name": "Kurz & präzise", + "preset.standard.name": "Standard / Text verbessern", + "preset.shorten.name": "Kürzen", + "preset.expand.name": "Ausformulieren", + "preset.change_tone.name": "Tonfall ändern", + "preset.custom.name": "Eigener Prompt", }, "en": { "app.name": "Blitztext", @@ -247,11 +243,11 @@ "settings.base_url.help": "Empty = OpenAI default. For OpenRouter: https://openrouter.ai/api/v1. Must start with http:// or https://.", "settings.llm_model.label": "LLM model:", "settings.llm_model.help": "Model name at the provider, e.g. 'gpt-4o-mini' (OpenAI) or 'openai/gpt-4o' (OpenRouter).", - "settings.tone.label": "Text improver tone:", - "settings.writing_preset.label": "Writing style preset:", - "settings.writing_preset.help": "Preset for the text improver (e.g. formal email, bullet points). With 'Standard', the tone above is used; every other preset determines the writing style itself and replaces the tone.", - "settings.compose_custom_preset.label": "Custom preset (Compose):", - "settings.compose_custom_preset.help": "Free system prompt for the 'Custom preset…' entry in the compose window.", + "settings.tone.label": "Target tone:", + "settings.writing_preset.label": "Text action:", + "settings.writing_preset.help": "Choose one distinct action. The target tone applies to 'Standard' and 'Change tone'.", + "settings.compose_custom_preset.label": "Custom prompt:", + "settings.compose_custom_preset.help": "Existing free system prompt for the separate 'Custom prompt' action.", "settings.compose_custom_preset.placeholder": "e.g. Rewrite the text as a factual press release…", "settings.emoji_density.label": "Emoji density:", "settings.dampf_prompt.label": "Dampf rewrite prompt:", @@ -340,17 +336,16 @@ "history.note.merged_filename_prefix": "Dictation-merged_", "tray.show_window": "Show window", "tray.compose": "Compose text…", - "tray.writing_preset": "Writing style preset", + "tray.writing_preset": "Text action", "tray.dictation_mode": "🎤 Dictation mode", "tray.history": "📋 History…", "tray.tts": "🔊 Read aloud…", "compose.window_title": "Compose Text", "compose.workflow.label": "Workflow:", - "compose.preset.label": "Style:", - "compose.preset.custom": "Custom preset…", - "compose.tone.label": "Tone:", - "compose.tone.tooltip_active": "Text improver tone (only with the 'Standard' preset).", - "compose.tone.tooltip_preset_overrides": "This preset defines the writing style itself — the tone is ignored.", + "compose.preset.label": "Text action:", + "compose.tone.label": "Target tone:", + "compose.tone.tooltip_active": "Target tone for 'Standard' and 'Change tone'.", + "compose.tone.tooltip_preset_overrides": "This action preserves its own tone; the target tone is ignored.", "tone.locker": "casual", "tone.neutral": "neutral", "tone.formal": "professional", @@ -406,14 +401,11 @@ "workflow.text_improver.name": "✨ Blitztext+", "workflow.dampf_ablassen.name": "🔥 Blitztext $%&!", "workflow.emoji_text.name": "😊 Blitztext :)", - "preset.standard.name": "Standard (improve text)", - "preset.email_formal.name": "Email - formal", - "preset.email_locker.name": "Email - casual", - "preset.stichpunkte.name": "Bullet points", - "preset.zusammenfassung.name": "Summary", - "preset.du_form.name": "Personal (Du form)", - "preset.sie_form.name": "Polite (Sie form)", - "preset.kurz_praezise.name": "Short & precise", + "preset.standard.name": "Standard / Improve text", + "preset.shorten.name": "Shorten", + "preset.expand.name": "Expand", + "preset.change_tone.name": "Change tone", + "preset.custom.name": "Custom prompt", }, } diff --git a/app/llm_service.py b/app/llm_service.py index b90d08d..c6565d1 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -6,7 +6,13 @@ from app.config import DEFAULTS from app.workflows import WorkflowType -from app.writing_presets import DEFAULT_PRESET_KEY, get_preset +from app.writing_presets import ( + CUSTOM_PRESET_KEY, + DEFAULT_PRESET_KEY, + migrate_preset_selection, + resolve_preset_prompt, + tone_description, +) logger = logging.getLogger("blitztext.llm_service") @@ -28,20 +34,22 @@ # Umformulieren, kein Auftrag an das Modell. Ohne diese Regeln deutete das # Modell z. B. "neue Session" als Meeting um und erfand Teilnehmer. _INTENT_RULES = ( - " Bewahre die Absicht des Nutzers exakt: Formuliere die Eingabe nur um, " - "führe sie nicht aus und beantworte sie nicht. Erfinde keinen Kontext – " - "keine Teilnehmer, Meetings, Rollen, Adressaten oder Ziele, die nicht " - "ausdrücklich genannt sind. Interpretiere Begriffe wie 'Session', " - "'Prompt', 'Branch', 'PR', 'Merge' oder 'Handover' im Software- und " - "Arbeitskontext, wenn die Eingabe danach klingt. Verlangt der Nutzer " - "einen Prompt oder eine Übergabe, formuliere einen direkt nutzbaren " - "Prompt bzw. eine Übergabe. Ist die Eingabe fragmentarisch, glätte nur " - "Sprache und Struktur, ohne die Aufgabe zu verändern." + " Bewahre die Sprache, Bedeutung, Fakten und den ausdrücklich genannten " + "Kontext. Bewahre die Absicht des Nutzers exakt. Formuliere die Eingabe " + "nur um, führe sie nicht aus und " + "beantworte sie nicht. Erfinde keinen Kontext – keine Teilnehmer, Meetings, " + "Rollen, Adressaten oder Ziele, die nicht ausdrücklich genannt sind. " + "Interpretiere Begriffe wie 'Session', 'Prompt', 'Branch', 'PR', 'Merge' " + "oder 'Handover' im Software- und Arbeitskontext, wenn die Eingabe danach " + "klingt. Verlangt der Nutzer einen Prompt oder eine Übergabe, formuliere " + "einen direkt nutzbaren Prompt bzw. eine Übergabe. Ist die Eingabe " + "fragmentarisch, glätte nur Sprache und Struktur, ohne die Aufgabe zu " + "verändern." ) _TEXT_IMPROVER_SYSTEM_TEMPLATE = ( "Du erhältst ein gesprochenes Transkript. Formuliere es zu einem sauberen, " - "gut lesbaren Text um. Ton: {tone}. Behalte den Inhalt vollständig. " + "gut lesbaren Text um. Ziel-Tonfall: {tone}. Behalte den Inhalt vollständig. " "Korrigiere Grammatik, Zeichensetzung und Struktur." + _INTENT_RULES + " Gib NUR den fertigen Text zurück." ) @@ -86,16 +94,23 @@ def __init__( writing_preset: str = DEFAULT_PRESET_KEY, base_url: str = "", model: str = "", + writing_custom_prompt: str = "", ) -> None: self.api_key = api_key or "" self.api_key_env = api_key_env or "OPENAI_API_KEY" self.base_url = (base_url or "").strip() self.model = (model or "").strip() or DEFAULT_LLM_MODEL - self.tone = tone + canonical_preset, migrated_tone = migrate_preset_selection( + writing_preset or DEFAULT_PRESET_KEY + ) + self.tone = migrated_tone or 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.writing_preset = canonical_preset + self.writing_custom_prompt = ( + writing_custom_prompt if isinstance(writing_custom_prompt, str) else "" + ) self._openai_installed = True self._client_is_fallback_mock = False @@ -158,6 +173,23 @@ def _custom_terms_instruction(self) -> str: + ", ".join(terms) ) + def _resolve_text_prompt( + self, + writing_preset: Optional[str], + tone: Optional[str], + custom_prompt: Optional[str], + ) -> tuple[str, str]: + effective_tone = tone if tone is not None else self.tone + selected = writing_preset if writing_preset is not None else self.writing_preset + canonical, migrated_tone = migrate_preset_selection(selected) + if migrated_tone is not None: + effective_tone = migrated_tone + if custom_prompt is not None and custom_prompt.strip(): + return custom_prompt.strip(), effective_tone + if canonical == CUSTOM_PRESET_KEY: + return self.writing_custom_prompt.strip(), effective_tone + return resolve_preset_prompt(canonical, effective_tone), effective_tone + def _rewrite_for_workflow( self, workflow: WorkflowType, @@ -175,15 +207,12 @@ def _rewrite_for_workflow( if workflow == WorkflowType.DAMPF_ABLASSEN: return self.dampf_ablassen(text, custom_system_prompt=self.dampf_system_prompt) if workflow == WorkflowType.TEXT_IMPROVER: - # Ein expliziter Freitext-Prompt (z. B. „Eigene Vorlage…“) hat - # Vorrang vor der gewählten Vorlage. Sonst gilt die Vorlage; nur - # bei „Standard“ (leerer Preset-Prompt) greift der Tonfall. - effective_tone = tone if tone is not None else self.tone - if custom_prompt is not None and custom_prompt.strip(): - system_prompt = custom_prompt - else: - system_prompt = get_preset(writing_preset or self.writing_preset).system_prompt - return self.text_improver(text, tone=effective_tone, custom_prompt=system_prompt) + system_prompt, effective_tone = self._resolve_text_prompt( + writing_preset, tone, custom_prompt + ) + return self.text_improver( + text, tone=effective_tone, custom_prompt=system_prompt + ) if workflow == WorkflowType.EMOJI_TEXT: return self.emoji_text(text, density=self.emoji_density) raise LLMServiceError(f"Unsupported workflow: {workflow}") @@ -222,7 +251,10 @@ def text_improver(self, transcript: str, tone: str = "neutral", custom_prompt: s if tone not in {"formal", "neutral", "locker"}: raise ValueError(f"invalid tone: {tone}") - system = (custom_prompt.strip() or _TEXT_IMPROVER_SYSTEM_TEMPLATE.format(tone=tone)) + self._custom_terms_instruction() + default_prompt = _TEXT_IMPROVER_SYSTEM_TEMPLATE.format( + tone=tone_description(tone) + ) + system = (custom_prompt.strip() or default_prompt) + self._custom_terms_instruction() return self._chat_completion(system, transcript) def emoji_text(self, transcript: str, density: str = "mittel") -> str: @@ -280,13 +312,13 @@ def build_system_prompt( if workflow == WorkflowType.DAMPF_ABLASSEN: return (self.dampf_system_prompt.strip() or _DAMPF_SYSTEM) + self._custom_terms_instruction() if workflow == WorkflowType.TEXT_IMPROVER: - effective_tone = tone if tone is not None else self.tone - if custom_prompt is not None and custom_prompt.strip(): - effective_system = custom_prompt.strip() - else: - preset_system = get_preset(writing_preset or self.writing_preset).system_prompt - effective_system = preset_system or _TEXT_IMPROVER_SYSTEM_TEMPLATE.format(tone=effective_tone) - return effective_system + self._custom_terms_instruction() + effective_system, effective_tone = self._resolve_text_prompt( + writing_preset, tone, custom_prompt + ) + default_prompt = _TEXT_IMPROVER_SYSTEM_TEMPLATE.format( + tone=tone_description(effective_tone) + ) + return (effective_system or default_prompt) + self._custom_terms_instruction() if workflow == WorkflowType.EMOJI_TEXT: return _EMOJI_SYSTEM_TEMPLATE.format(density=self.emoji_density) + self._custom_terms_instruction() raise LLMServiceError(f"Unsupported workflow: {workflow}") diff --git a/app/main_window.py b/app/main_window.py index 0ce9d4c..5406633 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -31,7 +31,7 @@ from app.llm_service import WorkflowType, LLM_WORKFLOWS from app.i18n import t from app import theme -from app.writing_presets import WRITING_PRESET_KEYS +from app.writing_presets import CUSTOM_PRESET_KEY, WRITING_PRESET_KEYS # Reihenfolge der Workflows in der Auswahl _WORKFLOW_ORDER = [ @@ -189,6 +189,8 @@ def _setup_ui(self) -> None: self._preset_combo = QComboBox() self._preset_combo.setMinimumHeight(28) for key in WRITING_PRESET_KEYS: + if key == CUSTOM_PRESET_KEY: + self._preset_combo.insertSeparator(self._preset_combo.count()) self._preset_combo.addItem(t(f"preset.{key}.name"), userData=key) self._preset_combo.currentIndexChanged.connect(self._on_preset_changed) self._preset_combo.setVisible(False) diff --git a/app/writing_presets.py b/app/writing_presets.py index 694bd1f..2da310b 100644 --- a/app/writing_presets.py +++ b/app/writing_presets.py @@ -1,38 +1,40 @@ -"""Vorgefertigte Schreibstil-Vorlagen (Presets) für den Text-Verbesserer. +"""Kernaktionen für den Text-Verbesserer und stabile Alt-ID-Migration. -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). +Die sichtbare Auswahl bleibt bewusst klein. Frühere Preset-IDs werden beim +Laden deterministisch auf eine Kernaktion abgebildet; eigene Prompt-Texte +bleiben außerhalb dieses Katalogs in der bestehenden Config-Ablage erhalten. """ from __future__ import annotations from dataclasses import dataclass +from typing import Any DEFAULT_PRESET_KEY = "standard" +CUSTOM_PRESET_KEY = "custom" _COMMON_RULES = ( - " Behalte den Inhalt vollständig und erfinde nichts dazu. Bewahre die " - "Absicht des Nutzers: Formuliere die Eingabe nur um, führe sie nicht aus " - "und beantworte sie nicht. Erfinde keinen Kontext – keine Adressaten, " - "Rollen, Meetings oder Ziele, die nicht ausdrücklich genannt sind. " - "Interpretiere Begriffe wie 'Session', 'Prompt', 'Branch', 'PR', 'Merge' " - "oder 'Handover' im Software- und Arbeitskontext, wenn die Eingabe danach " - "klingt. Korrigiere Grammatik und Zeichensetzung. Gib NUR den fertigen " - "Text zurück, ohne Vorbemerkung oder Erklärung." + " Bewahre die Sprache, Bedeutung, Fakten und den ausdrücklich genannten " + "Kontext. Bewahre die Absicht des Nutzers exakt. Erfinde nichts dazu. " + "Formuliere die Eingabe nur um, " + "führe sie nicht aus und beantworte sie nicht. Erfinde keinen Kontext – " + "keine Adressaten, Rollen, Meetings, Teilnehmer oder Ziele, die nicht " + "ausdrücklich genannt sind. Interpretiere Begriffe wie 'Session', " + "'Prompt', 'Branch', 'PR', 'Merge' oder 'Handover' im Software- und " + "Arbeitskontext, wenn die Eingabe danach klingt. Korrigiere Grammatik und " + "Zeichensetzung. Gib NUR den fertigen Text zurück, ohne Vorbemerkung oder " + "Erklärung." ) +_TONE_DESCRIPTIONS = { + "formal": "professionell und höflich", + "neutral": "neutral und sachlich", + "locker": "locker und freundlich", +} + @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. - """ + """Eine sichtbare Kernaktion mit stabiler ID und optionalem System-Prompt.""" key: str display_name: str @@ -40,84 +42,78 @@ class WritingPreset: _PRESETS: tuple[WritingPreset, ...] = ( + WritingPreset("standard", "Standard / Text verbessern", ""), WritingPreset( - "standard", - "Standard (Text verbessern)", - "", + "shorten", + "Kürzen", + "Du erhältst einen Text. Kürze den Text deutlich: Entferne Füllwörter, " + "Wiederholungen und unnötige Umwege, behalte aber alle wesentlichen " + "Informationen." + _COMMON_RULES, ), WritingPreset( - "email_formal", - "E-Mail – formell", - "Du erhältst ein gesprochenes Transkript. Formuliere es formell und " - "höflich in der Sie-Form um. Nur wenn die Eingabe erkennbar eine " - "Nachricht an eine Person oder Stelle ist, gestalte sie als E-Mail " - "mit klarer Struktur (passende Anrede, Hauptteil, freundlicher " - "Gruß); erfinde dabei keinen Empfänger. Andernfalls verbessere nur " - "Ton und Sprachqualität ohne E-Mail-Struktur." + _COMMON_RULES, + "expand", + "Ausformulieren", + "Du erhältst einen Text. Formuliere fragmentarische Sätze, Notizen und " + "Stichpunkte zu einem klaren, zusammenhängenden Fließtext aus. Ergänze " + "nur sprachlich notwendige Verbindungen, aber keine neuen Fakten, " + "Beispiele oder Annahmen." + _COMMON_RULES, ), WritingPreset( - "email_locker", - "E-Mail – locker", - "Du erhältst ein gesprochenes Transkript. Formuliere es locker und " - "freundlich in der Du-Form mit natürlichem, persönlichem Ton um. " - "Nur wenn die Eingabe erkennbar eine Nachricht an eine Person ist, " - "gestalte sie als E-Mail; erfinde dabei keinen Empfänger. " - "Andernfalls verbessere nur Ton und Sprachqualität ohne " - "E-Mail-Struktur." + _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. Ändere " - "nur Anrede und Ton, nicht Bedeutung, Kontext oder Zweck." + _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. Ändere " - "nur Anrede und Ton, nicht Bedeutung, Kontext oder Zweck." + _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. Kürze nur, erfinde keine neuen " - "Inhalte und ändere nicht Bedeutung, Kontext oder Zweck." + _COMMON_RULES, + "change_tone", + "Tonfall ändern", + "Du erhältst einen Text. Ändere gezielt nur den Tonfall. Ziel-Tonfall: " + "{tone}. Bewahre Inhalt, Aussage, Sprache, Kontext und Zweck unverändert." + + _COMMON_RULES, ), + WritingPreset(CUSTOM_PRESET_KEY, "Eigener Prompt", ""), ) WRITING_PRESETS: dict[str, WritingPreset] = {preset.key: preset for preset in _PRESETS} WRITING_PRESET_KEYS: tuple[str, ...] = tuple(preset.key for preset in _PRESETS) +# Ziel-ID plus optionaler Tonfall, der die bisherige Wirkung am besten bewahrt. +LEGACY_PRESET_MIGRATIONS: dict[str, tuple[str, str | None]] = { + "standard": ("standard", None), + "email_formal": ("change_tone", "formal"), + "email_locker": ("change_tone", "locker"), + "stichpunkte": ("shorten", None), + "zusammenfassung": ("shorten", None), + "du_form": ("change_tone", "locker"), + "sie_form": ("change_tone", "formal"), + "kurz_praezise": ("shorten", None), +} + + +def migrate_preset_selection(value: Any) -> tuple[str, str | None]: + """Mappt aktuelle und alte Werte idempotent auf eine gültige Kernaktion.""" + if isinstance(value, str): + if value in WRITING_PRESETS: + return value, None + if value in LEGACY_PRESET_MIGRATIONS: + return LEGACY_PRESET_MIGRATIONS[value] + return DEFAULT_PRESET_KEY, None + 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]) + """Liefert die kanonische Aktion, unbekannte Werte fallen auf Standard.""" + canonical_key, _ = migrate_preset_selection(key) + return WRITING_PRESETS[canonical_key] + + +def tone_description(tone: str) -> str: + """Liefert die verständliche Prompt-Beschreibung eines Tonfallwerts.""" + return _TONE_DESCRIPTIONS.get(tone, _TONE_DESCRIPTIONS["neutral"]) + + +def resolve_preset_prompt(key: str, tone: str = "neutral") -> str: + """Löst den Prompt einer Kernaktion einschließlich Ziel-Tonfall auf.""" + preset = get_preset(key) + if not preset.system_prompt: + return "" + return preset.system_prompt.format(tone=tone_description(tone)) 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) + """Position der kanonischen Aktion; unbekannte Werte wählen Standard.""" + canonical_key, _ = migrate_preset_selection(key) + return WRITING_PRESET_KEYS.index(canonical_key) diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py index 607a075..e0cd446 100644 --- a/tests/test_compose_window.py +++ b/tests/test_compose_window.py @@ -218,7 +218,7 @@ def test_input_reaches_direct_llm_path_and_sets_output(compose_window, qapp): window, llm, _paste = compose_window window.cmbWorkflow.setCurrentIndex(window.cmbWorkflow.findData(WorkflowType.TEXT_IMPROVER)) - window.cmbPreset.setCurrentIndex(window.cmbPreset.findData("email_formal")) + window.cmbPreset.setCurrentIndex(window.cmbPreset.findData("shorten")) window.txtInput.setPlainText("Hallo Welt") window.btnAction.click() @@ -228,7 +228,7 @@ def test_input_reaches_direct_llm_path_and_sets_output(compose_window, qapp): ) assert llm.calls == [ - (WorkflowType.TEXT_IMPROVER, "Hallo Welt", "email_formal"), + (WorkflowType.TEXT_IMPROVER, "Hallo Welt", "shorten"), ] assert window.txtOutput.toPlainText() == "OK" assert window.btnCopy.isEnabled() is True @@ -806,7 +806,7 @@ def test_compose_signature_leaves_unrelated_brackets(compose_window, qapp): # --- Paket J: Tonfall-Selektor & Eigene Vorlage im Compose-Fenster ---------- -from app.compose_window import COMPOSE_CUSTOM_PRESET_KEY # noqa: E402 +from app.writing_presets import CUSTOM_PRESET_KEY # noqa: E402 def _select_workflow(window, workflow: WorkflowType) -> None: @@ -824,10 +824,12 @@ def test_tone_selector_visible_and_enabled_for_standard(qapp): qapp.processEvents() try: _select_workflow(window, WorkflowType.TEXT_IMPROVER) + _select_preset(window, "shorten") _select_preset(window, "standard") qapp.processEvents() assert window.cmbTone.isVisible() is True assert window.cmbTone.isEnabled() is True + assert window.cmbTone.toolTip() == t("compose.tone.tooltip_active") finally: window.close() qapp.processEvents() @@ -840,7 +842,7 @@ def test_tone_selector_disabled_for_nonstandard_preset(qapp): qapp.processEvents() try: _select_workflow(window, WorkflowType.TEXT_IMPROVER) - _select_preset(window, "email_formal") + _select_preset(window, "shorten") qapp.processEvents() assert window.cmbTone.isVisible() is True assert window.cmbTone.isEnabled() is False @@ -857,7 +859,7 @@ def test_tone_selector_disabled_for_custom_preset(qapp): qapp.processEvents() try: _select_workflow(window, WorkflowType.TEXT_IMPROVER) - _select_preset(window, COMPOSE_CUSTOM_PRESET_KEY) + _select_preset(window, CUSTOM_PRESET_KEY) qapp.processEvents() assert window.cmbTone.isVisible() is True assert window.cmbTone.isEnabled() is False @@ -917,7 +919,7 @@ def test_selected_tone_is_passed_to_worker(qapp): qapp.processEvents() try: _select_workflow(window, WorkflowType.TEXT_IMPROVER) - _select_preset(window, "standard") + _select_preset(window, "change_tone") window.cmbTone.setCurrentIndex(window.cmbTone.findData("formal")) window.txtInput.setPlainText("Hallo Welt") window.btnAction.click() @@ -930,7 +932,7 @@ def test_selected_tone_is_passed_to_worker(qapp): @gui_only -def test_custom_preset_passes_config_prompt_to_worker(qapp): +def test_custom_action_is_resolved_centrally_by_llm_service(qapp): config = Config() config.compose_custom_preset_text = "FREITEXT-PROMPT" llm = _FakeLLMService() @@ -939,13 +941,12 @@ def test_custom_preset_passes_config_prompt_to_worker(qapp): qapp.processEvents() try: _select_workflow(window, WorkflowType.TEXT_IMPROVER) - _select_preset(window, COMPOSE_CUSTOM_PRESET_KEY) + _select_preset(window, CUSTOM_PRESET_KEY) window.txtInput.setPlainText("Hallo Welt") window.btnAction.click() _wait_until(qapp, lambda: not window._busy and window._worker_thread is None and llm.calls) - # Base preset falls back to "standard"; the free prompt is plumbed separately. - assert llm.calls[-1][2] == "standard" - assert llm.last_custom_prompt == "FREITEXT-PROMPT" + assert llm.calls[-1][2] == "custom" + assert llm.last_custom_prompt is None finally: window.close() qapp.processEvents() @@ -955,9 +956,9 @@ def test_custom_preset_passes_config_prompt_to_worker(qapp): def test_custom_preset_entry_present_in_combo(qapp): window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) try: - assert window.cmbPreset.findData(COMPOSE_CUSTOM_PRESET_KEY) >= 0 - idx = window.cmbPreset.findData(COMPOSE_CUSTOM_PRESET_KEY) - assert window.cmbPreset.itemText(idx) == t("compose.preset.custom") + assert window.cmbPreset.findData(CUSTOM_PRESET_KEY) >= 0 + idx = window.cmbPreset.findData(CUSTOM_PRESET_KEY) + assert window.cmbPreset.itemText(idx) == t("preset.custom.name") finally: window.close() qapp.processEvents() @@ -973,7 +974,7 @@ def test_tone_i18n_keys_present_and_complete(qapp, language): "compose.tone.label", "compose.tone.tooltip_active", "compose.tone.tooltip_preset_overrides", - "compose.preset.custom", + "preset.custom.name", "tone.locker", "tone.neutral", "tone.formal", @@ -983,3 +984,41 @@ def test_tone_i18n_keys_present_and_complete(qapp, language): finally: window.close() qapp.processEvents() + +# --- Goal 04: Compose zeigt dieselben fünf Aktionen ------------------------- + +GOAL_VISIBLE_PRESET_KEYS = ("standard", "shorten", "expand", "change_tone", "custom") + + +def _selectable_preset_values(combo): + return tuple( + combo.itemData(index) + for index in range(combo.count()) + if isinstance(combo.itemData(index), str) + ) + + +@gui_only +def test_goal_compose_visible_actions_are_reduced_and_ordered(qapp): + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) + try: + assert _selectable_preset_values(window.cmbPreset) == GOAL_VISIBLE_PRESET_KEYS + finally: + window.close() + qapp.processEvents() + + +@gui_only +def test_goal_tone_selector_is_active_for_standard_and_change_tone(qapp): + window = ComposeWindow(_FakeLLMService(), _FakePasteService(), Config()) + window.show() + qapp.processEvents() + try: + _select_workflow(window, WorkflowType.TEXT_IMPROVER) + for key in GOAL_VISIBLE_PRESET_KEYS: + _select_preset(window, key) + qapp.processEvents() + assert window.cmbTone.isEnabled() is (key in {"standard", "change_tone"}) + finally: + window.close() + qapp.processEvents() diff --git a/tests/test_config.py b/tests/test_config.py index b3fc41e..af9ba65 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -89,12 +89,12 @@ 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.writing_preset = "shorten" + assert config.writing_preset == "shorten" config.save() loaded = BlitztextConfig(config_dir=config_dir) - assert loaded.writing_preset == "email_formal" + assert loaded.writing_preset == "shorten" def test_invalid_preset_is_rejected(self, config): with pytest.raises(ValueError): @@ -428,3 +428,99 @@ def test_custom_preset_sanitized_fallback(self, config_dir): def test_custom_preset_setter_rejects_non_string(self, config): config.compose_custom_preset_text = 123 # type: ignore[arg-type] assert config.compose_custom_preset_text == "" + +# --- Goal 04: sichere und idempotente Preset-Migration --------------------- + +GOAL_LEGACY_CONFIG_MIGRATIONS = ( + ("standard", "standard", "neutral"), + ("email_formal", "change_tone", "formal"), + ("email_locker", "change_tone", "locker"), + ("stichpunkte", "shorten", "neutral"), + ("zusammenfassung", "shorten", "neutral"), + ("du_form", "change_tone", "locker"), + ("sie_form", "change_tone", "formal"), + ("kurz_praezise", "shorten", "neutral"), +) + + +@pytest.mark.parametrize("legacy, expected, expected_tone", GOAL_LEGACY_CONFIG_MIGRATIONS) +def test_goal_legacy_config_ids_migrate_without_touching_user_prompt( + config_dir, legacy, expected, expected_tone +): + config_dir.mkdir(parents=True, exist_ok=True) + config_file = config_dir / "config.json" + custom_prompt = " Mein eigener Prompt bleibt exakt. \n" + original = json.dumps( + { + "workflows": {"writing_preset": legacy, "text_improver_tone": "neutral"}, + "compose_custom_preset_text": custom_prompt, + }, + ensure_ascii=False, + ) + config_file.write_text(original, encoding="utf-8") + + loaded = BlitztextConfig(config_dir=config_dir) + + assert loaded.writing_preset == expected + assert loaded.text_improver_tone == expected_tone + assert loaded.compose_custom_preset_text == custom_prompt + assert config_file.read_text(encoding="utf-8") == original + assert loaded.writing_preset == BlitztextConfig(config_dir=config_dir).writing_preset + + +@pytest.mark.parametrize("legacy, expected, expected_tone", GOAL_LEGACY_CONFIG_MIGRATIONS) +def test_goal_migrated_selection_persists_after_controlled_save_and_reload( + config_dir, legacy, expected, expected_tone +): + config_dir.mkdir(parents=True, exist_ok=True) + custom_prompt = "Eigener Nutzer-Prompt – unverändert." + (config_dir / "config.json").write_text( + json.dumps( + { + "workflows": {"writing_preset": legacy}, + "compose_custom_preset_text": custom_prompt, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + loaded = BlitztextConfig(config_dir=config_dir) + loaded.save() + reloaded = BlitztextConfig(config_dir=config_dir) + + assert reloaded.writing_preset == expected + assert reloaded.text_improver_tone == expected_tone + assert reloaded.compose_custom_preset_text == custom_prompt + + +def test_goal_custom_action_and_prompt_persist_together(config, config_dir): + custom_prompt = " Verwende exakt diese eigene Anweisung. \n" + config.writing_preset = "custom" + config.compose_custom_preset_text = custom_prompt + config.save() + + reloaded = BlitztextConfig(config_dir=config_dir) + + assert reloaded.writing_preset == "custom" + assert reloaded.compose_custom_preset_text == custom_prompt + + +def test_goal_unknown_saved_id_falls_back_without_deleting_custom_prompt(config_dir): + config_dir.mkdir(parents=True, exist_ok=True) + custom_prompt = "Nicht überschreiben." + (config_dir / "config.json").write_text( + json.dumps( + { + "workflows": {"writing_preset": "veraltet-unbekannt"}, + "compose_custom_preset_text": custom_prompt, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + loaded = BlitztextConfig(config_dir=config_dir) + + assert loaded.writing_preset == "standard" + assert loaded.compose_custom_preset_text == custom_prompt diff --git a/tests/test_i18n.py b/tests/test_i18n.py index de6de27..2b95113 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -212,3 +212,35 @@ def test_set_language_case_sensitive(self): """set_language('EN') wirft ValueError (case-sensitive).""" with pytest.raises(ValueError): set_language("EN") + +# --- Goal 04: sichtbare Aktionsnamen DE/EN --------------------------------- + +@pytest.mark.parametrize( + "language, expected", + [ + ( + "de", + ( + "Standard / Text verbessern", + "Kürzen", + "Ausformulieren", + "Tonfall ändern", + "Eigener Prompt", + ), + ), + ( + "en", + ( + "Standard / Improve text", + "Shorten", + "Expand", + "Change tone", + "Custom prompt", + ), + ), + ], +) +def test_goal_visible_preset_names(language, expected): + keys = ("standard", "shorten", "expand", "change_tone", "custom") + set_language(language) + assert tuple(t(f"preset.{key}.name") for key in keys) == expected diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index f3b6fef..57dafc1 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -162,13 +162,13 @@ def test_rewrite_text_uses_override_preset_without_mutating_service(self, mock_c result = service.rewrite_text( WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT, - writing_preset="email_formal", + writing_preset="shorten", ) messages = mock_client.chat.completions.create.call_args.kwargs["messages"] system_message = next(m["content"] for m in messages if m["role"] == "system") assert result == "OK" - assert WRITING_PRESETS["email_formal"].system_prompt in system_message + assert WRITING_PRESETS["shorten"].system_prompt in system_message assert service.writing_preset == "standard" def test_openai_error_is_wrapped(self, service): @@ -196,16 +196,16 @@ def test_standard_preset_keeps_default_text_improver_prompt(self, service, mock_ # 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": + if key in {"standard", "custom"}: 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 = LLMService(api_key=DUMMY_API_KEY, client=mock_client, writing_preset="shorten") 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 + assert WRITING_PRESETS["shorten"].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") @@ -218,18 +218,18 @@ def test_custom_terms_still_applied_with_preset(self, mock_client): service = LLMService( api_key=DUMMY_API_KEY, client=mock_client, - writing_preset="stichpunkte", + writing_preset="shorten", 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 WRITING_PRESETS["shorten"].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 = LLMService(api_key=DUMMY_API_KEY, client=mock_client, writing_preset="shorten") 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") @@ -245,15 +245,15 @@ def _system_message(self, mock_client): messages = mock_client.chat.completions.create.call_args.kwargs["messages"] return next(m["content"] for m in messages if m["role"] == "system") - def test_tone_override_used_for_standard_preset(self, mock_client): + def test_tone_override_used_for_change_tone_action(self, mock_client): service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="neutral") service.rewrite_text( WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT, - writing_preset="standard", + writing_preset="change_tone", tone="formal", ) - assert "Ton: formal" in self._system_message(mock_client) + assert "Ziel-Tonfall: professionell und höflich" in self._system_message(mock_client) # Service-State bleibt unverändert (rückwärtskompatibel). assert service.tone == "neutral" @@ -262,10 +262,10 @@ def test_tone_none_falls_back_to_service_tone(self, mock_client): service.rewrite_text( WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT, - writing_preset="standard", + writing_preset="change_tone", tone=None, ) - assert "Ton: locker" in self._system_message(mock_client) + assert "Ziel-Tonfall: locker und freundlich" in self._system_message(mock_client) def test_custom_prompt_override_used_as_system(self, mock_client): service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) @@ -304,44 +304,45 @@ def test_custom_terms_still_applied_with_custom_prompt(self, mock_client): assert "Freier Prompt." in system_message assert ", ".join(CUSTOM_TERMS) in system_message - def test_tone_and_custom_prompt_default_none_keeps_legacy_behavior(self, mock_client): + def test_standard_without_overrides_applies_saved_tone(self, mock_client): service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="neutral") service.rewrite_text(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT) - assert "Ton: neutral" in self._system_message(mock_client) + system = self._system_message(mock_client) + assert "Ziel-Tonfall: neutral und sachlich" in system class TestBuildSystemPrompt: """Paket J: build_system_prompt() gibt den aufgelösten Prompt zurück ohne API-Call.""" - def test_standard_preset_with_tone_uses_template(self, mock_client): + def test_standard_preset_applies_saved_tone(self, mock_client): service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="formal") prompt = service.build_system_prompt(WorkflowType.TEXT_IMPROVER, writing_preset="standard") - assert "Ton: formal" in prompt + assert "Ziel-Tonfall: professionell und höflich" in prompt assert "Formuliere es zu einem sauberen" in prompt - def test_tone_override_reflected_in_prompt(self, mock_client): + def test_tone_override_reflected_in_change_tone_prompt(self, mock_client): service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="neutral") prompt = service.build_system_prompt( - WorkflowType.TEXT_IMPROVER, writing_preset="standard", tone="locker" + WorkflowType.TEXT_IMPROVER, writing_preset="change_tone", tone="locker" ) - assert "Ton: locker" in prompt + assert "Ziel-Tonfall: locker und freundlich" in prompt def test_non_standard_preset_uses_preset_prompt(self, mock_client): service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) - prompt = service.build_system_prompt(WorkflowType.TEXT_IMPROVER, writing_preset="email_formal") + prompt = service.build_system_prompt(WorkflowType.TEXT_IMPROVER, writing_preset="shorten") from app.writing_presets import WRITING_PRESETS - assert WRITING_PRESETS["email_formal"].system_prompt in prompt + assert WRITING_PRESETS["shorten"].system_prompt in prompt def test_custom_prompt_overrides_preset(self, mock_client): service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) prompt = service.build_system_prompt( WorkflowType.TEXT_IMPROVER, - writing_preset="email_formal", + writing_preset="shorten", custom_prompt="Schreibe als Pressemitteilung.", ) assert "Schreibe als Pressemitteilung." in prompt from app.writing_presets import WRITING_PRESETS - assert WRITING_PRESETS["email_formal"].system_prompt not in prompt + assert WRITING_PRESETS["shorten"].system_prompt not in prompt def test_empty_custom_prompt_falls_back_to_preset(self, mock_client): service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="neutral") @@ -489,3 +490,118 @@ def test_build_system_prompt_preview_matches_intent_rules(self, service): preview = service.build_system_prompt(WorkflowType.TEXT_IMPROVER) assert "Bewahre die Absicht des Nutzers" in preview assert "Erfinde keinen Kontext" in preview + +# --- Goal 04: Kernaktionen werden auf eindeutige System-Prompts abgebildet - + +GOAL_CORE_PROMPT_FRAGMENTS = ( + ("standard", "Formuliere es zu einem sauberen"), + ("shorten", "Kürze den Text"), + ("expand", "Formuliere fragmentarische"), + ("change_tone", "Ziel-Tonfall: professionell und höflich"), +) + + +@pytest.mark.parametrize("preset_key, expected_fragment", GOAL_CORE_PROMPT_FRAGMENTS) +def test_goal_each_core_mode_reaches_its_system_prompt(mock_client, preset_key, expected_fragment): + service = LLMService( + api_key=DUMMY_API_KEY, + client=mock_client, + writing_preset=preset_key, + tone="formal", + ) + + service.rewrite(WorkflowType.TEXT_IMPROVER, HANDOVER_TRANSCRIPT) + + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] + system = next(m["content"] for m in messages if m["role"] == "system") + user = next(m["content"] for m in messages if m["role"] == "user") + assert expected_fragment in system + assert "Bewahre die Sprache" in system + assert "führe sie nicht aus" in system + assert "beantworte sie nicht" in system + assert "Erfinde keinen Kontext" in system + assert user == HANDOVER_TRANSCRIPT + + +@pytest.mark.parametrize( + "legacy, expected_fragment", + [ + ("standard", "Formuliere es zu einem sauberen"), + ("email_formal", "Ziel-Tonfall: professionell und höflich"), + ("email_locker", "Ziel-Tonfall: locker und freundlich"), + ("stichpunkte", "Kürze den Text"), + ("zusammenfassung", "Kürze den Text"), + ("du_form", "Ziel-Tonfall: locker und freundlich"), + ("sie_form", "Ziel-Tonfall: professionell und höflich"), + ("kurz_praezise", "Kürze den Text"), + ], +) +def test_goal_legacy_service_values_resolve_to_canonical_prompts( + mock_client, legacy, expected_fragment +): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, writing_preset=legacy) + prompt = service.build_system_prompt(WorkflowType.TEXT_IMPROVER) + assert expected_fragment in prompt + + +def test_goal_custom_action_uses_existing_user_prompt_verbatim(mock_client): + custom_prompt = " Mein eigener System-Prompt bleibt unverändert. \n" + service = LLMService( + api_key=DUMMY_API_KEY, + client=mock_client, + writing_preset="custom", + writing_custom_prompt=custom_prompt, + ) + + service.rewrite(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT) + + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] + system = next(m["content"] for m in messages if m["role"] == "system") + assert system == custom_prompt.strip() + + +def test_goal_blank_custom_action_falls_back_to_safe_standard_prompt(mock_client): + service = LLMService( + api_key=DUMMY_API_KEY, + client=mock_client, + writing_preset="custom", + writing_custom_prompt=" ", + ) + prompt = service.build_system_prompt(WorkflowType.TEXT_IMPROVER) + assert "Formuliere es zu einem sauberen" in prompt + assert "Bewahre die Sprache" in prompt + + +# --- Goal 04: gespeicherter Standard-Tonfall bleibt wirksam ------------- + + +def test_goal_loaded_standard_keeps_saved_formal_tone_effect(tmp_path, mock_client): + import json + from app.config import BlitztextConfig + + config_dir = tmp_path / ".config" / "blitztext-linux" + config_dir.mkdir(parents=True) + (config_dir / "config.json").write_text( + json.dumps( + { + "workflows": { + "writing_preset": "standard", + "text_improver_tone": "formal", + } + } + ), + encoding="utf-8", + ) + loaded = BlitztextConfig(config_dir=config_dir) + service = LLMService( + api_key=DUMMY_API_KEY, + client=mock_client, + writing_preset=loaded.writing_preset, + tone=loaded.text_improver_tone, + ) + + prompt = service.build_system_prompt(WorkflowType.TEXT_IMPROVER) + + assert loaded.writing_preset == "standard" + assert loaded.text_improver_tone == "formal" + assert "Ziel-Tonfall: professionell und höflich" in prompt diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 36b30a3..deb76d1 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -170,9 +170,11 @@ def test_save_settings_persists_writing_preset(tmp_path): SettingsDialog.save_settings(fake) - assert fake.config.writing_preset == "email_formal" + assert fake.config.writing_preset == "change_tone" + assert fake.config.text_improver_tone == "formal" reloaded = BlitztextConfig(config_dir=config_dir) - assert reloaded.writing_preset == "email_formal" + assert reloaded.writing_preset == "change_tone" + assert reloaded.text_improver_tone == "formal" def test_save_settings_persists_llm_provider_fields(tmp_path): @@ -372,3 +374,42 @@ def setText(self, value): assert "CUSTOM_OPENAI_KEY" in fake.lbl_api_key_status.text assert "gesetzt" in fake.lbl_api_key_status.text assert secret_value not in fake.lbl_api_key_status.text + +# --- Goal 04: Settings nutzt dieselbe Auswahl und bestehende Prompt-Ablage -- + + +def test_goal_settings_lists_only_core_actions_and_keeps_single_custom_prompt_field(tmp_path): + from PyQt6.QtWidgets import QApplication + from app.writing_presets import WRITING_PRESET_KEYS + + qapp = QApplication.instance() or QApplication([]) + config = BlitztextConfig(config_dir=tmp_path / ".config" / "blitztext-linux") + config.writing_preset = "custom" + config.compose_custom_preset_text = "Bestehender Nutzer-Prompt." + dialog = SettingsDialog(config) + try: + values = tuple( + dialog.combo_writing_preset.itemData(index) + for index in range(dialog.combo_writing_preset.count()) + if isinstance(dialog.combo_writing_preset.itemData(index), str) + ) + assert values == WRITING_PRESET_KEYS == ( + "standard", "shorten", "expand", "change_tone", "custom" + ) + assert dialog.combo_writing_preset.currentData() == "custom" + assert dialog.edit_compose_custom_preset.toPlainText() == "Bestehender Nutzer-Prompt." + finally: + dialog.close() + qapp.processEvents() + + +def test_goal_settings_save_preserves_custom_selection_and_prompt(tmp_path): + config_dir = tmp_path / ".config" / "blitztext-linux" + fake = _fake_save_self(config_dir, "custom") + fake.edit_compose_custom_preset = _Edit(" Nutzer-Prompt exakt. \n") + + SettingsDialog.save_settings(fake) + reloaded = BlitztextConfig(config_dir=config_dir) + + assert reloaded.writing_preset == "custom" + assert reloaded.compose_custom_preset_text == " Nutzer-Prompt exakt. \n" diff --git a/tests/test_tray_preset_menu.py b/tests/test_tray_preset_menu.py index 2caea7e..b516fd1 100644 --- a/tests/test_tray_preset_menu.py +++ b/tests/test_tray_preset_menu.py @@ -223,3 +223,79 @@ def test_preset_combo_visible_only_for_text_improver(self, tray_app): f"Workflow {wf}: Combo sichtbar={window._preset_combo.isVisible()}, " f"erwartet={expected}" ) + +# --- Goal 04: Tray und Hauptfenster zeigen dieselben Kernaktionen ----------- + +GOAL_VISIBLE_ACTIONS = ("standard", "shorten", "expand", "change_tone", "custom") + + +@gui_only +def test_goal_tray_and_main_window_share_reduced_action_catalog(tray_app): + assert tuple(tray_app.preset_actions) == GOAL_VISIBLE_ACTIONS + window = tray_app._ensure_main_window() + main_values = tuple( + window._preset_combo.itemData(index) + for index in range(window._preset_combo.count()) + if isinstance(window._preset_combo.itemData(index), str) + ) + assert main_values == GOAL_VISIBLE_ACTIONS + + +@gui_only +def test_goal_custom_action_persists_without_changing_custom_prompt(tray_app): + tray_app.config.compose_custom_preset_text = "Vorhandener eigener Prompt." + + tray_app._on_writing_preset_selected("custom") + + reloaded = BlitztextConfig(config_dir=tray_app.config.config_dir) + assert reloaded.writing_preset == "custom" + assert reloaded.compose_custom_preset_text == "Vorhandener eigener Prompt." + +# --- Goal 04: offenes Compose-Fenster erhält den aktuellen Service -------- + + +@gui_only +def test_goal_tray_change_refreshes_service_in_open_compose(tray_app): + compose = tray_app._ensure_compose_window() + old_service = compose._llm_service + target = _other_key(tray_app.config.writing_preset) + + tray_app._on_writing_preset_selected(target) + + assert tray_app.llm_service is not old_service + assert compose._llm_service is tray_app.llm_service + + +@gui_only +def test_goal_main_window_change_refreshes_service_in_open_compose(tray_app): + compose = tray_app._ensure_compose_window() + old_service = compose._llm_service + target = _other_key(tray_app.config.writing_preset) + + tray_app.main_window_preset_changed(target) + + assert tray_app.llm_service is not old_service + assert compose._llm_service is tray_app.llm_service + + +@gui_only +def test_goal_settings_rebuild_refreshes_service_in_open_compose(tray_app, monkeypatch): + from PyQt6.QtWidgets import QDialog + import app.blitztext_linux as mod + + compose = tray_app._ensure_compose_window() + old_service = compose._llm_service + + class _AcceptedDialog: + def __init__(self, _config): + pass + + def exec(self): + return QDialog.DialogCode.Accepted + + monkeypatch.setattr(mod, "SettingsDialog", _AcceptedDialog) + + tray_app.show_settings_dialog() + + assert tray_app.llm_service is not old_service + assert compose._llm_service is tray_app.llm_service diff --git a/tests/test_writing_presets.py b/tests/test_writing_presets.py index c993fe3..2535512 100644 --- a/tests/test_writing_presets.py +++ b/tests/test_writing_presets.py @@ -1,122 +1,122 @@ -"""Tests für den Schreibstil-Preset-Katalog.""" +"""Tests für den reduzierten Schreibaktions-Katalog und Alt-ID-Migration.""" from __future__ import annotations import pytest +import app.writing_presets as preset_catalog from app.writing_presets import ( + CUSTOM_PRESET_KEY, DEFAULT_PRESET_KEY, WRITING_PRESET_KEYS, WRITING_PRESETS, WritingPreset, get_preset, + migrate_preset_selection, preset_index, + resolve_preset_prompt, ) -EXPECTED_KEYS = ( - "standard", - "email_formal", - "email_locker", - "stichpunkte", - "zusammenfassung", - "du_form", - "sie_form", - "kurz_praezise", -) +EXPECTED_KEYS = ("standard", "shorten", "expand", "change_tone", "custom") +LEGACY_MIGRATIONS = { + "standard": ("standard", None), + "email_formal": ("change_tone", "formal"), + "email_locker": ("change_tone", "locker"), + "stichpunkte": ("shorten", None), + "zusammenfassung": ("shorten", None), + "du_form": ("change_tone", "locker"), + "sie_form": ("change_tone", "formal"), + "kurz_praezise": ("shorten", None), +} class TestCatalogIntegrity: - def test_expected_keys_present_and_ordered(self): + def test_visible_keys_are_reduced_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) + assert tuple(WRITING_PRESETS) == WRITING_PRESET_KEYS - def test_default_key_is_standard(self): + def test_default_and_custom_keys_are_stable(self): assert DEFAULT_PRESET_KEY == "standard" + assert CUSTOM_PRESET_KEY == "custom" - def test_standard_prompt_is_empty(self): + def test_only_actions_with_builtin_behavior_have_prompts(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() + assert WRITING_PRESETS["custom"].system_prompt == "" + for key in ("shorten", "expand", "change_tone"): + assert WRITING_PRESETS[key].system_prompt.strip() + + def test_every_action_has_display_name(self): + assert tuple(p.display_name for p in WRITING_PRESETS.values()) == ( + "Standard / Text verbessern", + "Kürzen", + "Ausformulieren", + "Tonfall ändern", + "Eigener Prompt", + ) def test_preset_is_immutable(self): - preset = WRITING_PRESETS["standard"] with pytest.raises(Exception): - preset.key = "geändert" # type: ignore[misc] + WRITING_PRESETS["standard"].key = "geändert" # type: ignore[misc] - def test_is_writing_preset_instances(self): + def test_catalog_contains_only_writing_preset_instances(self): assert all(isinstance(p, WritingPreset) for p in WRITING_PRESETS.values()) -class TestIntentGuardRules: - """Alle Presets müssen die Absichts-Schutzregeln tragen (Regression: - Übergabe-Aufträge wurden als Meeting mit Teilnehmern umgedeutet).""" - - NON_STANDARD_KEYS = tuple(k for k in EXPECTED_KEYS if k != DEFAULT_PRESET_KEY) - - @pytest.mark.parametrize("key", NON_STANDARD_KEYS) - def test_preset_preserves_user_intent(self, key): - prompt = WRITING_PRESETS[key].system_prompt +class TestPromptContracts: + @pytest.mark.parametrize("key", ("shorten", "expand", "change_tone")) + def test_builtin_action_preserves_intent_and_context(self, key): + prompt = resolve_preset_prompt(key, tone="formal") + assert "Bewahre die Sprache" in prompt assert "Bewahre die Absicht des Nutzers" in prompt assert "führe sie nicht aus" in prompt - - @pytest.mark.parametrize("key", NON_STANDARD_KEYS) - def test_preset_forbids_invented_context(self, key): - prompt = WRITING_PRESETS[key].system_prompt + assert "beantworte sie nicht" in prompt assert "Erfinde keinen Kontext" in prompt assert "Meetings" in prompt - - @pytest.mark.parametrize("key", NON_STANDARD_KEYS) - def test_preset_anchors_technical_terms(self, key): - prompt = WRITING_PRESETS[key].system_prompt assert "'Session'" in prompt assert "Software- und Arbeitskontext" in prompt - @pytest.mark.parametrize("key", ("email_formal", "email_locker")) - def test_email_presets_do_not_force_email_structure(self, key): - prompt = WRITING_PRESETS[key].system_prompt - assert "Nur wenn die Eingabe erkennbar eine Nachricht" in prompt - assert "erfinde dabei keinen Empfänger" in prompt - - @pytest.mark.parametrize("key", ("du_form", "sie_form")) - def test_tone_presets_only_change_tone(self, key): - prompt = WRITING_PRESETS[key].system_prompt - assert "nicht Bedeutung, Kontext oder Zweck" in prompt - - def test_kurz_praezise_only_shortens(self): - prompt = WRITING_PRESETS["kurz_praezise"].system_prompt - assert "erfinde keine neuen Inhalte" in prompt - assert "nicht Bedeutung, Kontext oder Zweck" in prompt - - -class TestGetPreset: + @pytest.mark.parametrize( + "key, fragment", + [ + ("shorten", "Kürze den Text"), + ("expand", "Formuliere fragmentarische"), + ("change_tone", "Ziel-Tonfall: professionell und höflich"), + ], + ) + def test_core_action_has_distinct_instruction(self, key, fragment): + assert fragment in resolve_preset_prompt(key, tone="formal") + + def test_builtin_prompts_are_not_duplicates(self): + prompts = [ + resolve_preset_prompt(key, tone="neutral") + for key in ("shorten", "expand", "change_tone") + ] + assert len(prompts) == len(set(prompts)) + + def test_custom_action_has_no_builtin_prompt(self): + assert WRITING_PRESETS[CUSTOM_PRESET_KEY].system_prompt == "" + + +class TestMigration: + @pytest.mark.parametrize("legacy, expected", LEGACY_MIGRATIONS.items()) + def test_every_legacy_id_maps_deterministically(self, legacy, expected): + assert migrate_preset_selection(legacy) == expected + assert migrate_preset_selection(expected[0]) == (expected[0], None) + assert get_preset(legacy).key == expected[0] + assert WRITING_PRESET_KEYS[preset_index(legacy)] == expected[0] + + @pytest.mark.parametrize("unknown", ["", "kaputt", None, [], {}]) + def test_unknown_or_invalid_value_falls_back_to_standard(self, unknown): + assert migrate_preset_selection(unknown) == (DEFAULT_PRESET_KEY, None) + assert get_preset(unknown).key == DEFAULT_PRESET_KEY # type: ignore[arg-type] + + +class TestCurrentSelection: @pytest.mark.parametrize("key", EXPECTED_KEYS) - def test_known_keys_return_matching_preset(self, key): + def test_current_key_round_trips(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") + def test_module_exports_match_public_catalog(self): + assert preset_catalog.WRITING_PRESET_KEYS == EXPECTED_KEYS From 083b231e9e5eadf43e78e69ddc259593bb04139f Mon Sep 17 00:00:00 2001 From: gummiflip Date: Mon, 20 Jul 2026 16:48:02 +0200 Subject: [PATCH 5/6] fix(prompts): keep tone changes scoped to tone --- app/writing_presets.py | 11 ++++++----- tests/test_writing_presets.py | 8 ++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/writing_presets.py b/app/writing_presets.py index 2da310b..cb47fe2 100644 --- a/app/writing_presets.py +++ b/app/writing_presets.py @@ -20,11 +20,12 @@ "keine Adressaten, Rollen, Meetings, Teilnehmer oder Ziele, die nicht " "ausdrücklich genannt sind. Interpretiere Begriffe wie 'Session', " "'Prompt', 'Branch', 'PR', 'Merge' oder 'Handover' im Software- und " - "Arbeitskontext, wenn die Eingabe danach klingt. Korrigiere Grammatik und " - "Zeichensetzung. Gib NUR den fertigen Text zurück, ohne Vorbemerkung oder " - "Erklärung." + "Arbeitskontext, wenn die Eingabe danach klingt. Gib NUR den fertigen Text " + "zurück, ohne Vorbemerkung oder Erklärung." ) +_CORRECTION_RULES = " Korrigiere Grammatik und Zeichensetzung." + _TONE_DESCRIPTIONS = { "formal": "professionell und höflich", "neutral": "neutral und sachlich", @@ -48,7 +49,7 @@ class WritingPreset: "Kürzen", "Du erhältst einen Text. Kürze den Text deutlich: Entferne Füllwörter, " "Wiederholungen und unnötige Umwege, behalte aber alle wesentlichen " - "Informationen." + _COMMON_RULES, + "Informationen." + _CORRECTION_RULES + _COMMON_RULES, ), WritingPreset( "expand", @@ -56,7 +57,7 @@ class WritingPreset: "Du erhältst einen Text. Formuliere fragmentarische Sätze, Notizen und " "Stichpunkte zu einem klaren, zusammenhängenden Fließtext aus. Ergänze " "nur sprachlich notwendige Verbindungen, aber keine neuen Fakten, " - "Beispiele oder Annahmen." + _COMMON_RULES, + "Beispiele oder Annahmen." + _CORRECTION_RULES + _COMMON_RULES, ), WritingPreset( "change_tone", diff --git a/tests/test_writing_presets.py b/tests/test_writing_presets.py index 2535512..41f7f7a 100644 --- a/tests/test_writing_presets.py +++ b/tests/test_writing_presets.py @@ -120,3 +120,11 @@ def test_current_key_round_trips(self, key): def test_module_exports_match_public_catalog(self): assert preset_catalog.WRITING_PRESET_KEYS == EXPECTED_KEYS + + +def test_change_tone_prompt_changes_only_tone(): + prompt = resolve_preset_prompt("change_tone", "formal") + + assert "Ändere gezielt nur den Tonfall" in prompt + assert "Grammatik" not in prompt + assert "Zeichensetzung" not in prompt From 96174c8585d4977884f0ebc3255f413a5e3d6e0e Mon Sep 17 00:00:00 2001 From: gummiflip Date: Tue, 21 Jul 2026 00:38:39 +0200 Subject: [PATCH 6/6] test(gui): clean up app windows between tests --- tests/test_state_machine.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index 2460c0b..3cfd7b1 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -333,7 +333,22 @@ def gui_app(): qapp = QApplication.instance() or QApplication([]) app = BlitztextApp(qapp) app.stop_hotkey_worker() # kein echter evdev-Thread im Test - yield app + try: + yield app + finally: + app.stop_hotkey_worker() + for window in ( + app._compose_window, + app._main_window, + app._history_panel, + app._tts_window, + ): + if window is not None: + window.hide() + app.tray_icon.hide() + app.menu.close() + # Qt-Ereignisse verarbeiten, solange alle Python-Referenzen gültig sind. + qapp.processEvents() @gui_only