structguard / structguard.py
joachim-ganter's picture
Create structguard.py
86a91e2 verified
Raw
History Blame Contribute Delete
12.5 kB
# 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
# ─────────────────────────────────────────────────────
@dataclass
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.")