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 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.")
|
||||||
|
|
@ -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}
|
||||||
)
|
)
|
||||||
Loading…
Reference in New Issue
Block a user