2025-12-11 23:21:24 +01:00
|
|
|
|
"""
|
2025-12-15 15:40:39 +01:00
|
|
|
|
FILE: app/services/semantic_analyzer.py
|
|
|
|
|
|
DESCRIPTION: KI-gestützte Kanten-Validierung. Nutzt LLM (Background-Priority), um Kanten präzise einem Chunk zuzuordnen.
|
2025-12-16 12:07:28 +01:00
|
|
|
|
VERSION: 2.1.0 (Fix: Strict Edge String Validation against LLM Hallucinations)
|
2025-12-15 15:40:39 +01:00
|
|
|
|
STATUS: Active
|
|
|
|
|
|
DEPENDENCIES: app.services.llm_service, json, logging
|
2025-12-16 12:07:28 +01:00
|
|
|
|
LAST_ANALYSIS: 2025-12-16
|
2025-12-11 23:21:24 +01:00
|
|
|
|
"""
|
2025-12-12 10:25:01 +01:00
|
|
|
|
|
2025-12-11 23:21:24 +01:00
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
2025-12-12 13:53:54 +01:00
|
|
|
|
from typing import List, Optional
|
2025-12-12 10:25:01 +01:00
|
|
|
|
from dataclasses import dataclass
|
2025-12-11 23:21:24 +01:00
|
|
|
|
|
2025-12-12 11:40:38 +01:00
|
|
|
|
# Importe
|
2025-12-11 23:21:24 +01:00
|
|
|
|
from app.services.llm_service import LLMService
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
class SemanticAnalyzer:
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.llm = LLMService()
|
|
|
|
|
|
|
2025-12-16 12:07:28 +01:00
|
|
|
|
def _is_valid_edge_string(self, edge_str: str) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Prüft, ob ein String eine valide Kante im Format 'kind:target' ist.
|
|
|
|
|
|
Verhindert, dass LLM-Geschwätz ("Here is the list: ...") als Kante durchrutscht.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(edge_str, str) or ":" not in edge_str:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
parts = edge_str.split(":", 1)
|
|
|
|
|
|
kind = parts[0].strip()
|
|
|
|
|
|
target = parts[1].strip()
|
|
|
|
|
|
|
|
|
|
|
|
# Regel 1: Ein 'kind' (Beziehungstyp) darf keine Leerzeichen enthalten.
|
|
|
|
|
|
# Erlaubt: "derived_from", "related_to"
|
|
|
|
|
|
# Verboten: "derived end of instruction", "Here is the list"
|
|
|
|
|
|
if " " in kind:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# Regel 2: Plausible Länge für den Typ
|
|
|
|
|
|
if len(kind) > 40 or len(kind) < 2:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# Regel 3: Target darf nicht leer sein
|
|
|
|
|
|
if not target:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2025-12-12 11:40:38 +01:00
|
|
|
|
async def assign_edges_to_chunk(self, chunk_text: str, all_edges: List[str], note_type: str) -> List[str]:
|
2025-12-11 23:21:24 +01:00
|
|
|
|
"""
|
2025-12-12 11:40:38 +01:00
|
|
|
|
Sendet einen Chunk und eine Liste potenzieller Kanten an das LLM.
|
|
|
|
|
|
Das LLM filtert heraus, welche Kanten für diesen Chunk relevant sind.
|
2025-12-12 13:53:54 +01:00
|
|
|
|
|
|
|
|
|
|
Features:
|
|
|
|
|
|
- Retry Strategy: Wartet bei Überlastung (max_retries=5).
|
2025-12-12 19:00:35 +01:00
|
|
|
|
- Priority Queue: Läuft als "background" Task, um den Chat nicht zu blockieren.
|
2025-12-12 13:53:54 +01:00
|
|
|
|
- Observability: Loggt Input-Größe, Raw-Response und Parsing-Details.
|
2025-12-11 23:21:24 +01:00
|
|
|
|
"""
|
2025-12-12 11:40:38 +01:00
|
|
|
|
if not all_edges:
|
|
|
|
|
|
return []
|
2025-12-12 10:39:40 +01:00
|
|
|
|
|
2025-12-12 11:40:38 +01:00
|
|
|
|
# 1. Prompt laden
|
|
|
|
|
|
prompt_template = self.llm.prompts.get("edge_allocation_template")
|
2025-12-12 12:07:27 +01:00
|
|
|
|
|
2025-12-12 11:40:38 +01:00
|
|
|
|
if not prompt_template:
|
2025-12-12 13:53:54 +01:00
|
|
|
|
logger.warning("⚠️ [SemanticAnalyzer] Prompt 'edge_allocation_template' fehlt. Nutze Fallback.")
|
2025-12-12 12:07:27 +01:00
|
|
|
|
prompt_template = (
|
|
|
|
|
|
"TASK: Wähle aus den Kandidaten die relevanten Kanten für den Text.\n"
|
|
|
|
|
|
"TEXT: {chunk_text}\n"
|
|
|
|
|
|
"KANDIDATEN: {edge_list}\n"
|
|
|
|
|
|
"OUTPUT: JSON Liste von Strings [\"kind:target\"]."
|
|
|
|
|
|
)
|
2025-12-12 11:40:38 +01:00
|
|
|
|
|
|
|
|
|
|
# 2. Kandidaten-Liste formatieren
|
|
|
|
|
|
edges_str = "\n".join([f"- {e}" for e in all_edges])
|
2025-12-12 13:26:31 +01:00
|
|
|
|
|
2025-12-12 19:00:35 +01:00
|
|
|
|
# LOG: Request Info
|
2025-12-12 13:26:31 +01:00
|
|
|
|
logger.debug(f"🔍 [SemanticAnalyzer] Request: {len(chunk_text)} chars Text, {len(all_edges)} Candidates.")
|
2025-12-12 11:40:38 +01:00
|
|
|
|
|
|
|
|
|
|
# 3. Prompt füllen
|
|
|
|
|
|
final_prompt = prompt_template.format(
|
2025-12-12 19:00:35 +01:00
|
|
|
|
chunk_text=chunk_text[:3500],
|
2025-12-12 11:40:38 +01:00
|
|
|
|
edge_list=edges_str
|
2025-12-11 23:21:24 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2025-12-16 12:07:28 +01:00
|
|
|
|
# 4. LLM Call mit Traffic Control
|
2025-12-12 09:35:21 +01:00
|
|
|
|
response_json = await self.llm.generate_raw_response(
|
2025-12-12 11:40:38 +01:00
|
|
|
|
prompt=final_prompt,
|
2025-12-12 13:53:54 +01:00
|
|
|
|
force_json=True,
|
|
|
|
|
|
max_retries=5,
|
2025-12-12 19:00:35 +01:00
|
|
|
|
base_delay=5.0,
|
2025-12-16 12:07:28 +01:00
|
|
|
|
priority="background"
|
2025-12-12 09:35:21 +01:00
|
|
|
|
)
|
2025-12-12 10:25:01 +01:00
|
|
|
|
|
2025-12-12 19:00:35 +01:00
|
|
|
|
# LOG: Raw Response Preview
|
2025-12-12 13:26:31 +01:00
|
|
|
|
logger.debug(f"📥 [SemanticAnalyzer] Raw Response (Preview): {response_json[:200]}...")
|
|
|
|
|
|
|
2025-12-12 12:07:27 +01:00
|
|
|
|
# 5. Parsing & Cleaning
|
2025-12-12 11:40:38 +01:00
|
|
|
|
clean_json = response_json.replace("```json", "").replace("```", "").strip()
|
2025-12-12 13:26:31 +01:00
|
|
|
|
|
|
|
|
|
|
if not clean_json:
|
|
|
|
|
|
logger.warning("⚠️ [SemanticAnalyzer] Leere Antwort vom LLM erhalten. Trigger Fallback.")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(clean_json)
|
|
|
|
|
|
except json.JSONDecodeError as json_err:
|
|
|
|
|
|
logger.error(f"❌ [SemanticAnalyzer] JSON Decode Error.")
|
|
|
|
|
|
logger.error(f" Grund: {json_err}")
|
2025-12-12 19:00:35 +01:00
|
|
|
|
logger.error(f" Empfangener String: {clean_json[:500]}")
|
2025-12-12 13:26:31 +01:00
|
|
|
|
logger.info(" -> Workaround: Fallback auf 'Alle Kanten' (durch Chunker).")
|
|
|
|
|
|
return []
|
2025-12-11 23:21:24 +01:00
|
|
|
|
|
2025-12-12 12:07:27 +01:00
|
|
|
|
valid_edges = []
|
2025-12-12 11:40:38 +01:00
|
|
|
|
|
2025-12-12 12:07:27 +01:00
|
|
|
|
# 6. Robuste Validierung (List vs Dict)
|
2025-12-16 12:07:28 +01:00
|
|
|
|
# Wir sammeln erst alle Strings ein
|
|
|
|
|
|
raw_candidates = []
|
|
|
|
|
|
|
2025-12-12 11:40:38 +01:00
|
|
|
|
if isinstance(data, list):
|
2025-12-16 12:07:28 +01:00
|
|
|
|
raw_candidates = data
|
2025-12-12 12:07:27 +01:00
|
|
|
|
|
2025-12-12 11:40:38 +01:00
|
|
|
|
elif isinstance(data, dict):
|
2025-12-12 13:26:31 +01:00
|
|
|
|
logger.info(f"ℹ️ [SemanticAnalyzer] LLM lieferte Dict statt Liste. Versuche Reparatur. Keys: {list(data.keys())}")
|
2025-12-12 11:40:38 +01:00
|
|
|
|
for key, val in data.items():
|
2025-12-12 12:07:27 +01:00
|
|
|
|
# Fall A: {"edges": ["kind:target"]}
|
2025-12-12 13:26:31 +01:00
|
|
|
|
if key.lower() in ["edges", "results", "kanten", "matches"] and isinstance(val, list):
|
2025-12-16 12:07:28 +01:00
|
|
|
|
raw_candidates.extend(val)
|
2025-12-12 12:07:27 +01:00
|
|
|
|
|
2025-12-16 12:07:28 +01:00
|
|
|
|
# Fall B: {"kind": "target"} (Beziehung als Key)
|
2025-12-12 12:07:27 +01:00
|
|
|
|
elif isinstance(val, str):
|
2025-12-16 12:07:28 +01:00
|
|
|
|
raw_candidates.append(f"{key}:{val}")
|
2025-12-12 12:07:27 +01:00
|
|
|
|
|
|
|
|
|
|
# Fall C: {"kind": ["target1", "target2"]}
|
|
|
|
|
|
elif isinstance(val, list):
|
|
|
|
|
|
for target in val:
|
|
|
|
|
|
if isinstance(target, str):
|
2025-12-16 12:07:28 +01:00
|
|
|
|
raw_candidates.append(f"{key}:{target}")
|
|
|
|
|
|
|
|
|
|
|
|
# 7. Strict Validation Loop
|
|
|
|
|
|
for e in raw_candidates:
|
|
|
|
|
|
e_str = str(e)
|
|
|
|
|
|
if self._is_valid_edge_string(e_str):
|
|
|
|
|
|
valid_edges.append(e_str)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.debug(f" [SemanticAnalyzer] Invalid edge format rejected: '{e_str}'")
|
2025-12-12 12:07:27 +01:00
|
|
|
|
|
2025-12-16 12:07:28 +01:00
|
|
|
|
# Safety: Filtere nur Kanten, die halbwegs valide aussehen (Doppelcheck)
|
2025-12-12 13:26:31 +01:00
|
|
|
|
final_result = [e for e in valid_edges if ":" in e]
|
|
|
|
|
|
|
|
|
|
|
|
# LOG: Ergebnis
|
|
|
|
|
|
if final_result:
|
|
|
|
|
|
logger.info(f"✅ [SemanticAnalyzer] Success. {len(final_result)} Kanten zugewiesen.")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.debug(" [SemanticAnalyzer] Keine spezifischen Kanten erkannt (Empty Result).")
|
|
|
|
|
|
|
|
|
|
|
|
return final_result
|
2025-12-12 11:40:38 +01:00
|
|
|
|
|
2025-12-11 23:21:24 +01:00
|
|
|
|
except Exception as e:
|
2025-12-12 13:26:31 +01:00
|
|
|
|
logger.error(f"💥 [SemanticAnalyzer] Kritischer Fehler: {e}", exc_info=True)
|
2025-12-12 11:40:38 +01:00
|
|
|
|
return []
|
2025-12-12 09:22:00 +01:00
|
|
|
|
|
2025-12-11 23:21:24 +01:00
|
|
|
|
async def close(self):
|
2025-12-12 09:22:00 +01:00
|
|
|
|
if self.llm:
|
2025-12-12 10:25:01 +01:00
|
|
|
|
await self.llm.close()
|
|
|
|
|
|
|
2025-12-12 11:40:38 +01:00
|
|
|
|
# Singleton Helper
|
2025-12-12 10:25:01 +01:00
|
|
|
|
_analyzer_instance = None
|
|
|
|
|
|
def get_semantic_analyzer():
|
|
|
|
|
|
global _analyzer_instance
|
|
|
|
|
|
if _analyzer_instance is None:
|
|
|
|
|
|
_analyzer_instance = SemanticAnalyzer()
|
|
|
|
|
|
return _analyzer_instance
|