From dc16bbf8a4280153812269560e10c5637a69bf9e Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 08:07:53 +0100 Subject: [PATCH] bidirektionale Kanten --- app/frontend/ui.py | 256 ++++++++++++++++++++++++++++++++------------- 1 file changed, 181 insertions(+), 75 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 0403c3f..cddd768 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -76,22 +76,30 @@ if "messages" not in st.session_state: st.session_state.messages = [] if "user_id" not in st.session_state: st.session_state.user_id = str(uuid.uuid4()) # --- GRAPH STYLING CONFIG (WP-19) --- +# Colors based on types.yaml and standard conventions GRAPH_COLORS = { "project": "#ff9f43", # Orange - "concept": "#54a0ff", # Blau - "decision": "#5f27cd", # Lila - "risk": "#ff6b6b", # Rot - "person": "#1dd1a1", # Grün - "experience": "#feca57",# Gelb - "default": "#8395a7" # Grau + "concept": "#54a0ff", # Blue + "decision": "#5f27cd", # Purple + "risk": "#ff6b6b", # Red + "person": "#1dd1a1", # Green + "experience": "#feca57",# Yellow + "value": "#00d2d3", # Cyan + "goal": "#ff9ff3", # Pink + "default": "#8395a7" # Grey } +# Colors based on edge 'kind' EDGE_COLORS = { - "depends_on": "#ff6b6b", # Rot (Blocker) - "blocks": "#ee5253", # Dunkelrot - "related_to": "#c8d6e5", # Hellgrau - "next": "#54a0ff", # Blau - "derived_from": "#ff9ff3"# Pink + "depends_on": "#ff6b6b", # Red (Blocker) + "blocks": "#ee5253", # Dark Red + "caused_by": "#ff9ff3", # Pink + "related_to": "#c8d6e5", # Light Grey + "similar_to": "#c8d6e5", # Light Grey + "next": "#54a0ff", # Blue + "derived_from": "#ff9ff3",# Pink + "references": "#bdc3c7", # Grey + "belongs_to": "#2e86de" # Dark Blue } # --- HELPER FUNCTIONS --- @@ -228,7 +236,7 @@ def load_history_from_logs(limit=10): except: pass return queries -# --- WP-19 GRAPH SERVICE --- +# --- WP-19 GRAPH SERVICE (Advanced) --- class GraphExplorerService: def __init__(self, url, api_key=None, prefix="mindnet"): @@ -237,106 +245,199 @@ 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 def get_ego_graph(self, center_note_id: str): - """Erzeugt einen Ego-Graphen (Node + Nachbarn) für die Visualisierung.""" - nodes = {} # id -> Node Object - edges_list = [] # List of Edge Objects + """ + 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 # 1. Zentrale Note laden - center_note = self._fetch_note(center_note_id) + center_note = self._fetch_note_cached(center_note_id) if not center_note: return [], [] - self._add_node(nodes, center_note, is_center=True) + self._add_node_to_dict(nodes_dict, center_note, is_center=True) - # 2. Chunks der Note finden (Source Chunks) + center_title = center_note.get("title") + + # 2. Chunks der Note finden (für Edge-Suche) 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 ) - chunk_ids = [c.id for c in chunks] + center_chunk_ids = [c.id for c in chunks] - # 3. Kanten finden - if chunk_ids: - edge_filter = models.Filter( - must=[models.FieldCondition(key="source_id", match=models.MatchAny(any=chunk_ids))] + raw_edges = [] + + # 3. OUTGOING EDGES Suche + if center_chunk_ids: + out_filter = models.Filter( + must=[models.FieldCondition(key="source_id", match=models.MatchAny(any=center_chunk_ids))] ) - raw_edges, _ = self.client.scroll( - collection_name=self.edges_col, scroll_filter=edge_filter, limit=100, with_payload=True + 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 Suche + # Case A: Target ist einer unserer Chunks + 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) + 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) + + # 5. Kanten verarbeiten und auflösen + for record in raw_edges: + payload = record.payload - # 4. Targets auflösen - for re in raw_edges: - payload = re.payload - target_chunk_id = payload.get("target_id") - kind = payload.get("kind") - provenance = payload.get("provenance", "explicit") + src_ref = payload.get("source_id") + tgt_ref = payload.get("target_id") + 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'] - target_note = self._resolve_note_from_chunk(target_chunk_id) - - if target_note and target_note['note_id'] != center_note_id: - self._add_node(nodes, target_note) + # 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) - # Styling - color = EDGE_COLORS.get(kind, "#bdc3c7") - is_smart = provenance != "explicit" and provenance != "rule" + # 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) - label = f"{kind}" - if is_smart: label += " 🤖" + is_current_explicit = (provenance == "explicit" or provenance == "rule") - edges_list.append(Edge( - source=center_note_id, - target=target_note['note_id'], - label=label, - color=color, - dashes=is_smart, - title=f"Provenance: {provenance}" - )) + 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 + } - return list(nodes.values()), edges_list + # 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}" + )) - def _fetch_note(self, note_id): + 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 ) - return res[0].payload if res else None + if res: + self._note_cache[note_id] = res[0].payload + return res[0].payload + return None - def _resolve_note_from_chunk(self, chunk_id_or_title): - if "#" in chunk_id_or_title: - res = self.client.retrieve(collection_name=self.chunks_col, ids=[chunk_id_or_title], with_payload=True) - if res: - parent_id = res[0].payload.get("note_id") - return self._fetch_note(parent_id) - else: - # Versuch: Direkter Match auf Note Titel (für WikiLinks) - # In Production sollte das optimiert werden (Cache) - res, _ = self.client.scroll( - collection_name=self.notes_col, - scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchValue(value=chunk_id_or_title))]), - limit=1, with_payload=True - ) - return res[0].payload if res else None - 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 '#') + 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. + 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 + + # Fall 2: Vermutlich ein Titel (Wikilink) oder Note ID + # Versuch als Note ID + note_by_id = self._fetch_note_cached(ref_str) + if note_by_id: return note_by_id + + # Versuch als Titel + 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 + + return None - def _add_node(self, node_dict, note_payload, is_center=False): + 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 + size = 40 if is_center else 20 node_dict[nid] = Node( id=nid, label=note_payload.get("title", nid), size=size, color=color, - shape="dot", + shape="dot" if not is_center else "diamond", title=f"Type: {ntype}\nTags: {note_payload.get('tags')}", - font={'color': 'black'} + font={'color': 'black', 'face': 'arial'} ) # Init Graph Service @@ -626,6 +727,7 @@ def render_graph_explorer(): selected_note_id = None if search_term: + # Suche nach Titel für Autocomplete hits, _ = graph_service.client.scroll( collection_name=f"{COLLECTION_PREFIX}_notes", scroll_filter=models.Filter( @@ -645,7 +747,8 @@ def render_graph_explorer(): st.markdown(f"🔴 **Blocker** (Risk/Block)") st.markdown(f"🔵 **Konzept/Struktur**") st.markdown(f"🟣 **Entscheidung**") - st.markdown(f"🤖 _Gestrichelt = Smart Edge (KI)_") + st.markdown(f"--- **Solid**: Explicit Link") + st.markdown(f"- - **Dashed**: Smart/AI Link") with col_graph: if selected_note_id: @@ -656,8 +759,8 @@ def render_graph_explorer(): st.error("Knoten konnte nicht geladen werden.") else: config = Config( - width=800, - height=600, + width=900, + height=700, directed=True, physics=True, hierarchical=False, @@ -665,9 +768,12 @@ def render_graph_explorer(): highlightColor="#F7A7A6", collapsible=False ) + # Rendering the Graph + st.caption(f"Graph zeigt {len(nodes)} Knoten und {len(edges)} Kanten.") return_value = agraph(nodes=nodes, edges=edges, config=config) + if return_value: - st.info(f"Node geklickt: {return_value}") + st.info(f"Auswahl: {return_value}") else: st.info("👈 Bitte wähle links eine Notiz aus, um den Graphen zu starten.")