mindnet/app/core/ingestion/ingestion_validation.py
Lars 509efc9393 Implement WP-26 v1.3 (Phase 3): Enhance graph schema validation and edge handling
- Introduced a new function `load_graph_schema_full` to parse and cache both typical and prohibited edge types from the graph schema.
- Updated `load_graph_schema` to utilize the full schema for improved edge type extraction.
- Added `get_topology_info` to retrieve typical and prohibited edges for source/target pairs.
- Implemented `validate_intra_note_edge` and `validate_edge_against_schema` for schema validation of intra-note edges.
- Enhanced logging for schema validation outcomes and edge handling.
- Updated documentation to reflect new validation features and testing procedures.
2026-01-26 10:18:31 +01:00

271 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
FILE: app/core/ingestion/ingestion_validation.py
DESCRIPTION: WP-15b semantische Validierung von Kanten gegen den LocalBatchCache.
WP-24c: Erweiterung um automatische Symmetrie-Generierung (Inverse Kanten).
WP-25b: Konsequente Lazy-Prompt-Orchestration (prompt_key + variables).
WP-26 v1.3: Schema-Validierung für Intra-Note-Edges gegen graph_schema.md.
VERSION: 3.1.0 (WP-26: Intra-Note-Edge Schema-Validation)
STATUS: Active
FIX:
- WP-24c: Integration der EdgeRegistry zur dynamischen Inversions-Ermittlung.
- WP-24c: Implementierung von validate_and_symmetrize für bidirektionale Graphen.
- WP-25b: Beibehaltung der hierarchischen Prompt-Resolution und Modell-Spezi-Logik.
- WP-26: FA-12 Schema-Validierung gegen effektiven Chunk-Typ.
"""
import logging
from typing import Dict, Any, Optional, List, Tuple
from app.core.parser import NoteContext
# Import der neutralen Bereinigungs-Logik zur Vermeidung von Circular Imports
from app.core.registry import clean_llm_text
# WP-24c: Zugriff auf das dynamische Vokabular
from app.services.edge_registry import registry as edge_registry
# WP-26 v1.3: Graph-Schema für Validierung
from app.core.graph.graph_utils import get_topology_info
logger = logging.getLogger(__name__)
# ==============================================================================
# WP-26 v1.3: Schema-Validierung für Intra-Note-Edges (FA-12)
# ==============================================================================
def validate_intra_note_edge(
edge: Dict[str, Any],
source_chunk: Dict[str, Any],
target_chunk: Dict[str, Any],
strict_mode: bool = False
) -> Tuple[bool, float, Optional[str]]:
"""
WP-26 v1.3 (FA-12): Validiert eine Intra-Note-Edge gegen das graph_schema.md.
Verwendet den EFFEKTIVEN Typ (section_type || note_type) beider Chunks.
Args:
edge: Das Edge-Dict mit "kind", "source_id", "target_id"
source_chunk: Chunk-Payload der Quelle mit "type" (effektiver Typ)
target_chunk: Chunk-Payload des Ziels mit "type" (effektiver Typ)
strict_mode: Wenn True, werden atypische Edges abgelehnt (nicht nur gewarnt)
Returns:
Tuple (is_valid, confidence, reason)
- is_valid: True wenn die Edge erlaubt ist
- confidence: Angepasste Confidence (0.7 für atypische, 0.0 für prohibited)
- reason: Optional Begründung für Ablehnung/Warnung
"""
# Effektive Typen extrahieren (section_type hat Vorrang vor note_type)
source_type = source_chunk.get("type") or source_chunk.get("note_type") or "default"
target_type = target_chunk.get("type") or target_chunk.get("note_type") or "default"
edge_kind = edge.get("kind", "related_to")
# Schema-Lookup
topology = get_topology_info(source_type, target_type)
typical_edges = topology.get("typical", [])
prohibited_edges = topology.get("prohibited", [])
# 1. Prüfung: Ist die Edge verboten?
if edge_kind in prohibited_edges:
reason = f"Edge '{edge_kind}' von {source_type}{target_type} ist verboten (prohibited)"
logger.warning(f"🚫 [SCHEMA-VALIDATION] {reason}")
return (False, 0.0, reason)
# 2. Prüfung: Ist die Edge typisch?
if edge_kind in typical_edges:
# Edge ist typisch → volle Confidence
logger.debug(f"✅ [SCHEMA-VALIDATION] Edge '{edge_kind}' von {source_type}{target_type} ist typisch")
return (True, 1.0, None)
# 3. Edge ist atypisch (weder typical noch prohibited)
reason = f"Edge '{edge_kind}' von {source_type}{target_type} ist atypisch (nicht in typical: {typical_edges})"
if strict_mode:
# Im Strict-Mode werden atypische Edges abgelehnt
logger.warning(f"⚠️ [SCHEMA-VALIDATION] {reason} - ABGELEHNT (strict_mode)")
return (False, 0.0, reason)
else:
# Im normalen Modus: Edge erlaubt, aber mit reduzierter Confidence (0.7)
logger.info(f" [SCHEMA-VALIDATION] {reason} - erlaubt mit reduzierter Confidence")
return (True, 0.7, reason)
def validate_edge_against_schema(
edge: Dict[str, Any],
chunks_by_id: Dict[str, Dict[str, Any]],
strict_mode: bool = False
) -> Tuple[bool, Dict[str, Any]]:
"""
WP-26 v1.3: Wrapper für die Schema-Validierung mit Chunk-Lookup.
Args:
edge: Das Edge-Dict
chunks_by_id: Dictionary von chunk_id → chunk_payload
strict_mode: Wenn True, werden atypische Edges abgelehnt
Returns:
Tuple (is_valid, updated_edge)
- is_valid: True wenn die Edge erlaubt ist
- updated_edge: Edge mit ggf. angepasster Confidence
"""
source_id = edge.get("source_id", "")
target_id = edge.get("target_id", "")
is_internal = edge.get("is_internal", False)
# Nur Intra-Note-Edges validieren
if not is_internal:
return (True, edge)
# Chunks nachschlagen
source_chunk = chunks_by_id.get(source_id, {})
target_chunk = chunks_by_id.get(target_id, {})
# Wenn Chunks nicht gefunden → Edge erlauben (Integrität vor Präzision)
if not source_chunk or not target_chunk:
logger.debug(f"[SCHEMA-VALIDATION] Chunks nicht gefunden für {source_id} / {target_id} - Edge erlaubt")
return (True, edge)
# Schema-Validierung durchführen
is_valid, confidence, reason = validate_intra_note_edge(
edge=edge,
source_chunk=source_chunk,
target_chunk=target_chunk,
strict_mode=strict_mode
)
if not is_valid:
return (False, edge)
# Confidence anpassen wenn nötig
updated_edge = edge.copy()
if confidence < 1.0:
original_confidence = edge.get("confidence", 1.0)
updated_edge["confidence"] = min(original_confidence, confidence)
updated_edge["schema_validation_note"] = reason
return (True, updated_edge)
async def validate_edge_candidate(
chunk_text: str,
edge: Dict,
batch_cache: Dict[str, NoteContext],
llm_service: Any,
provider: Optional[str] = None,
profile_name: str = "ingest_validator"
) -> bool:
"""
WP-15b/25b: Validiert einen Kandidaten semantisch gegen das Ziel im Cache.
Nutzt Lazy-Prompt-Loading (PROMPT-TRACE) für deterministische YES/NO Entscheidungen.
"""
target_id = edge.get("to")
target_ctx = batch_cache.get(target_id)
# Robust Lookup Fix (v2.12.2): Support für Anker (Note#Section)
if not target_ctx and "#" in str(target_id):
base_id = target_id.split("#")[0]
target_ctx = batch_cache.get(base_id)
# Sicherheits-Fallback (Hard-Link Integrity)
# Wenn das Ziel nicht im Cache ist, erlauben wir die Kante (Link-Erhalt).
if not target_ctx:
logger.info(f" [VALIDATION SKIP] No context for '{target_id}' - allowing link.")
return True
try:
logger.info(f"⚖️ [VALIDATING] Relation '{edge.get('kind')}' -> '{target_id}' (Profile: {profile_name})...")
# WP-25b: Lazy-Prompt Aufruf.
# Übergabe von prompt_key und Variablen für modell-optimierte Formatierung.
raw_response = await llm_service.generate_raw_response(
prompt_key="edge_validation",
variables={
"chunk_text": chunk_text[:1500],
"target_title": target_ctx.title,
"target_summary": target_ctx.summary,
"edge_kind": edge.get("kind", "related_to")
},
priority="background",
profile_name=profile_name
)
# Bereinigung zur Sicherstellung der Interpretierbarkeit (Mistral/Qwen Safe)
response = clean_llm_text(raw_response)
# Semantische Prüfung des Ergebnisses
is_valid = "YES" in response.upper()
if is_valid:
logger.info(f"✅ [VALIDATED] Relation to '{target_id}' confirmed.")
else:
logger.info(f"🚫 [REJECTED] Relation to '{target_id}' irrelevant for this chunk.")
return is_valid
except Exception as e:
error_str = str(e).lower()
error_type = type(e).__name__
# WP-25b: Differenzierung zwischen transienten und permanenten Fehlern
# Transiente Fehler (Netzwerk) → erlauben (Integrität vor Präzision)
if any(x in error_str for x in ["timeout", "connection", "network", "unreachable", "refused"]):
logger.warning(f"⚠️ Transient error for {target_id}: {error_type} - {e}. Allowing edge.")
return True
# Permanente Fehler → ablehnen (Graph-Qualität schützen)
logger.error(f"❌ Permanent validation error for {target_id}: {error_type} - {e}")
return False
async def validate_and_symmetrize(
chunk_text: str,
edge: Dict,
source_id: str,
batch_cache: Dict[str, NoteContext],
llm_service: Any,
profile_name: str = "ingest_validator"
) -> List[Dict]:
"""
WP-24c: Erweitertes Validierungs-Gateway.
Prüft die Primärkante und erzeugt bei Erfolg automatisch die inverse Kante.
Returns:
List[Dict]: Eine Liste mit 0, 1 (nur Primär) oder 2 (Primär + Invers) Kanten.
"""
# 1. Semantische Prüfung der Primärkante (A -> B)
is_valid = await validate_edge_candidate(
chunk_text=chunk_text,
edge=edge,
batch_cache=batch_cache,
llm_service=llm_service,
profile_name=profile_name
)
if not is_valid:
return []
validated_edges = [edge]
# 2. WP-24c: Symmetrie-Generierung (B -> A)
# Wir laden den inversen Typ dynamisch aus der EdgeRegistry (Single Source of Truth)
original_kind = edge.get("kind", "related_to")
inverse_kind = edge_registry.get_inverse(original_kind)
# Wir erzeugen eine inverse Kante nur, wenn ein sinnvoller inverser Typ existiert
# und das Ziel der Primärkante (to) valide ist.
target_id = edge.get("to")
if target_id and source_id:
# Die inverse Kante zeigt vom Ziel der Primärkante zurück zur Quelle.
# Sie wird als 'virtual' markiert, um sie im Retrieval/UI identifizierbar zu machen.
inverse_edge = {
"to": source_id,
"kind": inverse_kind,
"provenance": "structure", # System-generiert, geschützt durch Firewall
"confidence": edge.get("confidence", 0.9) * 0.9, # Leichte Dämpfung für virtuelle Pfade
"virtual": True,
"note_id": target_id, # Die Note, von der die inverse Kante ausgeht
"rule_id": f"symmetry:{original_kind}"
}
# Wir fügen die Symmetrie nur hinzu, wenn sie einen echten Mehrwert bietet
# (Vermeidung von redundanten related_to -> related_to Loops)
if inverse_kind != original_kind or original_kind not in ["related_to", "references"]:
validated_edges.append(inverse_edge)
logger.info(f"🔄 [SYMMETRY] Generated inverse edge: '{target_id}' --({inverse_kind})--> '{source_id}'")
return validated_edges