""" 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