diff --git a/app/frontend/ui_components.py b/app/frontend/ui_components.py
index 52dbb54..7662773 100644
--- a/app/frontend/ui_components.py
+++ b/app/frontend/ui_components.py
@@ -11,7 +11,7 @@ from ui_api import save_draft_to_vault, analyze_draft_text, send_chat_message, s
from ui_config import HISTORY_FILE, COLLECTION_PREFIX, GRAPH_COLORS
# --- CALLBACKS ---
-# Diese Funktion muss oben stehen, damit sie vor dem Re-Run bekannt ist.
+# Diese müssen zwingend VOR dem Aufruf definiert sein.
def switch_to_editor_callback(note_payload):
"""
@@ -28,17 +28,17 @@ def switch_to_editor_callback(note_payload):
# 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)
+ # 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)
+ # 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
- # Dies ist riskant, aber besser als immer "Neu" zu erstellen
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",
@@ -49,6 +49,7 @@ def switch_to_editor_callback(note_payload):
})
# 4. Modus umschalten
+ # Wir setzen den Key des Radio-Buttons im Session State
st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor"
# --- UI RENDERER ---
@@ -161,9 +162,7 @@ def render_draft_editor(msg):
if origin_fname:
# Update Modus
display_name = str(origin_fname).split("/")[-1] # Nur Dateiname anzeigen
- st.info(f"📝 **Update-Modus**: Du bearbeitest `{display_name}`")
- # Debug Info im Tooltip oder Caption
- # st.caption(f"Pfad: {origin_fname}")
+ st.success(f"📂 **Datei-Modus**: `{origin_fname}`") # Voller Pfad zur Sicherheit
st.markdown(f'
', unsafe_allow_html=True)
else:
# Create Modus
@@ -269,7 +268,7 @@ def render_draft_editor(msg):
b1, b2 = st.columns([1, 1])
with b1:
# Label dynamisch
- save_label = "💾 Update speichern" if origin_fname else "💾 Neu anlegen & Indizieren"
+ 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..."):
@@ -280,12 +279,9 @@ def render_draft_editor(msg):
target_filename = origin_fname
else:
# NEU: Wir generieren einen Namen
- raw_title = final_meta.get("title", "")
- if not raw_title:
- clean_body = re.sub(r"[#*_\[\]()]", "", final_body).strip()
- raw_title = clean_body[:40] if clean_body else "draft"
- safe_title = slugify(raw_title)[:60] or "draft"
- target_filename = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}.md"
+ 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)
@@ -382,7 +378,7 @@ def render_graph_explorer(graph_service):
# 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!)
+ # Defaults angepasst für BarnesHut (Skalierung angepasst)
st.session_state.setdefault("graph_spacing", 150)
st.session_state.setdefault("graph_gravity", -3000)
@@ -435,74 +431,89 @@ def render_graph_explorer(graph_service):
center_id = st.session_state.graph_center_id
if center_id:
- # Action Bar
- c_action1, c_action2 = st.columns([3, 1])
- with c_action1:
- st.caption(f"Aktives Zentrum: **{center_id}**")
- with c_action2:
- # Bearbeiten Button mit Callback (on_click)
- # Holt die Daten aus dem Cache des Services
- note_data = graph_service._fetch_note_cached(center_id)
- if note_data:
- st.button("📝 Bearbeiten",
- use_container_width=True,
- on_click=switch_to_editor_callback,
- args=(note_data,))
- else:
- st.error("Datenfehler: Notiz nicht gefunden.")
-
+ # Container für Action Bar OBERHALB des Graphen (Layout Fix)
+ action_container = st.container()
+
+ # Graph Laden
with st.spinner(f"Lade Graph..."):
- # Daten laden (Nutzt den verbesserten Service mit Hover-Texten)
+ # 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
)
- if not nodes:
- st.warning("Keine Daten gefunden. (Notiz existiert evtl. nicht mehr)")
- else:
- # --- CONFIGURATION FÜR AGRAH (BARNES HUT) ---
- # Wir nutzen KEIN 'key' Argument, da dies Fehler verursacht.
- # Stattdessen vertrauen wir darauf, dass das Config-Objekt neu ist.
- # TRICK: Wir ändern die Höhe minimal basierend auf der Gravity, um Re-Render zu erzwingen.
- dyn_height = 800 + (abs(st.session_state.graph_gravity) % 5)
+ # Fetch Note Data für Button & Debug
+ note_data = graph_service._fetch_note_cached(center_id)
- 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()
+ # --- 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:
- # Klick auf das Zentrum selbst
- st.toast(f"Zentrum: {return_value}")
+ 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, um den Graphen zu starten.")
\ No newline at end of file
+ st.info("👈 Bitte wähle links eine Notiz aus.")
\ No newline at end of file
diff --git a/app/frontend/ui_graph_service.py b/app/frontend/ui_graph_service.py
index 774b51c..3032e62 100644
--- a/app/frontend/ui_graph_service.py
+++ b/app/frontend/ui_graph_service.py
@@ -1,3 +1,4 @@
+import re
from qdrant_client import QdrantClient, models
from streamlit_agraph import Node, Edge
from ui_config import GRAPH_COLORS, get_edge_color, SYSTEM_EDGES
@@ -13,8 +14,8 @@ class GraphExplorerService:
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.
+ Erstellt den Ego-Graphen um eine zentrale Notiz.
+ Lädt Volltext für das Zentrum und Snippets für Nachbarn.
"""
nodes_dict = {}
unique_edges = {}
@@ -26,7 +27,7 @@ class GraphExplorerService:
level_1_ids = {center_note_id}
- # Suche Kanten für Center
+ # Suche Kanten für Center (L1)
l1_edges = self._find_connected_edges([center_note_id], center_note.get("title"))
for edge_data in l1_edges:
@@ -43,23 +44,26 @@ class GraphExplorerService:
self._process_edge(edge_data, nodes_dict, unique_edges, current_depth=2)
# --- SMART CONTENT LOADING ---
- # 1. Fulltext für Center Node holen (Chunks stitchen)
+
+ # A. Fulltext für Center Node holen (Chunks zusammenfügen)
center_text = self._fetch_full_text_stitched(center_note_id)
if center_note_id in nodes_dict:
orig_title = nodes_dict[center_note_id].title
- # Titel im Objekt manipulieren für Hover
- nodes_dict[center_note_id].title = f"{orig_title}\n\n📄 VOLLTEXT:\n{center_text[:2000]}..."
+ clean_full = self._clean_markdown(center_text[:2000])
+ # Wir packen den Text in den Tooltip (title attribute)
+ nodes_dict[center_note_id].title = f"{orig_title}\n\n📄 INHALT:\n{clean_full}..."
- # 2. Previews für alle anderen Nodes holen
+ # B. Previews für alle Nachbarn holen (Batch)
all_ids = list(nodes_dict.keys())
previews = self._fetch_previews_for_nodes(all_ids)
for nid, node_obj in nodes_dict.items():
if nid != center_note_id:
- prev = previews.get(nid, "Kein Vorschau-Text.")
- node_obj.title = f"{node_obj.title}\n\n🔍 VORSCHAU:\n{prev[:600]}..."
+ prev_raw = previews.get(nid, "Kein Vorschau-Text.")
+ clean_prev = self._clean_markdown(prev_raw[:600])
+ node_obj.title = f"{node_obj.title}\n\n🔍 VORSCHAU:\n{clean_prev}..."
- # Graphen bauen
+ # Graphen bauen (Nodes & Edges finalisieren)
final_edges = []
for (src, tgt), data in unique_edges.items():
kind = data['kind']
@@ -67,24 +71,38 @@ 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 Logik
label_text = kind if show_labels else " "
final_edges.append(Edge(
source=src, target=tgt, label=label_text, color=color, dashes=is_smart,
- title=f"Relation: {kind}\nProvenance: {prov}" # Tooltip bleibt immer
+ title=f"Relation: {kind}\nProvenance: {prov}"
))
return list(nodes_dict.values()), final_edges
+ def _clean_markdown(self, text):
+ """Entfernt Markdown-Sonderzeichen für saubere Tooltips im Browser."""
+ if not text: return ""
+ # Entferne Header Marker (## )
+ text = re.sub(r'#+\s', '', text)
+ # Entferne Bold/Italic (** oder *)
+ text = re.sub(r'\*\*|__|\*|_', '', text)
+ # Entferne Links [Text](Url) -> Text
+ text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
+ # Entferne Wikilinks [[Link]] -> Link
+ text = re.sub(r'\[\[([^\]]+)\]\]', r'\1', text)
+ return text
+
def _fetch_full_text_stitched(self, note_id):
- """Holt ALLE Chunks einer Note, sortiert sie und baut den Text zusammen."""
+ """Lädt alle Chunks einer Note und baut den Text zusammen."""
try:
scroll_filter = models.Filter(
must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))]
)
+ # Limit hoch genug setzen
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True)
- # Sortieren nach 'ord'
+ # Sortieren nach 'ord' (Reihenfolge im Dokument)
chunks.sort(key=lambda x: x.payload.get('ord', 999))
full_text = []
@@ -97,51 +115,49 @@ class GraphExplorerService:
return "Fehler beim Laden des Volltexts."
def _fetch_previews_for_nodes(self, node_ids):
- """Holt den ersten Chunk ('window' oder 'text') für eine Liste von Nodes."""
+ """Holt Batch-weise den ersten Chunk für eine Liste von Nodes."""
if not node_ids: return {}
previews = {}
try:
scroll_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))])
+ # Limit = Anzahl Nodes * 3 (Puffer)
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=len(node_ids)*3, with_payload=True)
for c in chunks:
nid = c.payload.get("note_id")
+ # Nur den ersten gefundenen Chunk pro Note nehmen
if nid and nid not in previews:
previews[nid] = c.payload.get("window") or c.payload.get("text") or ""
except: pass
return previews
def _find_connected_edges(self, note_ids, note_title=None):
- """Findet In- und Outgoing Edges."""
- # Chunks finden
- scroll_filter = models.Filter(
- must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))]
- )
- chunks, _ = self.client.scroll(
- collection_name=self.chunks_col, scroll_filter=scroll_filter, limit=200
- )
+ """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 = []
- # --- OUTGOING SEARCH (Quelle = Chunk ODER Note) ---
- # Wir suchen jetzt auch nach der note_id als source_id, falls Edges direkt an der Note hängen
+ # 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 mit **kwargs nutzen wegen 'except' Keyword in Pydantic
+ # 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)
results.extend(res_out)
- # --- INCOMING SEARCH (Ziel = Chunk ODER Title ODER Note) ---
+ # 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)))
- # Target = Note ID
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids)))
if shoulds:
@@ -150,22 +166,22 @@ class GraphExplorerService:
should=shoulds
)
res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_f, limit=100, with_payload=True)
- results.extend(res_in)
-
+ results.extend(res_in)
return results
def _find_connected_edges_batch(self, note_ids):
- """Batch-Suche für Level 2."""
+ # Wrapper für Level 2 Suche
return self._find_connected_edges(note_ids)
def _process_edge(self, record, nodes_dict, unique_edges, current_depth):
+ """Verarbeitet eine rohe Edge, löst IDs auf und fügt sie den Dictionaries hinzu."""
payload = record.payload
src_ref = payload.get("source_id")
tgt_ref = payload.get("target_id")
kind = payload.get("kind")
provenance = payload.get("provenance", "explicit")
- # Resolve
+ # IDs zu Notes auflösen
src_note = self._resolve_note_from_ref(src_ref)
tgt_note = self._resolve_note_from_ref(tgt_ref)
@@ -174,15 +190,16 @@ class GraphExplorerService:
tgt_id = tgt_note['note_id']
if src_id != tgt_id:
- # Add Nodes
+ # Nodes hinzufügen
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)
+ # Kante hinzufügen (mit Deduplizierung)
key = (src_id, tgt_id)
existing = unique_edges.get(key)
should_update = True
+ # Bevorzuge explizite Kanten vor Smart Kanten
is_current_explicit = (provenance in ["explicit", "rule"])
if existing:
is_existing_explicit = (existing['provenance'] in ["explicit", "rule"])
@@ -190,9 +207,7 @@ class GraphExplorerService:
should_update = False
if should_update:
- unique_edges[key] = {
- "source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance
- }
+ unique_edges[key] = {"source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance}
return src_id, tgt_id
return None, None
@@ -209,18 +224,22 @@ class GraphExplorerService:
return None
def _resolve_note_from_ref(self, ref_str):
+ """Löst eine ID (Chunk, Note oder Titel) zu einer Note Payload auf."""
if not ref_str: return None
- # Fall A: Chunk ID
+ # Fall A: Chunk ID (enthält #)
if "#" in ref_str:
try:
+ # Versuch 1: Chunk ID direkt
res = self.client.retrieve(self.chunks_col, ids=[ref_str], with_payload=True)
if res: return self._fetch_note_cached(res[0].payload.get("note_id"))
except: pass
+
+ # Versuch 2: NoteID#Section (Hash abtrennen)
possible_note_id = ref_str.split("#")[0]
if self._fetch_note_cached(possible_note_id): return self._fetch_note_cached(possible_note_id)
- # Fall B: Note ID
+ # Fall B: Note ID direkt
if self._fetch_note_cached(ref_str): return self._fetch_note_cached(ref_str)
# Fall C: Titel
@@ -241,7 +260,7 @@ class GraphExplorerService:
ntype = note_payload.get("type", "default")
color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"])
- # Tooltip wird später durch smart content angereichert
+ # Basis-Tooltip (wird später erweitert)
tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}"
if level == 0: size = 45