Spaces:
Sleeping
Sleeping
| # structguard.py | |
| # StructGuard v2.1 β Structural & Semantic Validation Layer | |
| # Autor: J. M. Ganter | |
| # ORCID: 0009-0005-0499-2056 | |
| # | |
| # Changelog v2.1: | |
| # - No-Context-Bug behoben: dynamische Gewichtung ohne Kontext | |
| # - Schema-Validator erweitert (TypprΓΌfung, Enum, Pflichtfelder) | |
| # - StructVerdict Objekt mit vollstΓ€ndigem Audit-Trail | |
| # - Drei-Status-System: FREIGEGEBEN / UNSICHER / ABGELEHNT | |
| # - Fassaden-Klasse StructGuard kombiniert beide Validierungsstufen | |
| # | |
| # Lizenz: GNU Affero General Public License v3.0 (AGPL-3.0) | |
| # FΓΌr kommerzielle Lizenzen: Kontakt ΓΌber ORCID-Profil | |
| from dataclasses import dataclass | |
| from typing import Any, Dict, List, Optional | |
| import numpy as np | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # VERDICT-OBJEKT | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class StructVerdict: | |
| """ | |
| Ergebnisobjekt der StructGuard-Validierung. | |
| status: FREIGEGEBEN | UNSICHER | ABGELEHNT | |
| score: finaler KohΓ€renz-Score (0.0 β 1.0) | |
| fehler: Liste der Fehlerbeschreibungen (leer bei FREIGEGEBEN) | |
| details: Audit-Trail mit Phase und Einzelscores | |
| """ | |
| status: str | |
| score: Optional[float] = None | |
| fehler: Optional[List[str]] = None | |
| details: Optional[Dict[str, Any]] = None | |
| def ist_freigegeben(self) -> bool: | |
| return self.status == "FREIGEGEBEN" | |
| def ist_abgelehnt(self) -> bool: | |
| return self.status == "ABGELEHNT" | |
| def ist_unsicher(self) -> bool: | |
| return self.status == "UNSICHER" | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SCHEMA-VALIDATOR (Phase 1 β deterministisch) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Schema: | |
| """ | |
| Deterministischer Schema-Validator fΓΌr strukturierte LLM-Outputs. | |
| schema_def: Dict mit Feldname β erwarteter Typ (type) oder | |
| Liste erlaubter Werte (list). | |
| Alle Keys im schema_def gelten als Pflichtfelder. | |
| Beispiel: | |
| Schema({ | |
| "text": str, | |
| "kategorie": ["Dosierung", "Einheit", "Negation"], | |
| "quelle": str, | |
| }) | |
| """ | |
| def __init__(self, schema_def: Dict[str, Any]): | |
| self.schema_def = schema_def | |
| def validate(self, obj: Dict[str, Any]) -> List[str]: | |
| """ | |
| Validiert obj gegen das Schema. | |
| Gibt eine Liste von Fehlermeldungen zurΓΌck (leer = valide). | |
| """ | |
| fehler = [] | |
| if not isinstance(obj, dict): | |
| return ["Antwort ist kein Dict/JSON-Objekt."] | |
| for feld, erwartung in self.schema_def.items(): | |
| # Pflichtfeld prΓΌfen | |
| if feld not in obj: | |
| fehler.append(f"Pflichtfeld fehlt: '{feld}'") | |
| continue | |
| wert = obj[feld] | |
| # TypprΓΌfung (bool explizit ausschlieΓen bei int-Erwartung) | |
| if isinstance(erwartung, type): | |
| if erwartung is int and isinstance(wert, bool): | |
| fehler.append( | |
| f"Typfehler bei Feld '{feld}': bool nicht als int akzeptiert" | |
| ) | |
| elif not isinstance(wert, erwartung): | |
| fehler.append( | |
| f"Typfehler bei Feld '{feld}': " | |
| f"erwartet {erwartung.__name__}, " | |
| f"erhalten {type(wert).__name__}" | |
| ) | |
| # Enum-PrΓΌfung | |
| elif isinstance(erwartung, list): | |
| if wert not in erwartung: | |
| fehler.append( | |
| f"UngΓΌltiger Enum-Wert bei Feld '{feld}': " | |
| f"'{wert}' nicht in {erwartung}" | |
| ) | |
| return fehler | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SEMANTISCHER VALIDATOR (Phase 2 β embedding-basiert) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class StructGuardValidator: | |
| """ | |
| Embedding-basierter KohΓ€renz-Validator. | |
| PrΓΌft die semantische Ausrichtung zwischen Query, Answer und | |
| optionalem Kontext ΓΌber Cosinus-Γhnlichkeit. | |
| Gewichtung: | |
| MIT Kontext: score = 0.4 * query_sim + 0.6 * context_sim | |
| OHNE Kontext: score = query_sim | |
| (Fix v2.1: kein kΓΌnstlicher 0.6-Malus ohne Kontext) | |
| Thresholds: | |
| score >= accept_threshold β ACCEPT | |
| score >= revise_threshold β REVISE | |
| score < revise_threshold β REJECT | |
| """ | |
| def __init__( | |
| self, | |
| accept_threshold: float = 0.48, | |
| revise_threshold: float = 0.40, | |
| ): | |
| self.accept_threshold = accept_threshold | |
| self.revise_threshold = revise_threshold | |
| def _cosine(self, a: np.ndarray, b: np.ndarray) -> float: | |
| """Cosinus-Γhnlichkeit mit Epsilon-Schutz gegen Nullvektoren.""" | |
| return float( | |
| np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8) | |
| ) | |
| def validate( | |
| self, | |
| query_emb: np.ndarray, | |
| answer_emb: np.ndarray, | |
| context_embs: Optional[List[np.ndarray]] = None, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Validiert semantische KohΓ€renz zwischen Query, Answer und Kontext. | |
| Args: | |
| query_emb: Embedding des Queries (np.ndarray) | |
| answer_emb: Embedding der Antwort (np.ndarray) | |
| context_embs: Liste von Kontext-Embeddings (optional) | |
| Returns: | |
| Dict mit decision, score, query_sim, context_sim | |
| """ | |
| if context_embs is None: | |
| context_embs = [] | |
| # Einzelscores berechnen | |
| query_sim = self._cosine(query_emb, answer_emb) | |
| context_sims = [self._cosine(query_emb, c) for c in context_embs] | |
| context_sim = float(np.mean(context_sims)) if context_sims else 0.0 | |
| # FIX v2.1: Dynamische Gewichtung | |
| # Ohne Kontext: nur query_sim (max. Score = 1.0 erreichbar) | |
| # Mit Kontext: gewichtete Kombination | |
| if context_sims: | |
| base_score = 0.4 * query_sim + 0.6 * context_sim | |
| else: | |
| base_score = query_sim | |
| final_score = base_score | |
| # Entscheidung | |
| if final_score >= self.accept_threshold: | |
| decision = "ACCEPT" | |
| elif final_score >= self.revise_threshold: | |
| decision = "REVISE" | |
| else: | |
| decision = "REJECT" | |
| return { | |
| "decision": decision, | |
| "score": round(final_score, 3), | |
| "query_sim": round(query_sim, 3), | |
| "context_sim": round(context_sim, 3), | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STRUCTGUARD FASSADE (kombiniert Phase 1 + Phase 2) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class StructGuard: | |
| """ | |
| StructGuard v2.1 β Zweistufige Validierungspipeline. | |
| Phase 1: Schema-Validierung (hart, deterministisch) | |
| β Typfehler, fehlende Pflichtfelder, ungΓΌltige Enum-Werte | |
| β Bei Fehler: sofortiger Early-Exit mit ABGELEHNT | |
| Phase 2: Semantische KohΓ€renz (weich, embedding-basiert) | |
| β Cosinus-Γhnlichkeit zwischen Query, Answer und Kontext | |
| β Ergebnis: FREIGEGEBEN / UNSICHER / ABGELEHNT | |
| Verwendung in der kombinierten Pipeline mit SENTRY-DVL: | |
| StructGuard FREIGEGEBEN β SENTRY-DVL Pipeline | |
| StructGuard ABGELEHNT β Early-Exit, kein SENTRY-DVL | |
| """ | |
| def __init__( | |
| self, | |
| schema: Schema, | |
| semantic_validator: Optional[StructGuardValidator] = None, | |
| ): | |
| self.schema = schema | |
| self.semantic_validator = semantic_validator | |
| def pruefen( | |
| self, | |
| answer: Dict[str, Any], | |
| query_emb: Optional[np.ndarray] = None, | |
| answer_emb: Optional[np.ndarray] = None, | |
| context_embs: Optional[List[np.ndarray]] = None, | |
| ) -> StructVerdict: | |
| """ | |
| Hauptmethode: PrΓΌft answer gegen Schema und optional semantisch. | |
| Args: | |
| answer: LLM-Output als Dict | |
| query_emb: Embedding des Queries (optional) | |
| answer_emb: Embedding der Antwort (optional) | |
| context_embs: Liste von Kontext-Embeddings (optional) | |
| Returns: | |
| StructVerdict mit status, score, fehler, details | |
| """ | |
| # ββ Phase 1: Schema-Validierung ββββββββββββββββββ | |
| schema_errors = self.schema.validate(answer) | |
| if schema_errors: | |
| return StructVerdict( | |
| status="ABGELEHNT", | |
| score=0.0, | |
| fehler=schema_errors, | |
| details={"phase": "schema"}, | |
| ) | |
| # ββ Kein semantischer Validator β nur Schema ββββββ | |
| if (self.semantic_validator is None | |
| or query_emb is None | |
| or answer_emb is None): | |
| return StructVerdict( | |
| status="FREIGEGEBEN", | |
| score=1.0, | |
| fehler=[], | |
| details={"phase": "schema_only"}, | |
| ) | |
| # ββ Phase 2: Semantische KohΓ€renz βββββββββββββββββ | |
| result = self.semantic_validator.validate( | |
| query_emb=query_emb, | |
| answer_emb=answer_emb, | |
| context_embs=context_embs, | |
| ) | |
| decision = result["decision"] | |
| score = result["score"] | |
| if decision == "ACCEPT": | |
| status = "FREIGEGEBEN" | |
| elif decision == "REVISE": | |
| status = "UNSICHER" | |
| else: | |
| status = "ABGELEHNT" | |
| return StructVerdict( | |
| status=status, | |
| score=score, | |
| fehler=[] if status == "FREIGEGEBEN" | |
| else [f"Semantische Entscheidung: {decision}"], | |
| details={"phase": "semantic", **result}, | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # DIREKTER TESTLAUF | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| print("StructGuard v2.1 β Testlauf\n") | |
| # Schema definieren | |
| schema = Schema({ | |
| "text": str, | |
| "kategorie": ["Dosierung", "Einheit", "Negation", "Semantik"], | |
| "quelle": str, | |
| }) | |
| guard = StructGuard(schema=schema) | |
| # Test 1: Strukturfehler | |
| a1 = {"text": 12345, "kategorie": "Dosierung", "quelle": "Arzt"} | |
| v1 = guard.pruefen(a1) | |
| print(f"Test 1 (Typfehler): {v1.status} | {v1.fehler}") | |
| # Test 2: Korrekte Struktur | |
| a2 = {"text": "Metformin 500 mg zweimal tΓ€glich", | |
| "kategorie": "Dosierung", "quelle": "Arzt"} | |
| v2 = guard.pruefen(a2) | |
| print(f"Test 2 (korrekt): {v2.status} | Score: {v2.score}") | |
| # Test 3: Mit semantischem Validator | |
| sem = StructGuardValidator() | |
| guard_sem = StructGuard(schema=schema, semantic_validator=sem) | |
| q = np.array([1.0, 0.0, 0.0]) | |
| a = np.array([0.95, 0.05, 0.0]) | |
| c = [np.array([0.97, 0.03, 0.0])] | |
| v3 = guard_sem.pruefen(a2, query_emb=q, answer_emb=a, context_embs=c) | |
| print(f"Test 3 (semantisch): {v3.status} | Score: {v3.score}") | |
| # Test 4: Fix v2.1 β kein Kontext | |
| v4 = guard_sem.pruefen(a2, | |
| query_emb=np.array([1.0, 0.0, 0.0]), | |
| answer_emb=np.array([1.0, 0.0, 0.0]), | |
| context_embs=[]) | |
| print(f"Test 4 (kein Kontext): {v4.status} | Score: {v4.score} " | |
| f"(Fix v2.1 aktiv)") | |
| print("\nStructGuard v2.1 erfolgreich geladen.") |