From 0b47ffdcb6f48c2638d34d9b08ca15c6264cfaa0 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 12:42:24 +0100 Subject: [PATCH] bug fixing --- app/frontend/ui_components.py | 161 +++++++++++++++++-------------- app/frontend/ui_graph_service.py | 99 +++++++++++-------- 2 files changed, 145 insertions(+), 115 deletions(-) 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