diff --git a/docs/CHATGPT_HANDOFF.md b/docs/CHATGPT_HANDOFF.md index 08b4512..3f49679 100644 --- a/docs/CHATGPT_HANDOFF.md +++ b/docs/CHATGPT_HANDOFF.md @@ -102,6 +102,8 @@ Rootserver: - Allgemeine unpersönliche Technikfragen zu RAM/SSD, SQLite, HTTP, Git und ähnlichen Begriffen werden gezielter `allowed`; `never` und `ask` behalten Vorrang bei Secrets, interner Infrastruktur und persönlichem Kontext. +- Gesundheits-, Symptom-, Blutungs-, Verletzungs-, Medikamenten-, Diagnose-, + Behandlungs- und medizinische Notfallfragen sind zwingend lokal `never`. - `/privacy` trennt Inhaltsklassifikation, tatsächlichen Provider, Unterdrückung durch Modus `local` und technischen Provider-Fallback. @@ -176,7 +178,7 @@ Letzter bestätigter Projektstand: - uv-Lock und `uv sync --dev`: bestanden - Python in `.venv`: 3.12.13 - Ruff in `.venv`: 0.16.0 -- Unit-Tests: 66 bestanden; neue Technikklassifikation und Privacy-Ausgabe abgedeckt +- Unit-Tests: 69 bestanden; medizinisches `never` und Cloudclient-Sperre abgedeckt - PowerShell-Syntax des Startskripts: erfolgreich geparst - Ruff Lint: bestanden - Ruff Formatprüfung: bestanden @@ -287,9 +289,9 @@ Abnahmestatus: ## Nächster sinnvoller Auftrag -Ein separates, kleines Paket für Chatkomfort und Robustheit planen, -beispielsweise Sitzungstitel/Suche oder Streaming. Noch keine Obsidian- -Integration, Tools, Sprache oder Serversteuerung beginnen. +Auf `feat/chat-comfort` als nächstes Streaming mit sicherem Abbruch umsetzen. +Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema. +Noch keine Obsidian-Integration oder Tools beginnen. Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen. diff --git a/src/javis/security/privacy.py b/src/javis/security/privacy.py index d44c627..936b15f 100644 --- a/src/javis/security/privacy.py +++ b/src/javis/security/privacy.py @@ -42,8 +42,13 @@ _NEVER_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( ), ( re.compile( - r"(?i)\b(diagnose|gesundheit|krankheit|medikament|patient|arztbericht|" - r"psychotherapie|blutwert)\b" + r"(?i)\b(symptom(?:e|en)?|blut(?:e|est|et|en|ung|ungen|wert|werte)?|" + r"verletz(?:ung|ungen|t)|wunde[n]?|medikament(?:e|en)?|diagnos(?:e|en|tiziert)|" + r"behandl(?:ung|ungen|e|en)|medizinisch(?:e[rsn]?|er)?\s+notfall|" + r"notaufnahme|rettungsdienst|gesundheit|krankheit(?:en)?|patient(?:en)?|" + r"arzt|ärzt(?:in|lich(?:e[rsn]?|er)?)|arztbericht|krankenhaus|" + r"psychotherapie|schmerz(?:en)?|fieber|atemnot|bewusstlos|ohnmacht|" + r"vergiftung|allergisch(?:e[rsn]?|er)?\s+reaktion)\b" ), "Gesundheitsinformation", ), diff --git a/tests/unit/test_privacy.py b/tests/unit/test_privacy.py index bf9ebb5..3cd40cf 100644 --- a/tests/unit/test_privacy.py +++ b/tests/unit/test_privacy.py @@ -63,6 +63,26 @@ class PrivacyRouterTests(unittest.TestCase): CloudPolicy.NEVER, ) + def test_medical_questions_are_always_never(self) -> None: + for question in ( + "Wenn ich blute, sollte ich dann zum Arzt?", + "Welche Symptome hat eine Grippe?", + "Wie nehme ich dieses Medikament ein?", + "Was bedeutet diese Diagnose?", + "Wie sollte eine Verletzung behandelt werden?", + ): + with self.subTest(question=question): + self.assertEqual( + self.router.classify(question).policy, + CloudPolicy.NEVER, + ) + + def test_technical_terms_cannot_override_medical_never(self) -> None: + decision = self.router.classify( + "Wie speichere ich meine Symptome und Diagnose mit Python in SQLite?" + ) + self.assertEqual(decision.policy, CloudPolicy.NEVER) + def test_unknown_content_is_conservatively_ask(self) -> None: self.assertEqual( self.router.classify("Erkläre das bitte genauer.").policy, diff --git a/tests/unit/test_provider_router.py b/tests/unit/test_provider_router.py index 6378c16..c075fd4 100644 --- a/tests/unit/test_provider_router.py +++ b/tests/unit/test_provider_router.py @@ -131,6 +131,23 @@ class HybridProviderTests(unittest.TestCase): self.assertTrue(router.last_route.fallback) self.assertIn("lokale Antwort", self.notices[-1]) + def test_medical_never_routes_locally_before_cloud_construction(self) -> None: + constructed = False + + def cloud_factory() -> _RecordingProvider: + nonlocal constructed + constructed = True + return self.cloud + + router = self._router(cloud_provider_factory=cloud_factory, mode="gemini") + + answer = router.chat([ChatMessage("user", "Wenn ich blute, sollte ich dann zum Arzt?")]) + + self.assertEqual(answer, "Lokal") + self.assertFalse(constructed) + self.assertFalse(self.approvals) + self.assertEqual(router.last_route.privacy_policy, CloudPolicy.NEVER) + def test_ask_defaults_to_local_when_approval_is_denied(self) -> None: router = self._router()