From 028ad7e9412a7447d4a901bd4b7b5c3009235063 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 07:34:51 +0100 Subject: [PATCH 01/36] UI erste Version --- app/frontend/ui.py | 299 +++++++++++++++++++++++++++++++++------------ 1 file changed, 224 insertions(+), 75 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 1752718..0403c3f 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -10,6 +10,14 @@ from datetime import datetime from pathlib import Path from dotenv import load_dotenv +# --- WP-19 GRAPH IMPORTS --- +try: + from streamlit_agraph import agraph, Node, Edge, Config + from qdrant_client import QdrantClient, models +except ImportError: + st.error("Fehlende Bibliotheken! Bitte installiere: pip install streamlit-agraph qdrant-client") + st.stop() + # --- CONFIGURATION --- load_dotenv() API_BASE_URL = os.getenv("MINDNET_API_URL", "http://localhost:8002") @@ -19,17 +27,23 @@ INGEST_ANALYZE_ENDPOINT = f"{API_BASE_URL}/ingest/analyze" INGEST_SAVE_ENDPOINT = f"{API_BASE_URL}/ingest/save" HISTORY_FILE = Path("data/logs/search_history.jsonl") +# Qdrant Config (Direct Access for Graph) +QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333") +QDRANT_KEY = os.getenv("QDRANT_API_KEY", None) +if QDRANT_KEY == "": QDRANT_KEY = None +COLLECTION_PREFIX = os.getenv("COLLECTION_PREFIX", "mindnet") + # Timeout Strategy timeout_setting = os.getenv("MINDNET_API_TIMEOUT") or os.getenv("MINDNET_LLM_TIMEOUT") API_TIMEOUT = float(timeout_setting) if timeout_setting else 300.0 # --- PAGE SETUP --- -st.set_page_config(page_title="mindnet v2.5", page_icon="🧠", layout="wide") +st.set_page_config(page_title="mindnet v2.6", page_icon="🧠", layout="wide") # --- CSS STYLING --- st.markdown(""" +""", unsafe_allow_html=True) + # --- MODULE IMPORTS --- try: from ui_config import QDRANT_URL, QDRANT_KEY, COLLECTION_PREFIX from ui_graph_service import GraphExplorerService - from ui_components import render_sidebar, render_chat_interface, render_manual_editor, render_graph_explorer + + # Neue modulare 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 + except ImportError as e: - st.error(f"Import Error: {e}. Bitte stelle sicher, dass alle UI-Dateien im selben Ordner liegen.") + st.error(f"Import Error: {e}. Bitte stelle sicher, dass alle UI-Dateien im Ordner liegen.") st.stop() -# --- PAGE SETUP --- -st.set_page_config(page_title="mindnet v2.6", page_icon="🧠", layout="wide") - -# --- CSS STYLING --- -st.markdown(""" - -""", unsafe_allow_html=True) - # --- SESSION STATE --- 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()) # --- SERVICE INIT --- -# Initialisiert den Graph Service einmalig graph_service = GraphExplorerService(QDRANT_URL, QDRANT_KEY, COLLECTION_PREFIX) # --- MAIN ROUTING --- diff --git a/app/frontend/ui_callbacks.py b/app/frontend/ui_callbacks.py new file mode 100644 index 0000000..dff6cbf --- /dev/null +++ b/app/frontend/ui_callbacks.py @@ -0,0 +1,37 @@ +import streamlit as st +from ui_utils import build_markdown_doc + +def switch_to_editor_callback(note_payload): + """ + Callback für den 'Bearbeiten'-Button im Graphen. + Bereitet den Session-State vor, damit der Editor im Update-Modus startet. + """ + # 1. Inhalt extrahieren (Fulltext bevorzugt, sonst Fallback) + content = note_payload.get('fulltext', '') + if not content: + content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (nur Metadaten verfügbar).") + + # 2. Single Source of Truth: 'path' Feld (Absoluter Pfad) + origin_fname = note_payload.get('path') + + # Fallback: Falls 'path' leer ist (Legacy Daten) + if not origin_fname: + origin_fname = note_payload.get('file_path') or note_payload.get('filename') + + # Notfall-Fallback: Konstruktion aus ID + if not origin_fname and 'note_id' in note_payload: + origin_fname = f"{note_payload['note_id']}.md" + + # 3. Message in den Chat-Verlauf injecten + # Diese Nachricht dient als Datencontainer für den Editor im "Manuellen Modus" + st.session_state.messages.append({ + "role": "assistant", + "intent": "INTERVIEW", + "content": content, + "query_id": f"edit_{note_payload['note_id']}", + "origin_filename": origin_fname, + "origin_note_id": note_payload['note_id'] + }) + + # 4. Modus umschalten (erzwingt Wechsel zum Editor-Tab beim nächsten Re-Run) + st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor" \ No newline at end of file diff --git a/app/frontend/ui_chat.py b/app/frontend/ui_chat.py new file mode 100644 index 0000000..3b5c56d --- /dev/null +++ b/app/frontend/ui_chat.py @@ -0,0 +1,56 @@ +import streamlit as st +from ui_api import send_chat_message, submit_feedback +from ui_editor import render_draft_editor + +def render_chat_interface(top_k, explain): + for idx, msg in enumerate(st.session_state.messages): + with st.chat_message(msg["role"]): + if msg["role"] == "assistant": + intent = msg.get("intent", "UNKNOWN") + st.markdown(f'
Intent: {intent}
', unsafe_allow_html=True) + + with st.expander("🐞 Payload", expanded=False): + st.json(msg) + + if intent == "INTERVIEW": + render_draft_editor(msg) + else: + st.markdown(msg["content"]) + + if "sources" in msg and msg["sources"]: + for hit in msg["sources"]: + with st.expander(f"📄 {hit.get('note_id', '?')} ({hit.get('total_score', 0):.2f})"): + st.markdown(f"_{hit.get('source', {}).get('text', '')[:300]}..._") + if hit.get('explanation'): + st.caption(f"Grund: {hit['explanation']['reasons'][0]['message']}") + + def _cb(qid=msg.get("query_id"), nid=hit.get('node_id')): + val = st.session_state.get(f"fb_src_{qid}_{nid}") + if val is not None: submit_feedback(qid, nid, val+1) + st.feedback("faces", key=f"fb_src_{msg.get('query_id')}_{hit.get('node_id')}", on_change=_cb) + + if "query_id" in msg: + qid = msg["query_id"] + st.feedback("stars", key=f"fb_glob_{qid}", on_change=lambda: submit_feedback(qid, "generated_answer", st.session_state[f"fb_glob_{qid}"]+1)) + else: + st.markdown(msg["content"]) + + if prompt := st.chat_input("Frage Mindnet..."): + st.session_state.messages.append({"role": "user", "content": prompt}) + st.rerun() + + if len(st.session_state.messages) > 0 and st.session_state.messages[-1]["role"] == "user": + with st.chat_message("assistant"): + with st.spinner("Thinking..."): + resp = send_chat_message(st.session_state.messages[-1]["content"], top_k, explain) + if "error" in resp: + st.error(resp["error"]) + else: + st.session_state.messages.append({ + "role": "assistant", + "content": resp.get("answer"), + "intent": resp.get("intent", "FACT"), + "sources": resp.get("sources", []), + "query_id": resp.get("query_id") + }) + st.rerun() \ No newline at end of file diff --git a/app/frontend/ui_components.py b/app/frontend/ui_components.py deleted file mode 100644 index 7662773..0000000 --- a/app/frontend/ui_components.py +++ /dev/null @@ -1,519 +0,0 @@ -import streamlit as st -import uuid -import re -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 - -# --- CALLBACKS --- -# Diese müssen zwingend VOR dem Aufruf definiert sein. - -def switch_to_editor_callback(note_payload): - """ - Callback für den Edit-Button im Graphen. - Lädt die Notiz in den Editor und setzt den Modus. - Nutzt den 'path'-Parameter aus Qdrant als Single Source of Truth für Updates. - """ - # 1. Inhalt extrahieren (Fulltext oder Fallback) - content = note_payload.get('fulltext', '') - if not content: - content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (nur Metadaten verfügbar).") - - # 2. Single Source of Truth Bestimmung (Dateipfad) - # Priorität 1: Der absolute Pfad aus dem Ingest-Prozess ('path') - origin_fname = note_payload.get('path') - - # Priorität 2: 'file_path' oder 'filename' (Legacy Felder, Fallback) - if not origin_fname: - origin_fname = note_payload.get('file_path') or note_payload.get('filename') - - # Priorität 3: Konstruktion aus ID (Notlösung, falls Metadaten unvollständig) - if not origin_fname and 'note_id' in note_payload: - # Annahme: Datei heißt {note_id}.md im Vault Root - origin_fname = f"{note_payload['note_id']}.md" - - # 3. Message in den Chat-Verlauf injecten (dient als Datencontainer für den Editor) - # Wir fügen eine "künstliche" Assistant-Nachricht hinzu, die der Editor dann ausliest. - st.session_state.messages.append({ - "role": "assistant", - "intent": "INTERVIEW", - "content": content, - "query_id": f"edit_{note_payload['note_id']}", - "origin_filename": origin_fname, # Pfad für Speicher-Logik - "origin_note_id": note_payload['note_id'] - }) - - # 4. Modus umschalten - # Wir setzen den Key des Radio-Buttons im Session State - st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor" - -# --- UI RENDERER --- - -def render_sidebar(): - """ - Rendert die Seitenleiste mit Navigation und Einstellungen. - """ - with st.sidebar: - st.title("🧠 mindnet") - st.caption("v2.6 | WP-19 Graph View") - - # Modus-Auswahl mit State-Key für programmatische Umschaltung - 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") - top_k = st.slider("Quellen (Top-K)", 1, 10, 5) - explain = st.toggle("Explanation Layer", True) - - st.divider() - st.subheader("🕒 Verlauf") - # Suchhistorie laden - for q in load_history_from_logs(HISTORY_FILE, 8): - if st.button(f"🔎 {q[:25]}...", key=f"hist_{q}", use_container_width=True): - st.session_state.messages.append({"role": "user", "content": q}) - st.rerun() - - return mode, top_k, explain - -def render_draft_editor(msg): - """ - Der Markdown-Editor. - Wird für neue Entwürfe UND für das Bearbeiten bestehender Notizen genutzt. - """ - # ID Generierung für Unique Keys - if "query_id" not in msg or not msg["query_id"]: - msg["query_id"] = str(uuid.uuid4()) - - qid = msg["query_id"] - key_base = f"draft_{qid}" - - # State Keys definieren - data_meta_key = f"{key_base}_data_meta" - data_sugg_key = f"{key_base}_data_suggestions" - widget_body_key = f"{key_base}_widget_body" - data_body_key = f"{key_base}_data_body" - - # --- INITIALISIERUNG --- - if f"{key_base}_init" not in st.session_state: - # Markdown parsen - meta, body = parse_markdown_draft(msg["content"]) - if "type" not in meta: meta["type"] = "default" - if "title" not in meta: meta["title"] = "" - tags = meta.get("tags", []) - meta["tags_str"] = ", ".join(tags) if isinstance(tags, list) else str(tags) - - # Daten in Session State schreiben - st.session_state[data_meta_key] = meta - st.session_state[data_sugg_key] = [] - st.session_state[data_body_key] = body.strip() - - # Widget-Werte vorbelegen - st.session_state[f"{key_base}_wdg_title"] = meta["title"] - st.session_state[f"{key_base}_wdg_type"] = meta["type"] - st.session_state[f"{key_base}_wdg_tags"] = meta["tags_str"] - - # WICHTIG: Original-Pfad aus der Message übernehmen (für Update-Logik) - st.session_state[f"{key_base}_origin_filename"] = msg.get("origin_filename") - - st.session_state[f"{key_base}_init"] = True - - # --- STATE RESURRECTION (falls Streamlit rerunt) --- - if widget_body_key not in st.session_state and data_body_key in st.session_state: - st.session_state[widget_body_key] = st.session_state[data_body_key] - - # --- SYNC FUNKTIONEN --- - def _sync_meta(): - meta = st.session_state[data_meta_key] - meta["title"] = st.session_state.get(f"{key_base}_wdg_title", "") - meta["type"] = st.session_state.get(f"{key_base}_wdg_type", "default") - meta["tags_str"] = st.session_state.get(f"{key_base}_wdg_tags", "") - st.session_state[data_meta_key] = meta - - def _sync_body(): - st.session_state[data_body_key] = st.session_state[widget_body_key] - - def _insert_text(text_to_insert): - current = st.session_state.get(widget_body_key, "") - new_text = f"{current}\n\n{text_to_insert}" - st.session_state[widget_body_key] = new_text - st.session_state[data_body_key] = new_text - - def _remove_text(text_to_remove): - current = st.session_state.get(widget_body_key, "") - new_text = current.replace(text_to_remove, "").strip() - st.session_state[widget_body_key] = new_text - st.session_state[data_body_key] = new_text - - # --- LAYOUT HEADER --- - origin_fname = st.session_state.get(f"{key_base}_origin_filename") - - if origin_fname: - # Update Modus - display_name = str(origin_fname).split("/")[-1] # Nur Dateiname anzeigen - st.success(f"📂 **Datei-Modus**: `{origin_fname}`") # Voller Pfad zur Sicherheit - st.markdown(f'
', unsafe_allow_html=True) - else: - # Create Modus - st.info("✨ **Erstell-Modus**: Neue Datei wird angelegt.") - st.markdown(f'
', unsafe_allow_html=True) - - st.markdown("### Editor") - - # Metadaten-Editor - meta_ref = st.session_state[data_meta_key] - c1, c2 = st.columns([2, 1]) - with c1: - st.text_input("Titel", key=f"{key_base}_wdg_title", on_change=_sync_meta) - with c2: - known_types = ["concept", "project", "decision", "experience", "journal", "value", "goal", "principle", "risk", "belief"] - curr_type = st.session_state.get(f"{key_base}_wdg_type", meta_ref["type"]) - if curr_type not in known_types: known_types.append(curr_type) - st.selectbox("Typ", known_types, key=f"{key_base}_wdg_type", on_change=_sync_meta) - - st.text_input("Tags", key=f"{key_base}_wdg_tags", on_change=_sync_meta) - - # Tabs - tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"]) - - with tab_edit: - st.text_area("Body", key=widget_body_key, height=600, on_change=_sync_body, label_visibility="collapsed") - - with tab_intel: - st.info("Klicke auf 'Analysieren', um Verknüpfungen für den AKTUELLEN Text zu finden.") - - if st.button("🔍 Analyse starten", key=f"{key_base}_analyze"): - st.session_state[data_sugg_key] = [] - text_to_analyze = st.session_state.get(widget_body_key, st.session_state.get(data_body_key, "")) - current_doc_type = st.session_state.get(f"{key_base}_wdg_type", "concept") - - with st.spinner("Analysiere..."): - analysis = analyze_draft_text(text_to_analyze, current_doc_type) - if "error" in analysis: - st.error(f"Fehler: {analysis['error']}") - else: - suggestions = analysis.get("suggestions", []) - st.session_state[data_sugg_key] = suggestions - if not suggestions: st.warning("Keine Vorschläge gefunden.") - else: st.success(f"{len(suggestions)} Vorschläge gefunden.") - - # Vorschläge rendern - suggestions = st.session_state[data_sugg_key] - if suggestions: - current_text_state = st.session_state.get(widget_body_key, "") - for idx, sugg in enumerate(suggestions): - link_text = sugg.get('suggested_markdown', '') - is_inserted = link_text in current_text_state - - bg_color = "#e6fffa" if is_inserted else "#ffffff" - border = "3px solid #28a745" if is_inserted else "3px solid #1a73e8" - - st.markdown(f""" -
- {sugg.get('target_title')} ({sugg.get('type')})
- {sugg.get('reason')}
- {link_text} -
- """, unsafe_allow_html=True) - - if is_inserted: - st.button("❌ Entfernen", key=f"del_{idx}_{key_base}", on_click=_remove_text, args=(link_text,)) - else: - st.button("➕ Einfügen", key=f"add_{idx}_{key_base}", on_click=_insert_text, args=(link_text,)) - - # Dokument für Vorschau/Save bauen - final_tags_str = st.session_state.get(f"{key_base}_wdg_tags", "") - final_tags = [t.strip() for t in final_tags_str.split(",") if t.strip()] - - final_meta = { - "id": "generated_on_save", - "type": st.session_state.get(f"{key_base}_wdg_type", "default"), - "title": st.session_state.get(f"{key_base}_wdg_title", "").strip(), - "status": "draft", - "tags": final_tags - } - - # ID behalten wenn vorhanden (Wichtig für Source of Truth) - if "origin_note_id" in msg: - final_meta["id"] = msg["origin_note_id"] - - final_body = st.session_state.get(widget_body_key, st.session_state[data_body_key]) - - # Fallback Titel aus H1 - if not final_meta["title"]: - h1_match = re.search(r"^#\s+(.*)$", final_body, re.MULTILINE) - if h1_match: final_meta["title"] = h1_match.group(1).strip() - - final_doc = build_markdown_doc(final_meta, final_body) - - with tab_view: - st.markdown('
', unsafe_allow_html=True) - st.markdown(final_doc) - st.markdown('
', unsafe_allow_html=True) - - st.markdown("---") - - # Footer Actions - b1, b2 = st.columns([1, 1]) - with b1: - # Label dynamisch - save_label = "💾 Speichern (Überschreiben)" if origin_fname else "💾 Neu anlegen & Indizieren" - - if st.button(save_label, type="primary", key=f"{key_base}_save"): - with st.spinner("Speichere im Vault..."): - - # ENTSCHEIDUNG: Update oder Neu? - if origin_fname: - # UPDATE: Wir nutzen den exakten Pfad aus Qdrant - target_filename = origin_fname - else: - # NEU: Wir generieren einen Namen - raw_title = final_meta.get("title", "draft") - target_file = f"{datetime.now().strftime('%Y%m%d')}-{slugify(raw_title)[:60]}.md" - target_filename = target_file - - # Senden an API - result = save_draft_to_vault(final_doc, filename=target_filename) - - if "error" in result: - st.error(f"Fehler: {result['error']}") - else: - st.success(f"Gespeichert: {result.get('file_path')}") - st.balloons() - - with b2: - if st.button("📋 Code anzeigen", key=f"{key_base}_btn_copy"): - st.code(final_doc, language="markdown") - - st.markdown("
", unsafe_allow_html=True) - -def render_chat_interface(top_k, explain): - """ - Rendert das Chat-Interface. - """ - for idx, msg in enumerate(st.session_state.messages): - with st.chat_message(msg["role"]): - if msg["role"] == "assistant": - intent = msg.get("intent", "UNKNOWN") - src = msg.get("intent_source", "?") - icon = {"EMPATHY":"❤️", "DECISION":"⚖️", "CODING":"💻", "FACT":"📚", "INTERVIEW":"📝"}.get(intent, "🧠") - st.markdown(f'
{icon} Intent: {intent} ({src})
', unsafe_allow_html=True) - - with st.expander("🐞 Debug Raw Payload", expanded=False): - st.json(msg) - - if intent == "INTERVIEW": - render_draft_editor(msg) - else: - st.markdown(msg["content"]) - - if "sources" in msg and msg["sources"]: - for hit in msg["sources"]: - with st.expander(f"📄 {hit.get('note_id', '?')} ({hit.get('total_score', 0):.2f})"): - st.markdown(f"_{hit.get('source', {}).get('text', '')[:300]}..._") - if hit.get('explanation'): - st.caption(f"Grund: {hit['explanation']['reasons'][0]['message']}") - - def _cb(qid=msg.get("query_id"), nid=hit.get('node_id')): - val = st.session_state.get(f"fb_src_{qid}_{nid}") - if val is not None: submit_feedback(qid, nid, val+1) - st.feedback("faces", key=f"fb_src_{msg.get('query_id')}_{hit.get('node_id')}", on_change=_cb) - - if "query_id" in msg: - qid = msg["query_id"] - st.feedback("stars", key=f"fb_glob_{qid}", on_change=lambda: submit_feedback(qid, "generated_answer", st.session_state[f"fb_glob_{qid}"]+1)) - else: - st.markdown(msg["content"]) - - if prompt := st.chat_input("Frage Mindnet..."): - st.session_state.messages.append({"role": "user", "content": prompt}) - st.rerun() - - if len(st.session_state.messages) > 0 and st.session_state.messages[-1]["role"] == "user": - with st.chat_message("assistant"): - with st.spinner("Thinking..."): - resp = send_chat_message(st.session_state.messages[-1]["content"], top_k, explain) - if "error" in resp: - st.error(resp["error"]) - else: - st.session_state.messages.append({ - "role": "assistant", - "content": resp.get("answer"), - "intent": resp.get("intent", "FACT"), - "intent_source": resp.get("intent_source", "Unknown"), - "sources": resp.get("sources", []), - "query_id": resp.get("query_id") - }) - st.rerun() - -def render_manual_editor(): - """ - Wrapper für den manuellen Editor-Modus (Startet mit leerem Template). - """ - mock_msg = { - "content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n", - "query_id": "manual_mode_v2" - } - render_draft_editor(mock_msg) - -# --- GRAPH EXPLORER --- - -def render_graph_explorer(graph_service): - st.header("🕸️ Graph Explorer") - - # Session State initialisieren - if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - - # Defaults speichern für Persistenz - st.session_state.setdefault("graph_depth", 2) - st.session_state.setdefault("graph_show_labels", True) - # Defaults angepasst für BarnesHut (Skalierung angepasst) - st.session_state.setdefault("graph_spacing", 150) - st.session_state.setdefault("graph_gravity", -3000) - - col_ctrl, col_graph = st.columns([1, 4]) - - with col_ctrl: - st.subheader("Fokus") - - # Suche - 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))]), - 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() - - st.divider() - - # 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("**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("Reset Layout"): - st.session_state.graph_spacing = 150 - 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) - - with col_graph: - center_id = st.session_state.graph_center_id - - if center_id: - # Container für Action Bar OBERHALB des Graphen (Layout Fix) - action_container = st.container() - - # Graph Laden - with st.spinner(f"Lade Graph..."): - # Daten laden (Cache wird genutzt) - nodes, edges = graph_service.get_ego_graph( - center_id, - depth=st.session_state.graph_depth, - show_labels=st.session_state.graph_show_labels - ) - - # Fetch Note Data für Button & Debug - note_data = graph_service._fetch_note_cached(center_id) - - # --- ACTION BAR RENDEREN --- - with action_container: - c_act1, c_act2 = st.columns([3, 1]) - with c_act1: - st.caption(f"Aktives Zentrum: **{center_id}**") - with c_act2: - if note_data: - st.button("📝 Bearbeiten", - use_container_width=True, - on_click=switch_to_editor_callback, - args=(note_data,)) - else: - st.error("Daten nicht verfügbar") - - # DATA INSPECTOR (Payload Debug) - with st.expander("🕵️ Data Inspector (Payload Debug)", expanded=False): - if note_data: - st.json(note_data) - if 'path' not in note_data: - st.error("ACHTUNG: Feld 'path' fehlt im Qdrant-Payload! Update-Modus wird nicht funktionieren.") - else: - st.success(f"Pfad gefunden: {note_data['path']}") - else: - st.info("Keine Daten geladen.") - - if not nodes: - st.warning("Keine Daten gefunden.") - else: - # --- CONFIGURATION (BarnesHut) --- - # Height-Trick für Re-Render (da key-Parameter nicht funktioniert) - # Ändere Height minimal basierend auf Gravity - dyn_height = 800 + (abs(st.session_state.graph_gravity) % 3) - - config = Config( - width=1000, - height=dyn_height, - directed=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 - ) - - return_value = agraph(nodes=nodes, edges=edges, config=config) - - # Interaktions-Logik - if return_value: - if return_value != center_id: - # Navigation: Neues Zentrum setzen - st.session_state.graph_center_id = return_value - st.rerun() - else: - # Klick auf das Zentrum selbst - st.toast(f"Zentrum: {return_value}") - - else: - st.info("👈 Bitte wähle links eine Notiz aus.") \ No newline at end of file diff --git a/app/frontend/ui_editor.py b/app/frontend/ui_editor.py new file mode 100644 index 0000000..43acedc --- /dev/null +++ b/app/frontend/ui_editor.py @@ -0,0 +1,189 @@ +import streamlit as st +import uuid +import re +from datetime import datetime + +from ui_utils import parse_markdown_draft, build_markdown_doc, slugify +from ui_api import save_draft_to_vault, analyze_draft_text + +def render_draft_editor(msg): + """ + Rendert den Markdown-Editor. + Nutzt 'origin_filename' aus der Message, um zwischen Update und Neu zu unterscheiden. + """ + if "query_id" not in msg or not msg["query_id"]: + msg["query_id"] = str(uuid.uuid4()) + + qid = msg["query_id"] + key_base = f"draft_{qid}" + + # State Keys + data_meta_key = f"{key_base}_data_meta" + data_sugg_key = f"{key_base}_data_suggestions" + widget_body_key = f"{key_base}_widget_body" + data_body_key = f"{key_base}_data_body" + + # --- INIT STATE --- + if f"{key_base}_init" not in st.session_state: + meta, body = parse_markdown_draft(msg["content"]) + if "type" not in meta: meta["type"] = "default" + if "title" not in meta: meta["title"] = "" + tags = meta.get("tags", []) + meta["tags_str"] = ", ".join(tags) if isinstance(tags, list) else str(tags) + + st.session_state[data_meta_key] = meta + st.session_state[data_sugg_key] = [] + st.session_state[data_body_key] = body.strip() + + st.session_state[f"{key_base}_wdg_title"] = meta["title"] + st.session_state[f"{key_base}_wdg_type"] = meta["type"] + st.session_state[f"{key_base}_wdg_tags"] = meta["tags_str"] + + # Pfad übernehmen (Source of Truth) + st.session_state[f"{key_base}_origin_filename"] = msg.get("origin_filename") + st.session_state[f"{key_base}_init"] = True + + # --- RESURRECTION --- + if widget_body_key not in st.session_state and data_body_key in st.session_state: + st.session_state[widget_body_key] = st.session_state[data_body_key] + + # --- SYNC HELPER --- + def _sync_meta(): + meta = st.session_state[data_meta_key] + meta["title"] = st.session_state.get(f"{key_base}_wdg_title", "") + meta["type"] = st.session_state.get(f"{key_base}_wdg_type", "default") + meta["tags_str"] = st.session_state.get(f"{key_base}_wdg_tags", "") + st.session_state[data_meta_key] = meta + + def _sync_body(): + st.session_state[data_body_key] = st.session_state[widget_body_key] + + def _insert_text(t): + st.session_state[widget_body_key] = f"{st.session_state.get(widget_body_key, '')}\n\n{t}" + st.session_state[data_body_key] = st.session_state[widget_body_key] + + def _remove_text(t): + st.session_state[widget_body_key] = st.session_state.get(widget_body_key, '').replace(t, "").strip() + st.session_state[data_body_key] = st.session_state[widget_body_key] + + # --- UI LAYOUT --- + + # Header Info + origin_fname = st.session_state.get(f"{key_base}_origin_filename") + if origin_fname: + display_name = str(origin_fname).split("/")[-1] + st.success(f"📂 **Update-Modus**: `{origin_fname}`") + st.markdown(f'
', unsafe_allow_html=True) + else: + st.info("✨ **Erstell-Modus**: Neue Datei wird angelegt.") + st.markdown(f'
', unsafe_allow_html=True) + + st.markdown("### Editor") + + # Meta Felder + meta_ref = st.session_state[data_meta_key] + c1, c2 = st.columns([2, 1]) + with c1: + st.text_input("Titel", key=f"{key_base}_wdg_title", on_change=_sync_meta) + with c2: + known_types = ["concept", "project", "decision", "experience", "journal", "value", "goal", "principle", "risk", "belief"] + curr_type = st.session_state.get(f"{key_base}_wdg_type", meta_ref["type"]) + if curr_type not in known_types: known_types.append(curr_type) + st.selectbox("Typ", known_types, key=f"{key_base}_wdg_type", on_change=_sync_meta) + + st.text_input("Tags", key=f"{key_base}_wdg_tags", on_change=_sync_meta) + + # Tabs + tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"]) + + with tab_edit: + st.text_area("Body", key=widget_body_key, height=600, on_change=_sync_body, label_visibility="collapsed") + + with tab_intel: + st.info("Analysiert den Text auf Verknüpfungsmöglichkeiten.") + if st.button("🔍 Analyse starten", key=f"{key_base}_analyze"): + st.session_state[data_sugg_key] = [] + text_to_analyze = st.session_state.get(widget_body_key, st.session_state.get(data_body_key, "")) + with st.spinner("Analysiere..."): + analysis = analyze_draft_text(text_to_analyze, st.session_state.get(f"{key_base}_wdg_type", "concept")) + if "error" in analysis: + st.error(f"Fehler: {analysis['error']}") + else: + suggestions = analysis.get("suggestions", []) + st.session_state[data_sugg_key] = suggestions + if not suggestions: st.warning("Keine Vorschläge.") + else: st.success(f"{len(suggestions)} Vorschläge gefunden.") + + suggestions = st.session_state[data_sugg_key] + if suggestions: + current_text = st.session_state.get(widget_body_key, "") + for idx, sugg in enumerate(suggestions): + link_text = sugg.get('suggested_markdown', '') + is_inserted = link_text in current_text + bg_color = "#e6fffa" if is_inserted else "#ffffff" + border = "3px solid #28a745" if is_inserted else "3px solid #1a73e8" + st.markdown(f"
{sugg.get('target_title')} ({sugg.get('type')})
{sugg.get('reason')}
{link_text}
", unsafe_allow_html=True) + if is_inserted: + st.button("❌ Entfernen", key=f"del_{idx}_{key_base}", on_click=_remove_text, args=(link_text,)) + else: + st.button("➕ Einfügen", key=f"add_{idx}_{key_base}", on_click=_insert_text, args=(link_text,)) + + # Save Logic Preparation + final_tags = [t.strip() for t in st.session_state.get(f"{key_base}_wdg_tags", "").split(",") if t.strip()] + final_meta = { + "id": "generated_on_save", + "type": st.session_state.get(f"{key_base}_wdg_type", "default"), + "title": st.session_state.get(f"{key_base}_wdg_title", "").strip(), + "status": "draft", + "tags": final_tags + } + if "origin_note_id" in msg: + final_meta["id"] = msg["origin_note_id"] + + final_body = st.session_state.get(widget_body_key, st.session_state[data_body_key]) + if not final_meta["title"]: + h1_match = re.search(r"^#\s+(.*)$", final_body, re.MULTILINE) + if h1_match: final_meta["title"] = h1_match.group(1).strip() + + final_doc = build_markdown_doc(final_meta, final_body) + + with tab_view: + st.markdown('
', unsafe_allow_html=True) + st.markdown(final_doc) + st.markdown('
', unsafe_allow_html=True) + + st.markdown("---") + + # Save Actions + b1, b2 = st.columns([1, 1]) + with b1: + save_label = "💾 Update speichern" if origin_fname else "💾 Neu anlegen & Indizieren" + + if st.button(save_label, type="primary", key=f"{key_base}_save"): + with st.spinner("Speichere im Vault..."): + if origin_fname: + # UPDATE: Ziel ist der exakte Pfad + target_file = origin_fname + else: + # CREATE: Neuer Dateiname + raw_title = final_meta.get("title", "draft") + target_file = f"{datetime.now().strftime('%Y%m%d')}-{slugify(raw_title)[:60]}.md" + + result = save_draft_to_vault(final_doc, filename=target_file) + if "error" in result: + st.error(f"Fehler: {result['error']}") + else: + st.success(f"Gespeichert: {result.get('file_path')}") + st.balloons() + with b2: + if st.button("📋 Code anzeigen", key=f"{key_base}_btn_copy"): + st.code(final_doc, language="markdown") + + st.markdown("
", unsafe_allow_html=True) + +def render_manual_editor(): + mock_msg = { + "content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n", + "query_id": "manual_mode_v2" + } + render_draft_editor(mock_msg) \ No newline at end of file diff --git a/app/frontend/ui_graph.py b/app/frontend/ui_graph.py new file mode 100644 index 0000000..6d438e3 --- /dev/null +++ b/app/frontend/ui_graph.py @@ -0,0 +1,126 @@ +import streamlit as st +from streamlit_agraph import agraph, Config +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(graph_service): + st.header("🕸️ Graph Explorer") + + if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None + + # Defaults + st.session_state.setdefault("graph_depth", 2) + st.session_state.setdefault("graph_show_labels", True) + st.session_state.setdefault("graph_spacing", 150) + st.session_state.setdefault("graph_gravity", -3000) + + col_ctrl, col_graph = st.columns([1, 4]) + + with col_ctrl: + st.subheader("Fokus") + search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...") + + 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))]), + 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() + + st.divider() + 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("**Physik (BarnesHut)**") + st.session_state.graph_spacing = st.slider("Federlänge", 50, 500, st.session_state.graph_spacing) + st.session_state.graph_gravity = st.slider("Abstoßung", -20000, -500, st.session_state.graph_gravity) + + if st.button("Reset Layout"): + st.session_state.graph_spacing = 150 + 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) + + with col_graph: + center_id = st.session_state.graph_center_id + + if center_id: + # Action Container oben + action_container = st.container() + + 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 + ) + note_data = graph_service._fetch_note_cached(center_id) + + # Action Bar rendern + with action_container: + c1, c2 = st.columns([3, 1]) + with c1: st.caption(f"Aktives Zentrum: **{center_id}**") + with c2: + if note_data: + st.button("📝 Bearbeiten", use_container_width=True, on_click=switch_to_editor_callback, args=(note_data,)) + else: + st.error("Datenfehler") + + with st.expander("🕵️ Data Inspector", expanded=False): + if note_data: + st.json(note_data) + if 'path' in note_data: st.success(f"Pfad OK: {note_data['path']}") + else: st.error("Pfad fehlt!") + else: st.info("Leer.") + + if not nodes: + st.warning("Keine Daten gefunden.") + else: + # Physik Config für BarnesHut + Height-Trick + dyn_height = 800 + (abs(st.session_state.graph_gravity) % 5) + + config = Config( + width=1000, + height=dyn_height, + directed=True, + physics={ + "enabled": True, + "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 + ) + + return_value = agraph(nodes=nodes, edges=edges, config=config) + + if return_value: + if return_value != center_id: + st.session_state.graph_center_id = return_value + st.rerun() + else: + st.toast(f"Zentrum: {return_value}") + 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 new file mode 100644 index 0000000..4691358 --- /dev/null +++ b/app/frontend/ui_sidebar.py @@ -0,0 +1,31 @@ +import streamlit as st +from ui_utils import load_history_from_logs +from ui_config import HISTORY_FILE + +def render_sidebar(): + with st.sidebar: + st.title("🧠 mindnet") + st.caption("v2.6 | WP-19 Graph View") + + 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") + top_k = st.slider("Quellen (Top-K)", 1, 10, 5) + explain = st.toggle("Explanation Layer", True) + + st.divider() + st.subheader("🕒 Verlauf") + for q in load_history_from_logs(HISTORY_FILE, 8): + if st.button(f"🔎 {q[:25]}...", key=f"hist_{q}", use_container_width=True): + st.session_state.messages.append({"role": "user", "content": q}) + st.rerun() + + return mode, top_k, explain \ No newline at end of file From 053c22bc156dc2bdaec6acee651b98e0167c25ac Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 12:55:13 +0100 Subject: [PATCH 16/36] editor update --- app/frontend/ui_callbacks.py | 4 ++-- app/frontend/ui_editor.py | 38 +++++++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/app/frontend/ui_callbacks.py b/app/frontend/ui_callbacks.py index dff6cbf..93c2d6e 100644 --- a/app/frontend/ui_callbacks.py +++ b/app/frontend/ui_callbacks.py @@ -23,12 +23,12 @@ def switch_to_editor_callback(note_payload): origin_fname = f"{note_payload['note_id']}.md" # 3. Message in den Chat-Verlauf injecten - # Diese Nachricht dient als Datencontainer für den Editor im "Manuellen Modus" + # WICHTIG: query_id muss mit 'edit_' beginnen, damit render_manual_editor sie erkennt! st.session_state.messages.append({ "role": "assistant", "intent": "INTERVIEW", "content": content, - "query_id": f"edit_{note_payload['note_id']}", + "query_id": f"edit_{note_payload['note_id']}", # Trigger für Erkennung "origin_filename": origin_fname, "origin_note_id": note_payload['note_id'] }) diff --git a/app/frontend/ui_editor.py b/app/frontend/ui_editor.py index 43acedc..29e37bd 100644 --- a/app/frontend/ui_editor.py +++ b/app/frontend/ui_editor.py @@ -68,11 +68,16 @@ def render_draft_editor(msg): # --- UI LAYOUT --- - # Header Info + # Header Info (Debug Pfad anzeigen, damit wir sicher sind) origin_fname = st.session_state.get(f"{key_base}_origin_filename") + if origin_fname: + # Dateiname extrahieren für saubere Anzeige display_name = str(origin_fname).split("/")[-1] - st.success(f"📂 **Update-Modus**: `{origin_fname}`") + st.success(f"📂 **Update-Modus**: `{display_name}`") + # Debugging: Zeige vollen Pfad im Tooltip oder klein darunter + with st.expander("Pfad-Details", expanded=False): + st.code(origin_fname) st.markdown(f'
', unsafe_allow_html=True) else: st.info("✨ **Erstell-Modus**: Neue Datei wird angelegt.") @@ -182,8 +187,27 @@ def render_draft_editor(msg): st.markdown("
", unsafe_allow_html=True) def render_manual_editor(): - mock_msg = { - "content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n", - "query_id": "manual_mode_v2" - } - render_draft_editor(mock_msg) \ No newline at end of file + """ + Rendert den manuellen Editor. + PRÜFT, ob eine Edit-Anfrage aus dem Graphen vorliegt! + """ + + target_msg = None + + # 1. Prüfen: Gibt es Nachrichten im Verlauf? + if st.session_state.messages: + last_msg = st.session_state.messages[-1] + + # 2. Ist die letzte Nachricht eine Edit-Anfrage? (Erkennbar am query_id prefix 'edit_') + qid = str(last_msg.get("query_id", "")) + if qid.startswith("edit_"): + target_msg = last_msg + + # 3. Fallback: Leeres Template, falls keine Edit-Anfrage vorliegt + if not target_msg: + target_msg = { + "content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n", + "query_id": f"manual_{uuid.uuid4()}" # Eigene ID, damit neuer State entsteht + } + + render_draft_editor(target_msg) \ No newline at end of file From 3861246ac695ed78eb07de86a82afd02de07138e Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 13:23:16 +0100 Subject: [PATCH 17/36] bug fix --- app/frontend/ui_callbacks.py | 52 +++++++++++++++++++++--------- app/frontend/ui_chat.py | 24 +++++++++++++- app/frontend/ui_editor.py | 4 +-- app/frontend/ui_graph.py | 61 ++++++++++++++++++++++++++---------- 4 files changed, 108 insertions(+), 33 deletions(-) diff --git a/app/frontend/ui_callbacks.py b/app/frontend/ui_callbacks.py index 93c2d6e..b9bf955 100644 --- a/app/frontend/ui_callbacks.py +++ b/app/frontend/ui_callbacks.py @@ -1,37 +1,61 @@ import streamlit as st +import os from ui_utils import build_markdown_doc def switch_to_editor_callback(note_payload): """ Callback für den 'Bearbeiten'-Button im Graphen. - Bereitet den Session-State vor, damit der Editor im Update-Modus startet. + Versucht, die Datei direkt aus dem Vault (Dateisystem) zu lesen. + Das garantiert, dass Frontmatter und Inhalt vollständig sind (Single Source of Truth). """ - # 1. Inhalt extrahieren (Fulltext bevorzugt, sonst Fallback) - content = note_payload.get('fulltext', '') - if not content: - content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (nur Metadaten verfügbar).") - - # 2. Single Source of Truth: 'path' Feld (Absoluter Pfad) + # 1. Pfad ermitteln (Priorität auf 'path' aus Qdrant) origin_fname = note_payload.get('path') - # Fallback: Falls 'path' leer ist (Legacy Daten) + # Fallback für Legacy-Datenfelder if not origin_fname: origin_fname = note_payload.get('file_path') or note_payload.get('filename') - # Notfall-Fallback: Konstruktion aus ID + content = "" + file_loaded = False + + # 2. Versuch: Direkt von der Festplatte lesen + # Wir prüfen, ob der Pfad existiert und lesen den aktuellen Stand der Datei. + if origin_fname and os.path.exists(origin_fname): + try: + with open(origin_fname, "r", encoding="utf-8") as f: + content = f.read() + file_loaded = True + except Exception as e: + # Fehler im Terminal loggen, aber UI nicht crashen lassen + print(f"Fehler beim Lesen von {origin_fname}: {e}") + + # 3. Fallback: Inhalt aus Qdrant nehmen (wenn Datei nicht zugreifbar) + if not file_loaded: + # Wir nehmen 'fulltext' aus dem Payload + content = note_payload.get('fulltext', '') + + if not content: + # Letzter Ausweg: Metadaten nehmen und Dummy-Content bauen + content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (Datei nicht gefunden).") + else: + # Check: Hat der Text ein Frontmatter? Wenn nein, rekonstruieren wir es. + if not content.strip().startswith("---"): + content = build_markdown_doc(note_payload, content) + + # Notfall-Pfad Konstruktion (falls gar kein Pfad im System ist) if not origin_fname and 'note_id' in note_payload: origin_fname = f"{note_payload['note_id']}.md" - # 3. Message in den Chat-Verlauf injecten - # WICHTIG: query_id muss mit 'edit_' beginnen, damit render_manual_editor sie erkennt! + # 4. Daten an den Editor übergeben + # Wir nutzen den Chat-Verlauf als Transportmittel für den State st.session_state.messages.append({ "role": "assistant", "intent": "INTERVIEW", "content": content, - "query_id": f"edit_{note_payload['note_id']}", # Trigger für Erkennung + "query_id": f"edit_{note_payload.get('note_id', 'unknown')}", # Trigger für den Editor "origin_filename": origin_fname, - "origin_note_id": note_payload['note_id'] + "origin_note_id": note_payload.get('note_id') }) - # 4. Modus umschalten (erzwingt Wechsel zum Editor-Tab beim nächsten Re-Run) + # 5. Modus umschalten (wechselt den Tab beim nächsten Rerun) st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor" \ No newline at end of file diff --git a/app/frontend/ui_chat.py b/app/frontend/ui_chat.py index 3b5c56d..31b552a 100644 --- a/app/frontend/ui_chat.py +++ b/app/frontend/ui_chat.py @@ -3,46 +3,68 @@ from ui_api import send_chat_message, submit_feedback from ui_editor import render_draft_editor def render_chat_interface(top_k, explain): + """ + Rendert das Chat-Interface. + Zeigt Nachrichten an und behandelt User-Input. + """ + # 1. Verlauf anzeigen for idx, msg in enumerate(st.session_state.messages): with st.chat_message(msg["role"]): if msg["role"] == "assistant": + # Intent Badge intent = msg.get("intent", "UNKNOWN") st.markdown(f'
Intent: {intent}
', unsafe_allow_html=True) + # Debugging (optional, gut für Entwicklung) with st.expander("🐞 Payload", expanded=False): st.json(msg) + # Unterscheidung: Normaler Text oder Editor-Modus (Interview) if intent == "INTERVIEW": render_draft_editor(msg) else: st.markdown(msg["content"]) + # Quellen anzeigen if "sources" in msg and msg["sources"]: for hit in msg["sources"]: - with st.expander(f"📄 {hit.get('note_id', '?')} ({hit.get('total_score', 0):.2f})"): + score = hit.get('total_score', 0) + # Wenn score None ist, 0.0 annehmen + if score is None: score = 0.0 + + with st.expander(f"📄 {hit.get('note_id', '?')} ({score:.2f})"): st.markdown(f"_{hit.get('source', {}).get('text', '')[:300]}..._") + + # Explanation Layer if hit.get('explanation'): st.caption(f"Grund: {hit['explanation']['reasons'][0]['message']}") + # Feedback Buttons pro Source def _cb(qid=msg.get("query_id"), nid=hit.get('node_id')): val = st.session_state.get(f"fb_src_{qid}_{nid}") if val is not None: submit_feedback(qid, nid, val+1) + st.feedback("faces", key=f"fb_src_{msg.get('query_id')}_{hit.get('node_id')}", on_change=_cb) + # Globales Feedback für die Antwort if "query_id" in msg: qid = msg["query_id"] st.feedback("stars", key=f"fb_glob_{qid}", on_change=lambda: submit_feedback(qid, "generated_answer", st.session_state[f"fb_glob_{qid}"]+1)) else: + # User Nachricht st.markdown(msg["content"]) + # 2. Input Feld if prompt := st.chat_input("Frage Mindnet..."): st.session_state.messages.append({"role": "user", "content": prompt}) st.rerun() + # 3. Antwort generieren (wenn letzte Nachricht vom User ist) if len(st.session_state.messages) > 0 and st.session_state.messages[-1]["role"] == "user": with st.chat_message("assistant"): with st.spinner("Thinking..."): resp = send_chat_message(st.session_state.messages[-1]["content"], top_k, explain) + if "error" in resp: st.error(resp["error"]) else: diff --git a/app/frontend/ui_editor.py b/app/frontend/ui_editor.py index 29e37bd..0dd2f80 100644 --- a/app/frontend/ui_editor.py +++ b/app/frontend/ui_editor.py @@ -75,8 +75,8 @@ def render_draft_editor(msg): # Dateiname extrahieren für saubere Anzeige display_name = str(origin_fname).split("/")[-1] st.success(f"📂 **Update-Modus**: `{display_name}`") - # Debugging: Zeige vollen Pfad im Tooltip oder klein darunter - with st.expander("Pfad-Details", expanded=False): + # Debugging: Zeige vollen Pfad im Expander + with st.expander("Dateipfad Details", expanded=False): st.code(origin_fname) st.markdown(f'
', unsafe_allow_html=True) else: diff --git a/app/frontend/ui_graph.py b/app/frontend/ui_graph.py index 6d438e3..3db5989 100644 --- a/app/frontend/ui_graph.py +++ b/app/frontend/ui_graph.py @@ -7,11 +7,13 @@ from ui_callbacks import switch_to_editor_callback def render_graph_explorer(graph_service): st.header("🕸️ Graph Explorer") + # Session State initialisieren if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Defaults + # Defaults speichern für Persistenz st.session_state.setdefault("graph_depth", 2) st.session_state.setdefault("graph_show_labels", True) + # Defaults angepasst für BarnesHut (andere Skala!) st.session_state.setdefault("graph_spacing", 150) st.session_state.setdefault("graph_gravity", -3000) @@ -19,8 +21,11 @@ def render_graph_explorer(graph_service): with col_ctrl: st.subheader("Fokus") + + # Suche 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", @@ -28,6 +33,7 @@ def render_graph_explorer(graph_service): 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): @@ -35,13 +41,16 @@ def render_graph_explorer(graph_service): st.rerun() st.divider() + + # 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("**Physik (BarnesHut)**") - st.session_state.graph_spacing = st.slider("Federlänge", 50, 500, st.session_state.graph_spacing) - st.session_state.graph_gravity = st.slider("Abstoßung", -20000, -500, st.session_state.graph_gravity) + # 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("Reset Layout"): st.session_state.graph_spacing = 150 @@ -57,46 +66,62 @@ def render_graph_explorer(graph_service): center_id = st.session_state.graph_center_id if center_id: - # Action Container oben + # Container für Action Bar OBERHALB des Graphen (Layout Fix) action_container = st.container() + # Graph Laden with st.spinner(f"Lade Graph..."): + # Daten laden (Cache wird genutzt) nodes, edges = graph_service.get_ego_graph( center_id, depth=st.session_state.graph_depth, show_labels=st.session_state.graph_show_labels ) + + # Fetch Note Data für Button & Debug + # Wir holen die Metadaten (inkl. path), was für den Editor-Callback reicht. note_data = graph_service._fetch_note_cached(center_id) - # Action Bar rendern + # --- ACTION BAR RENDEREN --- with action_container: - c1, c2 = st.columns([3, 1]) - with c1: st.caption(f"Aktives Zentrum: **{center_id}**") - with c2: + c_act1, c_act2 = st.columns([3, 1]) + with c_act1: + st.caption(f"Aktives Zentrum: **{center_id}**") + with c_act2: if note_data: - st.button("📝 Bearbeiten", use_container_width=True, on_click=switch_to_editor_callback, args=(note_data,)) + st.button("📝 Bearbeiten", + use_container_width=True, + on_click=switch_to_editor_callback, + args=(note_data,)) else: - st.error("Datenfehler") + st.error("Daten nicht verfügbar") - with st.expander("🕵️ Data Inspector", expanded=False): + # DATA INSPECTOR (Payload Debug) + with st.expander("🕵️ Data Inspector (Payload Debug)", expanded=False): if note_data: st.json(note_data) - if 'path' in note_data: st.success(f"Pfad OK: {note_data['path']}") - else: st.error("Pfad fehlt!") - else: st.info("Leer.") + if 'path' not in note_data: + st.error("ACHTUNG: Feld 'path' fehlt im Qdrant-Payload! Update-Modus wird nicht funktionieren.") + else: + st.success(f"Pfad gefunden: {note_data['path']}") + else: + st.info("Keine Daten geladen.") if not nodes: st.warning("Keine Daten gefunden.") else: - # Physik Config für BarnesHut + Height-Trick + # --- CONFIGURATION (BarnesHut) --- + # Height-Trick für Re-Render (da key-Parameter nicht funktioniert) + # Ändere Height minimal basierend auf Gravity dyn_height = 800 + (abs(st.session_state.graph_gravity) % 5) config = Config( width=1000, - height=dyn_height, + height=dyn_height, directed=True, physics={ "enabled": True, + # BarnesHut ist der Standard und stabilste Solver für Agraph "solver": "barnesHut", "barnesHut": { "gravitationalConstant": st.session_state.graph_gravity, @@ -116,11 +141,15 @@ def render_graph_explorer(graph_service): return_value = agraph(nodes=nodes, edges=edges, config=config) + # Interaktions-Logik if return_value: if return_value != center_id: + # Navigation: Neues Zentrum setzen st.session_state.graph_center_id = return_value st.rerun() else: + # Klick auf das Zentrum selbst st.toast(f"Zentrum: {return_value}") + else: st.info("👈 Bitte wähle links eine Notiz aus.") \ No newline at end of file From f7a4dab7078ff8c4553769798bdffdf18a5203ab Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 14:45:50 +0100 Subject: [PATCH 18/36] problem fix ausgehende Kanten --- app/frontend/ui_graph.py | 73 +++++++++++++++----------------- app/frontend/ui_graph_service.py | 50 +++++++++++++++++----- 2 files changed, 75 insertions(+), 48 deletions(-) diff --git a/app/frontend/ui_graph.py b/app/frontend/ui_graph.py index 3db5989..512e63d 100644 --- a/app/frontend/ui_graph.py +++ b/app/frontend/ui_graph.py @@ -8,24 +8,25 @@ def render_graph_explorer(graph_service): st.header("🕸️ Graph Explorer") # Session State initialisieren - if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None + if "graph_center_id" not in st.session_state: + st.session_state.graph_center_id = None - # Defaults speichern für Persistenz + # Defaults für View & Physik setzen st.session_state.setdefault("graph_depth", 2) st.session_state.setdefault("graph_show_labels", True) - # Defaults angepasst für BarnesHut (andere Skala!) - st.session_state.setdefault("graph_spacing", 150) - st.session_state.setdefault("graph_gravity", -3000) + st.session_state.setdefault("graph_spacing", 250) + st.session_state.setdefault("graph_gravity", -4000) col_ctrl, col_graph = st.columns([1, 4]) + # --- LINKE SPALTE: CONTROLS --- with col_ctrl: st.subheader("Fokus") - # Suche + # Sucheingabe search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...") - options = {} + # Suchlogik Qdrant if search_term: hits, _ = graph_service.client.scroll( collection_name=f"{COLLECTION_PREFIX}_notes", @@ -42,19 +43,18 @@ def render_graph_explorer(graph_service): st.divider() - # View Settings + # Layout & Physik Einstellungen 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("**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?") + st.session_state.graph_spacing = st.slider("Federlänge (Abstand)", 50, 800, st.session_state.graph_spacing) + st.session_state.graph_gravity = st.slider("Abstoßung (Gravity)", -20000, -500, st.session_state.graph_gravity) if st.button("Reset Layout"): - st.session_state.graph_spacing = 150 - st.session_state.graph_gravity = -3000 + st.session_state.graph_spacing = 250 + st.session_state.graph_gravity = -4000 st.rerun() st.divider() @@ -62,57 +62,55 @@ def render_graph_explorer(graph_service): for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) + # --- RECHTE SPALTE: GRAPH & ACTION BAR --- with col_graph: center_id = st.session_state.graph_center_id if center_id: - # Container für Action Bar OBERHALB des Graphen (Layout Fix) + # Action Container oben fixieren (Layout Fix) action_container = st.container() - # Graph Laden + # Graph und Daten laden with st.spinner(f"Lade Graph..."): - # Daten laden (Cache wird genutzt) nodes, edges = graph_service.get_ego_graph( center_id, depth=st.session_state.graph_depth, show_labels=st.session_state.graph_show_labels ) - # Fetch Note Data für Button & Debug - # Wir holen die Metadaten (inkl. path), was für den Editor-Callback reicht. - note_data = graph_service._fetch_note_cached(center_id) + # WICHTIG: Volle Daten inkl. Stitching für Editor holen + note_data = graph_service.get_note_with_full_content(center_id) - # --- ACTION BAR RENDEREN --- + # Action Bar rendern with action_container: - c_act1, c_act2 = st.columns([3, 1]) - with c_act1: + c1, c2 = st.columns([3, 1]) + with c1: st.caption(f"Aktives Zentrum: **{center_id}**") - with c_act2: + with c2: if note_data: st.button("📝 Bearbeiten", use_container_width=True, on_click=switch_to_editor_callback, args=(note_data,)) else: - st.error("Daten nicht verfügbar") + st.error("Datenfehler: Note nicht gefunden") - # DATA INSPECTOR (Payload Debug) - with st.expander("🕵️ Data Inspector (Payload Debug)", expanded=False): + # Debug Inspector + with st.expander("🕵️ Data Inspector", expanded=False): if note_data: st.json(note_data) - if 'path' not in note_data: - st.error("ACHTUNG: Feld 'path' fehlt im Qdrant-Payload! Update-Modus wird nicht funktionieren.") - else: - st.success(f"Pfad gefunden: {note_data['path']}") - else: - st.info("Keine Daten geladen.") + if 'path' in note_data: + st.success(f"Pfad OK: {note_data['path']}") + else: + st.error("Pfad fehlt!") + else: + st.info("Leer.") if not nodes: st.warning("Keine Daten gefunden.") else: # --- CONFIGURATION (BarnesHut) --- - # Height-Trick für Re-Render (da key-Parameter nicht funktioniert) - # Ändere Height minimal basierend auf Gravity + # Height-Trick für Re-Render (da key-Parameter manchmal crasht) dyn_height = 800 + (abs(st.session_state.graph_gravity) % 5) config = Config( @@ -121,11 +119,10 @@ def render_graph_explorer(graph_service): directed=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, + "centralGravity": 0.005, "springLength": st.session_state.graph_spacing, "springConstant": 0.04, "damping": 0.09, @@ -141,7 +138,7 @@ def render_graph_explorer(graph_service): return_value = agraph(nodes=nodes, edges=edges, config=config) - # Interaktions-Logik + # Interaktions-Logik (Klick auf Node) if return_value: if return_value != center_id: # Navigation: Neues Zentrum setzen @@ -152,4 +149,4 @@ def render_graph_explorer(graph_service): st.toast(f"Zentrum: {return_value}") else: - st.info("👈 Bitte wähle links eine Notiz aus.") \ 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 3032e62..a32971a 100644 --- a/app/frontend/ui_graph_service.py +++ b/app/frontend/ui_graph_service.py @@ -12,6 +12,26 @@ class GraphExplorerService: self.edges_col = f"{prefix}_edges" self._note_cache = {} + def get_note_with_full_content(self, note_id): + """ + Lädt die Metadaten der Note und rekonstruiert den gesamten Text + aus den Chunks (Stitching). Wichtig für den Editor-Fallback. + """ + # 1. Metadaten holen + meta = self._fetch_note_cached(note_id) + if not meta: return None + + # 2. Volltext aus Chunks bauen + full_text = self._fetch_full_text_stitched(note_id) + + # 3. Ergebnis kombinieren (Wir überschreiben das 'fulltext' Feld mit dem frischen Stitching) + # Wir geben eine Kopie zurück, um den Cache nicht zu verfälschen + complete_note = meta.copy() + if full_text: + complete_note['fulltext'] = full_text + + return complete_note + def get_ego_graph(self, center_note_id: str, depth=2, show_labels=True): """ Erstellt den Ego-Graphen um eine zentrale Notiz. @@ -25,6 +45,7 @@ class GraphExplorerService: if not center_note: return [], [] self._add_node_to_dict(nodes_dict, center_note, level=0) + # Initialset für Suche level_1_ids = {center_note_id} # Suche Kanten für Center (L1) @@ -36,7 +57,7 @@ class GraphExplorerService: if tgt_id: level_1_ids.add(tgt_id) # Level 2 Suche (begrenzt für Performance) - if depth > 1 and len(level_1_ids) > 1 and len(level_1_ids) < 60: + if depth > 1 and len(level_1_ids) > 1 and len(level_1_ids) < 80: l1_subset = list(level_1_ids - {center_note_id}) if l1_subset: l2_edges = self._find_connected_edges_batch(l1_subset) @@ -107,6 +128,7 @@ class GraphExplorerService: full_text = [] for c in chunks: + # 'text' ist der reine Inhalt ohne Overlap txt = c.payload.get('text', '') if txt: full_text.append(txt) @@ -133,13 +155,16 @@ class GraphExplorerService: def _find_connected_edges(self, note_ids, note_title=None): """Findet eingehende und ausgehende Kanten für Nodes.""" - # 1. Chunk IDs ermitteln (da Edges oft an Chunks hängen) - scroll_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))]) - chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=200) - chunk_ids = [c.id for c in chunks] results = [] + # 1. Chunk IDs ermitteln (da Edges oft an Chunks hängen) + 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] + # 2. Outgoing Edges suchen # Source kann sein: Eine Chunk ID ODER die Note ID selbst (bei direkten Links) source_candidates = chunk_ids + note_ids @@ -150,22 +175,27 @@ class GraphExplorerService: # FIX: MatchExcept Workaround für 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) + res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_f, limit=500, with_payload=True) results.extend(res_out) # 3. Incoming Edges suchen # Target kann sein: Chunk ID, Note ID, oder Note Titel (Wikilinks) 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))) - shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids))) + if chunk_ids: + shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=chunk_ids))) + + if note_ids: + shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids))) + + if note_title: + shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=note_title))) if shoulds: in_f = 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_f, limit=100, with_payload=True) + res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_f, limit=500, with_payload=True) results.extend(res_in) return results From f313f0873b12269e59037bb153e432f1c8128fb7 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 14:50:06 +0100 Subject: [PATCH 19/36] ausgehende Kanten --- app/frontend/ui_graph.py | 5 ++-- app/frontend/ui_graph_service.py | 44 +++++++++++++++++--------------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/app/frontend/ui_graph.py b/app/frontend/ui_graph.py index 512e63d..e7920cc 100644 --- a/app/frontend/ui_graph.py +++ b/app/frontend/ui_graph.py @@ -14,6 +14,7 @@ def render_graph_explorer(graph_service): # Defaults für View & Physik setzen st.session_state.setdefault("graph_depth", 2) st.session_state.setdefault("graph_show_labels", True) + # Höhere Default-Werte für Abstand st.session_state.setdefault("graph_spacing", 250) st.session_state.setdefault("graph_gravity", -4000) @@ -78,7 +79,7 @@ def render_graph_explorer(graph_service): show_labels=st.session_state.graph_show_labels ) - # WICHTIG: Volle Daten inkl. Stitching für Editor holen + # WICHTIG: Daten für Editor holen (inkl. Pfad) note_data = graph_service.get_note_with_full_content(center_id) # Action Bar rendern @@ -122,7 +123,7 @@ def render_graph_explorer(graph_service): "solver": "barnesHut", "barnesHut": { "gravitationalConstant": st.session_state.graph_gravity, - "centralGravity": 0.005, + "centralGravity": 0.005, # Extrem wichtig für die Ausbreitung! "springLength": st.session_state.graph_spacing, "springConstant": 0.04, "damping": 0.09, diff --git a/app/frontend/ui_graph_service.py b/app/frontend/ui_graph_service.py index a32971a..01b8191 100644 --- a/app/frontend/ui_graph_service.py +++ b/app/frontend/ui_graph_service.py @@ -154,49 +154,53 @@ class GraphExplorerService: return previews def _find_connected_edges(self, note_ids, note_title=None): - """Findet eingehende und ausgehende Kanten für Nodes.""" + """Findet eingehende und ausgehende Kanten.""" results = [] - # 1. Chunk IDs ermitteln (da Edges oft an Chunks hängen) + # 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) + + # 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 = [] 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] - - # 2. Outgoing Edges suchen - # Source kann sein: Eine Chunk ID ODER die Note ID selbst (bei direkten Links) - 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 Workaround für Pydantic - models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES})) - ]) - res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_f, limit=500, with_payload=True) - results.extend(res_out) - # 3. Incoming Edges suchen - # Target kann sein: Chunk ID, Note ID, oder Note Titel (Wikilinks) shoulds = [] + # Case A: Edge zeigt auf einen unserer Chunks if chunk_ids: 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))) + # Case C: Edge zeigt auf unseren Titel (Wikilinks) if note_title: shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=note_title))) if shoulds: - in_f = models.Filter( + 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_f, limit=500, with_payload=True) - results.extend(res_in) + res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_filter, limit=500, with_payload=True) + results.extend(res_in) + return results def _find_connected_edges_batch(self, note_ids): From d50ed7046732e68d0d3a46d7f09177fc1ffd3ce1 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 14:57:34 +0100 Subject: [PATCH 20/36] 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" ) From cba13370662a801dd137ab418593e4612a32aadd Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 15:00:05 +0100 Subject: [PATCH 21/36] bug fix --- app/frontend/ui_graph_cytoscape.py | 70 +++++++++++++++--------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index f6b65db..85470c7 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -1,5 +1,6 @@ import streamlit as st -from st_cytoscape import cytoscape +# KORREKTER IMPORT für 'pip install streamlit-cytoscapejs' +from streamlit_cytoscapejs import st_cytoscapejs from qdrant_client import models from ui_config import COLLECTION_PREFIX, GRAPH_COLORS from ui_callbacks import switch_to_editor_callback @@ -10,9 +11,9 @@ def render_graph_explorer_cytoscape(graph_service): 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 + # Layout Defaults für COSE + st.session_state.setdefault("cy_node_repulsion", 1000000) + st.session_state.setdefault("cy_ideal_edge_len", 150) col_ctrl, col_graph = st.columns([1, 4]) @@ -36,7 +37,7 @@ def render_graph_explorer_cytoscape(graph_service): st.divider() with st.expander("👁️ Layout Einstellungen", expanded=True): - st.caption("COSE Layout (Spezialisiert auf Abstand)") + st.caption("COSE Layout") 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) @@ -57,11 +58,7 @@ def render_graph_explorer_cytoscape(graph_service): # 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 @@ -72,39 +69,36 @@ def render_graph_explorer_cytoscape(graph_service): 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 + # 3. Konvertierung zu Cytoscape JSON 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 + # Node 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) + "bg_color": n.color, # Wichtig: Eigenes Attribut für Style mapping + "size": 40 if n.id == center_id else 25, "tooltip": n.title }, "selected": (n.id == center_id) } cy_elements.append(cy_node) - # Edges konvertieren for e in edges_data: + # Edge cy_edge = { "data": { "source": e.source, "target": e.target, "label": e.label, - "color": e.color + "line_color": e.color } } cy_elements.append(cy_edge) - # 4. Stylesheet (Design) + # 4. Stylesheet stylesheet = [ { "selector": "node", @@ -112,14 +106,13 @@ def render_graph_explorer_cytoscape(graph_service): "label": "data(label)", "width": "data(size)", "height": "data(size)", - "background-color": "data(color)", + "background-color": "data(bg_color)", # Mapping auf unser data feld "color": "#333", "font-size": "12px", "text-valign": "center", "text-halign": "center", "border-width": 2, - "border-color": "#fff", - "content": "data(label)" + "border-color": "#fff" } }, { @@ -133,8 +126,8 @@ def render_graph_explorer_cytoscape(graph_service): "selector": "edge", "style": { "width": 2, - "line-color": "data(color)", - "target-arrow-color": "data(color)", + "line-color": "data(line_color)", + "target-arrow-color": "data(line_color)", "target-arrow-shape": "triangle", "curve-style": "bezier", "label": "data(label)", @@ -146,9 +139,9 @@ def render_graph_explorer_cytoscape(graph_service): } ] - # 5. Rendering mit COSE Layout - # COSE ist der beste Algorithmus für "Non-Overlapping" Graphen - selected_node = cytoscape( + # 5. Rendering + # Die Bibliothek gibt eine Liste von geklickten Elementen zurück + clicked_elements = st_cytoscapejs( elements=cy_elements, stylesheet=stylesheet, layout={ @@ -173,15 +166,22 @@ def render_graph_explorer_cytoscape(graph_service): 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] + # Interaktion: Klick Event verarbeiten + if clicked_elements: + # clicked_elements ist eine Liste von Dictionaries + # Wir suchen nach einem Node-Klick + for el in clicked_elements: + # Prüfen ob es ein Node ist (hat 'id' aber keine 'source'/'target' im root, + # allerdings liefert die Lib oft die rohen Daten) + # Wir schauen auf die ID. + clicked_id = el.get("data", {}).get("id") if "data" in el else el.get("id") + if clicked_id and clicked_id != center_id: - st.session_state.graph_center_id = clicked_id - st.rerun() + # Safety check: ist es eine edge? Edges haben source/target + is_edge = "source" in el.get("data", {}) + if not is_edge: + 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 From af73ab9371749def38c52c80dc579a01d05314c7 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 15:01:58 +0100 Subject: [PATCH 22/36] bug fix --- app/frontend/ui_graph_cytoscape.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index 85470c7..eb5fbd3 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -58,6 +58,7 @@ def render_graph_explorer_cytoscape(graph_service): # 1. Daten laden with st.spinner("Lade Graph..."): + # Wir nutzen die bestehende Logik aus dem Service nodes_data, edges_data = graph_service.get_ego_graph(center_id) note_data = graph_service.get_note_with_full_content(center_id) @@ -87,11 +88,13 @@ def render_graph_explorer_cytoscape(graph_service): cy_elements.append(cy_node) for e in edges_data: - # Edge + # Edge Fix: e.to statt e.target nutzen! + target_id = getattr(e, "to", getattr(e, "target", None)) + cy_edge = { "data": { "source": e.source, - "target": e.target, + "target": target_id, # KORRIGIERT "label": e.label, "line_color": e.color } From bb41b11cce48a174162619d844dd1ffc046991bb Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 15:05:24 +0100 Subject: [PATCH 23/36] neue bibliothek --- app/frontend/ui_graph_cytoscape.py | 169 ++++++++++++++++------------- 1 file changed, 93 insertions(+), 76 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index eb5fbd3..d79b4d2 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -1,6 +1,6 @@ import streamlit as st -# KORREKTER IMPORT für 'pip install streamlit-cytoscapejs' -from streamlit_cytoscapejs import st_cytoscapejs +# WICHTIG: Wir nutzen jetzt 'st-cytoscape' (pip install st-cytoscape) +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 @@ -11,9 +11,9 @@ def render_graph_explorer_cytoscape(graph_service): if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Layout Defaults für COSE - st.session_state.setdefault("cy_node_repulsion", 1000000) - st.session_state.setdefault("cy_ideal_edge_len", 150) + # Layout Defaults für COSE (Compound Spring Embedder) + st.session_state.setdefault("cy_node_repulsion", 1000000) # Starke Abstoßung + st.session_state.setdefault("cy_ideal_edge_len", 200) # Ziel-Abstand col_ctrl, col_graph = st.columns([1, 4]) @@ -37,9 +37,9 @@ def render_graph_explorer_cytoscape(graph_service): st.divider() with st.expander("👁️ Layout Einstellungen", expanded=True): - st.caption("COSE Layout") - 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) + st.caption("COSE Layout (Optimiert für Abstände)") + st.session_state.cy_ideal_edge_len = st.slider("Kantenlänge (Ideal)", 50, 600, st.session_state.cy_ideal_edge_len) + st.session_state.cy_node_repulsion = st.slider("Knoten-Abstoßung", 100000, 5000000, st.session_state.cy_node_repulsion, step=100000) if st.button("Neu berechnen", key="cy_rerun"): st.rerun() @@ -58,8 +58,10 @@ def render_graph_explorer_cytoscape(graph_service): # 1. Daten laden with st.spinner("Lade Graph..."): - # Wir nutzen die bestehende Logik aus dem Service + # Wir holen die Agraph-Objekte vom Service 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 @@ -70,38 +72,49 @@ def render_graph_explorer_cytoscape(graph_service): 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 + # 3. Konvertierung zu Cytoscape JSON Format cy_elements = [] + # Nodes konvertieren for n in nodes_data: - # Node + # Wir bauen das 'label' so um, dass lange Titel umbrechen (optional) + label_display = n.label + if len(label_display) > 20: label_display = label_display[:20] + "..." + cy_node = { "data": { "id": n.id, - "label": n.label, - "bg_color": n.color, # Wichtig: Eigenes Attribut für Style mapping - "size": 40 if n.id == center_id else 25, + "label": label_display, + "full_label": n.label, + "color": n.color, + # Größe skalieren: Center größer + "size": 60 if n.id == center_id else 40, + # Tooltip Inhalt "tooltip": n.title }, - "selected": (n.id == center_id) + # Zentrum markieren + "selected": (n.id == center_id) } cy_elements.append(cy_node) + # Edges konvertieren for e in edges_data: - # Edge Fix: e.to statt e.target nutzen! + # FIX: Agraph Edges nutzen .to, nicht .target + # Wir prüfen sicherheitshalber beide Attribute target_id = getattr(e, "to", getattr(e, "target", None)) - cy_edge = { - "data": { - "source": e.source, - "target": target_id, # KORRIGIERT - "label": e.label, - "line_color": e.color + if target_id: + cy_edge = { + "data": { + "source": e.source, + "target": target_id, + "label": e.label, + "color": e.color + } } - } - cy_elements.append(cy_edge) + cy_elements.append(cy_edge) - # 4. Stylesheet + # 4. Stylesheet (Design Definitionen) stylesheet = [ { "selector": "node", @@ -109,82 +122,86 @@ def render_graph_explorer_cytoscape(graph_service): "label": "data(label)", "width": "data(size)", "height": "data(size)", - "background-color": "data(bg_color)", # Mapping auf unser data feld + "background-color": "data(color)", "color": "#333", - "font-size": "12px", + "font-size": "14px", "text-valign": "center", "text-halign": "center", "border-width": 2, - "border-color": "#fff" + "border-color": "#fff", + "text-wrap": "wrap", + "text-max-width": "100px" } }, { "selector": "node:selected", "style": { - "border-width": 4, - "border-color": "#FF5733" + "border-width": 5, + "border-color": "#FF5733", + "background-color": "#FF5733", + "color": "#fff" } }, { "selector": "edge", "style": { - "width": 2, - "line-color": "data(line_color)", - "target-arrow-color": "data(line_color)", + "width": 3, + "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" + "font-size": "11px", + "color": "#555", + "text-background-opacity": 0.8, + "text-background-color": "#ffffff", + "text-rotation": "autorotate" } } ] - # 5. Rendering - # Die Bibliothek gibt eine Liste von geklickten Elementen zurück - clicked_elements = st_cytoscapejs( - 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" - ) + # 5. Rendering mit COSE Layout + # COSE ist der Schlüssel für gute Abstände! + with st.spinner("Berechne Layout..."): + selected_element = 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": 200, + "nodeRepulsion": st.session_state.cy_node_repulsion, + "edgeElasticity": 100, + "nestingFactor": 5, + "gravity": 50, + "numIter": 1000, + "initialTemp": 200, + "coolingFactor": 0.95, + "minTemp": 1.0 + }, + key="cyto_graph_obj", # Ein fester Key ist hier okay, da das Layout-Dict sich ändert + height="700px" + ) # Interaktion: Klick Event verarbeiten - if clicked_elements: - # clicked_elements ist eine Liste von Dictionaries - # Wir suchen nach einem Node-Klick - for el in clicked_elements: - # Prüfen ob es ein Node ist (hat 'id' aber keine 'source'/'target' im root, - # allerdings liefert die Lib oft die rohen Daten) - # Wir schauen auf die ID. - clicked_id = el.get("data", {}).get("id") if "data" in el else el.get("id") + # st-cytoscape gibt ein Dictionary zurück + if selected_element: + # Prüfen, ob es ein Node-Klick war + # Die Struktur ist: {'nodes': ['id1', 'id2'], 'edges': [...]} + clicked_nodes = selected_element.get("nodes", []) + + if clicked_nodes: + # Wir nehmen den ersten (und meist einzigen) selektierten Node + clicked_id = clicked_nodes[0] if clicked_id and clicked_id != center_id: - # Safety check: ist es eine edge? Edges haben source/target - is_edge = "source" in el.get("data", {}) - if not is_edge: - st.session_state.graph_center_id = clicked_id - st.rerun() + 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 From 245a96809a37f814dbaa38e543e4ff4c5a357381 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 15:16:01 +0100 Subject: [PATCH 24/36] upgrade cytoscape --- app/frontend/ui_graph_cytoscape.py | 171 ++++++++++++++++------------- 1 file changed, 93 insertions(+), 78 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index d79b4d2..ca21b04 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -1,5 +1,5 @@ import streamlit as st -# WICHTIG: Wir nutzen jetzt 'st-cytoscape' (pip install st-cytoscape) +# Wir nutzen das mächtigere 'st-cytoscape' (pip install st-cytoscape) from st_cytoscape import cytoscape from qdrant_client import models from ui_config import COLLECTION_PREFIX, GRAPH_COLORS @@ -8,16 +8,18 @@ from ui_callbacks import switch_to_editor_callback def render_graph_explorer_cytoscape(graph_service): st.header("🕸️ Graph Explorer (Cytoscape)") + # State Init if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Layout Defaults für COSE (Compound Spring Embedder) - st.session_state.setdefault("cy_node_repulsion", 1000000) # Starke Abstoßung - st.session_state.setdefault("cy_ideal_edge_len", 200) # Ziel-Abstand + # Defaults für Cytoscape Session State + st.session_state.setdefault("cy_node_repulsion", 1000000) + st.session_state.setdefault("cy_ideal_edge_len", 150) + st.session_state.setdefault("cy_depth", 2) # Eigene Tiefe für diesen Tab col_ctrl, col_graph = st.columns([1, 4]) - # --- CONTROLS --- + # --- CONTROLS (Linke Spalte) --- with col_ctrl: st.subheader("Fokus") search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search") @@ -36,12 +38,18 @@ def render_graph_explorer_cytoscape(graph_service): st.rerun() st.divider() - with st.expander("👁️ Layout Einstellungen", expanded=True): - st.caption("COSE Layout (Optimiert für Abstände)") - st.session_state.cy_ideal_edge_len = st.slider("Kantenlänge (Ideal)", 50, 600, st.session_state.cy_ideal_edge_len) - st.session_state.cy_node_repulsion = st.slider("Knoten-Abstoßung", 100000, 5000000, st.session_state.cy_node_repulsion, step=100000) + + # EINSTELLUNGEN + with st.expander("👁️ Ansicht & Layout", expanded=True): + # 1. Tiefe (Tier) + st.session_state.cy_depth = st.slider("Tiefe (Tier)", 1, 3, st.session_state.cy_depth, key="cy_depth_slider") - if st.button("Neu berechnen", key="cy_rerun"): + st.markdown("**COSE Layout**") + # 2. Layout Parameter + st.session_state.cy_ideal_edge_len = st.slider("Kantenlänge", 50, 600, st.session_state.cy_ideal_edge_len, key="cy_len_slider") + st.session_state.cy_node_repulsion = st.slider("Knoten-Abstoßung", 100000, 5000000, st.session_state.cy_node_repulsion, step=100000, key="cy_rep_slider") + + if st.button("Neu berechnen / Reset", key="cy_rerun"): st.rerun() st.divider() @@ -49,19 +57,23 @@ def render_graph_explorer_cytoscape(graph_service): for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) - # --- GRAPH AREA --- + # --- GRAPH AREA (Rechte Spalte) --- 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 holen die Agraph-Objekte vom Service - nodes_data, edges_data = graph_service.get_ego_graph(center_id) + # 1. Daten laden (Mit Tiefe!) + with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): + # Hier nutzen wir die dynamische Tiefe aus dem Slider + # Wir holen die Agraph-Objekte vom Service, da die Logik dort zentralisiert ist + nodes_data, edges_data = graph_service.get_ego_graph( + center_id, + depth=st.session_state.cy_depth + ) - # Note Data für Editor Button + # Note Data für Editor Button (Volltext/Pfad via Service) note_data = graph_service.get_note_with_full_content(center_id) # 2. Action Bar @@ -70,36 +82,38 @@ def render_graph_explorer_cytoscape(graph_service): 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") + st.button("📝 Bearbeiten", use_container_width=True, on_click=switch_to_editor_callback, args=(note_data,), key="cy_edit_btn") - # 3. Konvertierung zu Cytoscape JSON Format + # 3. Daten Konvertierung (Agraph -> Cytoscape JSON) cy_elements = [] - # Nodes konvertieren + # Nodes for n in nodes_data: - # Wir bauen das 'label' so um, dass lange Titel umbrechen (optional) - label_display = n.label - if len(label_display) > 20: label_display = label_display[:20] + "..." + # Wir holen den Hover-Text aus 'n.title', den der Service vorbereitet hat (Fulltext/Snippet) + tooltip_text = n.title if n.title else n.label + + # Label kürzen für Anzeige im Kreis, aber voll im Tooltip + display_label = n.label + if len(display_label) > 15 and " " in display_label: + display_label = display_label.replace(" ", "\n", 1) # Zeilenumbruch cy_node = { "data": { "id": n.id, - "label": label_display, + "label": display_label, "full_label": n.label, - "color": n.color, - # Größe skalieren: Center größer - "size": 60 if n.id == center_id else 40, - # Tooltip Inhalt - "tooltip": n.title + "bg_color": n.color, + "size": 50 if n.id == center_id else 30, + # Dieses Feld wird oft automatisch als natives 'title' Attribut gerendert (Browser Tooltip) + "title": tooltip_text }, - # Zentrum markieren - "selected": (n.id == center_id) + "selected": (n.id == center_id) } cy_elements.append(cy_node) - # Edges konvertieren + # Edges for e in edges_data: - # FIX: Agraph Edges nutzen .to, nicht .target + # Kompatibilität: Agraph nutzt 'to', Cytoscape braucht 'target' # Wir prüfen sicherheitshalber beide Attribute target_id = getattr(e, "to", getattr(e, "target", None)) @@ -109,12 +123,12 @@ def render_graph_explorer_cytoscape(graph_service): "source": e.source, "target": target_id, "label": e.label, - "color": e.color + "line_color": e.color } } cy_elements.append(cy_edge) - # 4. Stylesheet (Design Definitionen) + # 4. Styling (CSS-like) stylesheet = [ { "selector": "node", @@ -122,21 +136,23 @@ def render_graph_explorer_cytoscape(graph_service): "label": "data(label)", "width": "data(size)", "height": "data(size)", - "background-color": "data(color)", + "background-color": "data(bg_color)", "color": "#333", - "font-size": "14px", + "font-size": "12px", "text-valign": "center", "text-halign": "center", + "text-wrap": "wrap", + "text-max-width": "80px", "border-width": 2, "border-color": "#fff", - "text-wrap": "wrap", - "text-max-width": "100px" + # Tooltip Mapping (funktioniert je nach Browser/Wrapper) + "content": "data(label)" } }, { "selector": "node:selected", "style": { - "border-width": 5, + "border-width": 4, "border-color": "#FF5733", "background-color": "#FF5733", "color": "#fff" @@ -145,60 +161,59 @@ def render_graph_explorer_cytoscape(graph_service): { "selector": "edge", "style": { - "width": 3, - "line-color": "data(color)", - "target-arrow-color": "data(color)", + "width": 2, + "line-color": "data(line_color)", + "target-arrow-color": "data(line_color)", "target-arrow-shape": "triangle", "curve-style": "bezier", "label": "data(label)", - "font-size": "11px", - "color": "#555", - "text-background-opacity": 0.8, - "text-background-color": "#ffffff", + "font-size": "10px", + "color": "#666", + "text-background-opacity": 0.7, + "text-background-color": "#fff", "text-rotation": "autorotate" } } ] - # 5. Rendering mit COSE Layout - # COSE ist der Schlüssel für gute Abstände! - with st.spinner("Berechne Layout..."): - selected_element = 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": 200, - "nodeRepulsion": st.session_state.cy_node_repulsion, - "edgeElasticity": 100, - "nestingFactor": 5, - "gravity": 50, - "numIter": 1000, - "initialTemp": 200, - "coolingFactor": 0.95, - "minTemp": 1.0 - }, - key="cyto_graph_obj", # Ein fester Key ist hier okay, da das Layout-Dict sich ändert - height="700px" - ) + # 5. Rendering & Layout + # COSE Layout für optimale Abstände + selected_element = 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 + }, + # Dynamischer Key für Re-Render bei Einstellungsänderung + key=f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}", + height="800px" + ) - # Interaktion: Klick Event verarbeiten - # st-cytoscape gibt ein Dictionary zurück + # 6. Interaktions-Logik (Navigation) if selected_element: - # Prüfen, ob es ein Node-Klick war - # Die Struktur ist: {'nodes': ['id1', 'id2'], 'edges': [...]} + # st-cytoscape gibt ein Dictionary der selektierten Elemente zurück + # Struktur: {'nodes': ['id1'], 'edges': []} clicked_nodes = selected_element.get("nodes", []) if clicked_nodes: - # Wir nehmen den ersten (und meist einzigen) selektierten Node clicked_id = clicked_nodes[0] + # Wenn auf einen anderen Knoten geklickt wurde -> Navigieren if clicked_id and clicked_id != center_id: st.session_state.graph_center_id = clicked_id st.rerun() From 3a2a78e0fd78bffebc819dae2d893678dcaddff8 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 15:26:47 +0100 Subject: [PATCH 25/36] erneuter upgrade con ui_graph_cytoscape --- app/frontend/ui_graph_cytoscape.py | 92 +++++++++++++++++++----------- 1 file changed, 58 insertions(+), 34 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index ca21b04..c5987fe 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -1,5 +1,4 @@ import streamlit as st -# Wir nutzen das mächtigere 'st-cytoscape' (pip install st-cytoscape) from st_cytoscape import cytoscape from qdrant_client import models from ui_config import COLLECTION_PREFIX, GRAPH_COLORS @@ -15,7 +14,7 @@ def render_graph_explorer_cytoscape(graph_service): # Defaults für Cytoscape Session State st.session_state.setdefault("cy_node_repulsion", 1000000) st.session_state.setdefault("cy_ideal_edge_len", 150) - st.session_state.setdefault("cy_depth", 2) # Eigene Tiefe für diesen Tab + st.session_state.setdefault("cy_depth", 2) col_ctrl, col_graph = st.columns([1, 4]) @@ -66,36 +65,57 @@ def render_graph_explorer_cytoscape(graph_service): # 1. Daten laden (Mit Tiefe!) with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): - # Hier nutzen wir die dynamische Tiefe aus dem Slider - # Wir holen die Agraph-Objekte vom Service, da die Logik dort zentralisiert ist + # Agraph-Objekte vom Service holen nodes_data, edges_data = graph_service.get_ego_graph( center_id, depth=st.session_state.cy_depth ) - # Note Data für Editor Button (Volltext/Pfad via Service) + # Note Data für Editor & Inspector (Volltext) note_data = graph_service.get_note_with_full_content(center_id) - # 2. Action Bar + # 2. Action Bar & Inspector 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_btn") - + + # --- DATA INSPECTOR --- + with st.expander("🕵️ Data Inspector (Details)", expanded=False): + if note_data: + col_i1, col_i2 = st.columns(2) + with col_i1: + st.markdown(f"**Titel:** {note_data.get('title')}") + st.markdown(f"**Typ:** `{note_data.get('type')}`") + with col_i2: + st.markdown(f"**Tags:** {', '.join(note_data.get('tags', []))}") + path_check = "✅" if note_data.get('path') else "❌" + st.markdown(f"**Pfad:** {path_check}") + + st.divider() + # Vorschau des Inhalts + content_preview = note_data.get('fulltext', '')[:500] + st.text_area("Inhalt (Vorschau)", content_preview + "...", height=100, disabled=True) + + with st.expander("Raw JSON"): + st.json(note_data) + else: + st.warning("Keine Daten für diesen Knoten.") + # 3. Daten Konvertierung (Agraph -> Cytoscape JSON) cy_elements = [] # Nodes for n in nodes_data: - # Wir holen den Hover-Text aus 'n.title', den der Service vorbereitet hat (Fulltext/Snippet) + # Hover-Text aus n.title (vom Service vorbereitet) tooltip_text = n.title if n.title else n.label - # Label kürzen für Anzeige im Kreis, aber voll im Tooltip + # Label kürzen für Anzeige im Kreis display_label = n.label if len(display_label) > 15 and " " in display_label: - display_label = display_label.replace(" ", "\n", 1) # Zeilenumbruch + display_label = display_label.replace(" ", "\n", 1) cy_node = { "data": { @@ -104,8 +124,8 @@ def render_graph_explorer_cytoscape(graph_service): "full_label": n.label, "bg_color": n.color, "size": 50 if n.id == center_id else 30, - # Dieses Feld wird oft automatisch als natives 'title' Attribut gerendert (Browser Tooltip) - "title": tooltip_text + # Tooltip Datenfeld + "tooltip": tooltip_text }, "selected": (n.id == center_id) } @@ -113,8 +133,7 @@ def render_graph_explorer_cytoscape(graph_service): # Edges for e in edges_data: - # Kompatibilität: Agraph nutzt 'to', Cytoscape braucht 'target' - # Wir prüfen sicherheitshalber beide Attribute + # Kompatibilität Agraph -> Cytoscape target_id = getattr(e, "to", getattr(e, "target", None)) if target_id: @@ -145,8 +164,8 @@ def render_graph_explorer_cytoscape(graph_service): "text-max-width": "80px", "border-width": 2, "border-color": "#fff", - # Tooltip Mapping (funktioniert je nach Browser/Wrapper) - "content": "data(label)" + # Tooltip Mapping (Browser abhängig) + "title": "data(tooltip)" } }, { @@ -168,8 +187,8 @@ def render_graph_explorer_cytoscape(graph_service): "curve-style": "bezier", "label": "data(label)", "font-size": "10px", - "color": "#666", - "text-background-opacity": 0.7, + "color": "#777", + "text-background-opacity": 0.8, "text-background-color": "#fff", "text-rotation": "autorotate" } @@ -177,8 +196,10 @@ def render_graph_explorer_cytoscape(graph_service): ] # 5. Rendering & Layout - # COSE Layout für optimale Abstände - selected_element = cytoscape( + graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}" + + # Das Event-Dictionary enthält die geklickten Elemente + clicked_elements = cytoscape( elements=cy_elements, stylesheet=stylesheet, layout={ @@ -199,24 +220,27 @@ def render_graph_explorer_cytoscape(graph_service): "coolingFactor": 0.95, "minTemp": 1.0 }, - # Dynamischer Key für Re-Render bei Einstellungsänderung - key=f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}", + key=graph_key, height="800px" ) - # 6. Interaktions-Logik (Navigation) - if selected_element: - # st-cytoscape gibt ein Dictionary der selektierten Elemente zurück - # Struktur: {'nodes': ['id1'], 'edges': []} - clicked_nodes = selected_element.get("nodes", []) + # 6. Interaktions-Logik (Navigation Fix) + if clicked_elements: + # clicked_elements['nodes'] ist eine Liste von IDs + clicked_node_ids = clicked_elements.get("nodes", []) - if clicked_nodes: - clicked_id = clicked_nodes[0] - - # Wenn auf einen anderen Knoten geklickt wurde -> Navigieren - if clicked_id and clicked_id != center_id: - st.session_state.graph_center_id = clicked_id - st.rerun() + target_node = None + + # Wir suchen einen Node, der NICHT das aktuelle Zentrum ist + for nid in clicked_node_ids: + if nid != center_id: + target_node = nid + break + + # Wenn ein neuer Knoten gefunden wurde -> Navigieren + if target_node: + st.session_state.graph_center_id = target_node + st.rerun() else: st.info("👈 Bitte wähle links eine Notiz aus.") \ No newline at end of file From 0294414d26e49628c8849a9c2c4cbfcebaa5d442 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 15:31:52 +0100 Subject: [PATCH 26/36] emprove ui_graph_cytoscape --- app/frontend/ui_graph_cytoscape.py | 221 +++++++++++++++-------------- 1 file changed, 114 insertions(+), 107 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index c5987fe..62f87f7 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -7,18 +7,22 @@ from ui_callbacks import switch_to_editor_callback def render_graph_explorer_cytoscape(graph_service): st.header("🕸️ Graph Explorer (Cytoscape)") - # State Init + # --- STATE INITIALISIERUNG --- if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None + + # Neu: Getrennter State für Inspektion vs. Navigation + if "graph_inspected_id" not in st.session_state: + st.session_state.graph_inspected_id = None - # Defaults für Cytoscape Session State + # Defaults für Layout st.session_state.setdefault("cy_node_repulsion", 1000000) st.session_state.setdefault("cy_ideal_edge_len", 150) st.session_state.setdefault("cy_depth", 2) col_ctrl, col_graph = st.columns([1, 4]) - # --- CONTROLS (Linke Spalte) --- + # --- LINKES PANEL: SUCHE & SETTINGS --- with col_ctrl: st.subheader("Fokus") search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search") @@ -31,24 +35,23 @@ def render_graph_explorer_cytoscape(graph_service): ) options = {h.payload['title']: h.payload['note_id'] for h in hits} if options: + # Bei Suche setzen wir beides neu: Zentrum und Inspektion 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] + new_id = options[selected_title] + st.session_state.graph_center_id = new_id + st.session_state.graph_inspected_id = new_id # Gleichzeitig inspizieren st.rerun() st.divider() - # EINSTELLUNGEN - with st.expander("👁️ Ansicht & Layout", expanded=True): - # 1. Tiefe (Tier) + with st.expander("👁️ Layout Einstellungen", expanded=True): st.session_state.cy_depth = st.slider("Tiefe (Tier)", 1, 3, st.session_state.cy_depth, key="cy_depth_slider") - st.markdown("**COSE Layout**") - # 2. Layout Parameter st.session_state.cy_ideal_edge_len = st.slider("Kantenlänge", 50, 600, st.session_state.cy_ideal_edge_len, key="cy_len_slider") st.session_state.cy_node_repulsion = st.slider("Knoten-Abstoßung", 100000, 5000000, st.session_state.cy_node_repulsion, step=100000, key="cy_rep_slider") - if st.button("Neu berechnen / Reset", key="cy_rerun"): + if st.button("Neu berechnen", key="cy_rerun"): st.rerun() st.divider() @@ -56,63 +59,88 @@ def render_graph_explorer_cytoscape(graph_service): for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) - # --- GRAPH AREA (Rechte Spalte) --- + # --- RECHTES PANEL: GRAPH & INSPECTOR --- with col_graph: center_id = st.session_state.graph_center_id - if center_id: - action_container = st.container() + # Falls noch nichts ausgewählt, initialisiere mit Inspektion oder None + if not center_id and st.session_state.graph_inspected_id: + center_id = st.session_state.graph_inspected_id + st.session_state.graph_center_id = center_id - # 1. Daten laden (Mit Tiefe!) + if center_id: + # Sync: Wenn Inspection None ist, setze auf Center + if not st.session_state.graph_inspected_id: + st.session_state.graph_inspected_id = center_id + + inspected_id = st.session_state.graph_inspected_id + + # --- DATEN LADEN --- + # 1. Graph für das ZENTRUM laden with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): - # Agraph-Objekte vom Service holen nodes_data, edges_data = graph_service.get_ego_graph( center_id, depth=st.session_state.cy_depth ) - # Note Data für Editor & Inspector (Volltext) - note_data = graph_service.get_note_with_full_content(center_id) + # 2. Daten für den INSPIZIERTEN Knoten laden (für Editor/Inspector) + inspected_data = graph_service.get_note_with_full_content(inspected_id) - # 2. Action Bar & Inspector + # --- ACTION BAR (OBEN) --- + action_container = st.container() 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_btn") + # Info Zeile + c1, c2, c3 = st.columns([2, 1, 1]) - # --- DATA INSPECTOR --- - with st.expander("🕵️ Data Inspector (Details)", expanded=False): - if note_data: + with c1: + st.info(f"**Ausgewählt:** {inspected_data.get('title', inspected_id) if inspected_data else inspected_id}") + + with c2: + # NAVIGATION: Nur anzeigen, wenn Inspiziert != Zentrum + if inspected_id != center_id: + if st.button("🎯 Als Zentrum setzen", use_container_width=True, key="cy_nav_btn"): + st.session_state.graph_center_id = inspected_id + st.rerun() + else: + st.caption("_(Ist aktuelles Zentrum)_") + + with c3: + # EDITIEREN: Immer für den INSPIZIERTEN Knoten + if inspected_data: + st.button("📝 Bearbeiten", + use_container_width=True, + on_click=switch_to_editor_callback, + args=(inspected_data,), + key="cy_edit_btn") + + # --- INSPECTOR --- + with st.expander("🕵️ Data Inspector (Details)", expanded=True): # Default offen für bessere UX + if inspected_data: col_i1, col_i2 = st.columns(2) with col_i1: - st.markdown(f"**Titel:** {note_data.get('title')}") - st.markdown(f"**Typ:** `{note_data.get('type')}`") + 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(note_data.get('tags', []))}") - path_check = "✅" if note_data.get('path') else "❌" - st.markdown(f"**Pfad:** {path_check}") + st.markdown(f"**Tags:** {', '.join(inspected_data.get('tags', []))}") + path_str = inspected_data.get('path') or inspected_data.get('file_path') or "N/A" + st.markdown(f"**Pfad:** `{path_str}`") st.divider() - # Vorschau des Inhalts - content_preview = note_data.get('fulltext', '')[:500] - st.text_area("Inhalt (Vorschau)", content_preview + "...", height=100, disabled=True) - - with st.expander("Raw JSON"): - st.json(note_data) + content_preview = inspected_data.get('fulltext', '')[:600] + st.text_area("Inhalt (Vorschau)", content_preview + "...", height=150, disabled=True) else: - st.warning("Keine Daten für diesen Knoten.") + st.warning("Keine Daten für Auswahl geladen.") - # 3. Daten Konvertierung (Agraph -> Cytoscape JSON) + # --- GRAPH RENDERING --- cy_elements = [] - # Nodes + # Nodes konvertieren for n in nodes_data: - # Hover-Text aus n.title (vom Service vorbereitet) - tooltip_text = n.title if n.title else n.label + # Styles berechnen + is_center = (n.id == center_id) + is_inspected = (n.id == inspected_id) - # Label kürzen für Anzeige im Kreis + tooltip_text = n.title if n.title else n.label display_label = n.label if len(display_label) > 15 and " " in display_label: display_label = display_label.replace(" ", "\n", 1) @@ -121,60 +149,59 @@ def render_graph_explorer_cytoscape(graph_service): "data": { "id": n.id, "label": display_label, - "full_label": n.label, - "bg_color": n.color, - "size": 50 if n.id == center_id else 30, - # Tooltip Datenfeld + "bg_color": n.color, "tooltip": tooltip_text }, - "selected": (n.id == center_id) + # Selektion markiert den INSPIZIERTEN Knoten, nicht zwingend das Zentrum + "selected": is_inspected, + "classes": "center" if is_center else "" } cy_elements.append(cy_node) - # Edges + # Edges konvertieren for e in edges_data: - # Kompatibilität Agraph -> Cytoscape 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 + "source": e.source, "target": target_id, "label": e.label, "line_color": e.color } } cy_elements.append(cy_edge) - # 4. Styling (CSS-like) + # Stylesheet definieren stylesheet = [ { "selector": "node", "style": { "label": "data(label)", - "width": "data(size)", - "height": "data(size)", + "width": "30px", "height": "30px", "background-color": "data(bg_color)", - "color": "#333", - "font-size": "12px", - "text-valign": "center", - "text-halign": "center", - "text-wrap": "wrap", - "text-max-width": "80px", - "border-width": 2, - "border-color": "#fff", - # Tooltip Mapping (Browser abhängig) + "color": "#333", "font-size": "12px", + "text-valign": "center", "text-halign": "center", + "text-wrap": "wrap", "text-max-width": "90px", + "border-width": 2, "border-color": "#fff", "title": "data(tooltip)" } }, + # Style für den inspizierten Knoten (Gelber Rahmen, Größer) { "selector": "node:selected", + "style": { + "border-width": 6, + "border-color": "#FFC300", # Gelb/Gold für Auswahl + "width": "50px", "height": "50px", + "font-weight": "bold", + "z-index": 999 + } + }, + # Style für das Zentrum (Roter Rahmen, falls nicht selektiert) + { + "selector": ".center", "style": { "border-width": 4, - "border-color": "#FF5733", - "background-color": "#FF5733", - "color": "#fff" + "border-color": "#FF5733", # Rot + "width": "40px", "height": "40px" } }, { @@ -186,61 +213,41 @@ def render_graph_explorer_cytoscape(graph_service): "target-arrow-shape": "triangle", "curve-style": "bezier", "label": "data(label)", - "font-size": "10px", - "color": "#777", - "text-background-opacity": 0.8, - "text-background-color": "#fff", - "text-rotation": "autorotate" + "font-size": "10px", "color": "#666", + "text-background-opacity": 0.8, "text-background-color": "#fff" } } ] - # 5. Rendering & Layout + # Render Graph graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}" - # Das Event-Dictionary enthält die geklickten Elemente 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, + "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 + "edgeElasticity": 100, "nestingFactor": 5, "gravity": 80, + "numIter": 1000, "initialTemp": 200, "coolingFactor": 0.95, "minTemp": 1.0 }, key=graph_key, - height="800px" + height="700px" ) - # 6. Interaktions-Logik (Navigation Fix) + # --- EVENT HANDLING (Selektion) --- if clicked_elements: - # clicked_elements['nodes'] ist eine Liste von IDs - clicked_node_ids = clicked_elements.get("nodes", []) - - target_node = None - - # Wir suchen einen Node, der NICHT das aktuelle Zentrum ist - for nid in clicked_node_ids: - if nid != center_id: - target_node = nid - break - - # Wenn ein neuer Knoten gefunden wurde -> Navigieren - if target_node: - st.session_state.graph_center_id = target_node - st.rerun() + clicked_nodes = clicked_elements.get("nodes", []) + if clicked_nodes: + clicked_id = clicked_nodes[0] + + # LOGIK: Nur Inspektion ändern, nicht Zentrum + 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 From 582aed61ec23d19754cec39860fe3fe96dff1850 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 15:35:39 +0100 Subject: [PATCH 27/36] bug fix --- app/frontend/ui_graph_cytoscape.py | 45 +++++++++++++++--------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index 62f87f7..f987096 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -11,7 +11,7 @@ def render_graph_explorer_cytoscape(graph_service): if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Neu: Getrennter State für Inspektion vs. Navigation + # Getrennter State für Inspektion vs. Navigation if "graph_inspected_id" not in st.session_state: st.session_state.graph_inspected_id = None @@ -35,12 +35,11 @@ def render_graph_explorer_cytoscape(graph_service): ) options = {h.payload['title']: h.payload['note_id'] for h in hits} if options: - # Bei Suche setzen wir beides neu: Zentrum und Inspektion 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 # Gleichzeitig inspizieren + st.session_state.graph_inspected_id = new_id st.rerun() st.divider() @@ -56,6 +55,7 @@ def render_graph_explorer_cytoscape(graph_service): st.divider() st.caption("Legende") + # Hier zeigen wir die Farben an, die auch im Graphen genutzt werden for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) @@ -76,27 +76,25 @@ def render_graph_explorer_cytoscape(graph_service): inspected_id = st.session_state.graph_inspected_id # --- DATEN LADEN --- - # 1. Graph für das ZENTRUM laden with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): nodes_data, edges_data = graph_service.get_ego_graph( center_id, depth=st.session_state.cy_depth ) - - # 2. Daten für den INSPIZIERTEN Knoten laden (für Editor/Inspector) + # Daten für den INSPIZIERTEN Knoten laden inspected_data = graph_service.get_note_with_full_content(inspected_id) # --- ACTION BAR (OBEN) --- action_container = st.container() with action_container: - # Info Zeile c1, c2, c3 = st.columns([2, 1, 1]) with c1: - st.info(f"**Ausgewählt:** {inspected_data.get('title', inspected_id) if inspected_data else inspected_id}") + title_show = inspected_data.get('title', inspected_id) if inspected_data else inspected_id + st.info(f"**Ausgewählt:** {title_show}") with c2: - # NAVIGATION: Nur anzeigen, wenn Inspiziert != Zentrum + # NAVIGATION: Nur wenn Inspiziert != Zentrum if inspected_id != center_id: if st.button("🎯 Als Zentrum setzen", use_container_width=True, key="cy_nav_btn"): st.session_state.graph_center_id = inspected_id @@ -105,7 +103,7 @@ def render_graph_explorer_cytoscape(graph_service): st.caption("_(Ist aktuelles Zentrum)_") with c3: - # EDITIEREN: Immer für den INSPIZIERTEN Knoten + # EDITIEREN if inspected_data: st.button("📝 Bearbeiten", use_container_width=True, @@ -114,7 +112,7 @@ def render_graph_explorer_cytoscape(graph_service): key="cy_edit_btn") # --- INSPECTOR --- - with st.expander("🕵️ Data Inspector (Details)", expanded=True): # Default offen für bessere UX + with st.expander("🕵️ Data Inspector (Details)", expanded=True): if inspected_data: col_i1, col_i2 = st.columns(2) with col_i1: @@ -136,7 +134,6 @@ def render_graph_explorer_cytoscape(graph_service): # Nodes konvertieren for n in nodes_data: - # Styles berechnen is_center = (n.id == center_id) is_inspected = (n.id == inspected_id) @@ -149,10 +146,10 @@ def render_graph_explorer_cytoscape(graph_service): "data": { "id": n.id, "label": display_label, - "bg_color": n.color, + # WICHTIG: Hier übergeben wir die Farbe an Cytoscape + "bg_color": n.color, "tooltip": tooltip_text }, - # Selektion markiert den INSPIZIERTEN Knoten, nicht zwingend das Zentrum "selected": is_inspected, "classes": "center" if is_center else "" } @@ -164,7 +161,10 @@ def render_graph_explorer_cytoscape(graph_service): if target_id: cy_edge = { "data": { - "source": e.source, "target": target_id, "label": e.label, "line_color": e.color + "source": e.source, + "target": target_id, + "label": e.label, + "line_color": e.color } } cy_elements.append(cy_edge) @@ -176,7 +176,8 @@ def render_graph_explorer_cytoscape(graph_service): "style": { "label": "data(label)", "width": "30px", "height": "30px", - "background-color": "data(bg_color)", + # HIER NUTZEN WIR DIE FARBE: + "background-color": "data(bg_color)", "color": "#333", "font-size": "12px", "text-valign": "center", "text-halign": "center", "text-wrap": "wrap", "text-max-width": "90px", @@ -184,23 +185,23 @@ def render_graph_explorer_cytoscape(graph_service): "title": "data(tooltip)" } }, - # Style für den inspizierten Knoten (Gelber Rahmen, Größer) + # Style für Selektion (Gelb) { "selector": "node:selected", "style": { "border-width": 6, - "border-color": "#FFC300", # Gelb/Gold für Auswahl + "border-color": "#FFC300", "width": "50px", "height": "50px", "font-weight": "bold", "z-index": 999 } }, - # Style für das Zentrum (Roter Rahmen, falls nicht selektiert) + # Style für Zentrum (Rot) { "selector": ".center", "style": { "border-width": 4, - "border-color": "#FF5733", # Rot + "border-color": "#FF5733", "width": "40px", "height": "40px" } }, @@ -238,13 +239,13 @@ def render_graph_explorer_cytoscape(graph_service): height="700px" ) - # --- EVENT HANDLING (Selektion) --- + # --- EVENT HANDLING (Nur Selektion ändern) --- if clicked_elements: clicked_nodes = clicked_elements.get("nodes", []) if clicked_nodes: clicked_id = clicked_nodes[0] - # LOGIK: Nur Inspektion ändern, nicht Zentrum + # Nur Inspektion ändern, NICHT das Zentrum neu laden if clicked_id != st.session_state.graph_inspected_id: st.session_state.graph_inspected_id = clicked_id st.rerun() From eb45d78c4744535eeb32a39fa92d4803d4c6186f Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 15:42:36 +0100 Subject: [PATCH 28/36] kleine visuelle verbesserung an ui_graph_cytoscape --- app/frontend/ui_graph_cytoscape.py | 50 +++++++++++++++++------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index f987096..b78d214 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -11,7 +11,7 @@ def render_graph_explorer_cytoscape(graph_service): if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Getrennter State für Inspektion vs. Navigation + # Getrennter State für Inspektion (Gelber Rahmen) vs. Navigation (Roter Rahmen) if "graph_inspected_id" not in st.session_state: st.session_state.graph_inspected_id = None @@ -55,7 +55,6 @@ def render_graph_explorer_cytoscape(graph_service): st.divider() st.caption("Legende") - # Hier zeigen wir die Farben an, die auch im Graphen genutzt werden for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) @@ -63,7 +62,7 @@ def render_graph_explorer_cytoscape(graph_service): with col_graph: center_id = st.session_state.graph_center_id - # Falls noch nichts ausgewählt, initialisiere mit Inspektion oder None + # Initialisierung falls leer if not center_id and st.session_state.graph_inspected_id: center_id = st.session_state.graph_inspected_id st.session_state.graph_center_id = center_id @@ -77,16 +76,18 @@ def render_graph_explorer_cytoscape(graph_service): # --- DATEN LADEN --- with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): + # 1. Graph Daten nodes_data, edges_data = graph_service.get_ego_graph( center_id, depth=st.session_state.cy_depth ) - # Daten für den INSPIZIERTEN Knoten laden + # 2. Detail Daten (nur für die Inspektion) inspected_data = graph_service.get_note_with_full_content(inspected_id) - # --- ACTION BAR (OBEN) --- + # --- ACTION BAR --- action_container = st.container() with action_container: + # Info Zeile c1, c2, c3 = st.columns([2, 1, 1]) with c1: @@ -98,6 +99,7 @@ def render_graph_explorer_cytoscape(graph_service): if inspected_id != center_id: if st.button("🎯 Als Zentrum setzen", use_container_width=True, key="cy_nav_btn"): st.session_state.graph_center_id = inspected_id + # WICHTIG: Beim Navigieren (Zentrumswechsel) wird neu gerendert st.rerun() else: st.caption("_(Ist aktuelles Zentrum)_") @@ -111,8 +113,8 @@ def render_graph_explorer_cytoscape(graph_service): args=(inspected_data,), key="cy_edit_btn") - # --- INSPECTOR --- - with st.expander("🕵️ Data Inspector (Details)", expanded=True): + # --- INSPECTOR (Standard: Geschlossen) --- + with st.expander("🕵️ Data Inspector (Details)", expanded=False): if inspected_data: col_i1, col_i2 = st.columns(2) with col_i1: @@ -129,12 +131,14 @@ def render_graph_explorer_cytoscape(graph_service): else: st.warning("Keine Daten für Auswahl geladen.") - # --- GRAPH RENDERING --- + # --- GRAPH PREPARATION --- cy_elements = [] - # Nodes konvertieren + # Nodes for n in nodes_data: is_center = (n.id == center_id) + # Nur der aktuell inspizierte Knoten bekommt 'selected: True'. + # Alle anderen bekommen automatisch False. Das löst das "Abwahl"-Problem. is_inspected = (n.id == inspected_id) tooltip_text = n.title if n.title else n.label @@ -146,7 +150,6 @@ def render_graph_explorer_cytoscape(graph_service): "data": { "id": n.id, "label": display_label, - # WICHTIG: Hier übergeben wir die Farbe an Cytoscape "bg_color": n.color, "tooltip": tooltip_text }, @@ -155,7 +158,7 @@ def render_graph_explorer_cytoscape(graph_service): } cy_elements.append(cy_node) - # Edges konvertieren + # Edges for e in edges_data: target_id = getattr(e, "to", getattr(e, "target", None)) if target_id: @@ -169,14 +172,13 @@ def render_graph_explorer_cytoscape(graph_service): } cy_elements.append(cy_edge) - # Stylesheet definieren + # Stylesheet stylesheet = [ { "selector": "node", "style": { "label": "data(label)", "width": "30px", "height": "30px", - # HIER NUTZEN WIR DIE FARBE: "background-color": "data(bg_color)", "color": "#333", "font-size": "12px", "text-valign": "center", "text-halign": "center", @@ -185,23 +187,21 @@ def render_graph_explorer_cytoscape(graph_service): "title": "data(tooltip)" } }, - # Style für Selektion (Gelb) { "selector": "node:selected", "style": { "border-width": 6, - "border-color": "#FFC300", + "border-color": "#FFC300", # Gelb = Inspektion "width": "50px", "height": "50px", "font-weight": "bold", "z-index": 999 } }, - # Style für Zentrum (Rot) { "selector": ".center", "style": { "border-width": 4, - "border-color": "#FF5733", + "border-color": "#FF5733", # Rot = Zentrum "width": "40px", "height": "40px" } }, @@ -220,8 +220,11 @@ def render_graph_explorer_cytoscape(graph_service): } ] - # Render Graph - graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}" + # --- RENDERING (STABLE KEY) --- + # WICHTIG: Der Key darf NICHT 'inspected_id' enthalten! + # Nur wenn sich das ZENTRUM oder das LAYOUT ändert, darf die Komponente + # neu initialisiert werden. Bei reiner Selektion bleibt der Key gleich. + graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}_{st.session_state.cy_node_repulsion}" clicked_elements = cytoscape( elements=cy_elements, @@ -235,19 +238,22 @@ def render_graph_explorer_cytoscape(graph_service): "edgeElasticity": 100, "nestingFactor": 5, "gravity": 80, "numIter": 1000, "initialTemp": 200, "coolingFactor": 0.95, "minTemp": 1.0 }, - key=graph_key, + key=graph_key, height="700px" ) - # --- EVENT HANDLING (Nur Selektion ändern) --- + # --- SELECTION HANDLING --- if clicked_elements: clicked_nodes = clicked_elements.get("nodes", []) if clicked_nodes: clicked_id = clicked_nodes[0] - # Nur Inspektion ändern, NICHT das Zentrum neu laden + # Wenn auf einen neuen Knoten geklickt wurde: if clicked_id != st.session_state.graph_inspected_id: + # 1. State aktualisieren (Inspektion verschieben) st.session_state.graph_inspected_id = clicked_id + # 2. Rerun triggern, um UI (Inspector/Buttons) zu aktualisieren + # Da der graph_key sich NICHT ändert, bleibt der Graph stabil! st.rerun() else: From 0cb216c2bca7da6bfcb63e3fff2074a4dce97d84 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 16:12:19 +0100 Subject: [PATCH 29/36] update --- app/frontend/ui_graph_cytoscape.py | 96 ++++++++++++++++-------------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index b78d214..df7645f 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -11,7 +11,6 @@ def render_graph_explorer_cytoscape(graph_service): if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Getrennter State für Inspektion (Gelber Rahmen) vs. Navigation (Roter Rahmen) if "graph_inspected_id" not in st.session_state: st.session_state.graph_inspected_id = None @@ -22,7 +21,7 @@ def render_graph_explorer_cytoscape(graph_service): col_ctrl, col_graph = st.columns([1, 4]) - # --- LINKES PANEL: SUCHE & SETTINGS --- + # --- LINKES PANEL --- with col_ctrl: st.subheader("Fokus") search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search") @@ -58,17 +57,16 @@ def render_graph_explorer_cytoscape(graph_service): for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) - # --- RECHTES PANEL: GRAPH & INSPECTOR --- + # --- RECHTES PANEL --- with col_graph: center_id = st.session_state.graph_center_id - # Initialisierung falls leer + # Init Fallback if not center_id and st.session_state.graph_inspected_id: center_id = st.session_state.graph_inspected_id st.session_state.graph_center_id = center_id if center_id: - # Sync: Wenn Inspection None ist, setze auf Center if not st.session_state.graph_inspected_id: st.session_state.graph_inspected_id = center_id @@ -76,33 +74,30 @@ def render_graph_explorer_cytoscape(graph_service): # --- DATEN LADEN --- with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): - # 1. Graph Daten nodes_data, edges_data = graph_service.get_ego_graph( center_id, depth=st.session_state.cy_depth ) - # 2. Detail Daten (nur für die Inspektion) + # Daten nur für den INSPECTOR laden inspected_data = graph_service.get_note_with_full_content(inspected_id) # --- ACTION BAR --- action_container = st.container() with action_container: - # Info Zeile c1, c2, c3 = st.columns([2, 1, 1]) with c1: title_show = inspected_data.get('title', inspected_id) if inspected_data else inspected_id - st.info(f"**Ausgewählt:** {title_show}") + st.info(f"**Info:** {title_show}") with c2: - # NAVIGATION: Nur wenn Inspiziert != Zentrum + # NAVIGATION if inspected_id != center_id: if st.button("🎯 Als Zentrum setzen", use_container_width=True, key="cy_nav_btn"): st.session_state.graph_center_id = inspected_id - # WICHTIG: Beim Navigieren (Zentrumswechsel) wird neu gerendert st.rerun() else: - st.caption("_(Ist aktuelles Zentrum)_") + st.caption("_(Zentrum)_") with c3: # EDITIEREN @@ -113,8 +108,8 @@ def render_graph_explorer_cytoscape(graph_service): args=(inspected_data,), key="cy_edit_btn") - # --- INSPECTOR (Standard: Geschlossen) --- - with st.expander("🕵️ Data Inspector (Details)", expanded=False): + # --- DATA INSPECTOR (Eingeklappt für mehr Platz) --- + with st.expander("🕵️ Data Inspector", expanded=False): if inspected_data: col_i1, col_i2 = st.columns(2) with col_i1: @@ -122,23 +117,18 @@ def render_graph_explorer_cytoscape(graph_service): st.markdown(f"**Typ:** `{inspected_data.get('type')}`") with col_i2: st.markdown(f"**Tags:** {', '.join(inspected_data.get('tags', []))}") - path_str = inspected_data.get('path') or inspected_data.get('file_path') or "N/A" - st.markdown(f"**Pfad:** `{path_str}`") - - st.divider() - content_preview = inspected_data.get('fulltext', '')[:600] - st.text_area("Inhalt (Vorschau)", content_preview + "...", height=150, disabled=True) + path_check = "✅" if inspected_data.get('path') else "❌" + st.markdown(f"**Pfad:** {path_check}") + st.text_area("Inhalt", inspected_data.get('fulltext', '')[:1000], height=200, disabled=True) else: - st.warning("Keine Daten für Auswahl geladen.") + st.warning("Keine Daten geladen.") - # --- GRAPH PREPARATION --- + # --- GRAPH ELEMENTS --- cy_elements = [] - # Nodes for n in nodes_data: is_center = (n.id == center_id) - # Nur der aktuell inspizierte Knoten bekommt 'selected: True'. - # Alle anderen bekommen automatisch False. Das löst das "Abwahl"-Problem. + # Selektion wird visuell übergeben is_inspected = (n.id == inspected_id) tooltip_text = n.title if n.title else n.label @@ -158,7 +148,6 @@ def render_graph_explorer_cytoscape(graph_service): } cy_elements.append(cy_node) - # Edges for e in edges_data: target_id = getattr(e, "to", getattr(e, "target", None)) if target_id: @@ -172,7 +161,7 @@ def render_graph_explorer_cytoscape(graph_service): } cy_elements.append(cy_edge) - # Stylesheet + # --- STYLESHEET --- stylesheet = [ { "selector": "node", @@ -187,21 +176,22 @@ def render_graph_explorer_cytoscape(graph_service): "title": "data(tooltip)" } }, + # Selektierter Knoten (Gelb) { "selector": "node:selected", "style": { "border-width": 6, - "border-color": "#FFC300", # Gelb = Inspektion + "border-color": "#FFC300", "width": "50px", "height": "50px", - "font-weight": "bold", - "z-index": 999 + "font-weight": "bold" } }, + # Zentrum (Rot) { "selector": ".center", "style": { "border-width": 4, - "border-color": "#FF5733", # Rot = Zentrum + "border-color": "#FF5733", "width": "40px", "height": "40px" } }, @@ -220,40 +210,56 @@ def render_graph_explorer_cytoscape(graph_service): } ] - # --- RENDERING (STABLE KEY) --- - # WICHTIG: Der Key darf NICHT 'inspected_id' enthalten! - # Nur wenn sich das ZENTRUM oder das LAYOUT ändert, darf die Komponente - # neu initialisiert werden. Bei reiner Selektion bleibt der Key gleich. - graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}_{st.session_state.cy_node_repulsion}" + # --- CONFIG: SINGLE SELECT --- + # Das erzwingt, dass beim Klicken einer Node die anderen deselektiert werden + cy_config = { + "selectionType": "single", + "boxSelectionEnabled": False, + "userZoomingEnabled": True, + "userPanningEnabled": True + } + + # --- RENDER --- + # Stable Key: Ändert sich NICHT bei Inspektion, nur bei Zentrum/Layout + 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 Config layout={ "name": "cose", "idealEdgeLength": st.session_state.cy_ideal_edge_len, - "nodeOverlap": 20, "refresh": 20, "fit": True, "padding": 50, - "randomize": False, "componentSpacing": 100, + "nodeOverlap": 20, + "refresh": 20, + "fit": True, + "padding": 50, + "randomize": False, # WICHTIG gegen das Springen! + "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 + "edgeElasticity": 100, + "nestingFactor": 5, + "gravity": 80, + "numIter": 1000, + "initialTemp": 200, + "coolingFactor": 0.95, + "minTemp": 1.0, + "animate": False # Animation aus, damit es sofort stabil ist }, + config=cy_config, # Config Objekt übergeben key=graph_key, height="700px" ) - # --- SELECTION HANDLING --- + # --- INTERACTION --- if clicked_elements: clicked_nodes = clicked_elements.get("nodes", []) if clicked_nodes: clicked_id = clicked_nodes[0] - # Wenn auf einen neuen Knoten geklickt wurde: + # Wenn neuer Knoten geklickt wurde -> Inspektion ändern if clicked_id != st.session_state.graph_inspected_id: - # 1. State aktualisieren (Inspektion verschieben) st.session_state.graph_inspected_id = clicked_id - # 2. Rerun triggern, um UI (Inspector/Buttons) zu aktualisieren - # Da der graph_key sich NICHT ändert, bleibt der Graph stabil! st.rerun() else: From 8772271648cf7a6b0c734ac8069ec3894b580452 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 16:15:32 +0100 Subject: [PATCH 30/36] bug fix --- app/frontend/ui_graph_cytoscape.py | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index df7645f..108d0f2 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -11,6 +11,7 @@ def render_graph_explorer_cytoscape(graph_service): if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None + # Getrennter State für Inspektion (Gelber Rahmen) vs. Navigation (Roter Rahmen) if "graph_inspected_id" not in st.session_state: st.session_state.graph_inspected_id = None @@ -78,7 +79,7 @@ def render_graph_explorer_cytoscape(graph_service): center_id, depth=st.session_state.cy_depth ) - # Daten nur für den INSPECTOR laden + # Daten für den INSPECTOR laden inspected_data = graph_service.get_note_with_full_content(inspected_id) # --- ACTION BAR --- @@ -108,7 +109,7 @@ def render_graph_explorer_cytoscape(graph_service): args=(inspected_data,), key="cy_edit_btn") - # --- DATA INSPECTOR (Eingeklappt für mehr Platz) --- + # --- DATA INSPECTOR (Eingeklappt) --- with st.expander("🕵️ Data Inspector", expanded=False): if inspected_data: col_i1, col_i2 = st.columns(2) @@ -128,7 +129,7 @@ def render_graph_explorer_cytoscape(graph_service): for n in nodes_data: is_center = (n.id == center_id) - # Selektion wird visuell übergeben + # Selektion wird visuell über Stylesheet gesteuert is_inspected = (n.id == inspected_id) tooltip_text = n.title if n.title else n.label @@ -176,7 +177,6 @@ def render_graph_explorer_cytoscape(graph_service): "title": "data(tooltip)" } }, - # Selektierter Knoten (Gelb) { "selector": "node:selected", "style": { @@ -186,7 +186,6 @@ def render_graph_explorer_cytoscape(graph_service): "font-weight": "bold" } }, - # Zentrum (Rot) { "selector": ".center", "style": { @@ -210,23 +209,13 @@ def render_graph_explorer_cytoscape(graph_service): } ] - # --- CONFIG: SINGLE SELECT --- - # Das erzwingt, dass beim Klicken einer Node die anderen deselektiert werden - cy_config = { - "selectionType": "single", - "boxSelectionEnabled": False, - "userZoomingEnabled": True, - "userPanningEnabled": True - } - # --- RENDER --- - # Stable Key: Ändert sich NICHT bei Inspektion, nur bei Zentrum/Layout 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 Config + # Layout Config (config Argument entfernt!) layout={ "name": "cose", "idealEdgeLength": st.session_state.cy_ideal_edge_len, @@ -234,7 +223,7 @@ def render_graph_explorer_cytoscape(graph_service): "refresh": 20, "fit": True, "padding": 50, - "randomize": False, # WICHTIG gegen das Springen! + "randomize": False, "componentSpacing": 100, "nodeRepulsion": st.session_state.cy_node_repulsion, "edgeElasticity": 100, @@ -244,9 +233,8 @@ def render_graph_explorer_cytoscape(graph_service): "initialTemp": 200, "coolingFactor": 0.95, "minTemp": 1.0, - "animate": False # Animation aus, damit es sofort stabil ist + "animate": False }, - config=cy_config, # Config Objekt übergeben key=graph_key, height="700px" ) From 48de6aed8ce268819c550e5525b09de20875b723 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 16:22:48 +0100 Subject: [PATCH 31/36] neu Strategie zum Node Select --- app/frontend/ui_graph_cytoscape.py | 83 +++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 23 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index 108d0f2..c314ebf 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -11,7 +11,6 @@ def render_graph_explorer_cytoscape(graph_service): if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Getrennter State für Inspektion (Gelber Rahmen) vs. Navigation (Roter Rahmen) if "graph_inspected_id" not in st.session_state: st.session_state.graph_inspected_id = None @@ -22,7 +21,7 @@ def render_graph_explorer_cytoscape(graph_service): col_ctrl, col_graph = st.columns([1, 4]) - # --- LINKES PANEL --- + # --- LINKES PANEL: SUCHE & SETTINGS --- with col_ctrl: st.subheader("Fokus") search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search") @@ -58,7 +57,7 @@ def render_graph_explorer_cytoscape(graph_service): for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) - # --- RECHTES PANEL --- + # --- RECHTES PANEL: GRAPH & INSPECTOR --- with col_graph: center_id = st.session_state.graph_center_id @@ -68,6 +67,7 @@ def render_graph_explorer_cytoscape(graph_service): st.session_state.graph_center_id = center_id if center_id: + # Sync: Wenn Inspection None ist, setze auf Center if not st.session_state.graph_inspected_id: st.session_state.graph_inspected_id = center_id @@ -75,11 +75,12 @@ def render_graph_explorer_cytoscape(graph_service): # --- DATEN LADEN --- with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): + # 1. Graph Daten nodes_data, edges_data = graph_service.get_ego_graph( center_id, depth=st.session_state.cy_depth ) - # Daten für den INSPECTOR laden + # 2. Detail Daten (nur für den Inspector) inspected_data = graph_service.get_note_with_full_content(inspected_id) # --- ACTION BAR --- @@ -89,7 +90,7 @@ def render_graph_explorer_cytoscape(graph_service): with c1: title_show = inspected_data.get('title', inspected_id) if inspected_data else inspected_id - st.info(f"**Info:** {title_show}") + st.info(f"**Ausgewählt:** {title_show}") with c2: # NAVIGATION @@ -98,7 +99,7 @@ def render_graph_explorer_cytoscape(graph_service): st.session_state.graph_center_id = inspected_id st.rerun() else: - st.caption("_(Zentrum)_") + st.caption("_(Ist aktuelles Zentrum)_") with c3: # EDITIEREN @@ -128,9 +129,10 @@ def render_graph_explorer_cytoscape(graph_service): cy_elements = [] for n in nodes_data: - is_center = (n.id == center_id) - # Selektion wird visuell über Stylesheet gesteuert - is_inspected = (n.id == inspected_id) + # Logik: Wir vergeben Klassen statt Selection-State + classes = [] + if n.id == center_id: classes.append("center") + if n.id == inspected_id: classes.append("inspected") tooltip_text = n.title if n.title else n.label display_label = n.label @@ -144,8 +146,8 @@ def render_graph_explorer_cytoscape(graph_service): "bg_color": n.color, "tooltip": tooltip_text }, - "selected": is_inspected, - "classes": "center" if is_center else "" + "classes": " ".join(classes), + "selected": False # Wir deaktivieren die interne Selektion beim Init } cy_elements.append(cy_node) @@ -162,7 +164,7 @@ def render_graph_explorer_cytoscape(graph_service): } cy_elements.append(cy_edge) - # --- STYLESHEET --- + # --- STYLESHEET (Klassen-basiert) --- stylesheet = [ { "selector": "node", @@ -177,23 +179,44 @@ def render_graph_explorer_cytoscape(graph_service): "title": "data(tooltip)" } }, + # Klasse .inspected = Gelber Rahmen (Ersetzt :selected) { - "selector": "node:selected", + "selector": ".inspected", "style": { "border-width": 6, - "border-color": "#FFC300", + "border-color": "#FFC300", # Gelb/Gold "width": "50px", "height": "50px", - "font-weight": "bold" + "font-weight": "bold", + "z-index": 999 } }, + # Klasse .center = Roter Rahmen { "selector": ".center", "style": { "border-width": 4, - "border-color": "#FF5733", + "border-color": "#FF5733", # Rot "width": "40px", "height": "40px" } }, + # Wenn beides zutrifft (Zentrum ist inspiziert) + { + "selector": ".center.inspected", + "style": { + "border-width": 6, + "border-color": "#FF5733", # Rot gewinnt oder Mix + "width": "55px", "height": "55px" + } + }, + # Interne Selektion unsichtbar machen oder angleichen + { + "selector": "node:selected", + "style": { + "overlay-opacity": 0, # Kein blauer Schleier + "border-width": 6, + "border-color": "#FFC300" # Feedback beim Klick + } + }, { "selector": "edge", "style": { @@ -210,12 +233,14 @@ def render_graph_explorer_cytoscape(graph_service): ] # --- RENDER --- + # KEY-STRATEGIE: + # Der Key hängt NUR vom Zentrum und den Settings ab. + # Wenn sich inspected_id ändert, bleibt der Key GLEICH -> Kein Re-Layout, nur Style-Update. 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 Config (config Argument entfernt!) layout={ "name": "cose", "idealEdgeLength": st.session_state.cy_ideal_edge_len, @@ -223,7 +248,7 @@ def render_graph_explorer_cytoscape(graph_service): "refresh": 20, "fit": True, "padding": 50, - "randomize": False, + "randomize": False, # WICHTIG gegen Springen "componentSpacing": 100, "nodeRepulsion": st.session_state.cy_node_repulsion, "edgeElasticity": 100, @@ -239,15 +264,27 @@ def render_graph_explorer_cytoscape(graph_service): height="700px" ) - # --- INTERACTION --- + # --- EVENT HANDLING --- if clicked_elements: clicked_nodes = clicked_elements.get("nodes", []) + + # Wir suchen nach der Node, die neu angeklickt wurde if clicked_nodes: - clicked_id = clicked_nodes[0] + # Wir nehmen die letzte Node in der Liste (meistens die neu geklickte) + # oder filtern nach der, die nicht schon inspected ist + candidate_id = None - # Wenn neuer Knoten geklickt wurde -> Inspektion ändern - if clicked_id != st.session_state.graph_inspected_id: - st.session_state.graph_inspected_id = clicked_id + # Strategie: Wenn Liste > 1, nimm die ID die NICHT inspected_id ist + if len(clicked_nodes) > 1: + for nid in clicked_nodes: + if nid != st.session_state.graph_inspected_id: + candidate_id = nid + break + else: + candidate_id = clicked_nodes[0] + + if candidate_id and candidate_id != st.session_state.graph_inspected_id: + st.session_state.graph_inspected_id = candidate_id st.rerun() else: From 9a55d45832024aabb53d0652a6f6602166a6cc02 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 16:29:08 +0100 Subject: [PATCH 32/36] update --- app/frontend/ui_graph_cytoscape.py | 158 +++++++++++++++++------------ 1 file changed, 95 insertions(+), 63 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index c314ebf..38d0023 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -7,25 +7,31 @@ from ui_callbacks import switch_to_editor_callback def render_graph_explorer_cytoscape(graph_service): st.header("🕸️ Graph Explorer (Cytoscape)") - # --- STATE INITIALISIERUNG --- + # --- 1. STATE INITIALISIERUNG --- + # Graph Zentrum (Roter Rahmen, bestimmt die geladenen Daten) if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None + # Inspizierter Knoten (Gelber Rahmen, bestimmt Inspector & Editor) if "graph_inspected_id" not in st.session_state: st.session_state.graph_inspected_id = None - # Defaults für Layout + # Defaults für Layout-Parameter (COSE) st.session_state.setdefault("cy_node_repulsion", 1000000) st.session_state.setdefault("cy_ideal_edge_len", 150) st.session_state.setdefault("cy_depth", 2) + # Layout Spalten col_ctrl, col_graph = st.columns([1, 4]) - # --- LINKES PANEL: SUCHE & SETTINGS --- + # --- 2. LINKES PANEL (CONTROLS) --- with col_ctrl: st.subheader("Fokus") + + # Suchfeld search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search") + # Suchlogik if search_term: hits, _ = graph_service.client.scroll( collection_name=f"{COLLECTION_PREFIX}_notes", @@ -33,18 +39,22 @@ def render_graph_explorer_cytoscape(graph_service): 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] + # Bei Suche setzen wir beides neu st.session_state.graph_center_id = new_id st.session_state.graph_inspected_id = new_id st.rerun() st.divider() + # Layout Einstellungen with st.expander("👁️ Layout Einstellungen", expanded=True): st.session_state.cy_depth = st.slider("Tiefe (Tier)", 1, 3, st.session_state.cy_depth, key="cy_depth_slider") + st.markdown("**COSE Layout**") st.session_state.cy_ideal_edge_len = st.slider("Kantenlänge", 50, 600, st.session_state.cy_ideal_edge_len, key="cy_len_slider") st.session_state.cy_node_repulsion = st.slider("Knoten-Abstoßung", 100000, 5000000, st.session_state.cy_node_repulsion, step=100000, key="cy_rep_slider") @@ -53,15 +63,17 @@ def render_graph_explorer_cytoscape(graph_service): st.rerun() st.divider() + + # Legende st.caption("Legende") for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) - # --- RECHTES PANEL: GRAPH & INSPECTOR --- + # --- 3. RECHTES PANEL (GRAPH & INSPECTOR) --- with col_graph: center_id = st.session_state.graph_center_id - # Init Fallback + # Initialisierung Fallback if not center_id and st.session_state.graph_inspected_id: center_id = st.session_state.graph_inspected_id st.session_state.graph_center_id = center_id @@ -73,27 +85,29 @@ def render_graph_explorer_cytoscape(graph_service): inspected_id = st.session_state.graph_inspected_id - # --- DATEN LADEN --- + # --- 3.1 DATEN LADEN --- with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): - # 1. Graph Daten + # 1. Graph Daten für das ZENTRUM nodes_data, edges_data = graph_service.get_ego_graph( center_id, depth=st.session_state.cy_depth ) - # 2. Detail Daten (nur für den Inspector) + # 2. Detail Daten für die INSPEKTION (Editor/Text) inspected_data = graph_service.get_note_with_full_content(inspected_id) - # --- ACTION BAR --- + # --- 3.2 ACTION BAR & INSPECTOR --- action_container = st.container() with action_container: + # Obere Zeile: Info & Buttons c1, c2, c3 = st.columns([2, 1, 1]) with c1: title_show = inspected_data.get('title', inspected_id) if inspected_data else inspected_id - st.info(f"**Ausgewählt:** {title_show}") + st.info(f"**Aktuell gewählt:** {title_show}") with c2: - # NAVIGATION + # NAVIGATION BUTTON + # Nur anzeigen, wenn wir nicht schon im Zentrum sind if inspected_id != center_id: if st.button("🎯 Als Zentrum setzen", use_container_width=True, key="cy_nav_btn"): st.session_state.graph_center_id = inspected_id @@ -102,7 +116,7 @@ def render_graph_explorer_cytoscape(graph_service): st.caption("_(Ist aktuelles Zentrum)_") with c3: - # EDITIEREN + # EDIT BUTTON if inspected_data: st.button("📝 Bearbeiten", use_container_width=True, @@ -110,8 +124,8 @@ def render_graph_explorer_cytoscape(graph_service): args=(inspected_data,), key="cy_edit_btn") - # --- DATA INSPECTOR (Eingeklappt) --- - with st.expander("🕵️ Data Inspector", expanded=False): + # DATA INSPECTOR (Standard: Eingeklappt) + with st.expander("🕵️ Data Inspector (Details)", expanded=False): if inspected_data: col_i1, col_i2 = st.columns(2) with col_i1: @@ -121,20 +135,26 @@ def render_graph_explorer_cytoscape(graph_service): st.markdown(f"**Tags:** {', '.join(inspected_data.get('tags', []))}") path_check = "✅" if inspected_data.get('path') else "❌" st.markdown(f"**Pfad:** {path_check}") - st.text_area("Inhalt", inspected_data.get('fulltext', '')[:1000], height=200, disabled=True) + + st.text_area("Inhalt (Vorschau)", inspected_data.get('fulltext', '')[:1000], height=200, disabled=True) else: st.warning("Keine Daten geladen.") - # --- GRAPH ELEMENTS --- + # --- 3.3 ELEMENT VORBEREITUNG --- cy_elements = [] + # Nodes erstellen for n in nodes_data: - # Logik: Wir vergeben Klassen statt Selection-State + # Klassenlogik für Styling (statt Selection State) classes = [] - if n.id == center_id: classes.append("center") - if n.id == inspected_id: classes.append("inspected") + if n.id == center_id: + classes.append("center") - tooltip_text = n.title if n.title else n.label + # Wir markieren den inspizierten Knoten visuell + if n.id == inspected_id: + classes.append("inspected") + + # Label kürzen für Anzeige display_label = n.label if len(display_label) > 15 and " " in display_label: display_label = display_label.replace(" ", "\n", 1) @@ -144,13 +164,18 @@ def render_graph_explorer_cytoscape(graph_service): "id": n.id, "label": display_label, "bg_color": n.color, - "tooltip": tooltip_text + # Tooltip Inhalt + "tooltip": n.title if n.title else n.label }, "classes": " ".join(classes), - "selected": False # Wir deaktivieren die interne Selektion beim Init + # WICHTIG: Wir setzen selected immer auf False beim Init, + # damit wir nicht mit dem internen State des Browsers kämpfen. + # Die Visualisierung passiert über die Klasse .inspected + "selected": False } cy_elements.append(cy_node) + # Edges erstellen for e in edges_data: target_id = getattr(e, "to", getattr(e, "target", None)) if target_id: @@ -164,59 +189,71 @@ def render_graph_explorer_cytoscape(graph_service): } cy_elements.append(cy_edge) - # --- STYLESHEET (Klassen-basiert) --- + # --- 3.4 STYLESHEET --- stylesheet = [ + # BASIS NODE STYLE { "selector": "node", "style": { "label": "data(label)", - "width": "30px", "height": "30px", + "width": "30px", + "height": "30px", "background-color": "data(bg_color)", - "color": "#333", "font-size": "12px", - "text-valign": "center", "text-halign": "center", - "text-wrap": "wrap", "text-max-width": "90px", - "border-width": 2, "border-color": "#fff", + "color": "#333", + "font-size": "12px", + "text-valign": "center", + "text-halign": "center", + "text-wrap": "wrap", + "text-max-width": "90px", + "border-width": 2, + "border-color": "#fff", "title": "data(tooltip)" } }, - # Klasse .inspected = Gelber Rahmen (Ersetzt :selected) + # KLASSE: INSPECTED (Gelber Rahmen) - Ersetzt :selected { "selector": ".inspected", "style": { "border-width": 6, "border-color": "#FFC300", # Gelb/Gold - "width": "50px", "height": "50px", + "width": "50px", + "height": "50px", "font-weight": "bold", "z-index": 999 } }, - # Klasse .center = Roter Rahmen + # KLASSE: CENTER (Roter Rahmen) { "selector": ".center", "style": { "border-width": 4, "border-color": "#FF5733", # Rot - "width": "40px", "height": "40px" + "width": "40px", + "height": "40px" } }, - # Wenn beides zutrifft (Zentrum ist inspiziert) + # KOMBINATION: Center ist auch Inspected { "selector": ".center.inspected", "style": { "border-width": 6, - "border-color": "#FF5733", # Rot gewinnt oder Mix - "width": "55px", "height": "55px" + "border-color": "#FF5733", # Rot gewinnt (oder Mix) + "width": "55px", + "height": "55px" } }, - # Interne Selektion unsichtbar machen oder angleichen + # NATIVE SELEKTION (Unterdrücken/Anpassen) + # Wir machen den Standard-Selektionsrahmen unsichtbar(er), + # da wir .inspected nutzen. { "selector": "node:selected", "style": { - "overlay-opacity": 0, # Kein blauer Schleier + "overlay-opacity": 0, "border-width": 6, - "border-color": "#FFC300" # Feedback beim Klick + "border-color": "#FFC300" } }, + # EDGE STYLE { "selector": "edge", "style": { @@ -226,16 +263,20 @@ def render_graph_explorer_cytoscape(graph_service): "target-arrow-shape": "triangle", "curve-style": "bezier", "label": "data(label)", - "font-size": "10px", "color": "#666", - "text-background-opacity": 0.8, "text-background-color": "#fff" + "font-size": "10px", + "color": "#666", + "text-background-opacity": 0.8, + "text-background-color": "#fff" } } ] - # --- RENDER --- - # KEY-STRATEGIE: - # Der Key hängt NUR vom Zentrum und den Settings ab. - # Wenn sich inspected_id ändert, bleibt der Key GLEICH -> Kein Re-Layout, nur Style-Update. + # --- 3.5 RENDERING --- + # KEY STRATEGIE: + # Der Key darf NICHT von 'graph_inspected_id' abhängen. + # Er hängt nur von 'center_id' und Layout-Settings ab. + # Wenn wir eine Node anklicken, ändert sich inspected_id -> Rerun. + # Da der Key gleich bleibt, wird der Graph NICHT neu initialisiert -> Kein Springen! graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}" clicked_elements = cytoscape( @@ -248,7 +289,7 @@ def render_graph_explorer_cytoscape(graph_service): "refresh": 20, "fit": True, "padding": 50, - "randomize": False, # WICHTIG gegen Springen + "randomize": False, # WICHTIG für Stabilität "componentSpacing": 100, "nodeRepulsion": st.session_state.cy_node_repulsion, "edgeElasticity": 100, @@ -260,31 +301,22 @@ def render_graph_explorer_cytoscape(graph_service): "minTemp": 1.0, "animate": False }, - key=graph_key, + key=graph_key, height="700px" ) - # --- EVENT HANDLING --- + # --- 3.6 EVENT HANDLING --- if clicked_elements: clicked_nodes = clicked_elements.get("nodes", []) - - # Wir suchen nach der Node, die neu angeklickt wurde if clicked_nodes: - # Wir nehmen die letzte Node in der Liste (meistens die neu geklickte) - # oder filtern nach der, die nicht schon inspected ist - candidate_id = None + # Die Liste enthält die IDs der selektierten Knoten + clicked_id = clicked_nodes[0] - # Strategie: Wenn Liste > 1, nimm die ID die NICHT inspected_id ist - if len(clicked_nodes) > 1: - for nid in clicked_nodes: - if nid != st.session_state.graph_inspected_id: - candidate_id = nid - break - else: - candidate_id = clicked_nodes[0] - - if candidate_id and candidate_id != st.session_state.graph_inspected_id: - st.session_state.graph_inspected_id = candidate_id + # Wenn wir auf einen neuen Knoten klicken, ändern wir NUR die Inspektion. + # Das triggert einen Rerun. + # Da der graph_key gleich bleibt, wird nur der Style (.inspected Klasse) geupdated. + if clicked_id != st.session_state.graph_inspected_id: + st.session_state.graph_inspected_id = clicked_id st.rerun() else: From cee8fc05c23fff9337d4b142aaad86a15cf60747 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 16:35:39 +0100 Subject: [PATCH 33/36] verbesserung --- app/frontend/ui_graph_cytoscape.py | 183 +++++++++++++++-------------- 1 file changed, 98 insertions(+), 85 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index 38d0023..8d20eab 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -7,31 +7,34 @@ from ui_callbacks import switch_to_editor_callback def render_graph_explorer_cytoscape(graph_service): st.header("🕸️ Graph Explorer (Cytoscape)") - # --- 1. STATE INITIALISIERUNG --- - # Graph Zentrum (Roter Rahmen, bestimmt die geladenen Daten) + # --------------------------------------------------------- + # 1. STATE MANAGEMENT + # --------------------------------------------------------- + # Das aktive Zentrum des Graphen (bestimmt welche Knoten geladen werden) if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Inspizierter Knoten (Gelber Rahmen, bestimmt Inspector & Editor) + # Der aktuell inspizierte Knoten (bestimmt Inspector & Editor Inhalt) + # Getrennt vom Zentrum, damit man klicken kann ohne neu zu laden. if "graph_inspected_id" not in st.session_state: st.session_state.graph_inspected_id = None - # Defaults für Layout-Parameter (COSE) + # Layout Defaults für COSE Algorithmus st.session_state.setdefault("cy_node_repulsion", 1000000) st.session_state.setdefault("cy_ideal_edge_len", 150) st.session_state.setdefault("cy_depth", 2) - # Layout Spalten col_ctrl, col_graph = st.columns([1, 4]) - # --- 2. LINKES PANEL (CONTROLS) --- + # --------------------------------------------------------- + # 2. LINKES PANEL (Steuerung) + # --------------------------------------------------------- with col_ctrl: st.subheader("Fokus") # Suchfeld search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search") - # Suchlogik if search_term: hits, _ = graph_service.client.scroll( collection_name=f"{COLLECTION_PREFIX}_notes", @@ -44,7 +47,7 @@ def render_graph_explorer_cytoscape(graph_service): 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] - # Bei Suche setzen wir beides neu + # Bei neuer Suche setzen wir beides auf das Ziel st.session_state.graph_center_id = new_id st.session_state.graph_inspected_id = new_id st.rerun() @@ -69,45 +72,47 @@ def render_graph_explorer_cytoscape(graph_service): for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) - # --- 3. RECHTES PANEL (GRAPH & INSPECTOR) --- + # --------------------------------------------------------- + # 3. RECHTES PANEL (Graph & Tools) + # --------------------------------------------------------- with col_graph: center_id = st.session_state.graph_center_id - # Initialisierung Fallback + # Fallback Init if not center_id and st.session_state.graph_inspected_id: center_id = st.session_state.graph_inspected_id st.session_state.graph_center_id = center_id if center_id: - # Sync: Wenn Inspection None ist, setze auf Center + # Sync: Falls Inspektion leer ist, mit Zentrum füllen if not st.session_state.graph_inspected_id: st.session_state.graph_inspected_id = center_id inspected_id = st.session_state.graph_inspected_id - # --- 3.1 DATEN LADEN --- + # --- DATEN LADEN --- with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): - # 1. Graph Daten für das ZENTRUM + # 1. Graph Daten (Abhängig vom Zentrum) nodes_data, edges_data = graph_service.get_ego_graph( center_id, depth=st.session_state.cy_depth ) - # 2. Detail Daten für die INSPEKTION (Editor/Text) + # 2. Detail Daten (Abhängig von der Inspektion) + # Holt Metadaten UND den gestitchten Volltext inspected_data = graph_service.get_note_with_full_content(inspected_id) - # --- 3.2 ACTION BAR & INSPECTOR --- + # --- ACTION BAR --- action_container = st.container() with action_container: - # Obere Zeile: Info & Buttons c1, c2, c3 = st.columns([2, 1, 1]) with c1: + # Titel anzeigen title_show = inspected_data.get('title', inspected_id) if inspected_data else inspected_id - st.info(f"**Aktuell gewählt:** {title_show}") + st.info(f"**Info:** {title_show}") with c2: - # NAVIGATION BUTTON - # Nur anzeigen, wenn wir nicht schon im Zentrum sind + # NAVIGATION: Nur aktiv, wenn wir nicht schon im Zentrum sind if inspected_id != center_id: if st.button("🎯 Als Zentrum setzen", use_container_width=True, key="cy_nav_btn"): st.session_state.graph_center_id = inspected_id @@ -116,7 +121,7 @@ def render_graph_explorer_cytoscape(graph_service): st.caption("_(Ist aktuelles Zentrum)_") with c3: - # EDIT BUTTON + # EDITIEREN: Startet den Editor mit den Daten des inspizierten Knotens if inspected_data: st.button("📝 Bearbeiten", use_container_width=True, @@ -124,37 +129,55 @@ def render_graph_explorer_cytoscape(graph_service): args=(inspected_data,), key="cy_edit_btn") - # DATA INSPECTOR (Standard: Eingeklappt) + # --- DATA INSPECTOR --- with st.expander("🕵️ Data Inspector (Details)", expanded=False): if inspected_data: + # Spalte 1: IDs und Typen col_i1, col_i2 = st.columns(2) with col_i1: 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', []))}") - path_check = "✅" if inspected_data.get('path') else "❌" - st.markdown(f"**Pfad:** {path_check}") - st.text_area("Inhalt (Vorschau)", inspected_data.get('fulltext', '')[:1000], height=200, disabled=True) + # Spalte 2: Tags und Pfad-Status + with col_i2: + tags = inspected_data.get('tags', []) + st.markdown(f"**Tags:** {', '.join(tags) if tags else '-'}") + + has_path = bool(inspected_data.get('path')) + path_icon = "✅" if has_path else "❌" + st.markdown(f"**Pfad:** {path_icon}") + if has_path: + st.caption(f"_{inspected_data.get('path')}_") + + st.divider() + + # Content Preview + st.caption("Inhalt (Vorschau aus Stitching):") + fulltext = inspected_data.get('fulltext', '') + st.text_area("Body", fulltext[:1200] + "...", height=200, disabled=True, label_visibility="collapsed") + + # Raw Data Expander + with st.expander("📄 Raw JSON anzeigen"): + st.json(inspected_data) else: - st.warning("Keine Daten geladen.") + st.warning("Keine Daten für diesen Knoten gefunden.") - # --- 3.3 ELEMENT VORBEREITUNG --- + # --------------------------------------------------------- + # 4. GRAPH VORBEREITUNG (Elemente & Styles) + # --------------------------------------------------------- cy_elements = [] # Nodes erstellen for n in nodes_data: - # Klassenlogik für Styling (statt Selection State) + # Logik für visuelle Klassen classes = [] if n.id == center_id: classes.append("center") - - # Wir markieren den inspizierten Knoten visuell if n.id == inspected_id: classes.append("inspected") - # Label kürzen für Anzeige + # Label für Anzeige kürzen + tooltip_text = n.title if n.title else n.label display_label = n.label if len(display_label) > 15 and " " in display_label: display_label = display_label.replace(" ", "\n", 1) @@ -164,13 +187,11 @@ def render_graph_explorer_cytoscape(graph_service): "id": n.id, "label": display_label, "bg_color": n.color, - # Tooltip Inhalt - "tooltip": n.title if n.title else n.label + "tooltip": tooltip_text }, "classes": " ".join(classes), - # WICHTIG: Wir setzen selected immer auf False beim Init, - # damit wir nicht mit dem internen State des Browsers kämpfen. - # Die Visualisierung passiert über die Klasse .inspected + # Wir nutzen KEINE interne Selektion (:selected), + # sondern steuern das Aussehen über die Klasse .inspected "selected": False } cy_elements.append(cy_node) @@ -189,68 +210,57 @@ def render_graph_explorer_cytoscape(graph_service): } cy_elements.append(cy_edge) - # --- 3.4 STYLESHEET --- + # Stylesheet (Design Definitionen) stylesheet = [ - # BASIS NODE STYLE + # BASIS NODE { "selector": "node", "style": { "label": "data(label)", - "width": "30px", - "height": "30px", + "width": "30px", "height": "30px", "background-color": "data(bg_color)", - "color": "#333", - "font-size": "12px", - "text-valign": "center", - "text-halign": "center", - "text-wrap": "wrap", - "text-max-width": "90px", - "border-width": 2, - "border-color": "#fff", - "title": "data(tooltip)" + "color": "#333", "font-size": "12px", + "text-valign": "center", "text-halign": "center", + "text-wrap": "wrap", "text-max-width": "90px", + "border-width": 2, "border-color": "#fff", + "title": "data(tooltip)" # Hover Text } }, - # KLASSE: INSPECTED (Gelber Rahmen) - Ersetzt :selected + # INSPECTED (Gelber Rahmen) { "selector": ".inspected", "style": { "border-width": 6, - "border-color": "#FFC300", # Gelb/Gold - "width": "50px", - "height": "50px", + "border-color": "#FFC300", + "width": "50px", "height": "50px", "font-weight": "bold", "z-index": 999 } }, - # KLASSE: CENTER (Roter Rahmen) + # CENTER (Roter Rahmen) { "selector": ".center", "style": { "border-width": 4, - "border-color": "#FF5733", # Rot - "width": "40px", - "height": "40px" + "border-color": "#FF5733", + "width": "40px", "height": "40px" } }, - # KOMBINATION: Center ist auch Inspected + # CENTER + INSPECTED (Kombination) { "selector": ".center.inspected", "style": { "border-width": 6, - "border-color": "#FF5733", # Rot gewinnt (oder Mix) - "width": "55px", - "height": "55px" + "border-color": "#FF5733", # Zentrum Farbe dominiert + "width": "55px", "height": "55px" } }, - # NATIVE SELEKTION (Unterdrücken/Anpassen) - # Wir machen den Standard-Selektionsrahmen unsichtbar(er), - # da wir .inspected nutzen. + # SELECT STATE OVERRIDE (Verstecken des Standard-Rahmens) { "selector": "node:selected", "style": { - "overlay-opacity": 0, - "border-width": 6, - "border-color": "#FFC300" + "border-width": 0, + "overlay-opacity": 0 } }, # EDGE STYLE @@ -263,20 +273,18 @@ def render_graph_explorer_cytoscape(graph_service): "target-arrow-shape": "triangle", "curve-style": "bezier", "label": "data(label)", - "font-size": "10px", - "color": "#666", - "text-background-opacity": 0.8, - "text-background-color": "#fff" + "font-size": "10px", "color": "#666", + "text-background-opacity": 0.8, "text-background-color": "#fff" } } ] - # --- 3.5 RENDERING --- - # KEY STRATEGIE: - # Der Key darf NICHT von 'graph_inspected_id' abhängen. - # Er hängt nur von 'center_id' und Layout-Settings ab. - # Wenn wir eine Node anklicken, ändert sich inspected_id -> Rerun. - # Da der Key gleich bleibt, wird der Graph NICHT neu initialisiert -> Kein Springen! + # --------------------------------------------------------- + # 5. RENDERING + # --------------------------------------------------------- + # Der Key bestimmt, wann der Graph komplett neu gebaut wird. + # Wir nutzen hier center_id und settings. + # NICHT inspected_id -> Das verhindert das Springen beim Klicken! graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}" clicked_elements = cytoscape( @@ -289,7 +297,7 @@ def render_graph_explorer_cytoscape(graph_service): "refresh": 20, "fit": True, "padding": 50, - "randomize": False, # WICHTIG für Stabilität + "randomize": False, # Wichtig für Stabilität "componentSpacing": 100, "nodeRepulsion": st.session_state.cy_node_repulsion, "edgeElasticity": 100, @@ -301,22 +309,27 @@ def render_graph_explorer_cytoscape(graph_service): "minTemp": 1.0, "animate": False }, - key=graph_key, + key=graph_key, height="700px" ) - # --- 3.6 EVENT HANDLING --- + # --------------------------------------------------------- + # 6. EVENT HANDLING + # --------------------------------------------------------- if clicked_elements: clicked_nodes = clicked_elements.get("nodes", []) + if clicked_nodes: - # Die Liste enthält die IDs der selektierten Knoten clicked_id = clicked_nodes[0] - # Wenn wir auf einen neuen Knoten klicken, ändern wir NUR die Inspektion. - # Das triggert einen Rerun. - # Da der graph_key gleich bleibt, wird nur der Style (.inspected Klasse) geupdated. + # Wenn auf einen neuen Knoten geklickt wurde: if clicked_id != st.session_state.graph_inspected_id: + # 1. State aktualisieren (Inspektion verschieben) st.session_state.graph_inspected_id = clicked_id + + # 2. Rerun triggern + # Da graph_key sich NICHT ändert, bleibt der Graph stabil, + # nur die CSS-Klassen (.inspected) werden aktualisiert. st.rerun() else: From 9e25e5b26b27afbf8ba1990c999572b77050366b Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 16:40:13 +0100 Subject: [PATCH 34/36] merken der Einstellungen --- app/frontend/ui_graph_cytoscape.py | 51 +++++++++++++++++++----------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index 8d20eab..c6d35be 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -10,11 +10,11 @@ def render_graph_explorer_cytoscape(graph_service): # --------------------------------------------------------- # 1. STATE MANAGEMENT # --------------------------------------------------------- - # Das aktive Zentrum des Graphen (bestimmt welche Knoten geladen werden) + # Graph Zentrum (Roter Rahmen, bestimmt die geladenen Daten) if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Der aktuell inspizierte Knoten (bestimmt Inspector & Editor Inhalt) + # Inspizierter Knoten (Gelber Rahmen, bestimmt Inspector & Editor) # Getrennt vom Zentrum, damit man klicken kann ohne neu zu laden. if "graph_inspected_id" not in st.session_state: st.session_state.graph_inspected_id = None @@ -129,7 +129,7 @@ def render_graph_explorer_cytoscape(graph_service): args=(inspected_data,), key="cy_edit_btn") - # --- DATA INSPECTOR --- + # --- DATA INSPECTOR (Raw Data restored!) --- with st.expander("🕵️ Data Inspector (Details)", expanded=False): if inspected_data: # Spalte 1: IDs und Typen @@ -173,6 +173,8 @@ def render_graph_explorer_cytoscape(graph_service): classes = [] if n.id == center_id: classes.append("center") + + # Wir markieren den inspizierten Knoten visuell if n.id == inspected_id: classes.append("inspected") @@ -190,8 +192,8 @@ def render_graph_explorer_cytoscape(graph_service): "tooltip": tooltip_text }, "classes": " ".join(classes), - # Wir nutzen KEINE interne Selektion (:selected), - # sondern steuern das Aussehen über die Klasse .inspected + # WICHTIG: Wir deaktivieren die interne Selektion (:selected) komplett + # und nutzen nur unsere CSS Klassen (.inspected), um Mehrfachauswahl zu verhindern. "selected": False } cy_elements.append(cy_node) @@ -217,22 +219,28 @@ def render_graph_explorer_cytoscape(graph_service): "selector": "node", "style": { "label": "data(label)", - "width": "30px", "height": "30px", + "width": "30px", + "height": "30px", "background-color": "data(bg_color)", - "color": "#333", "font-size": "12px", - "text-valign": "center", "text-halign": "center", - "text-wrap": "wrap", "text-max-width": "90px", - "border-width": 2, "border-color": "#fff", + "color": "#333", + "font-size": "12px", + "text-valign": "center", + "text-halign": "center", + "text-wrap": "wrap", + "text-max-width": "90px", + "border-width": 2, + "border-color": "#fff", "title": "data(tooltip)" # Hover Text } }, - # INSPECTED (Gelber Rahmen) + # INSPECTED (Gelber Rahmen) - Ersetzt :selected { "selector": ".inspected", "style": { "border-width": 6, - "border-color": "#FFC300", - "width": "50px", "height": "50px", + "border-color": "#FFC300", # Gelb/Gold + "width": "50px", + "height": "50px", "font-weight": "bold", "z-index": 999 } @@ -242,8 +250,9 @@ def render_graph_explorer_cytoscape(graph_service): "selector": ".center", "style": { "border-width": 4, - "border-color": "#FF5733", - "width": "40px", "height": "40px" + "border-color": "#FF5733", # Rot + "width": "40px", + "height": "40px" } }, # CENTER + INSPECTED (Kombination) @@ -251,16 +260,18 @@ def render_graph_explorer_cytoscape(graph_service): "selector": ".center.inspected", "style": { "border-width": 6, - "border-color": "#FF5733", # Zentrum Farbe dominiert - "width": "55px", "height": "55px" + "border-color": "#FF5733", # Rot gewinnt (oder Mix) + "width": "55px", + "height": "55px" } }, - # SELECT STATE OVERRIDE (Verstecken des Standard-Rahmens) + # NATIVE SELEKTION (Unterdrücken) + # Das verhindert das "blaue Leuchten" oder Rahmen von Cytoscape selbst { "selector": "node:selected", "style": { + "overlay-opacity": 0, "border-width": 0, - "overlay-opacity": 0 } }, # EDGE STYLE @@ -282,6 +293,7 @@ def render_graph_explorer_cytoscape(graph_service): # --------------------------------------------------------- # 5. RENDERING # --------------------------------------------------------- + # KEY STRATEGIE: # Der Key bestimmt, wann der Graph komplett neu gebaut wird. # Wir nutzen hier center_id und settings. # NICHT inspected_id -> Das verhindert das Springen beim Klicken! @@ -320,6 +332,7 @@ def render_graph_explorer_cytoscape(graph_service): clicked_nodes = clicked_elements.get("nodes", []) if clicked_nodes: + # Wir nehmen die erste Node aus dem Event clicked_id = clicked_nodes[0] # Wenn auf einen neuen Knoten geklickt wurde: From eccdf43cd300244d330048e46d50523d6b9f1913 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 16:45:48 +0100 Subject: [PATCH 35/36] update --- app/frontend/ui_graph_cytoscape.py | 204 +++++++++++++---------------- 1 file changed, 90 insertions(+), 114 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index c6d35be..62b267e 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -4,25 +4,50 @@ from qdrant_client import models from ui_config import COLLECTION_PREFIX, GRAPH_COLORS from ui_callbacks import switch_to_editor_callback +def update_url_params(): + """Callback: Schreibt Slider-Werte in die URL und synchronisiert den State.""" + # Werte aus den Slider-Keys in die Logik-Variablen übertragen + if "cy_depth_slider" in st.session_state: + st.session_state.cy_depth = st.session_state.cy_depth_slider + if "cy_len_slider" in st.session_state: + st.session_state.cy_ideal_edge_len = st.session_state.cy_len_slider + if "cy_rep_slider" in st.session_state: + st.session_state.cy_node_repulsion = st.session_state.cy_rep_slider + + # In URL schreiben + st.query_params["depth"] = st.session_state.cy_depth + st.query_params["len"] = st.session_state.cy_ideal_edge_len + st.query_params["rep"] = st.session_state.cy_node_repulsion + def render_graph_explorer_cytoscape(graph_service): st.header("🕸️ Graph Explorer (Cytoscape)") # --------------------------------------------------------- - # 1. STATE MANAGEMENT + # 1. STATE & PERSISTENZ # --------------------------------------------------------- - # Graph Zentrum (Roter Rahmen, bestimmt die geladenen Daten) if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Inspizierter Knoten (Gelber Rahmen, bestimmt Inspector & Editor) - # Getrennt vom Zentrum, damit man klicken kann ohne neu zu laden. if "graph_inspected_id" not in st.session_state: st.session_state.graph_inspected_id = None - # Layout Defaults für COSE Algorithmus - st.session_state.setdefault("cy_node_repulsion", 1000000) - st.session_state.setdefault("cy_ideal_edge_len", 150) - st.session_state.setdefault("cy_depth", 2) + # Lade Einstellungen aus der URL (falls vorhanden), sonst Defaults + params = st.query_params + + # Helper um sicher int zu parsen + def get_param(key, default): + try: return int(params.get(key, default)) + except: return default + + # Initialisiere Session State Variablen, falls noch nicht vorhanden + if "cy_depth" not in st.session_state: + st.session_state.cy_depth = get_param("depth", 2) + + if "cy_ideal_edge_len" not in st.session_state: + st.session_state.cy_ideal_edge_len = get_param("len", 150) + + if "cy_node_repulsion" not in st.session_state: + st.session_state.cy_node_repulsion = get_param("rep", 1000000) col_ctrl, col_graph = st.columns([1, 4]) @@ -32,7 +57,6 @@ def render_graph_explorer_cytoscape(graph_service): with col_ctrl: st.subheader("Fokus") - # Suchfeld search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search") if search_term: @@ -47,33 +71,39 @@ def render_graph_explorer_cytoscape(graph_service): 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] - # Bei neuer Suche setzen wir beides auf das Ziel st.session_state.graph_center_id = new_id st.session_state.graph_inspected_id = new_id st.rerun() st.divider() - # Layout Einstellungen + # LAYOUT EINSTELLUNGEN (Mit URL Sync) with st.expander("👁️ Layout Einstellungen", expanded=True): - st.session_state.cy_depth = st.slider("Tiefe (Tier)", 1, 3, st.session_state.cy_depth, key="cy_depth_slider") + st.slider("Tiefe (Tier)", 1, 3, + value=st.session_state.cy_depth, + key="cy_depth_slider", + on_change=update_url_params) st.markdown("**COSE Layout**") - st.session_state.cy_ideal_edge_len = st.slider("Kantenlänge", 50, 600, st.session_state.cy_ideal_edge_len, key="cy_len_slider") - st.session_state.cy_node_repulsion = st.slider("Knoten-Abstoßung", 100000, 5000000, st.session_state.cy_node_repulsion, step=100000, key="cy_rep_slider") + st.slider("Kantenlänge", 50, 600, + value=st.session_state.cy_ideal_edge_len, + key="cy_len_slider", + on_change=update_url_params) + + st.slider("Knoten-Abstoßung", 100000, 5000000, step=100000, + value=st.session_state.cy_node_repulsion, + key="cy_rep_slider", + on_change=update_url_params) if st.button("Neu berechnen", key="cy_rerun"): st.rerun() st.divider() - - # Legende st.caption("Legende") for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) - - # --------------------------------------------------------- - # 3. RECHTES PANEL (Graph & Tools) +# --------------------------------------------------------- + # 3. RECHTES PANEL (GRAPH & INSPECTOR) # --------------------------------------------------------- with col_graph: center_id = st.session_state.graph_center_id @@ -84,7 +114,7 @@ def render_graph_explorer_cytoscape(graph_service): st.session_state.graph_center_id = center_id if center_id: - # Sync: Falls Inspektion leer ist, mit Zentrum füllen + # Sync Inspection if not st.session_state.graph_inspected_id: st.session_state.graph_inspected_id = center_id @@ -92,13 +122,12 @@ def render_graph_explorer_cytoscape(graph_service): # --- DATEN LADEN --- with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): - # 1. Graph Daten (Abhängig vom Zentrum) + # 1. Graph Daten nodes_data, edges_data = graph_service.get_ego_graph( center_id, depth=st.session_state.cy_depth ) - # 2. Detail Daten (Abhängig von der Inspektion) - # Holt Metadaten UND den gestitchten Volltext + # 2. Detail Daten (Inspector) inspected_data = graph_service.get_note_with_full_content(inspected_id) # --- ACTION BAR --- @@ -107,12 +136,11 @@ def render_graph_explorer_cytoscape(graph_service): c1, c2, c3 = st.columns([2, 1, 1]) with c1: - # Titel anzeigen title_show = inspected_data.get('title', inspected_id) if inspected_data else inspected_id - st.info(f"**Info:** {title_show}") + st.info(f"**Ausgewählt:** {title_show}") with c2: - # NAVIGATION: Nur aktiv, wenn wir nicht schon im Zentrum sind + # NAVIGATION if inspected_id != center_id: if st.button("🎯 Als Zentrum setzen", use_container_width=True, key="cy_nav_btn"): st.session_state.graph_center_id = inspected_id @@ -121,7 +149,7 @@ def render_graph_explorer_cytoscape(graph_service): st.caption("_(Ist aktuelles Zentrum)_") with c3: - # EDITIEREN: Startet den Editor mit den Daten des inspizierten Knotens + # EDITIEREN if inspected_data: st.button("📝 Bearbeiten", use_container_width=True, @@ -129,56 +157,33 @@ def render_graph_explorer_cytoscape(graph_service): args=(inspected_data,), key="cy_edit_btn") - # --- DATA INSPECTOR (Raw Data restored!) --- + # --- DATA INSPECTOR --- with st.expander("🕵️ Data Inspector (Details)", expanded=False): if inspected_data: - # Spalte 1: IDs und Typen col_i1, col_i2 = st.columns(2) with col_i1: st.markdown(f"**ID:** `{inspected_data.get('note_id')}`") st.markdown(f"**Typ:** `{inspected_data.get('type')}`") - - # Spalte 2: Tags und Pfad-Status with col_i2: - tags = inspected_data.get('tags', []) - st.markdown(f"**Tags:** {', '.join(tags) if tags else '-'}") - - has_path = bool(inspected_data.get('path')) - path_icon = "✅" if has_path else "❌" - st.markdown(f"**Pfad:** {path_icon}") - if has_path: - st.caption(f"_{inspected_data.get('path')}_") + st.markdown(f"**Tags:** {', '.join(inspected_data.get('tags', []))}") + path_check = "✅" if inspected_data.get('path') else "❌" + st.markdown(f"**Pfad:** {path_check}") - st.divider() + st.caption("Inhalt (Vorschau):") + st.text_area("Content Preview", inspected_data.get('fulltext', '')[:1000], height=200, disabled=True, label_visibility="collapsed") - # Content Preview - st.caption("Inhalt (Vorschau aus Stitching):") - fulltext = inspected_data.get('fulltext', '') - st.text_area("Body", fulltext[:1200] + "...", height=200, disabled=True, label_visibility="collapsed") - - # Raw Data Expander with st.expander("📄 Raw JSON anzeigen"): st.json(inspected_data) else: - st.warning("Keine Daten für diesen Knoten gefunden.") + st.warning("Keine Daten geladen.") - # --------------------------------------------------------- - # 4. GRAPH VORBEREITUNG (Elemente & Styles) - # --------------------------------------------------------- + # --- GRAPH ELEMENTS --- cy_elements = [] - # Nodes erstellen for n in nodes_data: - # Logik für visuelle Klassen - classes = [] - if n.id == center_id: - classes.append("center") + is_center = (n.id == center_id) + is_inspected = (n.id == inspected_id) - # Wir markieren den inspizierten Knoten visuell - if n.id == inspected_id: - classes.append("inspected") - - # Label für Anzeige kürzen tooltip_text = n.title if n.title else n.label display_label = n.label if len(display_label) > 15 and " " in display_label: @@ -191,14 +196,12 @@ def render_graph_explorer_cytoscape(graph_service): "bg_color": n.color, "tooltip": tooltip_text }, - "classes": " ".join(classes), - # WICHTIG: Wir deaktivieren die interne Selektion (:selected) komplett - # und nutzen nur unsere CSS Klassen (.inspected), um Mehrfachauswahl zu verhindern. - "selected": False + # Wir steuern das Aussehen rein über Klassen (.inspected / .center) + "classes": " ".join([c for c in ["center" if is_center else "", "inspected" if is_inspected else ""] if c]), + "selected": False } cy_elements.append(cy_node) - # Edges erstellen for e in edges_data: target_id = getattr(e, "to", getattr(e, "target", None)) if target_id: @@ -212,69 +215,58 @@ def render_graph_explorer_cytoscape(graph_service): } cy_elements.append(cy_edge) - # Stylesheet (Design Definitionen) + # --- STYLESHEET --- stylesheet = [ - # BASIS NODE { "selector": "node", "style": { "label": "data(label)", - "width": "30px", - "height": "30px", + "width": "30px", "height": "30px", "background-color": "data(bg_color)", - "color": "#333", - "font-size": "12px", - "text-valign": "center", - "text-halign": "center", - "text-wrap": "wrap", - "text-max-width": "90px", - "border-width": 2, - "border-color": "#fff", - "title": "data(tooltip)" # Hover Text + "color": "#333", "font-size": "12px", + "text-valign": "center", "text-halign": "center", + "text-wrap": "wrap", "text-max-width": "90px", + "border-width": 2, "border-color": "#fff", + "title": "data(tooltip)" } }, - # INSPECTED (Gelber Rahmen) - Ersetzt :selected + # Inspiziert (Gelber Rahmen) { "selector": ".inspected", "style": { "border-width": 6, - "border-color": "#FFC300", # Gelb/Gold - "width": "50px", - "height": "50px", + "border-color": "#FFC300", + "width": "50px", "height": "50px", "font-weight": "bold", "z-index": 999 } }, - # CENTER (Roter Rahmen) + # Zentrum (Roter Rahmen) { "selector": ".center", "style": { "border-width": 4, - "border-color": "#FF5733", # Rot - "width": "40px", - "height": "40px" + "border-color": "#FF5733", + "width": "40px", "height": "40px" } }, - # CENTER + INSPECTED (Kombination) + # Mix { "selector": ".center.inspected", "style": { "border-width": 6, - "border-color": "#FF5733", # Rot gewinnt (oder Mix) - "width": "55px", - "height": "55px" + "border-color": "#FF5733", + "width": "55px", "height": "55px" } }, - # NATIVE SELEKTION (Unterdrücken) - # Das verhindert das "blaue Leuchten" oder Rahmen von Cytoscape selbst + # Default Selection unterdrücken { "selector": "node:selected", "style": { - "overlay-opacity": 0, "border-width": 0, + "overlay-opacity": 0 } }, - # EDGE STYLE { "selector": "edge", "style": { @@ -290,13 +282,7 @@ def render_graph_explorer_cytoscape(graph_service): } ] - # --------------------------------------------------------- - # 5. RENDERING - # --------------------------------------------------------- - # KEY STRATEGIE: - # Der Key bestimmt, wann der Graph komplett neu gebaut wird. - # Wir nutzen hier center_id und settings. - # NICHT inspected_id -> Das verhindert das Springen beim Klicken! + # --- RENDER --- graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}" clicked_elements = cytoscape( @@ -309,7 +295,7 @@ def render_graph_explorer_cytoscape(graph_service): "refresh": 20, "fit": True, "padding": 50, - "randomize": False, # Wichtig für Stabilität + "randomize": False, "componentSpacing": 100, "nodeRepulsion": st.session_state.cy_node_repulsion, "edgeElasticity": 100, @@ -325,25 +311,15 @@ def render_graph_explorer_cytoscape(graph_service): height="700px" ) - # --------------------------------------------------------- - # 6. EVENT HANDLING - # --------------------------------------------------------- + # --- EVENT HANDLING --- if clicked_elements: clicked_nodes = clicked_elements.get("nodes", []) - if clicked_nodes: - # Wir nehmen die erste Node aus dem Event clicked_id = clicked_nodes[0] - # Wenn auf einen neuen Knoten geklickt wurde: if clicked_id != st.session_state.graph_inspected_id: - # 1. State aktualisieren (Inspektion verschieben) st.session_state.graph_inspected_id = clicked_id - - # 2. Rerun triggern - # Da graph_key sich NICHT ändert, bleibt der Graph stabil, - # nur die CSS-Klassen (.inspected) werden aktualisiert. st.rerun() else: - st.info("👈 Bitte wähle links eine Notiz aus.") \ No newline at end of file + st.info("👈 Bitte wähle links eine Notiz aus.") \ No newline at end of file From efd9c74cd355f162adb3d74fc8b37225ca0955b7 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 20:47:49 +0100 Subject: [PATCH 36/36] Dokumentation WP19 --- docs/00_General/00_documentation_map.md | 2 + docs/01_User_Manual/01_chat_usage_guide.md | 29 +++- .../03_tech_frontend.md | 160 ++++++++++++++++++ docs/04_Operations/04_admin_operations.md | 18 +- docs/05_Development/05_developer_guide.md | 42 +++-- docs/06_Roadmap/06_active_roadmap.md | 50 ++---- docs/99_Archive/99_legacy_workpackages.md | 18 +- requirements.txt | 6 +- 8 files changed, 267 insertions(+), 58 deletions(-) create mode 100644 docs/03_Technical_References/03_tech_frontend.md diff --git a/docs/00_General/00_documentation_map.md b/docs/00_General/00_documentation_map.md index 3957f31..1a443b9 100644 --- a/docs/00_General/00_documentation_map.md +++ b/docs/00_General/00_documentation_map.md @@ -44,6 +44,7 @@ Das Repository ist in **logische Domänen** unterteilt. | `03_tech_ingestion_pipeline.md`| **Import.** Ablauflogik (13 Schritte), Chunker-Profile, Smart Edge Allocation. | | `03_tech_retrieval_scoring.md` | **Suche.** Die mathematischen Formeln für Scoring, Hybrid Search und Explanation Layer. | | `03_tech_chat_backend.md` | **API & LLM.** Implementation des Routers, Traffic Control (Semaphore) und Feedback-Traceability. | +| `03_tech_frontend.md` | **UI & Graph.** Architektur des Streamlit-Frontends, State-Management, Cytoscape-Integration und Editor-Logik. | | `03_tech_configuration.md` | **Config.** Referenztabellen für `.env`, `types.yaml` und `retriever.yaml`. | ### 📂 04_Operations (Betrieb) @@ -77,6 +78,7 @@ Nutze diese Matrix, wenn du ein Workpackage bearbeitest, um die Dokumentation ko | **Importer / Parsing** | `03_tech_ingestion_pipeline.md` | | **Datenbank-Schema** | `03_tech_data_model.md` (Payloads anpassen) | | **Retrieval / Scoring** | `03_tech_retrieval_scoring.md` (Formeln anpassen) | +| **Frontend / Visualisierung** | 1. `03_tech_frontend.md` (Technische Details)
2. `01_chat_usage_guide.md` (Bedienung) | | **Chat-Logik / Prompts**| 1. `02_concept_ai_personality.md` (Konzept)
2. `03_tech_chat_backend.md` (Tech)
3. `01_chat_usage_guide.md` (User-Sicht) | | **Deployment / Server** | `04_admin_operations.md` | | **Neuen Features (Allg.)**| `06_active_roadmap.md` (Status Update) | diff --git a/docs/01_User_Manual/01_chat_usage_guide.md b/docs/01_User_Manual/01_chat_usage_guide.md index e618c31..a4e6eee 100644 --- a/docs/01_User_Manual/01_chat_usage_guide.md +++ b/docs/01_User_Manual/01_chat_usage_guide.md @@ -1,13 +1,13 @@ --- doc_type: user_manual audience: user, mindmaster -scope: chat, ui, feedback +scope: chat, ui, feedback, graph status: active version: 2.6 -context: "Anleitung zur Nutzung der Web-Oberfläche, der Chat-Personas und des Feedbacks." +context: "Anleitung zur Nutzung der Web-Oberfläche, der Chat-Personas und des Graph Explorers." --- -# Chat Usage Guide +# Chat & Graph Usage Guide **Quellen:** `user_guide.md` @@ -36,10 +36,25 @@ Seit Version 2.3.1 bedienst du Mindnet über eine grafische Oberfläche im Brows * **Grüner Punkt:** Hohe Relevanz (Score > 0.8). * **Klick darauf:** Zeigt den Textauszug und die **Begründung** (Explanation Layer). -### 2.2 Die Sidebar -* **Modus-Wahl:** Umschalten zwischen "💬 Chat" und "📝 Manueller Editor". - * *Feature:* Der Editor nutzt ein "Resurrection Pattern" – deine Eingaben bleiben erhalten, auch wenn du den Tab wechselst. -* **Settings:** Hier kannst du `Top-K` (Anzahl der Quellen) und `Explanation Layer` steuern. +### 2.2 Modus: 🕸️ Graph Explorer (Cytoscape) +*Neu in v2.6*: Eine interaktive Karte deines Wissens. + +**Die Farb-Logik:** +* 🔴 **Roter Rahmen:** Das aktuelle **Zentrum** (Ego). Alle geladenen Knoten sind Nachbarn dieses Knotens. +* 🟡 **Gelber Rahmen:** Der **inspizierte** Knoten. Dessen Details siehst du im "Data Inspector" und in der Action-Bar. + +**Bedienung:** +1. **Klick auf einen Knoten:** Wählt ihn aus (Gelb). Der Graph bleibt stabil, aber die Info-Leiste oben aktualisiert sich. +2. **Button "🎯 Als Zentrum setzen":** Lädt den Graphen neu und macht den ausgewählten Knoten zum roten Zentrum. +3. **Button "📝 Bearbeiten":** Springt mit dem Inhalt dieses Knotens direkt in den Editor. + +**Layout & Persistenz:** +Du kannst in der linken Spalte die Physik (Abstoßung, Kantenlänge) einstellen. Diese Einstellungen werden in der **URL gespeichert**. Du kannst den Link als Lesezeichen speichern, um genau diese Ansicht wiederzufinden. + +### 2.3 Modus: 📝 Manueller Editor +Ein Editor mit **"File System First"** Garantie. +* Wenn du eine Datei bearbeitest, liest Mindnet sie direkt von der Festplatte. +* **Resurrection:** Wenn du zwischendurch in den Graph wechselst und zurückkommst, ist dein getippter Text noch da. --- diff --git a/docs/03_Technical_References/03_tech_frontend.md b/docs/03_Technical_References/03_tech_frontend.md new file mode 100644 index 0000000..9af4009 --- /dev/null +++ b/docs/03_Technical_References/03_tech_frontend.md @@ -0,0 +1,160 @@ +--- +doc_type: technical_reference +audience: developer, frontend_architect +scope: architecture, graph_viz, state_management +status: active +version: 2.6 +context: "Technische Dokumentation des modularen Streamlit-Frontends, der Graph-Engines und des Editors." +--- + +# Technical Reference: Frontend & Visualization + +**Kontext:** Mindnet nutzt Streamlit nicht nur als einfaches UI, sondern als komplexe Applikation mit eigenem State-Management, Routing und Persistenz. + +--- + +## 1. Architektur & Modularisierung (WP19) + +Seit Version 2.6 ist das Frontend (`app/frontend/`) kein Monolith mehr, sondern in funktionale Module unterteilt. + +### 1.1 Dateistruktur & Aufgaben + +| Modul | Funktion | +| :--- | :--- | +| `ui.py` | **Router.** Der Entrypoint. Initialisiert Session-State und entscheidet anhand der Sidebar-Auswahl, welche View gerendert wird. | +| `ui_config.py` | **Konstanten.** Zentraler Ort für Farben (`GRAPH_COLORS`), API-Endpunkte und Timeouts. | +| `ui_api.py` | **Backend-Bridge.** Kapselt alle `requests`-Aufrufe an die FastAPI. | +| `ui_callbacks.py` | **State Transitions.** Logik für View-Wechsel (z.B. Sprung vom Graph in den Editor). | +| `ui_utils.py` | **Helper.** Markdown-Parsing (`parse_markdown_draft`) und String-Normalisierung. | +| `ui_graph_service.py`| **Data Logic.** Holt Daten aus Qdrant und bereitet Nodes/Edges auf (unabhängig von der Vis-Library). | +| `ui_graph_cytoscape.py`| **View: Graph.** Implementierung mit `st-cytoscape` (COSE Layout). | +| `ui_editor.py` | **View: Editor.** Logik für Drafts und manuelles Editieren. | + +### 1.2 Konfiguration (`ui_config.py`) + +Zentrale Steuerung der visuellen Semantik. + + # Mapping von Node-Typen zu Farben (Hex) + GRAPH_COLORS = { + "project": "#ff9f43", # Orange + "decision": "#5f27cd", # Lila + "experience": "#feca57", # Gelb (Empathie) + "concept": "#54a0ff", # Blau + "risk": "#ff6b6b" # Rot + } + +--- + +## 2. Graph Visualisierung + +Mindnet nutzt primär **Cytoscape** für Stabilität bei großen Graphen. + +### 2.1 Engine: Cytoscape (`st-cytoscape`) +* **Algorithmus:** `COSE` (Compound Spring Embedder). +* **Vorteil:** Verhindert Überlappungen aktiv (`nodeRepulsion`). + +### 2.2 Architektur-Pattern: "Active Inspector, Passive Graph" +Ein häufiges Problem bei Streamlit-Graphen ist das "Flackern" (Re-Render) bei Klicks. Wir lösen das durch Entkopplung: + +1. **Stable Key:** Der React-Key der Komponente hängt *nicht* von der Selektion ab, sondern nur vom Zentrum (`center_id`) und Layout-Settings. +2. **CSS-Selektion:** Wir nutzen **nicht** den nativen `:selected` State (buggy bei Single-Select), sondern injizieren eine CSS-Klasse `.inspected`. + +**Stylesheet Implementierung (`ui_graph_cytoscape.py`):** + + stylesheet = [ + { + "selector": "node", + "style": { "background-color": "data(bg_color)" } + }, + # Wir steuern das Highlight manuell über eine Klasse + { + "selector": ".inspected", + "style": { + "border-width": 6, + "border-color": "#FFC300", # Gelb/Gold + "z-index": 999 + } + }, + # Native Selektion wird unterdrückt/unsichtbar gemacht + { + "selector": "node:selected", + "style": { "overlay-opacity": 0, "border-width": 0 } + } + ] + +--- + +## 3. Editor & Single Source of Truth + +Ein kritisches Design-Pattern ist der Umgang mit Datenkonsistenz beim Editieren ("File System First"). + +### 3.1 Das Problem +Qdrant speichert Metadaten und Chunks, aber das Feld `fulltext` im Payload kann veraltet sein oder Formatierungen verlieren. + +### 3.2 Die Lösung (Logic Flow) +Der `switch_to_editor_callback` in `ui_callbacks.py` implementiert folgende Kaskade: + + def switch_to_editor_callback(note_payload): + # 1. Pfad aus Qdrant Payload lesen + origin_fname = note_payload.get('path') + + content = "" + # 2. Versuch: Hard Read von der Festplatte (Source of Truth) + if origin_fname and os.path.exists(origin_fname): + with open(origin_fname, "r", encoding="utf-8") as f: + content = f.read() + else: + # 3. Fallback: Rekonstruktion aus der DB ("Stitching") + # Nur Notfall, falls Docker-Volume fehlt + content = note_payload.get('fulltext', '') + + # State setzen (Transport via Message-Bus) + st.session_state.messages.append({ + "role": "assistant", + "intent": "INTERVIEW", + "content": content, + "origin_filename": origin_fname + }) + st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor" + +Dies garantiert, dass der Editor immer den **echten, aktuellen Stand** der Markdown-Datei anzeigt. + +--- + +## 4. State Management Patterns + +### 4.1 URL Persistenz (Deep Linking) +Layout-Einstellungen werden in der URL gespeichert, damit sie einen Page-Refresh (F5) überleben. + + # ui_graph_cytoscape.py + def update_url_params(): + st.query_params["depth"] = st.session_state.cy_depth + st.query_params["rep"] = st.session_state.cy_node_repulsion + + # Init + if "cy_depth" not in st.session_state: + st.session_state.cy_depth = int(st.query_params.get("depth", 2)) + +### 4.2 Resurrection Pattern +Verhindert Datenverlust, wenn der Nutzer während des Tippens den Tab wechselt. Der Editor-Inhalt wird bei jedem Keystroke (`on_change`) in `st.session_state` gespiegelt und beim Neuladen der Komponente von dort wiederhergestellt. + +### 4.3 Healing Parser (`ui_utils.py`) +Das LLM liefert oft invalides YAML oder Markdown. Der Parser (`parse_markdown_draft`): +* Repariert fehlende Frontmatter-Trenner (`---`). +* Extrahiert JSON/YAML aus Code-Blöcken. +* Normalisiert Tags (entfernt `#`). + +--- + +## 5. Constraints & Security (Known Limitations) + +### 5.1 File System Security +Der Editor ("File System First") vertraut dem Pfad im Qdrant-Feld `path`. +* **Risiko:** Path Traversal (z.B. `../../etc/passwd`). +* **Mitigation:** Aktuell findet keine strikte Prüfung statt, ob der Pfad innerhalb des `./vault` Ordners liegt. Das System setzt voraus, dass die Vektor-Datenbank eine **Trusted Source** ist und nur vom internen Importer befüllt wird. +* **ToDo:** Bei Öffnung der API für Dritte muss hier eine `Path.resolve().is_relative_to(VAULT_ROOT)` Prüfung implementiert werden. + +### 5.2 Browser Performance +Die Graph-Visualisierung (`st-cytoscape`) rendert Client-seitig im Browser. +* **Limit:** Ab ca. **500 Knoten/Kanten** kann das Rendering träge werden. +* **Design-Entscheidung:** Das UI ist auf **Ego-Graphen** (Nachbarn eines Knotens) und gefilterte Ansichten ausgelegt, nicht auf die Darstellung des gesamten Knowledge-Graphs ("Whole Brain Visualization"). \ No newline at end of file diff --git a/docs/04_Operations/04_admin_operations.md b/docs/04_Operations/04_admin_operations.md index 438203f..0efd209 100644 --- a/docs/04_Operations/04_admin_operations.md +++ b/docs/04_Operations/04_admin_operations.md @@ -85,6 +85,7 @@ Environment="STREAMLIT_SERVER_PORT=8501" Environment="STREAMLIT_SERVER_ADDRESS=0.0.0.0" Environment="STREAMLIT_SERVER_HEADLESS=true" +# WICHTIG: Pfad zur neuen Router-Datei (ui.py) ExecStart=/home/llmadmin/mindnet/.venv/bin/streamlit run app/frontend/ui.py Restart=always RestartSec=5 @@ -107,21 +108,28 @@ Führt den Sync stündlich durch. Nutzt `--purge-before-upsert` für Sauberkeit. ### 3.2 Troubleshooting Guide +**Fehler: "ModuleNotFoundError: No module named 'st_cytoscape'"** +* Ursache: Alte Dependencies oder falsches Paket installiert. +* Lösung: Environment aktualisieren. + ```bash + source .venv/bin/activate + pip uninstall streamlit-cytoscapejs + pip install st-cytoscape + # Oder sicherheitshalber: + pip install -r requirements.txt + ``` + **Fehler: "Vector dimension error: expected 768, got 384"** * Ursache: Alte DB (v2.2), neues Modell (v2.4). * Lösung: **Full Reset** (siehe Kap. 4.2). -**Fehler: "500 Internal Server Error" (Ollama)** -* Ursache: Timeout bei Cold-Start des Modells. -* Lösung: `MINDNET_LLM_TIMEOUT=300.0` in `.env` setzen. - **Fehler: Import sehr langsam** * Ursache: Smart Edges sind aktiv und analysieren jeden Chunk. * Lösung: `MINDNET_LLM_BACKGROUND_LIMIT` prüfen oder Feature in `types.yaml` deaktivieren. **Fehler: UI "Read timed out"** * Ursache: Backend braucht für Smart Edges länger als 60s. -* Lösung: `MINDNET_API_TIMEOUT=300.0` setzen. +* Lösung: `MINDNET_API_TIMEOUT=300.0` in `.env` setzen (oder im Systemd Service). --- diff --git a/docs/05_Development/05_developer_guide.md b/docs/05_Development/05_developer_guide.md index 56216f1..05bb6e1 100644 --- a/docs/05_Development/05_developer_guide.md +++ b/docs/05_Development/05_developer_guide.md @@ -32,6 +32,7 @@ Mindnet läuft in einer verteilten Umgebung (Post-WP15 Setup). Der Code ist modular in `app` (Logik), `scripts` (CLI) und `config` (Steuerung) getrennt. ### 2.1 Verzeichnisbaum + ```text mindnet/ ├── app/ @@ -50,8 +51,15 @@ mindnet/ │ │ ├── semantic_analyzer.py# LLM-Filter für Edges (WP15) │ │ ├── embeddings_client.py# Async Embeddings (HTTPX) │ │ └── discovery.py # Intelligence Logic (WP11) -│ ├── frontend/ -│ │ └── ui.py # Streamlit App inkl. Healing Parser +│ ├── frontend/ # UI Logic (WP19 Modularisierung) +│ │ ├── ui.py # Main Entrypoint & Routing +│ │ ├── ui_config.py # Styles & Constants +│ │ ├── ui_api.py # Backend Connector +│ │ ├── ui_callbacks.py # State Transitions +│ │ ├── ui_utils.py # Helper & Parsing +│ │ ├── ui_graph_service.py # Graph Data Logic +│ │ ├── ui_graph_cytoscape.py # Modern Graph View (St-Cytoscape) +│ │ └── ui_editor.py # Editor View │ └── main.py # Entrypoint der API ├── config/ # YAML-Konfigurationen (Single Source of Truth) ├── scripts/ # CLI-Tools (Import, Diagnose, Reset) @@ -61,6 +69,12 @@ mindnet/ ### 2.2 Modul-Details (Wie es funktioniert) +**Das Frontend (`app.frontend`) - *Neu in v2.6*** +* **Router (`ui.py`):** Entscheidet, welche View geladen wird. +* **Service-Layer (`ui_graph_service.py`):** Kapselt die Qdrant-Abfragen. Liefert rohe Nodes/Edges, die dann von den Views visualisiert werden. +* **Graph Engine:** Wir nutzen `st-cytoscape` für das Layout. Die Logik zur Vermeidung von Re-Renders (Stable Keys, CSS-Selektion) ist essentiell. +* **Data Consistency:** Der Editor (`ui_editor.py`) liest Dateien bevorzugt direkt vom Dateisystem ("File System First"), um Datenverlust durch veraltete DB-Einträge zu vermeiden. + **Der Importer (`scripts.import_markdown`)** * Das komplexeste Modul. * Nutzt `app.core.chunker` und `app.services.semantic_analyzer` (Smart Edges). @@ -76,10 +90,6 @@ mindnet/ * Implementiert die Scoring-Formel (`Semantik + Graph + Typ`). * **Hybrid Search:** Lädt dynamisch den Subgraphen (`graph_adapter.expand`). -**Das Frontend (`app.frontend.ui`)** -* **Resurrection Pattern:** Nutzt `st.session_state`, um Eingaben bei Tab-Wechseln zu erhalten. -* **Healing Parser:** Die Funktion `parse_markdown_draft` repariert defekte YAML-Frontmatter vom LLM automatisch. - **Traffic Control (`app.services.llm_service`)** * Stellt sicher, dass der Chat responsive bleibt, auch wenn ein Import läuft. * Nutzt `asyncio.Semaphore` (`MINDNET_LLM_BACKGROUND_LIMIT`), um Hintergrund-Jobs zu drosseln. @@ -91,6 +101,7 @@ mindnet/ **Voraussetzungen:** Python 3.10+, Docker, Ollama. **Installation:** + ```bash # 1. Repo & Venv git clone mindnet @@ -98,7 +109,7 @@ cd mindnet python3 -m venv .venv source .venv/bin/activate -# 2. Dependencies +# 2. Dependencies (inkl. st-cytoscape) pip install -r requirements.txt # 3. Ollama (Nomic ist Pflicht!) @@ -107,6 +118,7 @@ ollama pull nomic-embed-text ``` **Konfiguration (.env):** + ```ini QDRANT_URL="http://localhost:6333" COLLECTION_PREFIX="mindnet_dev" @@ -164,7 +176,11 @@ Mindnet lernt durch Konfiguration, nicht durch Training. DECISION: inject_types: ["value", "risk"] # Füge 'risk' hinzu ``` -3. **Kognition (`prompts.yaml`):** (Optional) Passe den System-Prompt an, falls nötig. +3. **Visualisierung (`ui_config.py`):** + Füge dem `GRAPH_COLORS` Dictionary einen Eintrag hinzu: + ```python + "risk": "#ff6b6b" + ``` ### Workflow B: Interview-Schema anpassen (WP07) Wenn Mindnet neue Fragen stellen soll (z.B. "Budget" bei Projekten): @@ -183,12 +199,14 @@ Wenn Mindnet neue Fragen stellen soll (z.B. "Budget" bei Projekten): ## 6. Tests & Debugging **Unit Tests:** + ```bash pytest tests/test_retriever_basic.py pytest tests/test_chunking.py ``` **Pipeline Tests:** + ```bash # JSON-Schema prüfen python3 -m scripts.payload_dryrun --vault ./test_vault @@ -198,6 +216,7 @@ python3 -m scripts.edges_full_check ``` **E2E Smoke Tests:** + ```bash # Decision Engine python tests/test_wp06_decision.py -p 8002 -e DECISION -q "Soll ich X tun?" @@ -211,21 +230,24 @@ python tests/test_feedback_smoke.py --url http://localhost:8002/query ## 7. Troubleshooting & One-Liners **DB komplett zurücksetzen (Vorsicht!):** + ```bash python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet_dev" --yes ``` **Einen einzelnen File inspizieren (Parser-Sicht):** + ```bash python3 tests/inspect_one_note.py --file ./vault/MeinFile.md ``` **Live-Logs sehen:** + ```bash journalctl -u mindnet-dev -f journalctl -u mindnet-ui-dev -f ``` **"Read timed out" im Frontend:** -* Ursache: Smart Edges brauchen länger als 60s. -* Lösung: `MINDNET_API_TIMEOUT=300.0` in `.env`. \ No newline at end of file +* Ursache: Backend braucht für Smart Edges länger als 60s. +* Lösung: `MINDNET_API_TIMEOUT=300.0` in `.env` setzen. \ No newline at end of file diff --git a/docs/06_Roadmap/06_active_roadmap.md b/docs/06_Roadmap/06_active_roadmap.md index 7fdf107..fa9c220 100644 --- a/docs/06_Roadmap/06_active_roadmap.md +++ b/docs/06_Roadmap/06_active_roadmap.md @@ -8,20 +8,20 @@ context: "Aktuelle Planung für kommende Features (ab WP16), Release-Strategie u # Mindnet Active Roadmap -**Aktueller Stand:** v2.6.0 (Post-WP15) -**Fokus:** Usability, Memory & Visualisierung. +**Aktueller Stand:** v2.6.0 (Post-WP19) +**Fokus:** Visualisierung, Exploration & Deep Search. ## 1. Programmstatus -Wir haben Phase D (Interaktion) weitgehend abgeschlossen. Das System ist stabil, verfügt über Traffic Control und Smart Edges. Der Fokus verschiebt sich nun auf **Phase E (Maintenance & Scaling)** sowie die Vertiefung der KI-Fähigkeiten. +Wir haben mit der Implementierung des Graph Explorers (WP19) einen Meilenstein in **Phase E (Maintenance & Scaling)** erreicht. Die Architektur ist nun modular. Der nächste Schritt (WP19a) vertieft die Analyse-Fähigkeiten. | Phase | Fokus | Status | | :--- | :--- | :--- | | **Phase A** | Fundament & Import | ✅ Fertig | | **Phase B** | Semantik & Graph | ✅ Fertig | | **Phase C** | Persönlichkeit | ✅ Fertig | -| **Phase D** | Interaktion & Tools | 🟡 Abschlussphase | -| **Phase E** | Maintenance & Visualisierung | 🚀 Start | +| **Phase D** | Interaktion & Tools | ✅ Fertig | +| **Phase E** | Maintenance & Visualisierung | 🚀 Aktiv | --- @@ -44,6 +44,7 @@ Eine Übersicht der implementierten Features zum schnellen Auffinden von Funktio | **WP-10a**| Draft Editor | GUI-Komponente zum Bearbeiten und Speichern generierter Notizen. | | **WP-11** | Backend Intelligence | `nomic-embed-text` (768d) und Matrix-Logik für Kanten-Typisierung. | | **WP-15** | Smart Edge Allocation | LLM-Filter für Kanten in Chunks + Traffic Control (Semaphore). | +| **WP-19** | Graph Visualisierung | **Frontend Modularisierung:** Umbau auf `ui_*.py`.
**Graph Engines:** Parallelbetrieb von Cytoscape (COSE) und Agraph.
**Tools:** "Single Source of Truth" Editor, Persistenz via URL. | --- @@ -51,19 +52,24 @@ Eine Übersicht der implementierten Features zum schnellen Auffinden von Funktio Diese Features stehen als nächstes an. +### WP-19a – Graph Intelligence & Discovery (Sprint-Fokus) +**Status:** 🚀 Startklar +**Ziel:** Vom "Anschauen" zum "Verstehen". Deep-Dive Werkzeuge für den Graphen. +* **Discovery Screen:** Neuer Tab für semantische Suche ("Finde Notizen über Vaterschaft") und Wildcard-Filter. +* **Filter-Logik:** "Zeige nur Wege, die zu `type:decision` führen". +* **Chunk Inspection:** Umschaltbare Granularität (Notiz vs. Chunk) zur Validierung des Smart Chunkers. + ### WP-16 – Auto-Discovery & Enrichment **Status:** 🟡 Geplant **Ziel:** Automatisches Erkennen von fehlenden Kanten in "dummem" Text *vor* der Speicherung. * **Problem:** Nutzer vergessen Wikilinks. * **Lösung:** Ein "Enricher" scannt Text vor dem Import, findet Keywords (z.B. "Mindnet") und schlägt Links vor (`[[Mindnet]]`). -* **Abgrenzung:** Anders als *Active Intelligence* (WP11, UI-basiert) läuft dies im Backend (Importer). ### WP-17 – Conversational Memory (Gedächtnis) **Status:** 🟡 Geplant **Ziel:** Echte Dialoge statt Request-Response. * **Tech:** Erweiterung des `ChatRequest` DTO um `history`. * **Logik:** Token-Management (Context Window Balancing zwischen RAG-Doks und Chat-Verlauf). -* **Nutzen:** Rückfragen ("Was meinst du damit?") werden möglich. ### WP-18 – Graph Health & Maintenance **Status:** 🟡 Geplant (Prio 2) @@ -71,17 +77,10 @@ Diese Features stehen als nächstes an. * **Feature:** Cronjob `check_graph_integrity.py`. * **Funktion:** Findet "Dangling Edges" (Links auf gelöschte Notizen) und repariert/löscht sie. -### WP-19 – Graph Visualisierung & Explorer -**Status:** 🟡 Geplant (Prio 1) -**Ziel:** Vertrauen durch Transparenz. -* **UI:** Neuer Tab "🕸️ Graph" in Streamlit. -* **Tech:** `streamlit-agraph` oder `pyvis`. -* **Nutzen:** Visuelle Kontrolle der *Smart Edge Allocation* ("Hat das LLM die Kante wirklich hierhin gesetzt?"). - ### WP-20 – Cloud Hybrid Mode (Optional) **Status:** ⚪ Optional **Ziel:** "Turbo-Modus" für Massen-Imports. -* **Konzept:** Switch in `.env`, um statt Ollama (Lokal) auf Google Gemini (Cloud) umzuschalten, wenn Datenschutz-unkritische Daten verarbeitet werden. +* **Konzept:** Switch in `.env`, um statt Ollama (Lokal) auf Google Gemini (Cloud) umzuschalten. --- @@ -89,20 +88,7 @@ Diese Features stehen als nächstes an. ```mermaid graph TD - WP15(Smart Edges) --> WP19(Visualisierung) - WP15 --> WP16(Auto-Discovery) - WP10(Chat UI) --> WP17(Memory) - WP03(Import) --> WP18(Health Check) -``` - -**Nächstes Release (v2.7):** -* Ziel: Visualisierung (WP19) + Conversational Memory (WP17). -* ETA: Q1 2026. - ---- - -## 5. Governance - -* **Versionierung:** Semantic Versioning via Gitea Tags. -* **Feature-Branches:** Jedes WP erhält einen Branch `feature/wpXX-name`. -* **Sync First:** Bevor ein neuer Branch erstellt wird, muss `main` gepullt werden. \ No newline at end of file + WP19(Graph Viz) --> WP19a(Discovery) + WP19a --> WP17(Memory) + WP15(Smart Edges) --> WP16(Auto-Discovery) + WP03(Import) --> WP18(Health Check) \ No newline at end of file diff --git a/docs/99_Archive/99_legacy_workpackages.md b/docs/99_Archive/99_legacy_workpackages.md index 86efb9f..7eed15f 100644 --- a/docs/99_Archive/99_legacy_workpackages.md +++ b/docs/99_Archive/99_legacy_workpackages.md @@ -2,12 +2,12 @@ doc_type: archive audience: historian, architect status: archived -context: "Archivierte Details zu abgeschlossenen Workpackages (WP01-WP15). Referenz für historische Design-Entscheidungen." +context: "Archivierte Details zu abgeschlossenen Workpackages (WP01-WP19). Referenz für historische Design-Entscheidungen." --- # Legacy Workpackages (Archiv) -**Quellen:** `Programmplan_V2.2.md` +**Quellen:** `Programmplan_V2.2.md`, `Active Roadmap` **Status:** Abgeschlossen / Implementiert. Dieses Dokument dient als Referenz für die Entstehungsgeschichte von Mindnet v2.6. @@ -79,4 +79,16 @@ Dieses Dokument dient als Referenz für die Entstehungsgeschichte von Mindnet v2 ### WP-15 – Smart Edge Allocation (Meilenstein) * **Problem:** "Broadcasting". Ein Chunk erbte alle Links der Note, auch irrelevante. Das verwässerte die Suchergebnisse. * **Lösung:** LLM prüft jeden Chunk auf Link-Relevanz. -* **Tech:** Einführung von **Traffic Control** (Semaphore), um Import und Chat zu parallelisieren, ohne die Hardware zu überlasten. \ No newline at end of file +* **Tech:** Einführung von **Traffic Control** (Semaphore), um Import und Chat zu parallelisieren, ohne die Hardware zu überlasten. + +--- + +## Phase E: Visualisierung & Maintenance (WP19) + +### WP-19 – Graph Visualisierung & Modularisierung +* **Ziel:** Transparenz über die Datenstruktur schaffen und technische Schulden (Monolith) abbauen. +* **Ergebnis:** + * **Modularisierung:** Aufsplittung der `ui.py` in Router, Services und Views (`ui_*.py`). + * **Graph Explorer:** Einführung von `st-cytoscape` für stabile, nicht-überlappende Layouts (COSE) als Ergänzung zur Legacy-Engine (Agraph). + * **Single Source of Truth:** Der Editor lädt Inhalte nun direkt vom Dateisystem statt aus (potenziell veralteten) Vektor-Payloads. + * **UX:** Einführung von URL-Persistenz für Layout-Settings und CSS-basiertes Highlighting zur Vermeidung von Re-Renders. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1828b3d..0027259 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,4 +30,8 @@ tqdm>=4.67.1 pytest>=8.4.2 # --- Frontend (WP-10) --- -streamlit>=1.39.0 \ No newline at end of file +streamlit>=1.39.0 + +# Visualization (Parallelbetrieb) +streamlit-agraph>=0.0.45 +st-cytoscape>=1.0.0 \ No newline at end of file