2025-12-27 10:25:35 +01:00
|
|
|
|
"""
|
|
|
|
|
|
FILE: app/core/ingestion/ingestion_validation.py
|
|
|
|
|
|
DESCRIPTION: WP-15b semantische Validierung von Kanten gegen den LocalBatchCache.
|
2026-01-09 13:57:10 +01:00
|
|
|
|
WP-24c: Erweiterung um automatische Symmetrie-Generierung (Inverse Kanten).
|
|
|
|
|
|
WP-25b: Konsequente Lazy-Prompt-Orchestration (prompt_key + variables).
|
2026-01-26 10:18:31 +01:00
|
|
|
|
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)
|
2026-01-02 08:57:29 +01:00
|
|
|
|
STATUS: Active
|
|
|
|
|
|
FIX:
|
2026-01-09 13:57:10 +01:00
|
|
|
|
- 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.
|
2026-01-26 10:18:31 +01:00
|
|
|
|
- WP-26: FA-12 Schema-Validierung gegen effektiven Chunk-Typ.
|
2025-12-27 10:25:35 +01:00
|
|
|
|
"""
|
|
|
|
|
|
import logging
|
2026-01-26 10:18:31 +01:00
|
|
|
|
from typing import Dict, Any, Optional, List, Tuple
|
2025-12-27 10:25:35 +01:00
|
|
|
|
from app.core.parser import NoteContext
|
|
|
|
|
|
|
2026-01-09 13:57:10 +01:00
|
|
|
|
# Import der neutralen Bereinigungs-Logik zur Vermeidung von Circular Imports
|
2025-12-27 19:47:23 +01:00
|
|
|
|
from app.core.registry import clean_llm_text
|
2026-01-09 13:57:10 +01:00
|
|
|
|
# WP-24c: Zugriff auf das dynamische Vokabular
|
|
|
|
|
|
from app.services.edge_registry import registry as edge_registry
|
2026-01-26 10:18:31 +01:00
|
|
|
|
# WP-26 v1.3: Graph-Schema für Validierung
|
|
|
|
|
|
from app.core.graph.graph_utils import get_topology_info
|
2025-12-27 19:47:23 +01:00
|
|
|
|
|
2025-12-27 10:25:35 +01:00
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-01-26 10:18:31 +01:00
|
|
|
|
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
2025-12-27 10:25:35 +01:00
|
|
|
|
async def validate_edge_candidate(
|
|
|
|
|
|
chunk_text: str,
|
|
|
|
|
|
edge: Dict,
|
|
|
|
|
|
batch_cache: Dict[str, NoteContext],
|
|
|
|
|
|
llm_service: Any,
|
2026-01-02 08:57:29 +01:00
|
|
|
|
provider: Optional[str] = None,
|
|
|
|
|
|
profile_name: str = "ingest_validator"
|
2025-12-27 10:25:35 +01:00
|
|
|
|
) -> bool:
|
2025-12-27 19:47:23 +01:00
|
|
|
|
"""
|
2026-01-02 20:43:31 +01:00
|
|
|
|
WP-15b/25b: Validiert einen Kandidaten semantisch gegen das Ziel im Cache.
|
2026-01-09 13:57:10 +01:00
|
|
|
|
Nutzt Lazy-Prompt-Loading (PROMPT-TRACE) für deterministische YES/NO Entscheidungen.
|
2025-12-27 19:47:23 +01:00
|
|
|
|
"""
|
2025-12-27 10:25:35 +01:00
|
|
|
|
target_id = edge.get("to")
|
|
|
|
|
|
target_ctx = batch_cache.get(target_id)
|
|
|
|
|
|
|
2026-01-09 13:57:10 +01:00
|
|
|
|
# Robust Lookup Fix (v2.12.2): Support für Anker (Note#Section)
|
|
|
|
|
|
if not target_ctx and "#" in str(target_id):
|
2025-12-27 10:25:35 +01:00
|
|
|
|
base_id = target_id.split("#")[0]
|
|
|
|
|
|
target_ctx = batch_cache.get(base_id)
|
|
|
|
|
|
|
|
|
|
|
|
# Sicherheits-Fallback (Hard-Link Integrity)
|
2026-01-09 13:57:10 +01:00
|
|
|
|
# Wenn das Ziel nicht im Cache ist, erlauben wir die Kante (Link-Erhalt).
|
2025-12-27 10:25:35 +01:00
|
|
|
|
if not target_ctx:
|
|
|
|
|
|
logger.info(f"ℹ️ [VALIDATION SKIP] No context for '{target_id}' - allowing link.")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-01-02 08:57:29 +01:00
|
|
|
|
logger.info(f"⚖️ [VALIDATING] Relation '{edge.get('kind')}' -> '{target_id}' (Profile: {profile_name})...")
|
2025-12-27 10:25:35 +01:00
|
|
|
|
|
2026-01-02 20:43:31 +01:00
|
|
|
|
# WP-25b: Lazy-Prompt Aufruf.
|
2026-01-09 13:57:10 +01:00
|
|
|
|
# Übergabe von prompt_key und Variablen für modell-optimierte Formatierung.
|
2026-01-02 08:57:29 +01:00
|
|
|
|
raw_response = await llm_service.generate_raw_response(
|
2026-01-02 20:43:31 +01:00
|
|
|
|
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")
|
|
|
|
|
|
},
|
2026-01-02 08:57:29 +01:00
|
|
|
|
priority="background",
|
|
|
|
|
|
profile_name=profile_name
|
|
|
|
|
|
)
|
2025-12-27 19:47:23 +01:00
|
|
|
|
|
2026-01-09 13:57:10 +01:00
|
|
|
|
# Bereinigung zur Sicherstellung der Interpretierbarkeit (Mistral/Qwen Safe)
|
2025-12-27 19:47:23 +01:00
|
|
|
|
response = clean_llm_text(raw_response)
|
|
|
|
|
|
|
|
|
|
|
|
# Semantische Prüfung des Ergebnisses
|
2025-12-27 10:25:35 +01:00
|
|
|
|
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
|
2026-01-02 20:43:31 +01:00
|
|
|
|
|
2025-12-27 10:25:35 +01:00
|
|
|
|
except Exception as e:
|
2026-01-02 22:09:16 +01:00
|
|
|
|
error_str = str(e).lower()
|
|
|
|
|
|
error_type = type(e).__name__
|
|
|
|
|
|
|
2026-01-09 13:57:10 +01:00
|
|
|
|
# WP-25b: Differenzierung zwischen transienten und permanenten Fehlern
|
|
|
|
|
|
# Transiente Fehler (Netzwerk) → erlauben (Integrität vor Präzision)
|
2026-01-02 22:09:16 +01:00
|
|
|
|
if any(x in error_str for x in ["timeout", "connection", "network", "unreachable", "refused"]):
|
2026-01-09 13:57:10 +01:00
|
|
|
|
logger.warning(f"⚠️ Transient error for {target_id}: {error_type} - {e}. Allowing edge.")
|
2026-01-02 22:09:16 +01:00
|
|
|
|
return True
|
|
|
|
|
|
|
2026-01-09 13:57:10 +01:00
|
|
|
|
# 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
|