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-24 08:07:48 +01:00
|
|
|
|
WP-20 Fix: Volle Kompatibilität mit der provider-basierten Routing-Logik (OpenRouter Primary).
|
2025-12-23 21:57:50 +01:00
|
|
|
|
WP-22: Integration von valid_types zur Halluzinations-Vermeidung.
|
2025-12-25 17:17:55 +01:00
|
|
|
|
FIX: Mistral-sicheres JSON-Parsing (<s> & [OUT] Handling) und 100% Logik-Erhalt.
|
|
|
|
|
|
VERSION: 2.2.6
|
2025-12-15 15:40:39 +01:00
|
|
|
|
STATUS: Active
|
2025-12-25 17:17:55 +01:00
|
|
|
|
DEPENDENCIES: app.services.llm_service, app.services.edge_registry, json, logging, re
|
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-25 08:38:08 +01:00
|
|
|
|
import re
|
2025-12-25 17:17:55 +01:00
|
|
|
|
from typing import List, Optional, Any
|
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
|
2025-12-23 21:57:50 +01:00
|
|
|
|
# WP-22: Registry für Vokabular-Erzwingung
|
|
|
|
|
|
from app.services.edge_registry import registry as edge_registry
|
2025-12-11 23:21:24 +01:00
|
|
|
|
|
|
|
|
|
|
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.
|
2025-12-23 18:17:34 +01:00
|
|
|
|
Verhindert, dass LLM-Geschwätz als Kante durchrutscht.
|
2025-12-16 12:07:28 +01:00
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(edge_str, str) or ":" not in edge_str:
|
|
|
|
|
|
return False
|
2025-12-24 08:07:48 +01:00
|
|
|
|
|
2025-12-16 12:07:28 +01:00
|
|
|
|
parts = edge_str.split(":", 1)
|
|
|
|
|
|
kind = parts[0].strip()
|
|
|
|
|
|
target = parts[1].strip()
|
2025-12-24 08:07:48 +01:00
|
|
|
|
|
2025-12-16 12:07:28 +01:00
|
|
|
|
# Regel 1: Ein 'kind' (Beziehungstyp) darf keine Leerzeichen enthalten.
|
|
|
|
|
|
if " " in kind:
|
|
|
|
|
|
return False
|
2025-12-24 08:07:48 +01:00
|
|
|
|
|
2025-12-25 08:38:08 +01:00
|
|
|
|
# Regel 2: Plausible Länge für den Typ (Vermeidet Sätze als Typ)
|
2025-12-16 12:07:28 +01:00
|
|
|
|
if len(kind) > 40 or len(kind) < 2:
|
|
|
|
|
|
return False
|
2025-12-24 08:07:48 +01:00
|
|
|
|
|
2025-12-16 12:07:28 +01:00
|
|
|
|
# Regel 3: Target darf nicht leer sein
|
|
|
|
|
|
if not target:
|
|
|
|
|
|
return False
|
2025-12-24 08:07:48 +01:00
|
|
|
|
|
2025-12-16 12:07:28 +01:00
|
|
|
|
return True
|
|
|
|
|
|
|
2025-12-25 17:17:55 +01:00
|
|
|
|
def _extract_json_safely(self, text: str) -> Any:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Extrahiert JSON-Daten und bereinigt LLM-Steuerzeichen (Mistral/Llama).
|
|
|
|
|
|
Implementiert robuste Recovery-Logik für Cloud-Provider.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
# 1. Entferne Mistral/Llama Steuerzeichen und Tags
|
|
|
|
|
|
clean = text.replace("<s>", "").replace("</s>", "")
|
|
|
|
|
|
clean = clean.replace("[OUT]", "").replace("[/OUT]", "")
|
|
|
|
|
|
clean = clean.strip()
|
|
|
|
|
|
|
|
|
|
|
|
# 2. Suche nach Markdown JSON-Blöcken
|
|
|
|
|
|
match = re.search(r"```(?:json)?\s*(.*?)\s*```", clean, re.DOTALL)
|
|
|
|
|
|
payload = match.group(1) if match else clean
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
return json.loads(payload.strip())
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
# 3. Recovery: Suche nach der ersten [ und letzten ]
|
|
|
|
|
|
start = payload.find('[')
|
|
|
|
|
|
end = payload.rfind(']') + 1
|
|
|
|
|
|
if start != -1 and end > start:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return json.loads(payload[start:end])
|
|
|
|
|
|
except: pass
|
|
|
|
|
|
|
|
|
|
|
|
# 4. Zweite Recovery: Suche nach der ersten { und letzten }
|
|
|
|
|
|
start_obj = payload.find('{')
|
|
|
|
|
|
end_obj = payload.rfind('}') + 1
|
|
|
|
|
|
if start_obj != -1 and end_obj > start_obj:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return json.loads(payload[start_obj:end_obj])
|
|
|
|
|
|
except: pass
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
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.
|
2025-12-25 08:38:08 +01:00
|
|
|
|
Das LLM filtert heraus, welche Kanten für diesen Chunk relevant sind.
|
|
|
|
|
|
WP-20: Nutzt primär den konfigurierten Provider (z.B. OpenRouter).
|
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-25 08:38:08 +01:00
|
|
|
|
# 1. Bestimmung des Providers und Modells (Dynamisch über Settings)
|
2025-12-24 08:07:48 +01:00
|
|
|
|
provider = self.llm.settings.MINDNET_LLM_PROVIDER
|
2025-12-25 08:38:08 +01:00
|
|
|
|
model = self.llm.settings.OPENROUTER_MODEL if provider == "openrouter" else self.llm.settings.GEMINI_MODEL
|
2025-12-24 07:47:45 +01:00
|
|
|
|
|
2025-12-25 17:17:55 +01:00
|
|
|
|
# 2. Prompt laden (Provider-spezifisch via get_prompt)
|
2025-12-24 08:07:48 +01:00
|
|
|
|
prompt_template = self.llm.get_prompt("edge_allocation_template", provider)
|
|
|
|
|
|
|
2025-12-25 08:38:08 +01:00
|
|
|
|
if not prompt_template or not isinstance(prompt_template, str):
|
|
|
|
|
|
logger.warning("⚠️ [SemanticAnalyzer] Prompt 'edge_allocation_template' ungültig. Nutze Recovery-Template.")
|
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
|
|
|
|
|
2025-12-25 08:38:08 +01:00
|
|
|
|
# 3. Daten für Template vorbereiten (Vokabular-Check)
|
2025-12-23 21:57:50 +01:00
|
|
|
|
edge_registry.ensure_latest()
|
|
|
|
|
|
valid_types_str = ", ".join(sorted(list(edge_registry.valid_types)))
|
2025-12-12 11:40:38 +01:00
|
|
|
|
edges_str = "\n".join([f"- {e}" for e in all_edges])
|
2025-12-24 08:07:48 +01:00
|
|
|
|
|
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
|
|
|
|
|
2025-12-25 08:38:08 +01:00
|
|
|
|
# 4. Prompt füllen mit Format-Check (Kein Shortcut)
|
2025-12-23 18:17:34 +01:00
|
|
|
|
try:
|
2025-12-25 17:17:55 +01:00
|
|
|
|
# Wir begrenzen den Text auf eine vernünftige Länge für das Kontextfenster
|
2025-12-23 18:17:34 +01:00
|
|
|
|
final_prompt = prompt_template.format(
|
2025-12-25 08:38:08 +01:00
|
|
|
|
chunk_text=chunk_text[:6000],
|
2025-12-23 21:57:50 +01:00
|
|
|
|
edge_list=edges_str,
|
|
|
|
|
|
valid_types=valid_types_str
|
2025-12-23 18:17:34 +01:00
|
|
|
|
)
|
|
|
|
|
|
except Exception as format_err:
|
2025-12-25 08:38:08 +01:00
|
|
|
|
logger.error(f"❌ [SemanticAnalyzer] Prompt Formatting failed: {format_err}")
|
2025-12-23 18:17:34 +01:00
|
|
|
|
return []
|
2025-12-11 23:21:24 +01:00
|
|
|
|
|
|
|
|
|
|
try:
|
2025-12-25 08:38:08 +01:00
|
|
|
|
# 5. LLM Call mit Background Priority & Semaphore 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,
|
2025-12-25 08:38:08 +01:00
|
|
|
|
max_retries=3,
|
|
|
|
|
|
base_delay=2.0,
|
2025-12-24 08:07:48 +01:00
|
|
|
|
priority="background",
|
|
|
|
|
|
provider=provider,
|
|
|
|
|
|
model_override=model
|
2025-12-12 09:35:21 +01:00
|
|
|
|
)
|
2025-12-12 10:25:01 +01:00
|
|
|
|
|
2025-12-25 17:17:55 +01:00
|
|
|
|
# 6. Mistral-sicheres JSON Parsing via Helper
|
|
|
|
|
|
data = self._extract_json_safely(response_json)
|
2025-12-24 08:07:48 +01:00
|
|
|
|
|
2025-12-25 17:17:55 +01:00
|
|
|
|
if not data:
|
2025-12-12 13:26:31 +01:00
|
|
|
|
return []
|
|
|
|
|
|
|
2025-12-25 08:38:08 +01:00
|
|
|
|
# 7. Robuste Normalisierung (List vs Dict Recovery)
|
2025-12-16 12:07:28 +01:00
|
|
|
|
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 11:40:38 +01:00
|
|
|
|
elif isinstance(data, dict):
|
2025-12-25 08:38:08 +01:00
|
|
|
|
logger.info(f"ℹ️ [SemanticAnalyzer] LLM returned dict, trying recovery.")
|
|
|
|
|
|
for key in ["edges", "results", "kanten", "matches"]:
|
|
|
|
|
|
if key in data and isinstance(data[key], list):
|
|
|
|
|
|
raw_candidates.extend(data[key])
|
|
|
|
|
|
break
|
|
|
|
|
|
# Falls immer noch leer, nutze Schlüssel-Wert Paare als Behelf
|
|
|
|
|
|
if not raw_candidates:
|
|
|
|
|
|
for k, v in data.items():
|
|
|
|
|
|
if isinstance(v, str): raw_candidates.append(f"{k}:{v}")
|
2025-12-25 17:17:55 +01:00
|
|
|
|
elif isinstance(v, list):
|
|
|
|
|
|
for target in v:
|
|
|
|
|
|
if isinstance(target, str): raw_candidates.append(f"{k}:{target}")
|
2025-12-25 08:38:08 +01:00
|
|
|
|
|
|
|
|
|
|
# 8. Strikte Validierung gegen Kanten-Format
|
|
|
|
|
|
valid_edges = []
|
2025-12-16 12:07:28 +01:00
|
|
|
|
for e in raw_candidates:
|
2025-12-25 08:38:08 +01:00
|
|
|
|
e_str = str(e).strip()
|
2025-12-16 12:07:28 +01:00
|
|
|
|
if self._is_valid_edge_string(e_str):
|
|
|
|
|
|
valid_edges.append(e_str)
|
|
|
|
|
|
else:
|
2025-12-25 08:38:08 +01:00
|
|
|
|
logger.debug(f" [SemanticAnalyzer] Rejected invalid edge format: '{e_str}'")
|
2025-12-12 12:07:27 +01:00
|
|
|
|
|
2025-12-25 08:38:08 +01:00
|
|
|
|
if valid_edges:
|
|
|
|
|
|
logger.info(f"✅ [SemanticAnalyzer] Assigned {len(valid_edges)} edges to chunk.")
|
|
|
|
|
|
return valid_edges
|
2025-12-12 11:40:38 +01:00
|
|
|
|
|
2025-12-11 23:21:24 +01:00
|
|
|
|
except Exception as e:
|
2025-12-25 08:38:08 +01:00
|
|
|
|
logger.error(f"💥 [SemanticAnalyzer] Critical error during analysis: {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-25 08:38:08 +01:00
|
|
|
|
# Singleton Instanziierung
|
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()
|
2025-12-24 08:07:48 +01:00
|
|
|
|
return _analyzer_instance
|