diff --git a/app/frontend/ui.py b/app/frontend/ui.py index cddd768..ac23908 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -100,6 +100,7 @@ EDGE_COLORS = { "derived_from": "#ff9ff3",# Pink "references": "#bdc3c7", # Grey "belongs_to": "#2e86de" # Dark Blue + "contributes_to": "#1dd1a1" } # --- HELPER FUNCTIONS --- @@ -245,27 +246,19 @@ class GraphExplorerService: self.notes_col = f"{prefix}_notes" self.chunks_col = f"{prefix}_chunks" self.edges_col = f"{prefix}_edges" - self._note_cache = {} # Simple in-memory cache for the session + self._note_cache = {} def get_ego_graph(self, center_note_id: str): - """ - Bidirektionaler Ego-Graph: - 1. Lädt Center Node. - 2. Findet OUTGOING Edges (Source = Chunk von Center). - 3. Findet INCOMING Edges (Target = Chunk von Center ODER Target = Titel von Center). - 4. Dedupliziert auf Notiz-Ebene. - """ - nodes_dict = {} # note_id -> Node Object - unique_edges = {} # (source_note_id, target_note_id) -> Edge Data + nodes_dict = {} + unique_edges = {} - # 1. Zentrale Note laden center_note = self._fetch_note_cached(center_note_id) if not center_note: return [], [] self._add_node_to_dict(nodes_dict, center_note, is_center=True) center_title = center_note.get("title") - # 2. Chunks der Note finden (für Edge-Suche) + # Chunks laden scroll_filter = models.Filter( must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=center_note_id))] ) @@ -276,7 +269,7 @@ class GraphExplorerService: raw_edges = [] - # 3. OUTGOING EDGES Suche + # 1. OUTGOING: Source ist einer unserer Chunks if center_chunk_ids: out_filter = models.Filter( must=[models.FieldCondition(key="source_id", match=models.MatchAny(any=center_chunk_ids))] @@ -286,28 +279,26 @@ class GraphExplorerService: ) raw_edges.extend(res_out) - # 4. INCOMING EDGES Suche - # Case A: Target ist einer unserer Chunks + # 2. INCOMING: Target ist Chunk, Titel oder exakte Note-ID + # Hinweis: Target mit #Section (z.B. 'note#header') kann via Keyword-Index schwer gefunden werden, + # wenn wir den Header-Teil nicht kennen. + must_conditions = [] if center_chunk_ids: - in_chunk_filter = models.Filter( - must=[models.FieldCondition(key="target_id", match=models.MatchAny(any=center_chunk_ids))] - ) - res_in_c, _ = self.client.scroll( - collection_name=self.edges_col, scroll_filter=in_chunk_filter, limit=100, with_payload=True - ) - raw_edges.extend(res_in_c) - - # Case B: Target ist unser Titel (Wikilinks) + must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=center_chunk_ids))) if center_title: - in_title_filter = models.Filter( - must=[models.FieldCondition(key="target_id", match=models.MatchValue(value=center_title))] - ) - res_in_t, _ = self.client.scroll( - collection_name=self.edges_col, scroll_filter=in_title_filter, limit=50, with_payload=True - ) - raw_edges.extend(res_in_t) + must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=center_title))) + + # NEU: Auch exakte Note-ID als Target prüfen + must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=center_note_id))) - # 5. Kanten verarbeiten und auflösen + if must_conditions: + in_filter = models.Filter(should=must_conditions) # 'should' wirkt wie OR + res_in, _ = self.client.scroll( + collection_name=self.edges_col, scroll_filter=in_filter, limit=100, with_payload=True + ) + raw_edges.extend(res_in) + + # Verarbeitung for record in raw_edges: payload = record.payload @@ -316,29 +307,23 @@ class GraphExplorerService: kind = payload.get("kind", "related_to") provenance = payload.get("provenance", "explicit") - # Resolve Source Note src_note = self._resolve_note_from_ref(src_ref) - # Resolve Target Note 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'] - # Keine Self-Loops und valide Verbindung if src_id != tgt_id: - # Nodes hinzufügen (falls noch nicht da) self._add_node_to_dict(nodes_dict, src_note) self._add_node_to_dict(nodes_dict, tgt_note) - # Deduplizierung: Wir behalten die "stärkste" Kante - # Wenn bereits eine explizite Kante existiert, überschreiben wir sie nicht mit einer AI-Kante key = (src_id, tgt_id) existing = unique_edges.get(key) is_current_explicit = (provenance == "explicit" or provenance == "rule") - should_update = True + if existing: is_existing_explicit = (existing['provenance'] == "explicit" or existing['provenance'] == "rule") if is_existing_explicit and not is_current_explicit: @@ -346,39 +331,25 @@ class GraphExplorerService: if should_update: unique_edges[key] = { - "source": src_id, - "target": tgt_id, - "kind": kind, - "provenance": provenance + "source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance } - # 6. Agraph Edge Objekte erstellen final_edges = [] for (src, tgt), data in unique_edges.items(): kind = data['kind'] prov = data['provenance'] - color = EDGE_COLORS.get(kind, "#bdc3c7") is_smart = (prov != "explicit" and prov != "rule") - label = f"{kind}" - # AI Edges gestrichelt - dashes = is_smart - final_edges.append(Edge( - source=src, - target=tgt, - label=label, - color=color, - dashes=dashes, - title=f"Provenance: {prov}, Type: {kind}" + source=src, target=tgt, label=kind, color=color, dashes=is_smart, + title=f"Provenance: {prov}\nType: {kind}" )) return list(nodes_dict.values()), final_edges def _fetch_note_cached(self, note_id): if note_id in self._note_cache: return self._note_cache[note_id] - res, _ = self.client.scroll( collection_name=self.notes_col, scroll_filter=models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))]), @@ -390,54 +361,50 @@ class GraphExplorerService: return None def _resolve_note_from_ref(self, ref_str): - """Löst eine ID (Chunk) oder einen String (Titel) zu einer Note Payload auf.""" if not ref_str: return None - # Fall 1: Chunk ID (enthält '#') + # Fall A: Chunk ID (Format: note_id#cXX) if "#" in ref_str: - # Wir könnten den Chunk holen, aber effizienter ist es, die note_id aus dem Chunk-String zu parsen, - # WENN das Format strikt 'note_id#cXX' ist. Um sicher zu gehen, fragen wir Qdrant. + # 1. Versuch: Echte Chunk ID in DB suchen try: res = self.client.retrieve(collection_name=self.chunks_col, ids=[ref_str], with_payload=True) if res: parent_id = res[0].payload.get("note_id") return self._fetch_note_cached(parent_id) - except: pass # Falls ID nicht existiert + except: pass - # Fall 2: Vermutlich ein Titel (Wikilink) oder Note ID - # Versuch als Note ID + # 2. Versuch (NEU): Es ist ein Link auf eine Section (z.B. "note-id#Header") + # Wir entfernen den Hash-Teil und suchen die Basis-Notiz + possible_note_id = ref_str.split("#")[0] + note_by_id = self._fetch_note_cached(possible_note_id) + if note_by_id: return note_by_id + + # Fall B: Es ist direkt die Note ID note_by_id = self._fetch_note_cached(ref_str) if note_by_id: return note_by_id - # Versuch als Titel + # Fall C: Es ist der Titel (Wikilink) res, _ = self.client.scroll( collection_name=self.notes_col, scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchValue(value=ref_str))]), limit=1, with_payload=True ) if res: - payload = res[0].payload - self._note_cache[payload['note_id']] = payload - return payload + p = res[0].payload + self._note_cache[p['note_id']] = p + return p return None def _add_node_to_dict(self, node_dict, note_payload, is_center=False): nid = note_payload.get("note_id") if nid in node_dict: return - ntype = note_payload.get("type", "default") color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"]) - size = 40 if is_center else 20 - + size = 35 if is_center else 20 node_dict[nid] = Node( - id=nid, - label=note_payload.get("title", nid), - size=size, - color=color, - shape="dot" if not is_center else "diamond", - title=f"Type: {ntype}\nTags: {note_payload.get('tags')}", - font={'color': 'black', 'face': 'arial'} + id=nid, label=note_payload.get("title", nid), size=size, color=color, shape="dot", + title=f"Type: {ntype}\nTags: {note_payload.get('tags')}", font={'color': 'black'} ) # Init Graph Service