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 streamlit_agraph import agraph, Config
from qdrant_client import models 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_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_api import save_draft_to_vault, analyze_draft_text, send_chat_message, submit_feedback
from ui_config import HISTORY_FILE, COLLECTION_PREFIX, GRAPH_COLORS 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(): def render_sidebar():
"""
Rendert die Sidebar mit Modus-Auswahl und Verlauf.
"""
with st.sidebar: with st.sidebar:
st.title("🧠 mindnet") st.title("🧠 mindnet")
st.caption("v2.6 | WP-19 Graph View") 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.divider()
st.subheader("⚙️ Settings") st.subheader("⚙️ Settings")
@ -270,30 +296,33 @@ def render_manual_editor():
} }
render_draft_editor(mock_msg) render_draft_editor(mock_msg)
# --- GRAPH EXPLORER (WP-19) ---
def render_graph_explorer(graph_service): def render_graph_explorer(graph_service):
"""
Rendert den erweiterten Graph Explorer (WP-19).
"""
st.header("🕸️ Graph Explorer") st.header("🕸️ Graph Explorer")
# State Management für Graph Navigation # State Init für den Graph-Explorer
if "graph_center_id" not in st.session_state: if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None
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]) col_ctrl, col_graph = st.columns([1, 4])
with col_ctrl: with col_ctrl:
st.subheader("Fokus") st.subheader("Fokus")
# 1. Suchfeld
search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...") search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...")
options = {} options = {}
if search_term: if search_term:
hits, _ = graph_service.client.scroll( hits, _ = graph_service.client.scroll(
collection_name=f"{COLLECTION_PREFIX}_notes", collection_name=f"{COLLECTION_PREFIX}_notes",
scroll_filter=models.Filter( scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))]),
must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))]
),
limit=10 limit=10
) )
options = {h.payload['title']: h.payload['note_id'] for h in hits} options = {h.payload['title']: h.payload['note_id'] for h in hits}
@ -305,27 +334,54 @@ def render_graph_explorer(graph_service):
st.rerun() st.rerun()
st.divider() st.divider()
st.caption("Legende (Wichtigste Typen)")
for k, v in list(GRAPH_COLORS.items())[:8]: # --- VIEW SETTINGS ---
st.markdown(f"<span style='color:{v}'>●</span> {k}", unsafe_allow_html=True) with st.expander("👁️ Ansicht & Layout", expanded=True):
st.caption("Weitere Farben siehe `ui_config.py`") 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.divider()
depth_val = st.slider("Tiefe (Tier)", 1, 3, 2, help="Level 1 = Nachbarn, Level 2 = Nachbarn der Nachbarn") st.caption("Legende (Top Typen)")
st.info("💡 Tipp: Ein Klick auf einen Knoten zentriert die Ansicht neu.") for k, v in list(GRAPH_COLORS.items())[:8]:
st.markdown(f"<span style='color:{v}'>●</span> {k}", unsafe_allow_html=True)
with col_graph: with col_graph:
center_id = st.session_state.graph_center_id center_id = st.session_state.graph_center_id
if center_id: if center_id:
with st.spinner(f"Lade Graph für {center_id} (Tiefe {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.")
nodes, edges = graph_service.get_ego_graph(center_id, depth=depth_val) # 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: 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: else:
# CONFIG: ForceAtlas2Based für maximale Entzerrung
config = Config( config = Config(
width=1000, width=1000,
height=800, height=800,
@ -335,30 +391,31 @@ def render_graph_explorer(graph_service):
nodeHighlightBehavior=True, nodeHighlightBehavior=True,
highlightColor="#F7A7A6", highlightColor="#F7A7A6",
collapsible=False, collapsible=False,
# Solver Wechsel: ForceAtlas2Based ist besser für Entzerrung # Solver Wechsel: ForceAtlas2Based
solver="forceAtlas2Based", solver="forceAtlas2Based",
forceAtlas2Based={ forceAtlas2Based={
"theta": 0.5, "theta": 0.5,
"gravitationalConstant": -100, # Starke Abstoßung "gravitationalConstant": st.session_state.graph_gravity, # Dynamisch
"centralGravity": 0.005, # Sehr schwacher Zug zur Mitte (verhindert Klumpen) "centralGravity": 0.005,
"springConstant": 0.08, "springConstant": 0.08,
"springLength": 150, # Längere Kanten "springLength": st.session_state.graph_spacing, # Dynamisch
"damping": 0.4, "damping": 0.4,
"avoidOverlap": 1 # Versucht aktiv, Überlappungen zu vermeiden "avoidOverlap": 1
}, },
stabilization={ stabilization={"enabled": True, "iterations": 800}
"enabled": True,
"iterations": 1000
}
) )
st.caption(f"Zentrum: **{center_id}** | Knoten: {len(nodes)} | Kanten: {len(edges)}") # Interaktion
return_value = agraph(nodes=nodes, edges=edges, config=config) return_value = agraph(nodes=nodes, edges=edges, config=config)
# NAVIGATION LOGIK if return_value:
if return_value and return_value != center_id: # Wenn auf eine andere Node geklickt wurde:
if return_value != center_id:
st.session_state.graph_center_id = return_value st.session_state.graph_center_id = return_value
st.rerun() st.rerun()
else: else:
st.info("👈 Wähle links eine Notiz, um den Graphen zu starten.") # Wenn auf die ZENTRALE Node geklickt wurde
st.toast(f"Zentrum: {return_value}")
else:
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.edges_col = f"{prefix}_edges"
self._note_cache = {} 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 = {} nodes_dict = {}
unique_edges = {} unique_edges = {}
@ -20,20 +24,18 @@ class GraphExplorerService:
if not center_note: return [], [] if not center_note: return [], []
self._add_node_to_dict(nodes_dict, center_note, level=0) 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} level_1_ids = {center_note_id}
# Suche Kanten für Center # Suche Kanten für Center
l1_edges = self._find_connected_edges([center_note_id], center_note.get("title")) l1_edges = self._find_connected_edges([center_note_id], center_note.get("title"))
# Verarbeite L1 Kanten
for edge_data in l1_edges: for edge_data in l1_edges:
src_id, tgt_id = self._process_edge(edge_data, nodes_dict, unique_edges, current_depth=1) 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 src_id: level_1_ids.add(src_id)
if tgt_id: level_1_ids.add(tgt_id) if tgt_id: level_1_ids.add(tgt_id)
# Level 2 Suche (begrenzt, um Chaos zu vermeiden) # Level 2 Suche (begrenzt für Performance)
if depth > 1 and len(level_1_ids) > 1 and len(level_1_ids) < 40: # Limit 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}) l1_subset = list(level_1_ids - {center_note_id})
if l1_subset: if l1_subset:
l2_edges = self._find_connected_edges_batch(l1_subset) l2_edges = self._find_connected_edges_batch(l1_subset)
@ -48,9 +50,12 @@ class GraphExplorerService:
color = get_edge_color(kind) color = get_edge_color(kind)
is_smart = (prov != "explicit" and prov != "rule") 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( final_edges.append(Edge(
source=src, target=tgt, label=kind, color=color, dashes=is_smart, source=src, target=tgt, label=label_text, color=color, dashes=is_smart,
title=f"Provenance: {prov}\nType: {kind}" title=f"Relation: {kind}\nProvenance: {prov}" # Tooltip bleibt immer
)) ))
return list(nodes_dict.values()), final_edges return list(nodes_dict.values()), final_edges
@ -69,13 +74,13 @@ class GraphExplorerService:
results = [] results = []
# --- OUTGOING SEARCH (Quelle = Chunk ODER Note) --- # --- 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 source_candidates = chunk_ids + note_ids
if source_candidates: if source_candidates:
out_f = models.Filter(must=[ out_f = models.Filter(must=[
models.FieldCondition(key="source_id", match=models.MatchAny(any=source_candidates)), 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})) 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=100, with_payload=True)
@ -90,7 +95,7 @@ class GraphExplorerService:
if shoulds: if shoulds:
in_f = models.Filter( 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}))], must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))],
should=shoulds should=shoulds
) )
@ -110,6 +115,7 @@ class GraphExplorerService:
kind = payload.get("kind") kind = payload.get("kind")
provenance = payload.get("provenance", "explicit") provenance = payload.get("provenance", "explicit")
# Resolve
src_note = self._resolve_note_from_ref(src_ref) src_note = self._resolve_note_from_ref(src_ref)
tgt_note = self._resolve_note_from_ref(tgt_ref) tgt_note = self._resolve_note_from_ref(tgt_ref)
@ -118,12 +124,15 @@ class GraphExplorerService:
tgt_id = tgt_note['note_id'] tgt_id = tgt_note['note_id']
if src_id != tgt_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, src_note, level=current_depth)
self._add_node_to_dict(nodes_dict, tgt_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) key = (src_id, tgt_id)
existing = unique_edges.get(key) existing = unique_edges.get(key)
# Update logic: Explicit > Smart
should_update = True should_update = True
is_current_explicit = (provenance in ["explicit", "rule"]) is_current_explicit = (provenance in ["explicit", "rule"])
if existing: if existing:
@ -183,7 +192,9 @@ class GraphExplorerService:
ntype = note_payload.get("type", "default") ntype = note_payload.get("type", "default")
color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["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 if level == 0: size = 45
elif level == 1: size = 25 elif level == 1: size = 25
else: size = 15 else: size = 15
@ -194,6 +205,6 @@ class GraphExplorerService:
size=size, size=size,
color=color, color=color,
shape="dot" if level > 0 else "diamond", 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} font={'color': 'black', 'face': 'arial', 'size': 14 if level < 2 else 0}
) )