From cf91730e452e44f96bd35a62310ea70889b7e56f Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 12:25:52 +0100 Subject: [PATCH] neue visualisierung --- app/frontend/ui_components.py | 52 +++++++++------- app/frontend/ui_graph_service.py | 104 +++++++++++++++++++------------ 2 files changed, 93 insertions(+), 63 deletions(-) diff --git a/app/frontend/ui_components.py b/app/frontend/ui_components.py index 7aff35b..52dbb54 100644 --- a/app/frontend/ui_components.py +++ b/app/frontend/ui_components.py @@ -382,7 +382,8 @@ def render_graph_explorer(graph_service): # Defaults speichern für Persistenz st.session_state.setdefault("graph_depth", 2) st.session_state.setdefault("graph_show_labels", True) - st.session_state.setdefault("graph_spacing", 200) + # Defaults angepasst für BarnesHut (andere Skala!) + st.session_state.setdefault("graph_spacing", 150) st.session_state.setdefault("graph_gravity", -3000) col_ctrl, col_graph = st.columns([1, 4]) @@ -415,12 +416,13 @@ def render_graph_explorer(graph_service): st.session_state.graph_depth = st.slider("Tiefe (Tier)", 1, 3, st.session_state.graph_depth) st.session_state.graph_show_labels = st.checkbox("Kanten-Beschriftung", st.session_state.graph_show_labels) - st.markdown("**Dynamisches Layout**") - st.session_state.graph_spacing = st.slider("Abstand (Feder)", 50, 400, st.session_state.graph_spacing) - st.session_state.graph_gravity = st.slider("Abstoßung (Gravity)", -8000, -100, st.session_state.graph_gravity) + st.markdown("**Physik (BarnesHut)**") + # ACHTUNG: BarnesHut reagiert anders. Spring Length ist wichtig. + st.session_state.graph_spacing = st.slider("Federlänge (Abstand)", 50, 500, st.session_state.graph_spacing, help="Wie lang sollen die Verbindungen sein?") + st.session_state.graph_gravity = st.slider("Abstoßung (Gravity)", -20000, -1000, st.session_state.graph_gravity, help="Wie stark sollen sich Knoten abstoßen?") - if st.button("Standard wiederherstellen"): - st.session_state.graph_spacing = 200 + if st.button("Reset Layout"): + st.session_state.graph_spacing = 150 st.session_state.graph_gravity = -3000 st.rerun() @@ -439,7 +441,7 @@ def render_graph_explorer(graph_service): st.caption(f"Aktives Zentrum: **{center_id}**") with c_action2: # Bearbeiten Button mit Callback (on_click) - # Holt die Daten aus dem Cache des Services (wurde durch get_ego_graph dort abgelegt oder wir holen es neu) + # Holt die Daten aus dem Cache des Services note_data = graph_service._fetch_note_cached(center_id) if note_data: st.button("📝 Bearbeiten", @@ -460,30 +462,34 @@ def render_graph_explorer(graph_service): if not nodes: st.warning("Keine Daten gefunden. (Notiz existiert evtl. nicht mehr)") else: - # CONFIGURATION für agraph + # --- CONFIGURATION FÜR AGRAH (BARNES HUT) --- # Wir nutzen KEIN 'key' Argument, da dies Fehler verursacht. # Stattdessen vertrauen wir darauf, dass das Config-Objekt neu ist. + # TRICK: Wir ändern die Höhe minimal basierend auf der Gravity, um Re-Render zu erzwingen. + dyn_height = 800 + (abs(st.session_state.graph_gravity) % 5) + config = Config( width=1000, - height=800, + height=dyn_height, directed=True, - physics=True, + physics={ + "enabled": True, + # BarnesHut ist der Standard und stabilste Solver für Agraph + "solver": "barnesHut", + "barnesHut": { + "gravitationalConstant": st.session_state.graph_gravity, + "centralGravity": 0.3, + "springLength": st.session_state.graph_spacing, + "springConstant": 0.04, + "damping": 0.09, + "avoidOverlap": 0.1 + }, + "stabilization": {"enabled": True, "iterations": 600} + }, hierarchical=False, nodeHighlightBehavior=True, highlightColor="#F7A7A6", - collapsible=False, - # Physik: ForceAtlas2Based für beste Entzerrung - solver="forceAtlas2Based", - forceAtlas2Based={ - "theta": 0.5, - "gravitationalConstant": st.session_state.graph_gravity, - "centralGravity": 0.005, - "springConstant": 0.08, - "springLength": st.session_state.graph_spacing, - "damping": 0.4, - "avoidOverlap": 1 - }, - stabilization={"enabled": True, "iterations": 800} + collapsible=False ) return_value = agraph(nodes=nodes, edges=edges, config=config) diff --git a/app/frontend/ui_graph_service.py b/app/frontend/ui_graph_service.py index b9bdb7f..774b51c 100644 --- a/app/frontend/ui_graph_service.py +++ b/app/frontend/ui_graph_service.py @@ -12,13 +12,16 @@ class GraphExplorerService: self._note_cache = {} def get_ego_graph(self, center_note_id: str, depth=2, show_labels=True): + """ + Erstellt den Graphen. + show_labels=False versteckt die Kantenbeschriftung für mehr Übersicht. + """ nodes_dict = {} unique_edges = {} # 1. Center Note laden center_note = self._fetch_note_cached(center_note_id) if not center_note: return [], [] - # Node vorerst ohne Vorschau hinzufügen self._add_node_to_dict(nodes_dict, center_note, level=0) level_1_ids = {center_note_id} @@ -31,7 +34,7 @@ class GraphExplorerService: if src_id: level_1_ids.add(src_id) if tgt_id: level_1_ids.add(tgt_id) - # Level 2 Suche + # Level 2 Suche (begrenzt für Performance) if depth > 1 and len(level_1_ids) > 1 and len(level_1_ids) < 60: l1_subset = list(level_1_ids - {center_note_id}) if l1_subset: @@ -39,19 +42,22 @@ class GraphExplorerService: for edge_data in l2_edges: self._process_edge(edge_data, nodes_dict, unique_edges, current_depth=2) - # --- NEU: Content Previews (Chunks) laden --- - # Wir holen für alle gesammelten Nodes den ersten Chunk als Vorschau - all_node_ids = list(nodes_dict.keys()) - previews = self._fetch_previews_for_nodes(all_node_ids) + # --- SMART CONTENT LOADING --- + # 1. Fulltext für Center Node holen (Chunks stitchen) + 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 + # Titel im Objekt manipulieren für Hover + nodes_dict[center_note_id].title = f"{orig_title}\n\n📄 VOLLTEXT:\n{center_text[:2000]}..." + + # 2. Previews für alle anderen Nodes holen + all_ids = list(nodes_dict.keys()) + previews = self._fetch_previews_for_nodes(all_ids) - # Nodes aktualisieren mit Vorschau-Text - final_nodes = [] for nid, node_obj in nodes_dict.items(): - # Preview Text in den Tooltip injizieren - prev_text = previews.get(nid, "Kein Inhaltstext gefunden.") - # Wir hängen den Text an den existierenden Title (Hover) an - node_obj.title = f"{node_obj.title}\n\n📝 VORSCHAU:\n{prev_text[:400]}..." - final_nodes.append(node_obj) + if nid != center_note_id: + prev = previews.get(nid, "Kein Vorschau-Text.") + node_obj.title = f"{node_obj.title}\n\n🔍 VORSCHAU:\n{prev[:600]}..." # Graphen bauen final_edges = [] @@ -60,49 +66,53 @@ class GraphExplorerService: prov = data['provenance'] color = get_edge_color(kind) is_smart = (prov != "explicit" and prov != "rule") + + # Label Logik: Wenn show_labels False ist, zeigen wir keinen Text an label_text = kind if show_labels else " " final_edges.append(Edge( source=src, target=tgt, label=label_text, color=color, dashes=is_smart, - title=f"Relation: {kind}\nProvenance: {prov}" + title=f"Relation: {kind}\nProvenance: {prov}" # Tooltip bleibt immer )) - return final_nodes, final_edges + return list(nodes_dict.values()), final_edges - def _fetch_previews_for_nodes(self, node_ids): - """Holt für eine Liste von Note-IDs jeweils einen Chunk als Vorschau.""" - if not node_ids: return {} - - # Wir suchen Chunks, die zu diesen Notes gehören - # Optimierung: Wir holen einfach Chunks und gruppieren sie. - # Limit muss hoch genug sein für alle Nodes im Graphen - previews = {} + def _fetch_full_text_stitched(self, note_id): + """Holt ALLE Chunks einer Note, sortiert sie und baut den Text zusammen.""" try: scroll_filter = models.Filter( - must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))] - ) - # Wir holen Chunks. Sortierung ist in Qdrant schwierig ohne Vektor, - # aber Scroll gibt meistens insertion order oder id order. - chunks, _ = self.client.scroll( - collection_name=self.chunks_col, - scroll_filter=scroll_filter, - limit=len(node_ids) * 3, # 3 Chunks pro Note Puffer - with_payload=True + must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))] ) + chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True) + # Sortieren nach 'ord' + chunks.sort(key=lambda x: x.payload.get('ord', 999)) + + full_text = [] + for c in chunks: + txt = c.payload.get('text', '') + if txt: full_text.append(txt) + + return "\n\n".join(full_text) + except: + return "Fehler beim Laden des Volltexts." + + def _fetch_previews_for_nodes(self, node_ids): + """Holt den ersten Chunk ('window' oder 'text') für eine Liste von Nodes.""" + if not node_ids: return {} + previews = {} + try: + scroll_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))]) + chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=len(node_ids)*3, with_payload=True) for c in chunks: nid = c.payload.get("note_id") - # Nur den ersten Chunk pro Note speichern if nid and nid not in previews: - # Bevorzugt 'window' (Kontext) oder 'text' - text = c.payload.get("window") or c.payload.get("text") or "" - previews[nid] = text - except Exception as e: - print(f"Preview fetch error: {e}") - + previews[nid] = c.payload.get("window") or c.payload.get("text") or "" + except: pass return previews def _find_connected_edges(self, note_ids, note_title=None): + """Findet In- und Outgoing Edges.""" # Chunks finden scroll_filter = models.Filter( must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))] @@ -113,19 +123,25 @@ class GraphExplorerService: chunk_ids = [c.id for c in chunks] results = [] + + # --- OUTGOING SEARCH (Quelle = Chunk ODER Note) --- + # Wir suchen jetzt auch nach der note_id als source_id, falls Edges direkt an der Note hängen source_candidates = chunk_ids + note_ids if source_candidates: out_f = models.Filter(must=[ models.FieldCondition(key="source_id", match=models.MatchAny(any=source_candidates)), + # FIX: MatchExcept mit **kwargs nutzen wegen 'except' Keyword in Pydantic models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES})) ]) res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_f, limit=100, with_payload=True) results.extend(res_out) + # --- INCOMING SEARCH (Ziel = Chunk ODER Title ODER Note) --- shoulds = [] if chunk_ids: shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=chunk_ids))) if note_title: shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=note_title))) + # Target = Note ID shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids))) if shoulds: @@ -139,6 +155,7 @@ class GraphExplorerService: return results def _find_connected_edges_batch(self, note_ids): + """Batch-Suche für Level 2.""" return self._find_connected_edges(note_ids) def _process_edge(self, record, nodes_dict, unique_edges, current_depth): @@ -148,6 +165,7 @@ class GraphExplorerService: kind = payload.get("kind") provenance = payload.get("provenance", "explicit") + # Resolve src_note = self._resolve_note_from_ref(src_ref) tgt_note = self._resolve_note_from_ref(tgt_ref) @@ -156,9 +174,11 @@ class GraphExplorerService: tgt_id = tgt_note['note_id'] if src_id != tgt_id: + # Add Nodes self._add_node_to_dict(nodes_dict, src_note, level=current_depth) self._add_node_to_dict(nodes_dict, tgt_note, level=current_depth) + # Add Edge (Deduplication Logic) key = (src_id, tgt_id) existing = unique_edges.get(key) @@ -190,6 +210,8 @@ class GraphExplorerService: def _resolve_note_from_ref(self, ref_str): if not ref_str: return None + + # Fall A: Chunk ID if "#" in ref_str: try: res = self.client.retrieve(self.chunks_col, ids=[ref_str], with_payload=True) @@ -198,8 +220,10 @@ class GraphExplorerService: possible_note_id = ref_str.split("#")[0] if self._fetch_note_cached(possible_note_id): return self._fetch_note_cached(possible_note_id) + # Fall B: Note ID if self._fetch_note_cached(ref_str): return self._fetch_note_cached(ref_str) + # 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))]), @@ -217,7 +241,7 @@ class GraphExplorerService: ntype = note_payload.get("type", "default") color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"]) - # Basis-Tooltip (wird später erweitert) + # Tooltip wird später durch smart content angereichert tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}" if level == 0: size = 45