""" app/services/discovery.py Service für Link-Vorschläge und Knowledge-Discovery (WP-11). Analysiert Drafts auf Keywords und semantische Ähnlichkeiten. """ import logging from typing import List, Dict, Any, Set from qdrant_client.http import models as rest 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: def __init__(self, collection_prefix: str = "mindnet"): self.prefix = collection_prefix self.cfg = QdrantConfig.from_env() self.cfg.prefix = collection_prefix self.client = get_client(self.cfg) async def analyze_draft(self, text: str, current_type: str) -> Dict[str, Any]: """ Analysiert einen Draft-Text und schlägt Verlinkungen vor. Kombiniert Exact Match (Titel) und Semantic Match (Vektor). """ suggestions = [] # 1. Exact Match: Finde Begriffe im Text, die als Notiz-Titel existieren # (Bei sehr großen Vaults >10k sollte dies gecached werden) 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"]) suggestions.append({ "type": "exact_match", "text_found": entity["match"], "target_title": entity["title"], "target_id": entity["id"], "confidence": 1.0, "reason": "Existierender Notiz-Titel" }) # 2. Semantic Match: Finde inhaltlich ähnliche Notizen via Vektor-Suche # Wir filtern Ergebnisse heraus, die wir schon per Exact Match gefunden haben. semantic_hits = self._get_semantic_suggestions(text) for hit in semantic_hits: if hit.node_id in existing_target_ids: continue # Schwellwert: Nur relevante Vorschläge anzeigen (z.B. > 0.65) # Wir nutzen den total_score, der bereits Typ-Gewichte enthält. if hit.total_score > 0.65: suggestions.append({ "type": "semantic_match", "text_found": (hit.source.get("text") or "")[:50] + "...", # Snippet "target_title": hit.payload.get("title", "Unbekannt"), "target_id": hit.node_id, "confidence": round(hit.total_score, 2), "reason": f"Inhaltliche Ähnlichkeit (Score: {round(hit.total_score, 2)})" }) return { "draft_length": len(text), "suggestions_count": len(suggestions), "suggestions": suggestions } def _fetch_all_titles_and_aliases(self) -> List[Dict]: """Lädt alle Titel und Aliases aus der Notes-Collection.""" notes = [] next_page = None col_name = f"{self.prefix}_notes" try: while True: # Scroll API nutzen, um effizient alle Metadaten zu laden res, next_page = self.client.scroll( collection_name=col_name, limit=1000, offset=next_page, with_payload=True, with_vectors=False ) for point in res: pl = point.payload or {} notes.append({ "id": pl.get("note_id"), "title": pl.get("title"), "aliases": pl.get("aliases", []) }) if next_page is None: break except Exception as e: logger.error(f"Error fetching titles: {e}") return [] return notes def _find_entities_in_text(self, text: str, entities: List[Dict]) -> List[Dict]: """ Sucht Vorkommen von Titeln im Text (Case-Insensitive). """ found = [] text_lower = text.lower() for entity in entities: # 1. Titel prüfen title = entity.get("title") if title and title.lower() in text_lower: found.append({ "match": title, "title": title, "id": entity["id"] }) continue # Wenn Titel gefunden, Aliases nicht mehr prüfen (Prio) # 2. Aliases prüfen aliases = entity.get("aliases") if aliases and isinstance(aliases, list): for alias in aliases: if alias and str(alias).lower() in text_lower: found.append({ "match": alias, "title": title, # Target ist immer der Haupt-Titel "id": entity["id"] }) break return found def _get_semantic_suggestions(self, text: str): """Wrapper um den Hybrid Retriever.""" # Wir nutzen eine vereinfachte Query req = QueryRequest( query=text, top_k=5, explain=False ) try: # hybrid_retrieve ist sync, wird aber schnell genug sein für diesen Kontext res = hybrid_retrieve(req) return res.results except Exception as e: logger.error(f"Semantic suggestion failed: {e}") return []