from qdrant_client import QdrantClient, models from streamlit_agraph import Node, Edge from ui_config import GRAPH_COLORS, EDGE_COLORS class GraphExplorerService: def __init__(self, url, api_key=None, prefix="mindnet"): self.client = QdrantClient(url=url, api_key=api_key) self.prefix = prefix self.notes_col = f"{prefix}_notes" self.chunks_col = f"{prefix}_chunks" self.edges_col = f"{prefix}_edges" self._note_cache = {} def get_ego_graph(self, center_note_id: str): nodes_dict = {} unique_edges = {} # 1. Center 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 Center Note finden scroll_filter = models.Filter( must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=center_note_id))] ) chunks, _ = self.client.scroll( collection_name=self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True ) center_chunk_ids = [c.id for c in chunks] raw_edges = [] # 3. OUTGOING EDGES: Source = einer meiner Chunks if center_chunk_ids: out_filter = models.Filter( must=[models.FieldCondition(key="source_id", match=models.MatchAny(any=center_chunk_ids))] ) res_out, _ = self.client.scroll( collection_name=self.edges_col, scroll_filter=out_filter, limit=100, with_payload=True ) raw_edges.extend(res_out) # 4. INCOMING EDGES: Target = Chunk, Titel oder Note-ID must_conditions = [] if center_chunk_ids: must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=center_chunk_ids))) if center_title: must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=center_title))) # FIX: Auch exakte Note-ID als Target prüfen must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=center_note_id))) if must_conditions: in_filter = models.Filter(should=must_conditions) # 'should' = 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) # 5. Verarbeitung & Auflösung for record in raw_edges: payload = record.payload src_ref = payload.get("source_id") tgt_ref = payload.get("target_id") kind = payload.get("kind", "related_to") provenance = payload.get("provenance", "explicit") 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'] # Keine Self-Loops und valide Verbindung if src_id != tgt_id: self._add_node_to_dict(nodes_dict, src_note) self._add_node_to_dict(nodes_dict, tgt_note) key = (src_id, tgt_id) existing = unique_edges.get(key) # Deduplizierung: Explizite Kanten überschreiben Smart Edges 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: should_update = False if should_update: unique_edges[key] = { "source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance } # 6. Agraph Objekte bauen 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") final_edges.append(Edge( 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))]), limit=1, with_payload=True ) if res: self._note_cache[note_id] = res[0].payload return res[0].payload return None def _resolve_note_from_ref(self, ref_str): if not ref_str: return None # Fall A: Chunk ID (Format: note_id#cXX) if "#" in ref_str: # Versuch 1: Echte Chunk ID in DB 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 # Versuch 2: Section Link (note-id#Header) -> Hash abschneiden 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 # 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: 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 = 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'} )