diff --git a/app/frontend/ui_components.py b/app/frontend/ui_components.py index 0b318cf..742063e 100644 --- a/app/frontend/ui_components.py +++ b/app/frontend/ui_components.py @@ -5,19 +5,45 @@ from datetime import datetime from streamlit_agraph import agraph, Config from qdrant_client import models -# Importe aus den anderen Modulen from ui_utils import parse_markdown_draft, build_markdown_doc, load_history_from_logs, slugify from ui_api import save_draft_to_vault, analyze_draft_text, send_chat_message, submit_feedback from ui_config import HISTORY_FILE, COLLECTION_PREFIX, GRAPH_COLORS +# --- Helper zum Modus-Wechsel --- +def switch_to_editor(note_payload): + """Lädt eine Note in den Editor und wechselt den Tab.""" + # Wir simulieren eine Message, wie sie der Chatbot zurückgeben würde + content = note_payload.get('fulltext', '') + if not content: + # Fallback: Wir rekonstruieren minimales Markdown + content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (nur Metadaten verfügbar).") + + # State setzen für den Editor + st.session_state.messages.append({ + "role": "assistant", + "intent": "INTERVIEW", + "content": content, + "query_id": f"edit_{note_payload['note_id']}" + }) + + # Modus umschalten (muss via session_state key im Radio-Widget passieren) + st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor" + st.rerun() + def render_sidebar(): - """ - Rendert die Sidebar mit Modus-Auswahl und Verlauf. - """ with st.sidebar: st.title("🧠 mindnet") st.caption("v2.6 | WP-19 Graph View") - mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor", "🕸️ Graph Explorer"], index=0) + + # State-gebundenes Radio Widget für Modus-Wechsel + if "sidebar_mode_selection" not in st.session_state: + st.session_state["sidebar_mode_selection"] = "💬 Chat" + + mode = st.radio( + "Modus", + ["💬 Chat", "📝 Manueller Editor", "🕸️ Graph Explorer"], + key="sidebar_mode_selection" + ) st.divider() st.subheader("⚙️ Settings") @@ -270,62 +296,92 @@ def render_manual_editor(): } render_draft_editor(mock_msg) +# --- GRAPH EXPLORER (WP-19) --- + def render_graph_explorer(graph_service): - """ - Rendert den erweiterten Graph Explorer (WP-19). - """ st.header("🕸️ Graph Explorer") - # State Management für Graph Navigation - if "graph_center_id" not in st.session_state: - st.session_state.graph_center_id = None + # State Init für den Graph-Explorer + if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None + + # Defaults speichern für Persistenz während der Session (mit setdefault) + st.session_state.setdefault("graph_depth", 2) + st.session_state.setdefault("graph_show_labels", True) + st.session_state.setdefault("graph_spacing", 200) # Standard etwas höher für mehr Luft + st.session_state.setdefault("graph_gravity", -3000) col_ctrl, col_graph = st.columns([1, 4]) with col_ctrl: st.subheader("Fokus") + # 1. Suchfeld search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...") options = {} if search_term: hits, _ = graph_service.client.scroll( collection_name=f"{COLLECTION_PREFIX}_notes", - scroll_filter=models.Filter( - must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))] - ), + scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))]), limit=10 ) options = {h.payload['title']: h.payload['note_id'] for h in hits} - - if options: - selected_title = st.selectbox("Ergebnisse:", list(options.keys())) - if st.button("Laden", use_container_width=True): - st.session_state.graph_center_id = options[selected_title] - st.rerun() + + if options: + selected_title = st.selectbox("Ergebnisse:", list(options.keys())) + if st.button("Laden", use_container_width=True): + st.session_state.graph_center_id = options[selected_title] + st.rerun() st.divider() - st.caption("Legende (Wichtigste Typen)") + + # --- VIEW SETTINGS --- + with st.expander("👁️ Ansicht & Layout", expanded=True): + 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) + + if st.button("Standard wiederherstellen"): + st.session_state.graph_spacing = 200 + st.session_state.graph_gravity = -3000 + st.rerun() + + st.divider() + st.caption("Legende (Top Typen)") for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) - st.caption("Weitere Farben siehe `ui_config.py`") - - st.divider() - depth_val = st.slider("Tiefe (Tier)", 1, 3, 2, help="Level 1 = Nachbarn, Level 2 = Nachbarn der Nachbarn") - st.info("💡 Tipp: Ein Klick auf einen Knoten zentriert die Ansicht neu.") with col_graph: center_id = st.session_state.graph_center_id if center_id: - with st.spinner(f"Lade Graph für {center_id} (Tiefe {depth_val})..."): - - nodes, edges = graph_service.get_ego_graph(center_id, depth=depth_val) + # 1. Action Bar für die aktive Node + c_action1, c_action2 = st.columns([3, 1]) + with c_action1: + st.caption(f"Aktives Zentrum: **{center_id}**") + with c_action2: + # Button um die aktuelle Zentrale Note zu editieren + if st.button("📝 Bearbeiten", use_container_width=True): + note_data = graph_service._fetch_note_cached(center_id) + if note_data: + switch_to_editor(note_data) + else: + st.error("Fehler beim Laden der Daten.") + + # 2. Graph Rendern + with st.spinner(f"Lade Graph..."): + nodes, edges = graph_service.get_ego_graph( + center_id, + depth=st.session_state.graph_depth, + show_labels=st.session_state.graph_show_labels + ) if not nodes: - st.warning("Keine Daten gefunden. Vielleicht existiert die Notiz nicht mehr?") + st.warning("Keine Daten gefunden. (Notiz existiert evtl. nicht mehr)") else: - # CONFIG: ForceAtlas2Based für maximale Entzerrung config = Config( width=1000, height=800, @@ -335,30 +391,31 @@ def render_graph_explorer(graph_service): nodeHighlightBehavior=True, highlightColor="#F7A7A6", collapsible=False, - # Solver Wechsel: ForceAtlas2Based ist besser für Entzerrung + # Solver Wechsel: ForceAtlas2Based solver="forceAtlas2Based", forceAtlas2Based={ "theta": 0.5, - "gravitationalConstant": -100, # Starke Abstoßung - "centralGravity": 0.005, # Sehr schwacher Zug zur Mitte (verhindert Klumpen) + "gravitationalConstant": st.session_state.graph_gravity, # Dynamisch + "centralGravity": 0.005, "springConstant": 0.08, - "springLength": 150, # Längere Kanten + "springLength": st.session_state.graph_spacing, # Dynamisch "damping": 0.4, - "avoidOverlap": 1 # Versucht aktiv, Überlappungen zu vermeiden + "avoidOverlap": 1 }, - stabilization={ - "enabled": True, - "iterations": 1000 - } + stabilization={"enabled": True, "iterations": 800} ) - st.caption(f"Zentrum: **{center_id}** | Knoten: {len(nodes)} | Kanten: {len(edges)}") - + # Interaktion return_value = agraph(nodes=nodes, edges=edges, config=config) - # NAVIGATION LOGIK - if return_value and return_value != center_id: - st.session_state.graph_center_id = return_value - st.rerun() + if return_value: + # Wenn auf eine andere Node geklickt wurde: + if return_value != center_id: + st.session_state.graph_center_id = return_value + st.rerun() + else: + # Wenn auf die ZENTRALE Node geklickt wurde + st.toast(f"Zentrum: {return_value}") + else: - st.info("👈 Wähle links eine Notiz, um den Graphen zu starten.") \ No newline at end of file + st.info("👈 Bitte wähle links eine Notiz aus, um den Graphen zu starten.") \ No newline at end of file diff --git a/app/frontend/ui_graph_service.py b/app/frontend/ui_graph_service.py index 4177df2..5c08e94 100644 --- a/app/frontend/ui_graph_service.py +++ b/app/frontend/ui_graph_service.py @@ -11,7 +11,11 @@ class GraphExplorerService: self.edges_col = f"{prefix}_edges" self._note_cache = {} - def get_ego_graph(self, center_note_id: str, depth=2): + 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 = {} @@ -20,20 +24,18 @@ class GraphExplorerService: if not center_note: return [], [] self._add_node_to_dict(nodes_dict, center_note, level=0) - # Wir sammeln IDs für Level 2 Suche level_1_ids = {center_note_id} # Suche Kanten für Center l1_edges = self._find_connected_edges([center_note_id], center_note.get("title")) - # Verarbeite L1 Kanten for edge_data in l1_edges: src_id, tgt_id = self._process_edge(edge_data, nodes_dict, unique_edges, current_depth=1) if src_id: level_1_ids.add(src_id) if tgt_id: level_1_ids.add(tgt_id) - # Level 2 Suche (begrenzt, um Chaos zu vermeiden) - if depth > 1 and len(level_1_ids) > 1 and len(level_1_ids) < 40: # Limit für Performance + # 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: l2_edges = self._find_connected_edges_batch(l1_subset) @@ -48,9 +50,12 @@ class GraphExplorerService: 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=kind, color=color, dashes=is_smart, - title=f"Provenance: {prov}\nType: {kind}" + source=src, target=tgt, label=label_text, color=color, dashes=is_smart, + title=f"Relation: {kind}\nProvenance: {prov}" # Tooltip bleibt immer )) return list(nodes_dict.values()), final_edges @@ -69,13 +74,13 @@ class GraphExplorerService: results = [] # --- OUTGOING SEARCH (Quelle = Chunk ODER Note) --- - # FIX: Wir suchen jetzt auch nach der note_id als source_id, falls Edges direkt an der Note hängen + # 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: Pydantic "except" keyword workaround + # FIX: MatchExcept mit **kwargs nutzen wegen 'except' Keyword 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) @@ -90,7 +95,7 @@ class GraphExplorerService: if shoulds: in_f = models.Filter( - # FIX: Pydantic "except" keyword workaround + # FIX: MatchExcept mit **kwargs nutzen wegen 'except' Keyword must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))], should=shoulds ) @@ -110,6 +115,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) @@ -118,12 +124,15 @@ 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) + # Update logic: Explicit > Smart should_update = True is_current_explicit = (provenance in ["explicit", "rule"]) if existing: @@ -183,7 +192,9 @@ class GraphExplorerService: ntype = note_payload.get("type", "default") color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"]) - # Size Adjustment für Hierarchie + # HOVER TEXT: Vorschau bauen + hover_text = f"Titel: {note_payload.get('title')}\nTyp: {ntype}\nTags: {note_payload.get('tags', [])}" + if level == 0: size = 45 elif level == 1: size = 25 else: size = 15 @@ -194,6 +205,6 @@ class GraphExplorerService: size=size, color=color, shape="dot" if level > 0 else "diamond", - title=f"Type: {ntype}\nTags: {note_payload.get('tags')}", + title=hover_text, # Hover im Browser font={'color': 'black', 'face': 'arial', 'size': 14 if level < 2 else 0} ) \ No newline at end of file