From 0e8268c20403ea99230b9d3736434936d90bd660 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sat, 4 Jul 2026 15:50:28 +0200 Subject: [PATCH] fix(llm): Absichts-Schutzregeln in Blitztext+-Prompt und Schreibstil-Presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blitztext+ deutete Arbeitsaufträge teils frei um (z. B. 'neue Session' als Meeting mit Teilnehmern). Der Standard-Prompt und alle Presets erhalten Schutzregeln: Absicht bewahren, Eingabe nicht ausführen oder beantworten, keinen Kontext (Teilnehmer/Meetings/Rollen/Adressaten) erfinden, Technikbegriffe wie Session/Prompt/Branch/PR/Merge/Handover im Arbeitskontext belassen. E-Mail-Presets erzeugen E-Mail-Struktur nur noch bei erkennbaren Nachrichten und erfinden keinen Empfänger; Du-/Sie-Form ändern nur Anrede und Ton; Kurz & präzise kürzt ohne neue Inhalte. Regressionstests decken den konkreten Fehlerfall ab. --- app/llm_service.py | 18 ++++++++- app/writing_presets.py | 38 ++++++++++++------ tests/test_llm_service.py | 73 +++++++++++++++++++++++++++++++++++ tests/test_writing_presets.py | 41 ++++++++++++++++++++ 4 files changed, 157 insertions(+), 13 deletions(-) diff --git a/app/llm_service.py b/app/llm_service.py index cb76bd4..b90d08d 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -24,10 +24,26 @@ "lösungsorientiert sein. Gib NUR die fertige Nachricht zurück." ) +# Schutzregeln gegen freie Interpretation: Das Transkript ist Material zum +# 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." +) + _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. " - "Korrigiere Grammatik, Zeichensetzung und Struktur. Gib NUR den fertigen Text zurück." + "Korrigiere Grammatik, Zeichensetzung und Struktur." + _INTENT_RULES + + " Gib NUR den fertigen Text zurück." ) _EMOJI_SYSTEM_TEMPLATE = ( diff --git a/app/writing_presets.py b/app/writing_presets.py index 36b20f6..694bd1f 100644 --- a/app/writing_presets.py +++ b/app/writing_presets.py @@ -13,9 +13,14 @@ DEFAULT_PRESET_KEY = "standard" _COMMON_RULES = ( - " Behalte den Inhalt vollständig und erfinde nichts dazu. Korrigiere " - "Grammatik und Zeichensetzung. Gib NUR den fertigen Text zurück, ohne " - "Vorbemerkung oder Erklärung." + " 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." ) @@ -43,16 +48,22 @@ class WritingPreset: WritingPreset( "email_formal", "E-Mail – formell", - "Du erhältst ein gesprochenes Transkript. Formuliere daraus eine " - "formelle, höfliche E-Mail in der Sie-Form mit klarer Struktur " - "(passende Anrede, Hauptteil, freundlicher Gruß)." + _COMMON_RULES, + "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, ), WritingPreset( "email_locker", "E-Mail – locker", - "Du erhältst ein gesprochenes Transkript. Formuliere daraus eine " - "lockere, freundliche E-Mail in der Du-Form mit natürlichem, " - "persönlichem Ton." + _COMMON_RULES, + "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", @@ -71,20 +82,23 @@ class WritingPreset: "du_form", "Persönlich (Du-Form)", "Du erhältst ein gesprochenes Transkript. Formuliere es zu einem " - "klaren, gut lesbaren Text in der persönlichen Du-Form um." + _COMMON_RULES, + "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." + _COMMON_RULES, + "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." + _COMMON_RULES, + "alle wesentlichen Informationen. Kürze nur, erfinde keine neuen " + "Inhalte und ändere nicht Bedeutung, Kontext oder Zweck." + _COMMON_RULES, ), ) diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 5597e47..f3b6fef 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -30,6 +30,14 @@ def service(mock_client): return LLMService(api_key=DUMMY_API_KEY, client=mock_client) +# Konkreter Fehlerfall: Diese Diktat-Eingabe wurde früher als Meeting +# umgedeutet ("alle Teilnehmer", Moderationsanweisung) statt als +# Übergabe-Prompt für eine neue Arbeits-Session erhalten zu bleiben. +HANDOVER_TRANSCRIPT = ( + "Erstelle eine kurze Übergabe prompt damit der Plan in neuer Session ausgeführt wird" +) + + class TestLLMServiceInit: def test_empty_api_key_is_not_available(self, mock_client): service = LLMService(api_key="", client=mock_client, api_key_env="CUSTOM_OPENAI_KEY") @@ -416,3 +424,68 @@ def test_unavailable_service_raises(self, mock_client): service = LLMService(api_key="", client=mock_client) with pytest.raises(LLMServiceError): service.rewrite_raw("System.", "User.") + + +class TestIntentPreservationRegression: + """Regression: Übergabe-/Prompt-Aufträge dürfen nicht als Meeting umgedeutet werden. + + Die Tests laufen ohne echtes LLM. Geprüft wird der Prompt-Vertrag: Die + Schutzregeln müssen im System-Prompt ankommen und das Original-Transkript + muss unverändert als User-Message übertragen werden. + """ + + def _messages(self, mock_client): + kwargs = mock_client.chat.completions.create.call_args.kwargs + system = next(m["content"] for m in kwargs["messages"] if m["role"] == "system") + user = next(m["content"] for m in kwargs["messages"] if m["role"] == "user") + return system, user + + def test_standard_preset_system_prompt_contains_intent_rules(self, service, mock_client): + service.rewrite(WorkflowType.TEXT_IMPROVER, HANDOVER_TRANSCRIPT) + system, _ = self._messages(mock_client) + assert "Bewahre die Absicht des Nutzers" in system + assert "führe sie nicht aus" in system + assert "beantworte sie nicht" in system + + def test_standard_preset_forbids_invented_meeting_context(self, service, mock_client): + service.rewrite(WorkflowType.TEXT_IMPROVER, HANDOVER_TRANSCRIPT) + system, _ = self._messages(mock_client) + assert "Teilnehmer" in system + assert "Meetings" in system + assert "Erfinde keinen Kontext" in system + + def test_standard_preset_anchors_technical_terms_in_work_context(self, service, mock_client): + service.rewrite(WorkflowType.TEXT_IMPROVER, HANDOVER_TRANSCRIPT) + system, _ = self._messages(mock_client) + for term in ("'Session'", "'Prompt'", "'Branch'", "'PR'", "'Merge'", "'Handover'"): + assert term in system + assert "Software- und Arbeitskontext" in system + + def test_standard_preset_demands_usable_prompt_for_prompt_requests(self, service, mock_client): + service.rewrite(WorkflowType.TEXT_IMPROVER, HANDOVER_TRANSCRIPT) + system, _ = self._messages(mock_client) + assert "direkt nutzbaren" in system + assert "Übergabe" in system + + def test_handover_transcript_stays_verbatim_in_user_message(self, service, mock_client): + service.rewrite(WorkflowType.TEXT_IMPROVER, HANDOVER_TRANSCRIPT) + system, user = self._messages(mock_client) + assert user == HANDOVER_TRANSCRIPT + assert HANDOVER_TRANSCRIPT not in system + + @pytest.mark.parametrize( + "preset_key", + ["email_formal", "email_locker", "stichpunkte", "zusammenfassung", "du_form", "sie_form", "kurz_praezise"], + ) + def test_all_presets_carry_intent_rules(self, mock_client, preset_key): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, writing_preset=preset_key) + service.rewrite(WorkflowType.TEXT_IMPROVER, HANDOVER_TRANSCRIPT) + kwargs = mock_client.chat.completions.create.call_args.kwargs + system = next(m["content"] for m in kwargs["messages"] if m["role"] == "system") + assert "Bewahre die Absicht des Nutzers" in system + assert "Erfinde keinen Kontext" in system + + 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 diff --git a/tests/test_writing_presets.py b/tests/test_writing_presets.py index 0ad0903..c993fe3 100644 --- a/tests/test_writing_presets.py +++ b/tests/test_writing_presets.py @@ -57,6 +57,47 @@ def test_is_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 + 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 "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", EXPECTED_KEYS) def test_known_keys_return_matching_preset(self, key):