mindnet/app/services/discovery.py

148 lines
6.0 KiB
Python
Raw Normal View History

2025-12-10 22:05:35 +01:00
"""
app/services/discovery.py
Service für Link-Vorschläge und Knowledge-Discovery (WP-11).
2025-12-11 11:11:01 +01:00
Adaptiert für Async-Architecture (v2.4).
2025-12-10 22:05:35 +01:00
"""
import logging
2025-12-11 07:25:24 +01:00
import os
2025-12-11 11:11:01 +01:00
from typing import List, Dict, Any
2025-12-11 07:25:24 +01:00
import yaml
2025-12-10 22:05:35 +01:00
2025-12-11 11:11:01 +01:00
# Wir nutzen hier weiterhin die Low-Level Funktionen, da diese stabil sind
2025-12-10 22:05:35 +01:00
from app.core.qdrant import QdrantConfig, get_client
from app.models.dto import QueryRequest
from app.core.retriever import hybrid_retrieve
logger = logging.getLogger(__name__)
class DiscoveryService:
2025-12-10 22:17:40 +01:00
def __init__(self, collection_prefix: str = None):
2025-12-10 22:05:35 +01:00
self.cfg = QdrantConfig.from_env()
2025-12-10 22:17:40 +01:00
self.prefix = collection_prefix or self.cfg.prefix or "mindnet"
2025-12-10 22:05:35 +01:00
self.client = get_client(self.cfg)
2025-12-11 07:25:24 +01:00
self.registry = self._load_type_registry()
2025-12-10 22:05:35 +01:00
async def analyze_draft(self, text: str, current_type: str) -> Dict[str, Any]:
"""
Analysiert einen Draft-Text und schlägt Verlinkungen vor.
2025-12-11 11:11:01 +01:00
Kombiniert Exact Match (Titel/Alias) und Semantic Match.
2025-12-10 22:05:35 +01:00
"""
suggestions = []
2025-12-11 07:25:24 +01:00
default_edge_type = self._get_default_edge_type(current_type)
2025-12-10 22:05:35 +01:00
# 1. Exact Match: Finde Begriffe im Text, die als Notiz-Titel existieren
2025-12-11 11:11:01 +01:00
# (Dies läuft synchron, ist aber sehr schnell durch Qdrant Scroll)
2025-12-10 22:05:35 +01:00
known_entities = self._fetch_all_titles_and_aliases()
found_entities = self._find_entities_in_text(text, known_entities)
existing_target_ids = set()
for entity in found_entities:
existing_target_ids.add(entity["id"])
2025-12-11 07:25:24 +01:00
target_title = entity["title"]
suggested_md = f"[[rel:{default_edge_type} {target_title}]]"
2025-12-10 22:05:35 +01:00
suggestions.append({
"type": "exact_match",
"text_found": entity["match"],
2025-12-11 07:25:24 +01:00
"target_title": target_title,
2025-12-10 22:05:35 +01:00
"target_id": entity["id"],
2025-12-11 07:25:24 +01:00
"suggested_edge_type": default_edge_type,
"suggested_markdown": suggested_md,
2025-12-10 22:05:35 +01:00
"confidence": 1.0,
2025-12-11 11:11:01 +01:00
"reason": f"Exakter Treffer (Default für '{current_type}': {default_edge_type})"
2025-12-10 22:05:35 +01:00
})
2025-12-11 07:25:24 +01:00
# 2. Semantic Match: Finde inhaltlich ähnliche Notizen
2025-12-11 11:11:01 +01:00
# Wir filtern Ergebnisse heraus, die wir schon per Exact Match gefunden haben.
semantic_hits = await self._get_semantic_suggestions_async(text)
2025-12-10 22:05:35 +01:00
for hit in semantic_hits:
if hit.node_id in existing_target_ids:
continue
if hit.total_score > 0.65:
2025-12-11 11:11:01 +01:00
# FIX: Titel aus Payload lesen, nicht ID!
target_title = hit.payload.get("title") or hit.node_id
2025-12-11 07:25:24 +01:00
suggested_md = f"[[rel:{default_edge_type} {target_title}]]"
2025-12-10 22:05:35 +01:00
suggestions.append({
"type": "semantic_match",
2025-12-10 22:17:40 +01:00
"text_found": (hit.source.get("text") or "")[:50] + "...",
2025-12-11 07:25:24 +01:00
"target_title": target_title,
2025-12-10 22:05:35 +01:00
"target_id": hit.node_id,
2025-12-11 07:25:24 +01:00
"suggested_edge_type": default_edge_type,
"suggested_markdown": suggested_md,
2025-12-10 22:05:35 +01:00
"confidence": round(hit.total_score, 2),
2025-12-11 11:11:01 +01:00
"reason": f"Semantische Ähnlichkeit ({hit.total_score:.2f})"
2025-12-10 22:05:35 +01:00
})
return {
"draft_length": len(text),
"suggestions_count": len(suggestions),
"suggestions": suggestions
}
2025-12-11 11:11:01 +01:00
# --- Helpers ---
async def _get_semantic_suggestions_async(self, text: str):
"""Async Wrapper um den Hybrid Retriever."""
req = QueryRequest(query=text, top_k=5, explain=False)
try:
# Da hybrid_retrieve (noch) sync ist, rufen wir es direkt auf.
# In einer voll-async Umgebung würde man dies in einen Thread-Pool auslagern,
# aber da Qdrant-Client sync ist, ist das hier okay.
res = hybrid_retrieve(req)
return res.results
except Exception as e:
logger.error(f"Semantic suggestion failed: {e}")
return []
2025-12-11 07:25:24 +01:00
def _load_type_registry(self) -> dict:
path = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml")
if not os.path.exists(path):
2025-12-11 11:11:01 +01:00
if os.path.exists("types.yaml"): path = "types.yaml"
else: return {}
2025-12-11 07:25:24 +01:00
try:
2025-12-11 11:11:01 +01:00
with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {}
except Exception: return {}
2025-12-11 07:25:24 +01:00
def _get_default_edge_type(self, note_type: str) -> str:
types_cfg = self.registry.get("types", {})
type_def = types_cfg.get(note_type, {})
defaults = type_def.get("edge_defaults")
if defaults and isinstance(defaults, list) and len(defaults) > 0:
return defaults[0]
return "related_to"
2025-12-10 22:05:35 +01:00
def _fetch_all_titles_and_aliases(self) -> List[Dict]:
notes = []
next_page = None
col_name = f"{self.prefix}_notes"
try:
while True:
2025-12-11 11:11:01 +01:00
res, next_page = self.client.scroll(collection_name=col_name, limit=1000, offset=next_page, with_payload=True, with_vectors=False)
2025-12-10 22:05:35 +01:00
for point in res:
pl = point.payload or {}
2025-12-10 22:17:40 +01:00
aliases = pl.get("aliases") or []
2025-12-11 11:11:01 +01:00
if isinstance(aliases, str): aliases = [aliases]
notes.append({"id": pl.get("note_id"), "title": pl.get("title"), "aliases": aliases})
if next_page is None: break
except Exception: return []
2025-12-10 22:05:35 +01:00
return notes
def _find_entities_in_text(self, text: str, entities: List[Dict]) -> List[Dict]:
found = []
text_lower = text.lower()
for entity in entities:
title = entity.get("title")
if title and title.lower() in text_lower:
2025-12-11 11:11:01 +01:00
found.append({"match": title, "title": title, "id": entity["id"]})
continue
2025-12-11 07:25:24 +01:00
aliases = entity.get("aliases", [])
for alias in aliases:
if alias and str(alias).lower() in text_lower:
2025-12-11 11:11:01 +01:00
found.append({"match": alias, "title": title, "id": entity["id"]})
2025-12-11 07:25:24 +01:00
break
2025-12-11 11:11:01 +01:00
return found