Dynamische Layout-Einstellungen (Slider).

"Bearbeiten" Button im Graph.
Vollständige Chat- & Editor-Funktionen.
Switch-Helper für den Wechsel vom Graphen zum Editor.
Fix für ausgehende Kanten (Note-Level).
Fix für pydantic Validierung (MatchExcept).
Hover-Text (Tooltip) mit Inhalt.
Logik zum Ausblenden von Labels.
This commit is contained in:
Lars 2025-12-14 11:39:49 +01:00
parent f1bcbb1543
commit 4e8512c812
2 changed files with 127 additions and 59 deletions

View File

@ -5,19 +5,45 @@ from datetime import datetime
from streamlit_agraph import agraph, Config
from qdrant_client import models
# Importe aus den anderen Modulen
from ui_utils import parse_markdown_draft, build_markdown_doc, load_history_from_logs, slugify
from ui_api import save_draft_to_vault, analyze_draft_text, send_chat_message, submit_feedback
from ui_config import HISTORY_FILE, COLLECTION_PREFIX, GRAPH_COLORS
# --- Helper zum Modus-Wechsel ---
def switch_to_editor(note_payload):
"""Lädt eine Note in den Editor und wechselt den Tab."""
# Wir simulieren eine Message, wie sie der Chatbot zurückgeben würde
content = note_payload.get('fulltext', '')
if not content:
# Fallback: Wir rekonstruieren minimales Markdown
content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (nur Metadaten verfügbar).")
# State setzen für den Editor
st.session_state.messages.append({
"role": "assistant",
"intent": "INTERVIEW",
"content": content,
"query_id": f"edit_{note_payload['note_id']}"
})
# Modus umschalten (muss via session_state key im Radio-Widget passieren)
st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor"
st.rerun()
def render_sidebar():
"""
Rendert die Sidebar mit Modus-Auswahl und Verlauf.
"""
with st.sidebar:
st.title("🧠 mindnet")
st.caption("v2.6 | WP-19 Graph View")
mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor", "🕸️ Graph Explorer"], index=0)
# State-gebundenes Radio Widget für Modus-Wechsel
if "sidebar_mode_selection" not in st.session_state:
st.session_state["sidebar_mode_selection"] = "💬 Chat"
mode = st.radio(
"Modus",
["💬 Chat", "📝 Manueller Editor", "🕸️ Graph Explorer"],
key="sidebar_mode_selection"
)
st.divider()
st.subheader("⚙️ Settings")
@ -270,62 +296,92 @@ def render_manual_editor():
}
render_draft_editor(mock_msg)
# --- GRAPH EXPLORER (WP-19) ---
def render_graph_explorer(graph_service):
"""
Rendert den erweiterten Graph Explorer (WP-19).
"""
st.header("🕸️ Graph Explorer")
# State Management für Graph Navigation
if "graph_center_id" not in st.session_state:
st.session_state.graph_center_id = None
# State Init für den Graph-Explorer
if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None
# Defaults speichern für Persistenz während der Session (mit setdefault)
st.session_state.setdefault("graph_depth", 2)
st.session_state.setdefault("graph_show_labels", True)
st.session_state.setdefault("graph_spacing", 200) # Standard etwas höher für mehr Luft
st.session_state.setdefault("graph_gravity", -3000)
col_ctrl, col_graph = st.columns([1, 4])
with col_ctrl:
st.subheader("Fokus")
# 1. Suchfeld
search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...")
options = {}
if search_term:
hits, _ = graph_service.client.scroll(
collection_name=f"{COLLECTION_PREFIX}_notes",
scroll_filter=models.Filter(
must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))]
),
scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))]),
limit=10
)
options = {h.payload['title']: h.payload['note_id'] for h in hits}
if options:
selected_title = st.selectbox("Ergebnisse:", list(options.keys()))
if st.button("Laden", use_container_width=True):
st.session_state.graph_center_id = options[selected_title]
st.rerun()
if options:
selected_title = st.selectbox("Ergebnisse:", list(options.keys()))
if st.button("Laden", use_container_width=True):
st.session_state.graph_center_id = options[selected_title]
st.rerun()
st.divider()
st.caption("Legende (Wichtigste Typen)")
# --- VIEW SETTINGS ---
with st.expander("👁️ Ansicht & Layout", expanded=True):
st.session_state.graph_depth = st.slider("Tiefe (Tier)", 1, 3, st.session_state.graph_depth)
st.session_state.graph_show_labels = st.checkbox("Kanten-Beschriftung", st.session_state.graph_show_labels)
st.markdown("**Dynamisches Layout**")
st.session_state.graph_spacing = st.slider("Abstand (Feder)", 50, 400, st.session_state.graph_spacing)
st.session_state.graph_gravity = st.slider("Abstoßung (Gravity)", -8000, -100, st.session_state.graph_gravity)
if st.button("Standard wiederherstellen"):
st.session_state.graph_spacing = 200
st.session_state.graph_gravity = -3000
st.rerun()
st.divider()
st.caption("Legende (Top Typen)")
for k, v in list(GRAPH_COLORS.items())[:8]:
st.markdown(f"<span style='color:{v}'>●</span> {k}", unsafe_allow_html=True)
st.caption("Weitere Farben siehe `ui_config.py`")
st.divider()
depth_val = st.slider("Tiefe (Tier)", 1, 3, 2, help="Level 1 = Nachbarn, Level 2 = Nachbarn der Nachbarn")
st.info("💡 Tipp: Ein Klick auf einen Knoten zentriert die Ansicht neu.")
with col_graph:
center_id = st.session_state.graph_center_id
if center_id:
with st.spinner(f"Lade Graph für {center_id} (Tiefe {depth_val})..."):
nodes, edges = graph_service.get_ego_graph(center_id, depth=depth_val)
# 1. Action Bar für die aktive Node
c_action1, c_action2 = st.columns([3, 1])
with c_action1:
st.caption(f"Aktives Zentrum: **{center_id}**")
with c_action2:
# Button um die aktuelle Zentrale Note zu editieren
if st.button("📝 Bearbeiten", use_container_width=True):
note_data = graph_service._fetch_note_cached(center_id)
if note_data:
switch_to_editor(note_data)
else:
st.error("Fehler beim Laden der Daten.")
# 2. Graph Rendern
with st.spinner(f"Lade Graph..."):
nodes, edges = graph_service.get_ego_graph(
center_id,
depth=st.session_state.graph_depth,
show_labels=st.session_state.graph_show_labels
)
if not nodes:
st.warning("Keine Daten gefunden. Vielleicht existiert die Notiz nicht mehr?")
st.warning("Keine Daten gefunden. (Notiz existiert evtl. nicht mehr)")
else:
# CONFIG: ForceAtlas2Based für maximale Entzerrung
config = Config(
width=1000,
height=800,
@ -335,30 +391,31 @@ def render_graph_explorer(graph_service):
nodeHighlightBehavior=True,
highlightColor="#F7A7A6",
collapsible=False,
# Solver Wechsel: ForceAtlas2Based ist besser für Entzerrung
# Solver Wechsel: ForceAtlas2Based
solver="forceAtlas2Based",
forceAtlas2Based={
"theta": 0.5,
"gravitationalConstant": -100, # Starke Abstoßung
"centralGravity": 0.005, # Sehr schwacher Zug zur Mitte (verhindert Klumpen)
"gravitationalConstant": st.session_state.graph_gravity, # Dynamisch
"centralGravity": 0.005,
"springConstant": 0.08,
"springLength": 150, # Längere Kanten
"springLength": st.session_state.graph_spacing, # Dynamisch
"damping": 0.4,
"avoidOverlap": 1 # Versucht aktiv, Überlappungen zu vermeiden
"avoidOverlap": 1
},
stabilization={
"enabled": True,
"iterations": 1000
}
stabilization={"enabled": True, "iterations": 800}
)
st.caption(f"Zentrum: **{center_id}** | Knoten: {len(nodes)} | Kanten: {len(edges)}")
# Interaktion
return_value = agraph(nodes=nodes, edges=edges, config=config)
# NAVIGATION LOGIK
if return_value and return_value != center_id:
st.session_state.graph_center_id = return_value
st.rerun()
if return_value:
# Wenn auf eine andere Node geklickt wurde:
if return_value != center_id:
st.session_state.graph_center_id = return_value
st.rerun()
else:
# Wenn auf die ZENTRALE Node geklickt wurde
st.toast(f"Zentrum: {return_value}")
else:
st.info("👈 Wähle links eine Notiz, um den Graphen zu starten.")
st.info("👈 Bitte wähle links eine Notiz aus, um den Graphen zu starten.")

