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:
parent
f1bcbb1543
commit
4e8512c812
|
|
@ -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.")
|
||||
|
|
@ -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}
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user