From d50ed7046732e68d0d3a46d7f09177fc1ffd3ce1 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 14:57:34 +0100 Subject: [PATCH] cytoscape Versuch --- app/frontend/ui.py | 15 ++- app/frontend/ui_graph_cytoscape.py | 187 +++++++++++++++++++++++++++++ app/frontend/ui_sidebar.py | 7 +- 3 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 app/frontend/ui_graph_cytoscape.py diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 943dc1b..0c6060d 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -17,14 +17,17 @@ try: from ui_config import QDRANT_URL, QDRANT_KEY, COLLECTION_PREFIX from ui_graph_service import GraphExplorerService - # Neue modulare Komponenten + # Komponenten from ui_sidebar import render_sidebar from ui_chat import render_chat_interface from ui_editor import render_manual_editor - from ui_graph import render_graph_explorer + + # Die beiden Graph-Engines + from ui_graph import render_graph_explorer as render_graph_agraph + from ui_graph_cytoscape import render_graph_explorer_cytoscape # <-- Import except ImportError as e: - st.error(f"Import Error: {e}. Bitte stelle sicher, dass alle UI-Dateien im Ordner liegen.") + st.error(f"Import Error: {e}. Bitte stelle sicher, dass alle UI-Dateien im Ordner liegen und 'streamlit-cytoscapejs' installiert ist.") st.stop() # --- SESSION STATE --- @@ -41,5 +44,7 @@ if mode == "💬 Chat": render_chat_interface(top_k, explain) elif mode == "📝 Manueller Editor": render_manual_editor() -elif mode == "🕸️ Graph Explorer": - render_graph_explorer(graph_service) \ No newline at end of file +elif mode == "🕸️ Graph (Agraph)": + render_graph_agraph(graph_service) +elif mode == "🕸️ Graph (Cytoscape)": + render_graph_explorer_cytoscape(graph_service) \ No newline at end of file diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py new file mode 100644 index 0000000..f6b65db --- /dev/null +++ b/app/frontend/ui_graph_cytoscape.py @@ -0,0 +1,187 @@ +import streamlit as st +from st_cytoscape import cytoscape +from qdrant_client import models +from ui_config import COLLECTION_PREFIX, GRAPH_COLORS +from ui_callbacks import switch_to_editor_callback + +def render_graph_explorer_cytoscape(graph_service): + st.header("🕸️ Graph Explorer (Cytoscape)") + + if "graph_center_id" not in st.session_state: + st.session_state.graph_center_id = None + + # Layout Defaults für COSE (besser als Physik) + st.session_state.setdefault("cy_node_repulsion", 1000000) # Starke Abstoßung + st.session_state.setdefault("cy_ideal_edge_len", 150) # Ziel-Abstand + + col_ctrl, col_graph = st.columns([1, 4]) + + # --- CONTROLS --- + with col_ctrl: + st.subheader("Fokus") + 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"): + st.session_state.graph_center_id = options[selected_title] + st.rerun() + + st.divider() + with st.expander("👁️ Layout Einstellungen", expanded=True): + st.caption("COSE Layout (Spezialisiert auf Abstand)") + st.session_state.cy_ideal_edge_len = st.slider("Kantenlänge", 50, 500, st.session_state.cy_ideal_edge_len) + st.session_state.cy_node_repulsion = st.slider("Knoten-Abstoßung", 100000, 2000000, st.session_state.cy_node_repulsion, step=100000) + + if st.button("Neu berechnen", key="cy_rerun"): + st.rerun() + + st.divider() + st.caption("Legende") + for k, v in list(GRAPH_COLORS.items())[:8]: + st.markdown(f" {k}", unsafe_allow_html=True) + + # --- GRAPH AREA --- + with col_graph: + center_id = st.session_state.graph_center_id + + if center_id: + action_container = st.container() + + # 1. Daten laden + with st.spinner("Lade Graph..."): + # Wir nutzen die bestehende Logik aus dem Service + # get_ego_graph liefert Agraph-Objekte, die wir gleich konvertieren + nodes_data, edges_data = graph_service.get_ego_graph(center_id) + + # Note Data für Editor Button + note_data = graph_service.get_note_with_full_content(center_id) + + # 2. Action Bar + with action_container: + c1, c2 = st.columns([3, 1]) + with c1: st.caption(f"Zentrum: **{center_id}**") + with c2: + if note_data: + st.button("📝 Bearbeiten", use_container_width=True, on_click=switch_to_editor_callback, args=(note_data,), key="cy_edit") + + # 3. Konvertierung zu Cytoscape JSON Format + cy_elements = [] + + # Nodes konvertieren + for n in nodes_data: + # Agraph Node -> Cytoscape Element + # Wir nutzen n.id, n.label, n.color aus dem Agraph Objekt + cy_node = { + "data": { + "id": n.id, + "label": n.label, + "color": n.color, + "size": 40 if n.id == center_id else 25, + # Tooltip Inhalt steht in n.title (aus graph_service) + "tooltip": n.title + }, + "selected": (n.id == center_id) + } + cy_elements.append(cy_node) + + # Edges konvertieren + for e in edges_data: + cy_edge = { + "data": { + "source": e.source, + "target": e.target, + "label": e.label, + "color": e.color + } + } + cy_elements.append(cy_edge) + + # 4. Stylesheet (Design) + stylesheet = [ + { + "selector": "node", + "style": { + "label": "data(label)", + "width": "data(size)", + "height": "data(size)", + "background-color": "data(color)", + "color": "#333", + "font-size": "12px", + "text-valign": "center", + "text-halign": "center", + "border-width": 2, + "border-color": "#fff", + "content": "data(label)" + } + }, + { + "selector": "node:selected", + "style": { + "border-width": 4, + "border-color": "#FF5733" + } + }, + { + "selector": "edge", + "style": { + "width": 2, + "line-color": "data(color)", + "target-arrow-color": "data(color)", + "target-arrow-shape": "triangle", + "curve-style": "bezier", + "label": "data(label)", + "font-size": "10px", + "color": "#777", + "text-background-opacity": 1, + "text-background-color": "#fff" + } + } + ] + + # 5. Rendering mit COSE Layout + # COSE ist der beste Algorithmus für "Non-Overlapping" Graphen + selected_node = cytoscape( + elements=cy_elements, + stylesheet=stylesheet, + layout={ + "name": "cose", + "idealEdgeLength": st.session_state.cy_ideal_edge_len, + "nodeOverlap": 20, + "refresh": 20, + "fit": True, + "padding": 30, + "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 + }, + key="cyto_graph_obj", + height="800px" + ) + + # Interaktion: Klick Event + # Cytoscape gibt eine Liste zurück (z.B. {'nodes': ['id1'], 'edges': []}) + if selected_node: + clicked_nodes = selected_node.get("nodes", []) + if clicked_nodes: + clicked_id = clicked_nodes[0] + if clicked_id and clicked_id != center_id: + st.session_state.graph_center_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_sidebar.py b/app/frontend/ui_sidebar.py index 4691358..c771358 100644 --- a/app/frontend/ui_sidebar.py +++ b/app/frontend/ui_sidebar.py @@ -12,7 +12,12 @@ def render_sidebar(): mode = st.radio( "Modus", - ["💬 Chat", "📝 Manueller Editor", "🕸️ Graph Explorer"], + [ + "💬 Chat", + "📝 Manueller Editor", + "🕸️ Graph (Agraph)", + "🕸️ Graph (Cytoscape)" # <-- Neuer Punkt + ], key="sidebar_mode_selection" )