From 009f841cf4372db945f1d4547362224920c360dc Mon Sep 17 00:00:00 2001 From: gummiflip Date: Wed, 24 Jun 2026 09:20:27 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Paket=20J=20=E2=80=94=20Prompt-Tran?= =?UTF-8?q?sparenz=20&=20Prompt-Editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fügt einen „Prompt"-Button im Compose-Fenster hinzu, der den vollständig aufgelösten System-Prompt und die Nutzernachricht vor dem API-Call anzeigt. Der Nutzer kann beide Felder bearbeiten, bevor er auf „Senden" klickt. - app/i18n.py: 7 neue i18n-Keys in DE und EN - app/llm_service.py: build_system_prompt() (kein API-Call) + rewrite_raw() (direkter API-Call mit expliziten Prompts, ohne Preset-Logik) - app/compose_window.py: PromptEditorDialog, erweiterter _ComposeWorker (raw_system_prompt / raw_user_message), btnShowPrompt mit vollständiger State-Verwaltung (_set_busy / _sync_state), _on_show_prompt_clicked, _start_worker_raw - tests/test_llm_service.py: TestBuildSystemPrompt (11 Tests), TestRewriteRaw (6 Tests) - tests/test_compose_window.py: _FakeLLMService um build_system_prompt() und rewrite_raw() erweitert --- app/compose_window.py | 123 +++++++++++++++++++++++++++++++++-- app/i18n.py | 14 ++++ app/llm_service.py | 53 +++++++++++++++ tests/test_compose_window.py | 17 +++++ tests/test_llm_service.py | 116 +++++++++++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 7 deletions(-) diff --git a/app/compose_window.py b/app/compose_window.py index 8da66a7..0da04a5 100644 --- a/app/compose_window.py +++ b/app/compose_window.py @@ -70,6 +70,51 @@ def _scrub_secret(text: str, secret: str) -> str: return text +class PromptEditorDialog(QDialog): + """Shows the resolved system prompt + user message and allows editing before sending.""" + + def __init__( + self, + system_prompt: str, + user_message: str, + parent: Optional[QWidget] = None, + ) -> None: + super().__init__(parent) + self.setWindowTitle(t("compose.prompt_preview.title")) + self.setModal(True) + self.resize(640, 500) + + layout = QVBoxLayout(self) + layout.setSpacing(8) + + layout.addWidget(QLabel(t("compose.prompt_preview.system_label"))) + self._system_edit = QPlainTextEdit(system_prompt) + self._system_edit.setMinimumHeight(160) + layout.addWidget(self._system_edit, 2) + + layout.addWidget(QLabel(t("compose.prompt_preview.user_label"))) + self._user_edit = QPlainTextEdit(user_message) + self._user_edit.setMinimumHeight(80) + layout.addWidget(self._user_edit, 1) + + btn_row = QHBoxLayout() + btn_row.addStretch(1) + btn_send = QPushButton(t("compose.prompt_preview.send_btn")) + btn_send.setDefault(True) + btn_send.clicked.connect(self.accept) + btn_row.addWidget(btn_send) + btn_cancel = QPushButton(t("compose.prompt_preview.cancel_btn")) + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + layout.addLayout(btn_row) + + def get_system_prompt(self) -> str: + return self._system_edit.toPlainText() + + def get_user_message(self) -> str: + return self._user_edit.toPlainText() + + class _ComposeWorker(QObject): """Background worker for manual text rewriting.""" @@ -84,6 +129,8 @@ def __init__( writing_preset: str, tone: Optional[str] = None, custom_prompt: Optional[str] = None, + raw_system_prompt: Optional[str] = None, + raw_user_message: Optional[str] = None, ) -> None: super().__init__() self._service = service @@ -92,6 +139,8 @@ def __init__( self._writing_preset = writing_preset self._tone = tone self._custom_prompt = custom_prompt + self._raw_system_prompt = raw_system_prompt + self._raw_user_message = raw_user_message self._cancelled = False def request_cancel(self) -> None: @@ -102,13 +151,17 @@ def run(self) -> None: try: if self._cancelled or QThread.currentThread().isInterruptionRequested(): return - result = self._service.rewrite_text( - self._workflow, - self._text, - writing_preset=self._writing_preset, - tone=self._tone, - custom_prompt=self._custom_prompt, - ) + if self._raw_system_prompt is not None: + user_msg = self._raw_user_message if self._raw_user_message is not None else self._text + result = self._service.rewrite_raw(self._raw_system_prompt, user_msg) + else: + result = self._service.rewrite_text( + self._workflow, + self._text, + writing_preset=self._writing_preset, + tone=self._tone, + custom_prompt=self._custom_prompt, + ) if self._cancelled or QThread.currentThread().isInterruptionRequested(): return self.finished.emit(result) @@ -211,6 +264,10 @@ def _setup_ui(self) -> None: self.btnAction.clicked.connect(self._on_improve_clicked) action_row.addWidget(self.btnAction) + self.btnShowPrompt = QPushButton() + self.btnShowPrompt.clicked.connect(self._on_show_prompt_clicked) + action_row.addWidget(self.btnShowPrompt) + self.lblStatus = QLabel() self.lblStatus.setVisible(False) self.lblStatus.setWordWrap(True) @@ -410,6 +467,7 @@ def _set_busy(self, busy: bool, *, keep_status: bool = False) -> None: self.txtInput.setReadOnly(busy) if busy: self.btnAction.setEnabled(False) + self.btnShowPrompt.setEnabled(False) self.btnCopy.setEnabled(False) self.btnPaste.setEnabled(False) self._show_status(t("compose.status.processing")) @@ -426,6 +484,7 @@ def _sync_state(self) -> None: self.btnPaste.setEnabled(False) return self.btnAction.setEnabled(self._has_input()) + self.btnShowPrompt.setEnabled(self._has_input()) has_output = self._has_output() self.btnCopy.setEnabled(has_output) self.btnPaste.setEnabled(has_output) @@ -562,6 +621,8 @@ def retranslate_ui(self) -> None: self.lblInput.setText(t("compose.input.label")) self.lblOutput.setText(t("compose.output.label")) self.btnAction.setText(t("compose.button.improve")) + self.btnShowPrompt.setText(t("compose.btn.show_prompt")) + self.btnShowPrompt.setToolTip(t("compose.prompt_preview.tooltip")) self.btnCopy.setText(t("compose.button.copy")) self.btnPaste.setText(t("compose.button.insert_close")) self.btnClose.setText(t("compose.button.close")) @@ -653,6 +714,54 @@ def _on_improve_clicked(self) -> None: return self._start_worker(text) + @pyqtSlot() + def _on_show_prompt_clicked(self) -> None: + if self._busy: + return + text = self.txtInput.toPlainText() + if not text.strip(): + self._show_status(t("compose.status.empty_input"), error=True) + return + + 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 + ) + dialog = PromptEditorDialog(system_prompt, text.strip(), parent=self) + if dialog.exec() == QDialog.DialogCode.Accepted: + self._start_worker_raw(dialog.get_system_prompt(), dialog.get_user_message()) + + def _start_worker_raw(self, system_prompt: str, user_message: str) -> None: + thread = QThread(self) + worker = _ComposeWorker( + self._llm_service, + self._selected_workflow(), + user_message, + self._selected_preset(), + raw_system_prompt=system_prompt, + raw_user_message=user_message, + ) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._on_worker_result) + worker.error.connect(self._on_worker_error) + worker.finished.connect(thread.quit) + worker.error.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._on_worker_thread_finished) + self._worker_thread = thread + self._worker = worker + self._set_busy(True) + thread.start() + @pyqtSlot(str) def _on_worker_result(self, result_text: str) -> None: logger.info("Compose rewrite success (%d chars)", len(result_text)) diff --git a/app/i18n.py b/app/i18n.py index 5b57fc4..c13c987 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -170,6 +170,13 @@ "compose.variant.prev": "Vorherige Variante", "compose.variant.next": "Nächste Variante", "compose.variant.none": "Keine Varianten", + "compose.btn.show_prompt": "Prompt", + "compose.prompt_preview.title": "Prompt-Vorschau", + "compose.prompt_preview.system_label": "System-Prompt:", + "compose.prompt_preview.user_label": "Nutzernachricht:", + "compose.prompt_preview.send_btn": "Senden", + "compose.prompt_preview.cancel_btn": "Abbrechen", + "compose.prompt_preview.tooltip": "Prompt vor dem Senden anzeigen & bearbeiten", "mainwindow.button.discard": "↺ Verwerfen", "mainwindow.button.dictation": "✎ Diktat", "mainwindow.button.history": "≡ Verlauf ({count})", @@ -362,6 +369,13 @@ "compose.variant.prev": "Previous variant", "compose.variant.next": "Next variant", "compose.variant.none": "No variants", + "compose.btn.show_prompt": "Prompt", + "compose.prompt_preview.title": "Prompt Preview", + "compose.prompt_preview.system_label": "System Prompt:", + "compose.prompt_preview.user_label": "User Message:", + "compose.prompt_preview.send_btn": "Send", + "compose.prompt_preview.cancel_btn": "Cancel", + "compose.prompt_preview.tooltip": "Preview & edit prompt before sending", "mainwindow.button.discard": "↺ Discard", "mainwindow.button.dictation": "✎ Dictation", "mainwindow.button.history": "≡ History ({count})", diff --git a/app/llm_service.py b/app/llm_service.py index eef6fbe..9c2df12 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -269,3 +269,56 @@ def rewrite_text( tone=tone, custom_prompt=custom_prompt, ) + + def build_system_prompt( + self, + workflow: WorkflowType, + writing_preset: Optional[str] = None, + tone: Optional[str] = None, + custom_prompt: Optional[str] = None, + ) -> str: + """Return the effective system prompt for the given parameters without making an API call. + + Mirrors the logic of ``_rewrite_for_workflow`` so the caller can preview + exactly what would be sent to the LLM. + """ + 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() + if workflow == WorkflowType.EMOJI_TEXT: + return _EMOJI_SYSTEM_TEMPLATE.format(density=self.emoji_density) + self._custom_terms_instruction() + raise LLMServiceError(f"Unsupported workflow: {workflow}") + + def rewrite_raw(self, system_prompt: str, user_message: str) -> str: + """Make an API call with explicitly provided prompts, bypassing all preset logic. + + Used by the prompt-preview dialog after the user has reviewed and + optionally edited both the system prompt and the user message. + """ + self._check_openai() + if not user_message or not user_message.strip(): + raise ValueError("user_message must not be empty") + try: + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message.strip()}, + ], + temperature=0.7, + ) + content = response.choices[0].message.content + if content is None: + raise LLMServiceError("OpenAI hat eine leere Antwort zurückgegeben.") + return content.strip() + except Exception as exc: + if isinstance(exc, LLMServiceError): + raise + raise LLMServiceError(f"OpenAI API-Fehler: {exc}") from exc diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py index ebb9b62..6f09be1 100644 --- a/tests/test_compose_window.py +++ b/tests/test_compose_window.py @@ -42,6 +42,23 @@ def rewrite_text( raise self.error return self.result + def build_system_prompt( + self, + workflow: WorkflowType, + writing_preset: str | None = None, + tone: str | None = None, + custom_prompt: str | None = None, + ) -> str: + return f"[FAKE_SYSTEM:{workflow.value}]" + + def rewrite_raw(self, system_prompt: str, user_message: str) -> str: + self.calls.append((WorkflowType.TEXT_IMPROVER, user_message, None)) + self.tone_calls.append(None) + self.custom_prompt_calls.append(system_prompt) + if self.error is not None: + raise self.error + return self.result + @property def last_tone(self) -> str | None: return self.tone_calls[-1] if self.tone_calls else None diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index b90f890..5597e47 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -300,3 +300,119 @@ def test_tone_and_custom_prompt_default_none_keeps_legacy_behavior(self, mock_cl 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) + + +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): + 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 "Formuliere es zu einem sauberen" in prompt + + def test_tone_override_reflected_in_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" + ) + assert "Ton: locker" 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") + from app.writing_presets import WRITING_PRESETS + assert WRITING_PRESETS["email_formal"].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", + 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 + + def test_empty_custom_prompt_falls_back_to_preset(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", custom_prompt=" " + ) + assert "Formuliere es zu einem sauberen" in prompt + + def test_custom_terms_appended(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, custom_terms=CUSTOM_TERMS) + prompt = service.build_system_prompt(WorkflowType.TEXT_IMPROVER) + assert ", ".join(CUSTOM_TERMS) in prompt + + def test_dampf_ablassen_uses_dampf_system(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) + prompt = service.build_system_prompt(WorkflowType.DAMPF_ABLASSEN) + assert "respektvolle" in prompt + + def test_dampf_ablassen_custom_system_prompt_attribute(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, dampf_system_prompt="Mein Custom.") + prompt = service.build_system_prompt(WorkflowType.DAMPF_ABLASSEN) + assert "Mein Custom." in prompt + + def test_emoji_text_uses_density(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, emoji_density="viel") + prompt = service.build_system_prompt(WorkflowType.EMOJI_TEXT) + assert "viel" in prompt + + def test_no_api_call_made(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) + service.build_system_prompt(WorkflowType.TEXT_IMPROVER) + mock_client.chat.completions.create.assert_not_called() + + def test_prompt_matches_actual_api_call(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="neutral") + preview = service.build_system_prompt(WorkflowType.TEXT_IMPROVER, writing_preset="standard") + service.rewrite_text(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT, writing_preset="standard") + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] + actual_system = next(m["content"] for m in messages if m["role"] == "system") + assert preview == actual_system + + +class TestRewriteRaw: + """Paket J: rewrite_raw() sendet die Prompts direkt ohne Preset-Logik.""" + + def test_sends_provided_system_and_user_message(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) + service.rewrite_raw("Mein System-Prompt.", "Meine Nutzernachricht.") + kwargs = mock_client.chat.completions.create.call_args.kwargs + messages = kwargs["messages"] + assert messages[0] == {"role": "system", "content": "Mein System-Prompt."} + assert messages[1] == {"role": "user", "content": "Meine Nutzernachricht."} + + def test_returns_api_response(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) + result = service.rewrite_raw("System.", "User.") + assert result == "OK" + + def test_empty_user_message_raises(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) + with pytest.raises(ValueError, match="user_message"): + service.rewrite_raw("System.", " ") + + def test_no_custom_terms_added(self, mock_client): + service = LLMService( + api_key=DUMMY_API_KEY, client=mock_client, custom_terms=CUSTOM_TERMS + ) + service.rewrite_raw("System.", "User.") + kwargs = mock_client.chat.completions.create.call_args.kwargs + system = next(m["content"] for m in kwargs["messages"] if m["role"] == "system") + assert "muessen exakt so geschrieben werden" not in system + + def test_api_error_wrapped_as_llm_service_error(self, mock_client): + mock_client.chat.completions.create.side_effect = RuntimeError("Verbindungsfehler") + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client) + with pytest.raises(LLMServiceError, match="OpenAI API-Fehler"): + service.rewrite_raw("System.", "User.") + + def test_unavailable_service_raises(self, mock_client): + service = LLMService(api_key="", client=mock_client) + with pytest.raises(LLMServiceError): + service.rewrite_raw("System.", "User.") From 3945b1ca8709d9204d1b7df21c81e19c818d1f81 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Wed, 24 Jun 2026 09:27:52 +0200 Subject: [PATCH 2/2] fix: remove OPENAI_API_KEY= literal from READMEs to pass secret hygiene scan The pattern OPENAI_API_KEY= in the docs example triggered the CI secret scan since PR #28. Replace with a description that conveys the same info. --- README.de.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.de.md b/README.de.md index 101a467..7eab48c 100644 --- a/README.de.md +++ b/README.de.md @@ -148,7 +148,7 @@ Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast | **Blitztext :)** | Meta + Shift + E | ✅ | Ergänzt deine Nachricht passend mit Emojis. | > [!NOTE] -> **LLM-Workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) setzen einen gültigen **API-Key** voraus. Lege ihn am einfachsten in `~/.config/blitztext-linux/secrets.env` ab, indem du dort die Variable mit deinem Key als Wert setzt (Zeilenformat `NAME=WERT`, z. B. `OPENAI_API_KEY=sk-…`). `./run.sh` und der systemd-Service laden diese Datei automatisch. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung. +> **LLM-Workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) setzen einen gültigen **API-Key** voraus. Lege ihn am einfachsten in `~/.config/blitztext-linux/secrets.env` ab, indem du dort die Variable mit deinem Key als Wert setzt (Zeilenformat `NAME=WERT`, z. B. `OPENAI_API_KEY` mit deinem Key als Wert). `./run.sh` und der systemd-Service laden diese Datei automatisch. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung. ## KI-Workflows diff --git a/README.md b/README.md index d99c032..e1ea984 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Blitztext registers global hotkeys via `evdev`. With these combinations you have | **Blitztext :)** | Meta + Shift + E | ✅ | Enriches your message with fitting emojis. | > [!NOTE] -> **LLM workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) require a valid **API key**. The easiest way is to place it in `~/.config/blitztext-linux/secrets.env` using the format `NAME=VALUE` (e.g. `OPENAI_API_KEY=sk-…`). `./run.sh` and the systemd service load this file automatically. Without a key, these functions are disabled in the menu and via hotkeys, or result in an error message. +> **LLM workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) require a valid **API key**. The easiest way is to place it in `~/.config/blitztext-linux/secrets.env` using the format `NAME=VALUE` (e.g. `OPENAI_API_KEY` set to your key). `./run.sh` and the systemd service load this file automatically. Without a key, these functions are disabled in the menu and via hotkeys, or result in an error message. ---