diff --git a/app/frontend/ui_components.py b/app/frontend/ui_components.py index df585d7..7aff35b 100644 --- a/app/frontend/ui_components.py +++ b/app/frontend/ui_components.py @@ -5,48 +5,63 @@ 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 # --- CALLBACKS --- +# Diese Funktion muss oben stehen, damit sie vor dem Re-Run bekannt ist. def switch_to_editor_callback(note_payload): """ - Lädt eine Note in den Editor. - Versucht, den Original-Dateinamen zu erraten oder zu finden, um Duplikate zu vermeiden. + Callback für den Edit-Button im Graphen. + Lädt die Notiz in den Editor und setzt den Modus. + Nutzt den 'path'-Parameter aus Qdrant als Single Source of Truth für Updates. """ - # 1. Inhalt holen + # 1. Inhalt extrahieren (Fulltext oder Fallback) content = note_payload.get('fulltext', '') if not content: content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (nur Metadaten verfügbar).") - # 2. Dateinamen-Heuristik (Single Source of Truth) - # Idealfall: Qdrant hat das Feld 'file_path' oder 'filename' gespeichert. - # Fallback: Wir nutzen die note_id oder den Titel, müssen aber beim Speichern aufpassen. - origin_fname = note_payload.get('file_path') or note_payload.get('filename') + # 2. Single Source of Truth Bestimmung (Dateipfad) + # Priorität 1: Der absolute Pfad aus dem Ingest-Prozess ('path') + origin_fname = note_payload.get('path') - # Nachricht simulieren, die Daten in den Editor trägt + # Priorität 2: 'file_path' oder 'filename' (Legacy Felder) + if not origin_fname: + origin_fname = note_payload.get('file_path') or note_payload.get('filename') + + # Priorität 3: Konstruktion aus ID (Notlösung) + 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) st.session_state.messages.append({ "role": "assistant", "intent": "INTERVIEW", "content": content, "query_id": f"edit_{note_payload['note_id']}", - "origin_filename": origin_fname, # WICHTIG: Pfad mitschleifen - "origin_note_id": note_payload['note_id'] # ID für Fallback mitschleifen + "origin_filename": origin_fname, # Pfad für Speicher-Logik + "origin_note_id": note_payload['note_id'] }) - # 3. Modus umschalten + # 4. Modus umschalten st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor" # --- UI RENDERER --- def render_sidebar(): + """ + Rendert die Seitenleiste mit Navigation und Einstellungen. + """ with st.sidebar: st.title("🧠 mindnet") st.caption("v2.6 | WP-19 Graph View") - # State-gebundenes Radio Widget + # Modus-Auswahl mit State-Key für programmatische Umschaltung if "sidebar_mode_selection" not in st.session_state: st.session_state["sidebar_mode_selection"] = "💬 Chat" @@ -63,64 +78,61 @@ def render_sidebar(): st.divider() st.subheader("🕒 Verlauf") + # Suchhistorie laden for q in load_history_from_logs(HISTORY_FILE, 8): if st.button(f"🔎 {q[:25]}...", key=f"hist_{q}", use_container_width=True): st.session_state.messages.append({"role": "user", "content": q}) st.rerun() + return mode, top_k, explain def render_draft_editor(msg): """ - Smart Editor: Unterscheidet zwischen 'Neu' und 'Update'. + Der Markdown-Editor. + Wird für neue Entwürfe UND für das Bearbeiten bestehender Notizen genutzt. """ + # ID Generierung für Unique Keys if "query_id" not in msg or not msg["query_id"]: msg["query_id"] = str(uuid.uuid4()) qid = msg["query_id"] key_base = f"draft_{qid}" - # State Keys + # State Keys definieren data_meta_key = f"{key_base}_data_meta" data_sugg_key = f"{key_base}_data_suggestions" widget_body_key = f"{key_base}_widget_body" data_body_key = f"{key_base}_data_body" - # --- INIT STATE --- + # --- INITIALISIERUNG --- if f"{key_base}_init" not in st.session_state: - # Metadaten parsen + # Markdown parsen meta, body = parse_markdown_draft(msg["content"]) if "type" not in meta: meta["type"] = "default" if "title" not in meta: meta["title"] = "" tags = meta.get("tags", []) meta["tags_str"] = ", ".join(tags) if isinstance(tags, list) else str(tags) - # Daten in Session State laden + # Daten in Session State schreiben st.session_state[data_meta_key] = meta st.session_state[data_sugg_key] = [] st.session_state[data_body_key] = body.strip() - # Widget States + # Widget-Werte vorbelegen st.session_state[f"{key_base}_wdg_title"] = meta["title"] st.session_state[f"{key_base}_wdg_type"] = meta["type"] st.session_state[f"{key_base}_wdg_tags"] = meta["tags_str"] - # --- EDITOR LOGIK: Origin Filename --- - # Wir speichern den Original-Namen im State, um beim Speichern zu wissen, ob wir überschreiben müssen. - origin_file = msg.get("origin_filename") - if not origin_file and "origin_note_id" in msg: - # Fallback: Wenn wir keinen Pfad haben, aber eine ID, merken wir uns diese, - # um später ggf. intelligent zu speichern (z.B. {id}.md suchen) - # Hier vereinfacht: Wir setzen es erstmal auf None, User muss aufpassen. - pass - - st.session_state[f"{key_base}_origin_filename"] = origin_file + # WICHTIG: Original-Pfad aus der Message übernehmen (für Update-Logik) + st.session_state[f"{key_base}_origin_filename"] = msg.get("origin_filename") + st.session_state[f"{key_base}_init"] = True - # --- RESURRECTION --- + # --- STATE RESURRECTION (falls Streamlit rerunt) --- if widget_body_key not in st.session_state and data_body_key in st.session_state: st.session_state[widget_body_key] = st.session_state[data_body_key] - # --- SYNC FUNCTIONS --- + # --- SYNC FUNKTIONEN --- def _sync_meta(): meta = st.session_state[data_meta_key] meta["title"] = st.session_state.get(f"{key_base}_wdg_title", "") @@ -143,19 +155,24 @@ def render_draft_editor(msg): st.session_state[widget_body_key] = new_text st.session_state[data_body_key] = new_text - # --- UI LAYOUT --- - - # Header: Status anzeigen + # --- LAYOUT HEADER --- origin_fname = st.session_state.get(f"{key_base}_origin_filename") + if origin_fname: - st.info(f"📝 Bearbeitungs-Modus: Du editierst **{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.markdown(f'
', unsafe_allow_html=True) else: - st.info("✨ Neuer Entwurf (Wird als neue Datei angelegt)") + # Create Modus + st.info("✨ **Erstell-Modus**: Neue Datei wird angelegt.") st.markdown(f'
', unsafe_allow_html=True) st.markdown("### Editor") + # Metadaten-Editor meta_ref = st.session_state[data_meta_key] c1, c2 = st.columns([2, 1]) with c1: @@ -168,10 +185,11 @@ def render_draft_editor(msg): st.text_input("Tags", key=f"{key_base}_wdg_tags", on_change=_sync_meta) + # Tabs tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"]) with tab_edit: - st.text_area("Body", key=widget_body_key, height=500, on_change=_sync_body, label_visibility="collapsed") + st.text_area("Body", key=widget_body_key, height=600, on_change=_sync_body, label_visibility="collapsed") with tab_intel: st.info("Klicke auf 'Analysieren', um Verknüpfungen für den AKTUELLEN Text zu finden.") @@ -191,12 +209,14 @@ def render_draft_editor(msg): if not suggestions: st.warning("Keine Vorschläge gefunden.") else: st.success(f"{len(suggestions)} Vorschläge gefunden.") + # Vorschläge rendern suggestions = st.session_state[data_sugg_key] if suggestions: current_text_state = st.session_state.get(widget_body_key, "") for idx, sugg in enumerate(suggestions): link_text = sugg.get('suggested_markdown', '') is_inserted = link_text in current_text_state + bg_color = "#e6fffa" if is_inserted else "#ffffff" border = "3px solid #28a745" if is_inserted else "3px solid #1a73e8" @@ -213,6 +233,7 @@ def render_draft_editor(msg): else: st.button("➕ Einfügen", key=f"add_{idx}_{key_base}", on_click=_insert_text, args=(link_text,)) + # Dokument für Vorschau/Save bauen final_tags_str = st.session_state.get(f"{key_base}_wdg_tags", "") final_tags = [t.strip() for t in final_tags_str.split(",") if t.strip()] @@ -224,12 +245,13 @@ def render_draft_editor(msg): "tags": final_tags } - # ID wiederherstellen, falls vorhanden + # ID behalten wenn vorhanden (Wichtig für Source of Truth) if "origin_note_id" in msg: - final_meta["id"] = msg["origin_note_id"] + final_meta["id"] = msg["origin_note_id"] final_body = st.session_state.get(widget_body_key, st.session_state[data_body_key]) + # Fallback Titel aus H1 if not final_meta["title"]: h1_match = re.search(r"^#\s+(.*)$", final_body, re.MULTILINE) if h1_match: final_meta["title"] = h1_match.group(1).strip() @@ -243,17 +265,18 @@ def render_draft_editor(msg): st.markdown("---") + # Footer Actions b1, b2 = st.columns([1, 1]) with b1: - # Button Text dynamisch machen - btn_label = "💾 Update speichern" if origin_fname else "💾 Neu anlegen & Indizieren" + # Label dynamisch + save_label = "💾 Update speichern" if origin_fname else "💾 Neu anlegen & Indizieren" - if st.button(btn_label, type="primary", key=f"{key_base}_save"): + if st.button(save_label, type="primary", key=f"{key_base}_save"): with st.spinner("Speichere im Vault..."): # ENTSCHEIDUNG: Update oder Neu? if origin_fname: - # UPDATE: Wir nutzen den existierenden Dateinamen + # UPDATE: Wir nutzen den exakten Pfad aus Qdrant target_filename = origin_fname else: # NEU: Wir generieren einen Namen @@ -264,6 +287,7 @@ def render_draft_editor(msg): safe_title = slugify(raw_title)[:60] or "draft" target_filename = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}.md" + # Senden an API result = save_draft_to_vault(final_doc, filename=target_filename) if "error" in result: @@ -279,6 +303,9 @@ def render_draft_editor(msg): st.markdown("
", unsafe_allow_html=True) def render_chat_interface(top_k, explain): + """ + Rendert das Chat-Interface. + """ for idx, msg in enumerate(st.session_state.messages): with st.chat_message(msg["role"]): if msg["role"] == "assistant": @@ -335,6 +362,9 @@ def render_chat_interface(top_k, explain): st.rerun() def render_manual_editor(): + """ + Wrapper für den manuellen Editor-Modus (Startet mit leerem Template). + """ mock_msg = { "content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n", "query_id": "manual_mode_v2" @@ -346,8 +376,10 @@ def render_manual_editor(): def render_graph_explorer(graph_service): st.header("🕸️ Graph Explorer") + # Session State initialisieren if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None + # Defaults speichern für Persistenz st.session_state.setdefault("graph_depth", 2) st.session_state.setdefault("graph_show_labels", True) st.session_state.setdefault("graph_spacing", 200) @@ -358,6 +390,7 @@ def render_graph_explorer(graph_service): with col_ctrl: st.subheader("Fokus") + # Suche search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...") options = {} @@ -377,6 +410,7 @@ def render_graph_explorer(graph_service): st.divider() + # 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) @@ -399,11 +433,13 @@ 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: - # Button mit Callback (on_click) + # Bearbeiten Button mit Callback (on_click) + # Holt die Daten aus dem Cache des Services (wurde durch get_ego_graph dort abgelegt oder wir holen es neu) note_data = graph_service._fetch_note_cached(center_id) if note_data: st.button("📝 Bearbeiten", @@ -411,9 +447,10 @@ def render_graph_explorer(graph_service): on_click=switch_to_editor_callback, args=(note_data,)) else: - st.error("Datenfehler") + st.error("Datenfehler: Notiz nicht gefunden.") with st.spinner(f"Lade Graph..."): + # Daten laden (Nutzt den verbesserten Service mit Hover-Texten) nodes, edges = graph_service.get_ego_graph( center_id, depth=st.session_state.graph_depth, @@ -421,10 +458,11 @@ def render_graph_explorer(graph_service): ) if not nodes: - st.warning("Keine Daten gefunden.") + st.warning("Keine Daten gefunden. (Notiz existiert evtl. nicht mehr)") else: - # FIX: Key entfernt, da er in deiner Version TypeError verursacht. - # Wir verlassen uns auf Config-Update. + # CONFIGURATION für agraph + # Wir nutzen KEIN 'key' Argument, da dies Fehler verursacht. + # Stattdessen vertrauen wir darauf, dass das Config-Objekt neu ist. config = Config( width=1000, height=800, @@ -434,6 +472,7 @@ def render_graph_explorer(graph_service): nodeHighlightBehavior=True, highlightColor="#F7A7A6", collapsible=False, + # Physik: ForceAtlas2Based für beste Entzerrung solver="forceAtlas2Based", forceAtlas2Based={ "theta": 0.5, @@ -449,11 +488,14 @@ def render_graph_explorer(graph_service): 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: diff --git a/app/frontend/ui_graph_service.py b/app/frontend/ui_graph_service.py index 5c08e94..b9bdb7f 100644 --- a/app/frontend/ui_graph_service.py +++ b/app/frontend/ui_graph_service.py @@ -12,16 +12,13 @@ class GraphExplorerService: self._note_cache = {} 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 = {} # 1. Center Note laden center_note = self._fetch_note_cached(center_note_id) if not center_note: return [], [] + # Node vorerst ohne Vorschau hinzufügen self._add_node_to_dict(nodes_dict, center_note, level=0) level_1_ids = {center_note_id} @@ -34,7 +31,7 @@ class GraphExplorerService: if src_id: level_1_ids.add(src_id) if tgt_id: level_1_ids.add(tgt_id) - # Level 2 Suche (begrenzt für Performance) + # Level 2 Suche 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: @@ -42,6 +39,20 @@ class GraphExplorerService: for edge_data in l2_edges: self._process_edge(edge_data, nodes_dict, unique_edges, current_depth=2) + # --- NEU: Content Previews (Chunks) laden --- + # Wir holen für alle gesammelten Nodes den ersten Chunk als Vorschau + all_node_ids = list(nodes_dict.keys()) + previews = self._fetch_previews_for_nodes(all_node_ids) + + # Nodes aktualisieren mit Vorschau-Text + final_nodes = [] + for nid, node_obj in nodes_dict.items(): + # Preview Text in den Tooltip injizieren + prev_text = previews.get(nid, "Kein Inhaltstext gefunden.") + # Wir hängen den Text an den existierenden Title (Hover) an + node_obj.title = f"{node_obj.title}\n\n📝 VORSCHAU:\n{prev_text[:400]}..." + final_nodes.append(node_obj) + # Graphen bauen final_edges = [] for (src, tgt), data in unique_edges.items(): @@ -49,19 +60,49 @@ class GraphExplorerService: prov = data['provenance'] 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=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 + return final_nodes, final_edges + + def _fetch_previews_for_nodes(self, node_ids): + """Holt für eine Liste von Note-IDs jeweils einen Chunk als Vorschau.""" + if not node_ids: return {} + + # Wir suchen Chunks, die zu diesen Notes gehören + # Optimierung: Wir holen einfach Chunks und gruppieren sie. + # Limit muss hoch genug sein für alle Nodes im Graphen + previews = {} + try: + scroll_filter = models.Filter( + must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))] + ) + # Wir holen Chunks. Sortierung ist in Qdrant schwierig ohne Vektor, + # aber Scroll gibt meistens insertion order oder id order. + chunks, _ = self.client.scroll( + collection_name=self.chunks_col, + scroll_filter=scroll_filter, + limit=len(node_ids) * 3, # 3 Chunks pro Note Puffer + with_payload=True + ) + + for c in chunks: + nid = c.payload.get("note_id") + # Nur den ersten Chunk pro Note speichern + if nid and nid not in previews: + # Bevorzugt 'window' (Kontext) oder 'text' + text = c.payload.get("window") or c.payload.get("text") or "" + previews[nid] = text + except Exception as e: + print(f"Preview fetch error: {e}") + + 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))] @@ -72,30 +113,23 @@ class GraphExplorerService: 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 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 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) --- 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: in_f = models.Filter( - # FIX: MatchExcept mit **kwargs nutzen wegen 'except' Keyword must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))], should=shoulds ) @@ -105,7 +139,6 @@ class GraphExplorerService: return results def _find_connected_edges_batch(self, note_ids): - """Batch-Suche für Level 2.""" return self._find_connected_edges(note_ids) def _process_edge(self, record, nodes_dict, unique_edges, current_depth): @@ -115,7 +148,6 @@ 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) @@ -124,15 +156,12 @@ 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: @@ -161,8 +190,6 @@ class GraphExplorerService: def _resolve_note_from_ref(self, ref_str): if not ref_str: return None - - # Fall A: Chunk ID / Section if "#" in ref_str: try: res = self.client.retrieve(self.chunks_col, ids=[ref_str], with_payload=True) @@ -171,10 +198,8 @@ class GraphExplorerService: 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 if self._fetch_note_cached(ref_str): return self._fetch_note_cached(ref_str) - # Fall C: Titel res, _ = self.client.scroll( collection_name=self.notes_col, scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchValue(value=ref_str))]), @@ -192,8 +217,8 @@ class GraphExplorerService: ntype = note_payload.get("type", "default") color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"]) - # HOVER TEXT: Vorschau bauen - hover_text = f"Titel: {note_payload.get('title')}\nTyp: {ntype}\nTags: {note_payload.get('tags', [])}" + # Basis-Tooltip (wird später erweitert) + tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}" if level == 0: size = 45 elif level == 1: size = 25 @@ -205,6 +230,6 @@ class GraphExplorerService: size=size, color=color, shape="dot" if level > 0 else "diamond", - title=hover_text, # Hover im Browser + title=tooltip, font={'color': 'black', 'face': 'arial', 'size': 14 if level < 2 else 0} ) \ No newline at end of file