diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index e2e4cff..66ec94b 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -69,20 +69,26 @@ def render_graph_explorer_cytoscape(graph_service): search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search") if search_term: - hits, _ = graph_service.client.scroll( - collection_name=f"{COLLECTION_PREFIX}_notes", - limit=10, - scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))]) - ) - options = {h.payload['title']: h.payload['note_id'] for h in hits} - - if options: - selected_title = st.selectbox("Ergebnisse:", list(options.keys()), key="cy_select") - if st.button("Laden", use_container_width=True, key="cy_load"): - new_id = options[selected_title] - st.session_state.graph_center_id = new_id - st.session_state.graph_inspected_id = new_id - st.rerun() + try: + hits, _ = graph_service.client.scroll( + collection_name=f"{COLLECTION_PREFIX}_notes", + limit=10, + scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))]) + ) + options = {} + for h in hits: + if h.payload and 'title' in h.payload and 'note_id' in h.payload: + options[h.payload['title']] = h.payload['note_id'] + + if options: + selected_title = st.selectbox("Ergebnisse:", list(options.keys()), key="cy_select") + if st.button("Laden", use_container_width=True, key="cy_load"): + new_id = options[selected_title] + st.session_state.graph_center_id = new_id + st.session_state.graph_inspected_id = new_id + st.rerun() + except Exception as e: + st.error(f"Fehler bei der Suche: {e}") st.divider() @@ -174,7 +180,12 @@ def render_graph_explorer_cytoscape(graph_service): st.markdown(f"**ID:** `{inspected_data.get('note_id')}`") st.markdown(f"**Typ:** `{inspected_data.get('type')}`") with col_i2: - st.markdown(f"**Tags:** {', '.join(inspected_data.get('tags', []))}") + tags = inspected_data.get('tags', []) + if isinstance(tags, list): + tags_str = ', '.join(tags) if tags else "Keine" + else: + tags_str = str(tags) if tags else "Keine" + st.markdown(f"**Tags:** {tags_str}") path_check = "✅" if inspected_data.get('path') else "❌" st.markdown(f"**Pfad:** {path_check}") @@ -189,12 +200,27 @@ def render_graph_explorer_cytoscape(graph_service): # --- GRAPH ELEMENTS --- cy_elements = [] + # Validierung: Prüfe ob nodes_data vorhanden ist + if not nodes_data: + st.warning("⚠️ Keine Knoten gefunden. Bitte wähle eine andere Notiz.") + # Zeige trotzdem den Inspector, falls Daten vorhanden + if inspected_data: + st.info(f"**Hinweis:** Die Notiz '{inspected_data.get('title', inspected_id)}' wurde gefunden, hat aber keine Verbindungen im Graphen.") + return + + # Erstelle Set aller Node-IDs für schnelle Validierung + node_ids = {n.id for n in nodes_data if hasattr(n, 'id') and n.id} + + # Nodes hinzufügen for n in nodes_data: + if not hasattr(n, 'id') or not n.id: + continue + is_center = (n.id == center_id) is_inspected = (n.id == inspected_id) - tooltip_text = n.title if n.title else n.label - display_label = n.label + tooltip_text = getattr(n, 'title', None) or getattr(n, 'label', '') + display_label = getattr(n, 'label', str(n.id)) if len(display_label) > 15 and " " in display_label: display_label = display_label.replace(" ", "\n", 1) @@ -202,7 +228,7 @@ def render_graph_explorer_cytoscape(graph_service): "data": { "id": n.id, "label": display_label, - "bg_color": n.color, + "bg_color": getattr(n, 'color', '#8395a7'), "tooltip": tooltip_text }, # Wir steuern das Aussehen rein über Klassen (.inspected / .center) @@ -211,18 +237,22 @@ def render_graph_explorer_cytoscape(graph_service): } cy_elements.append(cy_node) - for e in edges_data: - target_id = getattr(e, "to", getattr(e, "target", None)) - if target_id: - cy_edge = { - "data": { - "source": e.source, - "target": target_id, - "label": e.label, - "line_color": e.color + # Edges hinzufügen - nur wenn beide Nodes im Graph vorhanden sind + if edges_data: + for e in edges_data: + source_id = getattr(e, "source", None) + target_id = getattr(e, "to", getattr(e, "target", None)) + # Nur hinzufügen, wenn beide IDs vorhanden UND beide Nodes im Graph sind + if source_id and target_id and source_id in node_ids and target_id in node_ids: + cy_edge = { + "data": { + "source": source_id, + "target": target_id, + "label": getattr(e, "label", ""), + "line_color": getattr(e, "color", "#bdc3c7") + } } - } - cy_elements.append(cy_edge) + cy_elements.append(cy_edge) # --- STYLESHEET --- stylesheet = [ @@ -292,43 +322,47 @@ def render_graph_explorer_cytoscape(graph_service): ] # --- RENDER --- - graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}" + # Nur rendern, wenn Elemente vorhanden sind + if not cy_elements: + st.warning("⚠️ Keine Graph-Elemente zum Anzeigen gefunden.") + else: + graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}" - clicked_elements = cytoscape( - elements=cy_elements, - stylesheet=stylesheet, - layout={ - "name": "cose", - "idealEdgeLength": st.session_state.cy_ideal_edge_len, - "nodeOverlap": 20, - "refresh": 20, - "fit": True, - "padding": 50, - "randomize": False, - "componentSpacing": 100, - "nodeRepulsion": st.session_state.cy_node_repulsion, - "edgeElasticity": 100, - "nestingFactor": 5, - "gravity": 80, - "numIter": 1000, - "initialTemp": 200, - "coolingFactor": 0.95, - "minTemp": 1.0, - "animate": False - }, - key=graph_key, - height="700px" - ) + clicked_elements = cytoscape( + elements=cy_elements, + stylesheet=stylesheet, + layout={ + "name": "cose", + "idealEdgeLength": st.session_state.cy_ideal_edge_len, + "nodeOverlap": 20, + "refresh": 20, + "fit": True, + "padding": 50, + "randomize": False, + "componentSpacing": 100, + "nodeRepulsion": st.session_state.cy_node_repulsion, + "edgeElasticity": 100, + "nestingFactor": 5, + "gravity": 80, + "numIter": 1000, + "initialTemp": 200, + "coolingFactor": 0.95, + "minTemp": 1.0, + "animate": False + }, + key=graph_key, + height="700px" + ) - # --- EVENT HANDLING --- - if clicked_elements: - clicked_nodes = clicked_elements.get("nodes", []) - if clicked_nodes: - clicked_id = clicked_nodes[0] - - if clicked_id != st.session_state.graph_inspected_id: - st.session_state.graph_inspected_id = clicked_id - st.rerun() + # --- EVENT HANDLING --- + if clicked_elements: + clicked_nodes = clicked_elements.get("nodes", []) + if clicked_nodes: + clicked_id = clicked_nodes[0] + + if clicked_id != st.session_state.graph_inspected_id: + st.session_state.graph_inspected_id = clicked_id + st.rerun() else: st.info("👈 Bitte wähle links eine Notiz aus.") \ No newline at end of file diff --git a/app/frontend/ui_graph_service.py b/app/frontend/ui_graph_service.py index bcaa0a3..87319f0 100644 --- a/app/frontend/ui_graph_service.py +++ b/app/frontend/ui_graph_service.py @@ -78,7 +78,7 @@ class GraphExplorerService: # A. Fulltext für Center Node holen (Chunks zusammenfügen) center_text = self._fetch_full_text_stitched(center_note_id) if center_note_id in nodes_dict: - orig_title = nodes_dict[center_note_id].title + orig_title = getattr(nodes_dict[center_note_id], 'title', None) or getattr(nodes_dict[center_note_id], 'label', '') clean_full = self._clean_markdown(center_text[:2000]) # Wir packen den Text in den Tooltip (title attribute) nodes_dict[center_note_id].title = f"{orig_title}\n\n📄 INHALT:\n{clean_full}..." @@ -91,7 +91,8 @@ class GraphExplorerService: if nid != center_note_id: prev_raw = previews.get(nid, "Kein Vorschau-Text.") clean_prev = self._clean_markdown(prev_raw[:600]) - node_obj.title = f"{node_obj.title}\n\n🔍 VORSCHAU:\n{clean_prev}..." + current_title = getattr(node_obj, 'title', None) or getattr(node_obj, 'label', '') + node_obj.title = f"{current_title}\n\n🔍 VORSCHAU:\n{clean_prev}..." # Graphen bauen (Nodes & Edges finalisieren) final_edges = [] @@ -167,27 +168,28 @@ class GraphExplorerService: 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})) + ]) + # Limit erhöht, um alle Kanten zu finden + 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. - # Chunk IDs der aktuellen Notes holen + # Chunk IDs der aktuellen Notes holen (Limit erhöht) 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,66 @@ 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))) + shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids))) - # Case C: Edge zeigt auf unseren Titel (Wikilinks) + # Case C: Edge zeigt auf unseren Titel (Wikilinks) - auch wenn note_title None ist, versuchen wir es mit den Titeln der Notes if note_title: shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=note_title))) + else: + # Fallback: Lade Titel der Notes, wenn note_title nicht übergeben wurde + for nid in note_ids: + note = self._fetch_note_cached(nid) + if note and note.get("title"): + shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=note.get("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) + # Limit erhöht, um alle eingehenden Kanten zu finden + 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 - lade Titel für alle Notes + note_titles = [] + for nid in note_ids: + note = self._fetch_note_cached(nid) + if note and note.get("title"): + note_titles.append(note.get("title")) + # Verwende den ersten Titel als Fallback (oder None, wenn keine gefunden) + title = note_titles[0] if note_titles else None + return self._find_connected_edges(note_ids, note_title=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.""" + 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 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 +271,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 @@ -275,25 +301,49 @@ class GraphExplorerService: try: # Versuch 1: Chunk ID direkt 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: + note = self._fetch_note_cached(note_id) + if note: return note + except Exception: + 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) + note = self._fetch_note_cached(possible_note_id) + if note: return note # 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 (exakte Übereinstimmung) + try: + res, _ = self.client.scroll( + collection_name=self.notes_col, + scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchValue(value=str(ref_str)))]), + limit=1, with_payload=True + ) + if res and res[0].payload: + self._note_cache[res[0].payload['note_id']] = res[0].payload + return res[0].payload + except Exception: + pass + + # Fall D: Titel (Text-Suche für Fuzzy-Matching, falls exakte Suche fehlschlägt) + try: + res, _ = self.client.scroll( + collection_name=self.notes_col, + scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchText(text=str(ref_str)))]), + limit=1, with_payload=True + ) + if res and res[0].payload: + self._note_cache[res[0].payload['note_id']] = res[0].payload + return res[0].payload + except Exception: + pass - # Fall C: 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: - self._note_cache[res[0].payload['note_id']] = res[0].payload - return res[0].payload return None def _add_node_to_dict(self, node_dict, note_payload, level=1):