View File

@ -11,7 +11,11 @@ class GraphExplorerService:
self.edges_col = f"{prefix}_edges"
self._note_cache = {}
def get_ego_graph(self, center_note_id: str, depth=2):
def get_ego_graph(self, center_note_id: str, depth=2, show_labels=True):
"""
Erstellt den Graphen.
show_labels=False versteckt die Kantenbeschriftung für mehr Übersicht.
"""
nodes_dict = {}
unique_edges = {}
@ -20,20 +24,18 @@ class GraphExplorerService:
if not center_note: return [], []
self._add_node_to_dict(nodes_dict, center_note, level=0)
# Wir sammeln IDs für Level 2 Suche
level_1_ids = {center_note_id}
# Suche Kanten für Center
l1_edges = self._find_connected_edges([center_note_id], center_note.get("title"))
# Verarbeite L1 Kanten
for edge_data in l1_edges:
src_id, tgt_id = self._process_edge(edge_data, nodes_dict, unique_edges, current_depth=1)
if src_id: level_1_ids.add(src_id)
if tgt_id: level_1_ids.add(tgt_id)
# Level 2 Suche (begrenzt, um Chaos zu vermeiden)
if depth > 1 and len(level_1_ids) > 1 and len(level_1_ids) < 40: # Limit für Performance
# Level 2 Suche (begrenzt für Performance)
if depth > 1 and len(level_1_ids) > 1 and len(level_1_ids) < 60:
l1_subset = list(level_1_ids - {center_note_id})
if l1_subset:
l2_edges = self._find_connected_edges_batch(l1_subset)
@ -48,9 +50,12 @@ class GraphExplorerService:
color = get_edge_color(kind)
is_smart = (prov != "explicit" and prov != "rule")
# Label Logik: Wenn show_labels False ist, zeigen wir keinen Text an
label_text = kind if show_labels else " "
final_edges.append(Edge(
source=src, target=tgt, label=kind, color=color, dashes=is_smart,
title=f"Provenance: {prov}\nType: {kind}"
source=src, target=tgt, label=label_text, color=color, dashes=is_smart,
title=f"Relation: {kind}\nProvenance: {prov}" # Tooltip bleibt immer
))
return list(nodes_dict.values()), final_edges
@ -69,13 +74,13 @@ class GraphExplorerService:
results = []
# --- OUTGOING SEARCH (Quelle = Chunk ODER Note) ---
# FIX: Wir suchen jetzt auch nach der note_id als source_id, falls Edges direkt an der Note hängen
# Wir suchen jetzt auch nach der note_id als source_id, falls Edges direkt an der Note hängen
source_candidates = chunk_ids + note_ids
if source_candidates:
out_f = models.Filter(must=[
models.FieldCondition(key="source_id", match=models.MatchAny(any=source_candidates)),
# FIX: Pydantic "except" keyword workaround
# FIX: MatchExcept mit **kwargs nutzen wegen 'except' Keyword
models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))
])
res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_f, limit=100, with_payload=True)
@ -90,7 +95,7 @@ class GraphExplorerService:
if shoulds:
in_f = models.Filter(
# FIX: Pydantic "except" keyword workaround
# FIX: MatchExcept mit **kwargs nutzen wegen 'except' Keyword
must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))],
should=shoulds
)
@ -110,6 +115,7 @@ class GraphExplorerService:
kind = payload.get("kind")
provenance = payload.get("provenance", "explicit")
# Resolve
src_note = self._resolve_note_from_ref(src_ref)
tgt_note = self._resolve_note_from_ref(tgt_ref)
@ -118,12 +124,15 @@ class GraphExplorerService:
tgt_id = tgt_note['note_id']
if src_id != tgt_id:
# Add Nodes
self._add_node_to_dict(nodes_dict, src_note, level=current_depth)
self._add_node_to_dict(nodes_dict, tgt_note, level=current_depth)
# Add Edge (Deduplication Logic)
key = (src_id, tgt_id)
existing = unique_edges.get(key)
# Update logic: Explicit > Smart
should_update = True
is_current_explicit = (provenance in ["explicit", "rule"])
if existing:
@ -183,7 +192,9 @@ class GraphExplorerService:
ntype = note_payload.get("type", "default")
color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"])
# Size Adjustment für Hierarchie
# HOVER TEXT: Vorschau bauen
hover_text = f"Titel: {note_payload.get('title')}\nTyp: {ntype}\nTags: {note_payload.get('tags', [])}"
if level == 0: size = 45
elif level == 1: size = 25
else: size = 15
@ -194,6 +205,6 @@ class GraphExplorerService:
size=size,
color=color,
shape="dot" if level > 0 else "diamond",
title=f"Type: {ntype}\nTags: {note_payload.get('tags')}",
title=hover_text, # Hover im Browser
font={'color': 'black', 'face': 'arial', 'size': 14 if level < 2 else 0}
)