diff --git a/app/frontend/ui_components.py b/app/frontend/ui_components.py index fd0393c..0b318cf 100644 --- a/app/frontend/ui_components.py +++ b/app/frontend/ui_components.py @@ -11,10 +11,12 @@ 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 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") - # Modus-Auswahl mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor", "🕸️ Graph Explorer"], index=0) st.divider() @@ -24,12 +26,10 @@ def render_sidebar(): st.divider() st.subheader("🕒 Verlauf") - # Historie 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): @@ -67,7 +67,6 @@ def render_draft_editor(msg): st.session_state[f"{key_base}_init"] = True # --- STATE RESURRECTION --- - # Falls Streamlit rerunt, stellen wir sicher, dass der Body nicht verloren geht 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] @@ -100,7 +99,6 @@ def render_draft_editor(msg): meta_ref = st.session_state[data_meta_key] - # Metadaten Zeile c1, c2 = st.columns([2, 1]) with c1: st.text_input("Titel", key=f"{key_base}_wdg_title", on_change=_sync_meta) @@ -112,7 +110,6 @@ def render_draft_editor(msg): st.text_input("Tags", key=f"{key_base}_wdg_tags", on_change=_sync_meta) - # Tabs für Bearbeitung tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"]) with tab_edit: @@ -136,7 +133,6 @@ 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 anzeigen suggestions = st.session_state[data_sugg_key] if suggestions: current_text_state = st.session_state.get(widget_body_key, "") @@ -144,7 +140,6 @@ def render_draft_editor(msg): link_text = sugg.get('suggested_markdown', '') is_inserted = link_text in current_text_state - # Styling je nach Status bg_color = "#e6fffa" if is_inserted else "#ffffff" border = "3px solid #28a745" if is_inserted else "3px solid #1a73e8" @@ -161,7 +156,6 @@ def render_draft_editor(msg): else: st.button("➕ Einfügen", key=f"add_{idx}_{key_base}", on_click=_insert_text, args=(link_text,)) - # Dokument zusammenbauen für Speicherung/Vorschau 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()] @@ -174,7 +168,6 @@ def render_draft_editor(msg): } final_body = st.session_state.get(widget_body_key, st.session_state[data_body_key]) - # Fallback Title 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() @@ -188,7 +181,6 @@ def render_draft_editor(msg): st.markdown("---") - # Footer Buttons b1, b2 = st.columns([1, 1]) with b1: if st.button("💾 Speichern & Indizieren", type="primary", key=f"{key_base}_save"): @@ -218,23 +210,19 @@ def render_chat_interface(top_k, explain): for idx, msg in enumerate(st.session_state.messages): with st.chat_message(msg["role"]): if msg["role"] == "assistant": - # Intent Badge intent = msg.get("intent", "UNKNOWN") src = msg.get("intent_source", "?") icon = {"EMPATHY":"❤️", "DECISION":"⚖️", "CODING":"💻", "FACT":"📚", "INTERVIEW":"📝"}.get(intent, "🧠") st.markdown(f'
{icon} Intent: {intent} ({src})
', unsafe_allow_html=True) - # Debug Info with st.expander("🐞 Debug Raw Payload", expanded=False): st.json(msg) - # Special Renderers if intent == "INTERVIEW": render_draft_editor(msg) else: st.markdown(msg["content"]) - # Quellen-Anzeige if "sources" in msg and msg["sources"]: for hit in msg["sources"]: with st.expander(f"📄 {hit.get('note_id', '?')} ({hit.get('total_score', 0):.2f})"): @@ -242,25 +230,21 @@ def render_chat_interface(top_k, explain): if hit.get('explanation'): st.caption(f"Grund: {hit['explanation']['reasons'][0]['message']}") - # Source Feedback def _cb(qid=msg.get("query_id"), nid=hit.get('node_id')): val = st.session_state.get(f"fb_src_{qid}_{nid}") if val is not None: submit_feedback(qid, nid, val+1) st.feedback("faces", key=f"fb_src_{msg.get('query_id')}_{hit.get('node_id')}", on_change=_cb) - # Global Feedback if "query_id" in msg: qid = msg["query_id"] st.feedback("stars", key=f"fb_glob_{qid}", on_change=lambda: submit_feedback(qid, "generated_answer", st.session_state[f"fb_glob_{qid}"]+1)) else: st.markdown(msg["content"]) - # Chat Input if prompt := st.chat_input("Frage Mindnet..."): st.session_state.messages.append({"role": "user", "content": prompt}) st.rerun() - # Antwort generieren (falls User zuletzt gefragt hat) if len(st.session_state.messages) > 0 and st.session_state.messages[-1]["role"] == "user": with st.chat_message("assistant"): with st.spinner("Thinking..."): @@ -296,12 +280,11 @@ def render_graph_explorer(graph_service): if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - col_ctrl, col_graph = st.columns([1, 4]) # Graph bekommt mehr Platz + col_ctrl, col_graph = st.columns([1, 4]) with col_ctrl: st.subheader("Fokus") - # 1. Suchfeld mit Autocomplete-Logik search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...") options = {} @@ -315,7 +298,6 @@ def render_graph_explorer(graph_service): ) options = {h.payload['title']: h.payload['note_id'] for h in hits} - # 2. Auswahlliste if options: selected_title = st.selectbox("Ergebnisse:", list(options.keys())) if st.button("Laden", use_container_width=True): @@ -323,18 +305,14 @@ def render_graph_explorer(graph_service): st.rerun() st.divider() - - # 3. Legende (Top Typen) st.caption("Legende (Wichtigste Typen)") - # Wir zeigen nur die ersten 6 Farben an, um die UI nicht zu sprengen for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) st.caption("Weitere Farben siehe `ui_config.py`") st.divider() - - # 4. Tiefe Steuerung 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 @@ -342,41 +320,45 @@ def render_graph_explorer(graph_service): if center_id: with st.spinner(f"Lade Graph für {center_id} (Tiefe {depth_val})..."): - # Daten laden (mit Tiefe) nodes, edges = graph_service.get_ego_graph(center_id, depth=depth_val) if not nodes: st.warning("Keine Daten gefunden. Vielleicht existiert die Notiz nicht mehr?") else: - # CONFIG: Abstand und Physik optimiert für Lesbarkeit + # CONFIG: ForceAtlas2Based für maximale Entzerrung config = Config( width=1000, - height=750, + height=800, directed=True, physics=True, hierarchical=False, - # Erweiterte Physik-Einstellungen - key="graph_view", nodeHighlightBehavior=True, highlightColor="#F7A7A6", collapsible=False, - # Tuning für Abstand: - gravity=-4000, # Starke Abstoßung (Minus-Wert) - central_gravity=0.3,# Zieht Nodes leicht zur Mitte - spring_length=250, # Längere Kanten für bessere Lesbarkeit - spring_strength=0.05, - damping=0.09 + # Solver Wechsel: ForceAtlas2Based ist besser für Entzerrung + solver="forceAtlas2Based", + forceAtlas2Based={ + "theta": 0.5, + "gravitationalConstant": -100, # Starke Abstoßung + "centralGravity": 0.005, # Sehr schwacher Zug zur Mitte (verhindert Klumpen) + "springConstant": 0.08, + "springLength": 150, # Längere Kanten + "damping": 0.4, + "avoidOverlap": 1 # Versucht aktiv, Überlappungen zu vermeiden + }, + stabilization={ + "enabled": True, + "iterations": 1000 + } ) st.caption(f"Zentrum: **{center_id}** | Knoten: {len(nodes)} | Kanten: {len(edges)}") - # Interaktion: Agraph gibt die ID des geklickten Nodes zurück return_value = agraph(nodes=nodes, edges=edges, config=config) # NAVIGATION LOGIK - # Wenn ein Node geklickt wurde UND es nicht der aktuelle Center ist -> Navigation if return_value and return_value != center_id: st.session_state.graph_center_id = return_value - st.rerun() # Refresh mit neuem Center + st.rerun() else: st.info("👈 Wähle links eine Notiz, um den Graphen zu starten.") \ No newline at end of file diff --git a/app/frontend/ui_graph_service.py b/app/frontend/ui_graph_service.py index b521784..4177df2 100644 --- a/app/frontend/ui_graph_service.py +++ b/app/frontend/ui_graph_service.py @@ -15,9 +15,7 @@ class GraphExplorerService: nodes_dict = {} unique_edges = {} - # --- LEVEL 1: Center & direkte Nachbarn --- - - # 1. Center Note + # 1. Center Note laden center_note = self._fetch_note_cached(center_note_id) if not center_note: return [], [] self._add_node_to_dict(nodes_dict, center_note, level=0) @@ -34,23 +32,19 @@ class GraphExplorerService: if src_id: level_1_ids.add(src_id) if tgt_id: level_1_ids.add(tgt_id) - # --- LEVEL 2: Nachbarn der Nachbarn --- - if depth > 1 and level_1_ids: - # Wir nehmen alle IDs aus Level 1 (außer Center, das haben wir schon) + # 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 l1_subset = list(level_1_ids - {center_note_id}) - if l1_subset: l2_edges = self._find_connected_edges_batch(l1_subset) for edge_data in l2_edges: self._process_edge(edge_data, nodes_dict, unique_edges, current_depth=2) - # --- GRAPH CONSTRUCTION --- + # Graphen bauen final_edges = [] for (src, tgt), data in unique_edges.items(): kind = data['kind'] prov = data['provenance'] - - # Dynamische Farbe holen color = get_edge_color(kind) is_smart = (prov != "explicit" and prov != "rule") @@ -62,8 +56,8 @@ class GraphExplorerService: return list(nodes_dict.values()), final_edges def _find_connected_edges(self, note_ids, note_title=None): - """Findet In- und Outgoing Edges für eine Liste von Note-IDs.""" - # 1. Chunks zu diesen Notes finden + """Findet In- und Outgoing Edges.""" + # Chunks finden scroll_filter = models.Filter( must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))] ) @@ -74,25 +68,29 @@ class GraphExplorerService: results = [] - # Outgoing (Source is Chunk) - if chunk_ids: + # --- 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 + source_candidates = chunk_ids + note_ids + + if source_candidates: out_f = models.Filter(must=[ - models.FieldCondition(key="source_id", match=models.MatchAny(any=chunk_ids)), - # FIX: MatchExcept mit **kwargs nutzen wegen 'except' Keyword + models.FieldCondition(key="source_id", match=models.MatchAny(any=source_candidates)), + # FIX: Pydantic "except" keyword workaround 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 (Target is Chunk OR Title OR NoteID) + # --- 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 + # FIX: Pydantic "except" keyword workaround must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))], should=shoulds ) @@ -102,7 +100,7 @@ class GraphExplorerService: return results def _find_connected_edges_batch(self, note_ids): - """Batch-Suche für Level 2 (nur ausgehend und eingehend auf Note-Ebene).""" + """Batch-Suche für Level 2.""" return self._find_connected_edges(note_ids) def _process_edge(self, record, nodes_dict, unique_edges, current_depth): @@ -112,7 +110,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) @@ -121,15 +118,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: @@ -184,31 +178,22 @@ class GraphExplorerService: def _add_node_to_dict(self, node_dict, note_payload, level=1): nid = note_payload.get("note_id") - - # Wenn Node schon da ist, aber wir finden ihn auf einem "höheren" Level (näher am Zentrum), - # updaten wir ihn nicht zwingend, außer wir wollen visuelle Eigenschaften ändern. if nid in node_dict: return ntype = note_payload.get("type", "default") color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"]) - # Größe & Label basierend auf Level - if level == 0: - size = 40 - label_prefix = "" - elif level == 1: - size = 25 - label_prefix = "" - else: - size = 15 # Level 2 kleiner - label_prefix = "" + # Size Adjustment für Hierarchie + if level == 0: size = 45 + elif level == 1: size = 25 + else: size = 15 node_dict[nid] = Node( id=nid, - label=f"{label_prefix}{note_payload.get('title', nid)}", + label=note_payload.get('title', nid), size=size, color=color, shape="dot" if level > 0 else "diamond", - title=f"Type: {ntype}\nLevel: {level}\nTags: {note_payload.get('tags')}", - font={'color': 'black', 'face': 'arial', 'size': 14 if level < 2 else 10} + title=f"Type: {ntype}\nTags: {note_payload.get('tags')}", + font={'color': 'black', 'face': 'arial', 'size': 14 if level < 2 else 0} ) \ No newline at end of file