diff --git a/app/frontend/ui_graph_service.py b/app/frontend/ui_graph_service.py index bcaa0a3..c0164c8 100644 --- a/app/frontend/ui_graph_service.py +++ b/app/frontend/ui_graph_service.py @@ -163,31 +163,33 @@ class GraphExplorerService: return previews def _find_connected_edges(self, note_ids, note_title=None): - """Findet eingehende und ausgehende Kanten.""" + """ + Findet eingehende und ausgehende Kanten. + WICHTIG: target_id enthält nur den Titel (ohne #Abschnitt). + target_section ist ein separates Feld für Abschnitt-Informationen. + """ results = [] + if not note_ids: + return results # 1. OUTGOING EDGES (Der "Owner"-Fix) # Wir suchen Kanten, die im Feld 'note_id' (Owner) eine unserer Notizen haben. # Das findet ALLE ausgehenden Kanten, egal ob sie an einem Chunk oder der Note hängen. - if note_ids: - out_filter = models.Filter(must=[ - models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids)), - models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES})) - ]) - # Limit hoch, um alles zu finden - res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_filter, limit=500, with_payload=True) - results.extend(res_out) + out_filter = models.Filter(must=[ + models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids)), + models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES})) + ]) + res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_filter, limit=2000, with_payload=True) + results.extend(res_out) - # 2. INCOMING EDGES (Ziel = Chunk ID oder Titel oder Note ID) - # Hier müssen wir Chunks auflösen, um Treffer auf Chunks zu finden. + # 2. INCOMING EDGES (Ziel = Chunk ID, Note ID oder Titel) + # WICHTIG: target_id enthält nur den Titel, target_section ist separat # Chunk IDs der aktuellen Notes holen - chunk_ids = [] - if note_ids: - c_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))]) - chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=c_filter, limit=300) - chunk_ids = [c.id for c in chunks] + c_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))]) + chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=c_filter, limit=1000, with_payload=False) + chunk_ids = [c.id for c in chunks] shoulds = [] # Case A: Edge zeigt auf einen unserer Chunks @@ -195,42 +197,92 @@ class GraphExplorerService: shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=chunk_ids))) # Case B: Edge zeigt direkt auf unsere Note ID - if note_ids: - shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids))) - - # Case C: Edge zeigt auf unseren Titel (Wikilinks) - if note_title: - shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=note_title))) + shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids))) + + # Case C: Edge zeigt auf unseren Titel + # WICHTIG: target_id enthält nur den Titel (z.B. "Meine Prinzipien 2025") + # target_section enthält die Abschnitt-Information (z.B. "P3 – Disziplin"), wenn gesetzt + + # Sammle alle relevanten Titel (inkl. Aliase) + titles_to_search = [] + if note_title: + titles_to_search.append(note_title) + + # Lade auch Titel aus den Notes selbst (falls note_title nicht übergeben wurde) + for nid in note_ids: + note = self._fetch_note_cached(nid) + if note: + note_title_from_db = note.get("title") + if note_title_from_db and note_title_from_db not in titles_to_search: + titles_to_search.append(note_title_from_db) + # Aliase hinzufügen + aliases = note.get("aliases", []) + if isinstance(aliases, str): + aliases = [aliases] + for alias in aliases: + if alias and alias not in titles_to_search: + titles_to_search.append(alias) + + # Für jeden Titel: Suche nach exaktem Match + # target_id enthält nur den Titel, daher reicht MatchValue + for title in titles_to_search: + shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=title))) if shoulds: in_filter = models.Filter( must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))], should=shoulds ) - res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_filter, limit=500, with_payload=True) + res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_filter, limit=2000, with_payload=True) results.extend(res_in) return results def _find_connected_edges_batch(self, note_ids): - # Wrapper für Level 2 Suche - return self._find_connected_edges(note_ids) + """ + Wrapper für Level 2 Suche. + Lädt Titel der ersten Note für Titel-basierte Suche. + """ + if not note_ids: + return [] + first_note = self._fetch_note_cached(note_ids[0]) + note_title = first_note.get("title") if first_note else None + return self._find_connected_edges(note_ids, note_title=note_title) def _process_edge(self, record, nodes_dict, unique_edges, current_depth): - """Verarbeitet eine rohe Edge, löst IDs auf und fügt sie den Dictionaries hinzu.""" + """ + Verarbeitet eine rohe Edge, löst IDs auf und fügt sie den Dictionaries hinzu. + + WICHTIG: Beide Richtungen werden unterstützt: + - Ausgehende Kanten: source_id gehört zu unserer Note (via note_id Owner) + - Eingehende Kanten: target_id zeigt auf unsere Note (via target_id Match) + """ + if not record or not record.payload: + return None, None + payload = record.payload src_ref = payload.get("source_id") tgt_ref = payload.get("target_id") kind = payload.get("kind") provenance = payload.get("provenance", "explicit") + # Prüfe, ob beide Referenzen vorhanden sind + if not src_ref or not tgt_ref: + return None, None + # IDs zu Notes auflösen + # WICHTIG: source_id kann Chunk-ID (note_id#c01), Note-ID oder Titel sein + # WICHTIG: target_id kann Chunk-ID, Note-ID oder Titel sein (ohne #Abschnitt) src_note = self._resolve_note_from_ref(src_ref) tgt_note = self._resolve_note_from_ref(tgt_ref) if src_note and tgt_note: - src_id = src_note['note_id'] - tgt_id = tgt_note['note_id'] + src_id = src_note.get('note_id') + tgt_id = tgt_note.get('note_id') + + # Prüfe, ob beide IDs vorhanden sind + if not src_id or not tgt_id: + return None, None if src_id != tgt_id: # Nodes hinzufügen @@ -245,7 +297,7 @@ class GraphExplorerService: # Bevorzuge explizite Kanten vor Smart Kanten is_current_explicit = (provenance in ["explicit", "rule"]) if existing: - is_existing_explicit = (existing['provenance'] in ["explicit", "rule"]) + is_existing_explicit = (existing.get('provenance', '') in ["explicit", "rule"]) if is_existing_explicit and not is_current_explicit: should_update = False @@ -267,33 +319,104 @@ class GraphExplorerService: return None def _resolve_note_from_ref(self, ref_str): - """Löst eine ID (Chunk, Note oder Titel) zu einer Note Payload auf.""" - if not ref_str: return None + """ + Löst eine Referenz zu einer Note Payload auf. - # Fall A: Chunk ID (enthält #) + WICHTIG: Wenn ref_str ein Titel#Abschnitt Format hat, wird nur der Titel-Teil verwendet. + Unterstützt: + - Note-ID: "20250101-meine-note" + - Chunk-ID: "20250101-meine-note#c01" + - Titel: "Meine Prinzipien 2025" + - Titel#Abschnitt: "Meine Prinzipien 2025#P3 – Disziplin" (trennt Abschnitt ab, sucht nur nach Titel) + """ + if not ref_str: + return None + + # Fall A: Enthält # (kann Chunk-ID oder Titel#Abschnitt sein) if "#" in ref_str: try: - # Versuch 1: Chunk ID direkt + # Versuch 1: Chunk ID direkt (Format: note_id#c01) res = self.client.retrieve(self.chunks_col, ids=[ref_str], with_payload=True) - if res: return self._fetch_note_cached(res[0].payload.get("note_id")) - except: pass + if res and res[0].payload: + note_id = res[0].payload.get("note_id") + if note_id: + return self._fetch_note_cached(note_id) + except: + pass - # Versuch 2: NoteID#Section (Hash abtrennen) - possible_note_id = ref_str.split("#")[0] - if self._fetch_note_cached(possible_note_id): return self._fetch_note_cached(possible_note_id) + # Versuch 2: NoteID#Section (Hash abtrennen und als Note-ID versuchen) + # z.B. "20250101-meine-note#Abschnitt" -> "20250101-meine-note" + possible_note_id = ref_str.split("#")[0].strip() + note = self._fetch_note_cached(possible_note_id) + if note: + return note + + # Versuch 3: Titel#Abschnitt (Hash abtrennen und als Titel suchen) + # z.B. "Meine Prinzipien 2025#P3 – Disziplin" -> "Meine Prinzipien 2025" + # WICHTIG: target_id enthält nur den Titel, daher suchen wir nur nach dem Titel-Teil + possible_title = ref_str.split("#")[0].strip() + if possible_title: + res, _ = self.client.scroll( + collection_name=self.notes_col, + scroll_filter=models.Filter(must=[ + models.FieldCondition(key="title", match=models.MatchValue(value=possible_title)) + ]), + limit=1, with_payload=True + ) + if res and res[0].payload: + payload = res[0].payload + self._note_cache[payload['note_id']] = payload + return payload + + # Fallback: Text-Suche für Fuzzy-Matching + res, _ = self.client.scroll( + collection_name=self.notes_col, + scroll_filter=models.Filter(must=[ + models.FieldCondition(key="title", match=models.MatchText(text=possible_title)) + ]), + limit=10, with_payload=True + ) + if res: + # Nimm das erste Ergebnis, das exakt oder beginnend mit possible_title übereinstimmt + for r in res: + if r.payload: + note_title = r.payload.get("title", "") + if note_title == possible_title or note_title.startswith(possible_title): + payload = r.payload + self._note_cache[payload['note_id']] = payload + return payload # Fall B: Note ID direkt - if self._fetch_note_cached(ref_str): return self._fetch_note_cached(ref_str) + note = self._fetch_note_cached(ref_str) + if note: + return note - # Fall C: Titel + # Fall C: Titel (exakte Übereinstimmung) res, _ = self.client.scroll( collection_name=self.notes_col, - scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchValue(value=ref_str))]), + scroll_filter=models.Filter(must=[ + models.FieldCondition(key="title", match=models.MatchValue(value=ref_str)) + ]), limit=1, with_payload=True ) - if res: - self._note_cache[res[0].payload['note_id']] = res[0].payload - return res[0].payload + if res and res[0].payload: + payload = res[0].payload + self._note_cache[payload['note_id']] = payload + return payload + + # Fall D: Titel (Text-Suche für Fuzzy-Matching) + res, _ = self.client.scroll( + collection_name=self.notes_col, + scroll_filter=models.Filter(must=[ + models.FieldCondition(key="title", match=models.MatchText(text=ref_str)) + ]), + limit=1, with_payload=True + ) + if res and res[0].payload: + payload = res[0].payload + self._note_cache[payload['note_id']] = payload + return payload + return None def _add_node_to_dict(self, node_dict, note_payload, level=1):