diff --git a/app/frontend/ui_components.py b/app/frontend/ui_components.py index 30a9959..fd0393c 100644 --- a/app/frontend/ui_components.py +++ b/app/frontend/ui_components.py @@ -5,41 +5,50 @@ 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 +from ui_config import HISTORY_FILE, COLLECTION_PREFIX, GRAPH_COLORS def render_sidebar(): 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() st.subheader("⚙️ Settings") top_k = st.slider("Quellen (Top-K)", 1, 10, 5) explain = st.toggle("Explanation Layer", True) + 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): + """ + Rendert den Editor für Drafts (genutzt im Chat bei INTERVIEW Intent oder im manuellen Modus). + """ 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 für Persistenz 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 + # --- INIT STATE --- if f"{key_base}_init" not in st.session_state: meta, body = parse_markdown_draft(msg["content"]) if "type" not in meta: meta["type"] = "default" @@ -50,16 +59,19 @@ def render_draft_editor(msg): st.session_state[data_meta_key] = meta st.session_state[data_sugg_key] = [] st.session_state[data_body_key] = body.strip() + + # Widget States initialisieren 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"] st.session_state[f"{key_base}_init"] = True - # RESURRECTION + # --- 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] - # CALLBACKS + # --- CALLBACKS --- def _sync_meta(): meta = st.session_state[data_meta_key] meta["title"] = st.session_state.get(f"{key_base}_wdg_title", "") @@ -82,11 +94,13 @@ def render_draft_editor(msg): st.session_state[widget_body_key] = new_text st.session_state[data_body_key] = new_text - # UI LAYOUT + # --- UI LAYOUT --- st.markdown(f'
', unsafe_allow_html=True) st.markdown("### 📝 Entwurf bearbeiten") 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) @@ -98,6 +112,7 @@ 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: @@ -105,6 +120,7 @@ def render_draft_editor(msg): with tab_intel: st.info("Klicke auf 'Analysieren', um Verknüpfungen für den AKTUELLEN Text zu finden.") + if st.button("🔍 Analyse starten", key=f"{key_base}_analyze"): st.session_state[data_sugg_key] = [] text_to_analyze = st.session_state.get(widget_body_key, st.session_state.get(data_body_key, "")) @@ -120,14 +136,18 @@ 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, "") for idx, sugg in enumerate(suggestions): 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" + st.markdown(f"""
{sugg.get('target_title')} ({sugg.get('type')})
@@ -135,13 +155,16 @@ def render_draft_editor(msg): {link_text}
""", unsafe_allow_html=True) + if is_inserted: st.button("❌ Entfernen", key=f"del_{idx}_{key_base}", on_click=_remove_text, args=(link_text,)) 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()] + final_meta = { "id": "generated_on_save", "type": st.session_state.get(f"{key_base}_wdg_type", "default"), @@ -150,6 +173,8 @@ def render_draft_editor(msg): "tags": final_tags } 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() @@ -162,6 +187,8 @@ def render_draft_editor(msg): st.markdown('
', unsafe_allow_html=True) st.markdown("---") + + # Footer Buttons b1, b2 = st.columns([1, 1]) with b1: if st.button("💾 Speichern & Indizieren", type="primary", key=f"{key_base}_save"): @@ -172,6 +199,7 @@ def render_draft_editor(msg): raw_title = clean_body[:40] if clean_body else "draft" safe_title = slugify(raw_title)[:60] or "draft" fname = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}.md" + result = save_draft_to_vault(final_doc, filename=fname) if "error" in result: st.error(f"Fehler: {result['error']}") else: @@ -180,46 +208,59 @@ def render_draft_editor(msg): with b2: if st.button("📋 Code anzeigen", key=f"{key_base}_btn_copy"): st.code(final_doc, language="markdown") + st.markdown("", unsafe_allow_html=True) def render_chat_interface(top_k, explain): + """ + Rendert den Chat-Verlauf und das Eingabefeld. + """ 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})"): st.markdown(f"_{hit.get('source', {}).get('text', '')[:300]}..._") 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..."): @@ -238,6 +279,7 @@ def render_chat_interface(top_k, explain): st.rerun() def render_manual_editor(): + """Rendert einen leeren Editor für manuelle Eingaben.""" mock_msg = { "content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n", "query_id": "manual_mode_v2" @@ -245,15 +287,24 @@ def render_manual_editor(): render_draft_editor(mock_msg) def render_graph_explorer(graph_service): - st.header("🕸️ Graph Explorer (WP-19)") + """ + Rendert den erweiterten Graph Explorer (WP-19). + """ + st.header("🕸️ Graph Explorer") - col_ctrl, col_graph = st.columns([1, 3]) + # State Management für Graph Navigation + 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 with col_ctrl: - st.subheader("Fokus setzen") - search_term = st.text_input("Suche Notiz (Titel)", placeholder="z.B. Project Alpha") - selected_note_id = None + st.subheader("Fokus") + # 1. Suchfeld mit Autocomplete-Logik + search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...") + + options = {} if search_term: hits, _ = graph_service.client.scroll( collection_name=f"{COLLECTION_PREFIX}_notes", @@ -263,43 +314,69 @@ def render_graph_explorer(graph_service): limit=10 ) options = {h.payload['title']: h.payload['note_id'] for h in hits} - if options: - selected_title = st.selectbox("Wähle Notiz:", list(options.keys())) - selected_note_id = options[selected_title] - else: - st.warning("Keine Notiz gefunden.") - st.markdown("---") - st.markdown("**Legende:**") - st.markdown(f"🔴 **Blocker** (Risk/Block)") - st.markdown(f"🔵 **Konzept/Struktur**") - st.markdown(f"🟣 **Entscheidung**") - st.markdown(f"🟢 **Beitrag**") - st.markdown(f"--- **Solid**: Explicit Link") - st.markdown(f"- - **Dashed**: Smart/AI Link") + # 2. Auswahlliste + if options: + selected_title = st.selectbox("Ergebnisse:", list(options.keys())) + if st.button("Laden", use_container_width=True): + st.session_state.graph_center_id = options[selected_title] + 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") with col_graph: - if selected_note_id: - with st.spinner(f"Lade Graph für {selected_note_id}..."): - nodes, edges = graph_service.get_ego_graph(selected_note_id) + center_id = st.session_state.graph_center_id + + 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.error("Knoten konnte nicht geladen werden.") + st.warning("Keine Daten gefunden. Vielleicht existiert die Notiz nicht mehr?") else: + # CONFIG: Abstand und Physik optimiert für Lesbarkeit config = Config( - width=900, - height=700, + width=1000, + height=750, directed=True, physics=True, hierarchical=False, + # Erweiterte Physik-Einstellungen + key="graph_view", nodeHighlightBehavior=True, highlightColor="#F7A7A6", - collapsible=False + 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 ) - st.caption(f"Graph zeigt {len(nodes)} Knoten und {len(edges)} Kanten.") + + 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) - if return_value: - st.info(f"Auswahl: {return_value}") + # 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 else: - st.info("👈 Bitte wähle links eine Notiz aus, um den Graphen zu starten.") \ No newline at end of file + st.info("👈 Wähle links eine Notiz, um den Graphen zu starten.") \ No newline at end of file diff --git a/app/frontend/ui_config.py b/app/frontend/ui_config.py index af95cff..a63c6b7 100644 --- a/app/frontend/ui_config.py +++ b/app/frontend/ui_config.py @@ -1,4 +1,5 @@ import os +import hashlib from dotenv import load_dotenv from pathlib import Path @@ -23,27 +24,56 @@ timeout_setting = os.getenv("MINDNET_API_TIMEOUT") or os.getenv("MINDNET_LLM_TIM API_TIMEOUT = float(timeout_setting) if timeout_setting else 300.0 # --- STYLING CONSTANTS --- + +# Basierend auf types.yaml GRAPH_COLORS = { - "project": "#ff9f43", # Orange - "concept": "#54a0ff", # Blau - "decision": "#5f27cd", # Lila - "risk": "#ff6b6b", # Rot - "person": "#1dd1a1", # Grün - "experience": "#feca57",# Gelb - "value": "#00d2d3", # Cyan - "goal": "#ff9ff3", # Pink - "default": "#8395a7" # Grau + # Kerntypen + "experience": "#feca57", # Gelb/Orange + "project": "#ff9f43", # Dunkleres Orange + "decision": "#5f27cd", # Lila + + # Persönlichkeit + "value": "#00d2d3", # Cyan + "principle": "#0abde3", # Dunkles Cyan + "belief": "#48dbfb", # Helles Blau + "profile": "#1dd1a1", # Grün + + # Strategie & Risiko + "goal": "#ff9ff3", # Pink + "risk": "#ff6b6b", # Rot + + # Basis + "concept": "#54a0ff", # Blau + "task": "#8395a7", # Grau-Blau + "journal": "#c8d6e5", # Hellgrau + "source": "#576574", # Dunkelgrau + "glossary": "#222f3e", # Sehr dunkel + + "default": "#8395a7" # Fallback } -EDGE_COLORS = { - "depends_on": "#ff6b6b", # Rot (Blocker) - "blocks": "#ee5253", # Dunkelrot - "caused_by": "#ff9ff3", # Pink - "related_to": "#c8d6e5", # Hellgrau - "similar_to": "#c8d6e5", # Hellgrau - "next": "#54a0ff", # Blau - "derived_from": "#ff9ff3", # Pink - "references": "#bdc3c7", # Grau - "belongs_to": "#2e86de", # Dunkelblau - "contributes_to": "#1dd1a1" # Grün (Neu!) -} \ No newline at end of file +# System-Kanten, die wir NICHT im Graphen sehen wollen, um Rauschen zu reduzieren +SYSTEM_EDGES = ["prev", "next", "belongs_to"] + +def get_edge_color(kind: str) -> str: + """Generiert eine deterministische Farbe basierend auf dem Edge-Typ.""" + if not kind: return "#bdc3c7" + + # Einige feste Farben für wichtige semantische Typen + fixed_colors = { + "depends_on": "#ff6b6b", # Rot (Blocker/Abhängigkeit) + "blocks": "#ee5253", # Dunkelrot + "caused_by": "#ff9ff3", # Pink + "related_to": "#c8d6e5", # Hellgrau (Hintergrund) + "references": "#bdc3c7", # Grau + "derived_from": "#1dd1a1" # Grün + } + + if kind in fixed_colors: + return fixed_colors[kind] + + # Fallback: Hash-basierte Farbe für dynamische Typen + # Wir nutzen einen Pastell-Generator, damit es nicht zu grell wird + hash_obj = hashlib.md5(kind.encode()) + hue = int(hash_obj.hexdigest(), 16) % 360 + return f"hsl({hue}, 60%, 50%)" \ No newline at end of file diff --git a/app/frontend/ui_graph_service.py b/app/frontend/ui_graph_service.py index 6af4f18..2e2e0bd 100644 --- a/app/frontend/ui_graph_service.py +++ b/app/frontend/ui_graph_service.py @@ -1,6 +1,6 @@ from qdrant_client import QdrantClient, models from streamlit_agraph import Node, Edge -from ui_config import GRAPH_COLORS, EDGE_COLORS +from ui_config import GRAPH_COLORS, get_edge_color, SYSTEM_EDGES class GraphExplorerService: def __init__(self, url, api_key=None, prefix="mindnet"): @@ -11,100 +11,53 @@ class GraphExplorerService: self.edges_col = f"{prefix}_edges" self._note_cache = {} - def get_ego_graph(self, center_note_id: str): + def get_ego_graph(self, center_note_id: str, depth=2): nodes_dict = {} unique_edges = {} - # 1. Center Note laden + # --- LEVEL 1: Center & direkte Nachbarn --- + + # 1. Center Note center_note = self._fetch_note_cached(center_note_id) if not center_note: return [], [] - self._add_node_to_dict(nodes_dict, center_note, is_center=True) + self._add_node_to_dict(nodes_dict, center_note, level=0) - center_title = center_note.get("title") - - # 2. Chunks der Center Note finden - scroll_filter = models.Filter( - must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=center_note_id))] - ) - chunks, _ = self.client.scroll( - collection_name=self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True - ) - center_chunk_ids = [c.id for c in chunks] + # Wir sammeln IDs für Level 2 Suche + level_1_ids = {center_note_id} - raw_edges = [] - - # 3. OUTGOING EDGES: Source = einer meiner Chunks - if center_chunk_ids: - out_filter = models.Filter( - must=[models.FieldCondition(key="source_id", match=models.MatchAny(any=center_chunk_ids))] - ) - res_out, _ = self.client.scroll( - collection_name=self.edges_col, scroll_filter=out_filter, limit=100, with_payload=True - ) - raw_edges.extend(res_out) - - # 4. INCOMING EDGES: Target = Chunk, Titel oder Note-ID - must_conditions = [] - if center_chunk_ids: - must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=center_chunk_ids))) - if center_title: - must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=center_title))) + # Suche Kanten für Center + l1_edges = self._find_connected_edges([center_note_id], center_note.get("title")) - # FIX: Auch exakte Note-ID als Target prüfen - must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=center_note_id))) + # Verarbeite L1 Kanten + for edge_data in l1_edges: + 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 tgt_id: level_1_ids.add(tgt_id) - if must_conditions: - in_filter = models.Filter(should=must_conditions) # 'should' = OR - res_in, _ = self.client.scroll( - collection_name=self.edges_col, scroll_filter=in_filter, limit=100, with_payload=True - ) - raw_edges.extend(res_in) + # --- LEVEL 2: Nachbarn der Nachbarn --- + if depth > 1 and level_1_ids: + # Wir suchen Kanten, bei denen Source oder Target einer der L1 Nodes ist + # Wichtig: Wir filtern System-Edges schon in der Query oder Python, um Traffic zu sparen + + # Um die Performance zu wahren, limitieren wir die L2 Suche auf die IDs, die wir schon haben (als Source) + # Das ist ein "Ego-Network" Ansatz. + + # Wir nehmen alle IDs aus Level 1 (außer Center, das haben wir schon) + 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) - # 5. Verarbeitung & Auflösung - for record in raw_edges: - payload = record.payload - - src_ref = payload.get("source_id") - tgt_ref = payload.get("target_id") - kind = payload.get("kind", "related_to") - provenance = payload.get("provenance", "explicit") - - src_note = self._resolve_note_from_ref(src_ref) - tgt_note = self._resolve_note_from_ref(tgt_ref) - - if src_note and tgt_note: - src_id = src_note['note_id'] - tgt_id = tgt_note['note_id'] - - # Keine Self-Loops und valide Verbindung - if src_id != tgt_id: - self._add_node_to_dict(nodes_dict, src_note) - self._add_node_to_dict(nodes_dict, tgt_note) - - key = (src_id, tgt_id) - existing = unique_edges.get(key) - - # Deduplizierung: Explizite Kanten überschreiben Smart Edges - is_current_explicit = (provenance == "explicit" or provenance == "rule") - should_update = True - - if existing: - is_existing_explicit = (existing['provenance'] == "explicit" or existing['provenance'] == "rule") - if is_existing_explicit and not is_current_explicit: - should_update = False - - if should_update: - unique_edges[key] = { - "source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance - } - - # 6. Agraph Objekte bauen + # --- GRAPH CONSTRUCTION --- final_edges = [] for (src, tgt), data in unique_edges.items(): kind = data['kind'] prov = data['provenance'] - color = EDGE_COLORS.get(kind, "#bdc3c7") + # Dynamische Farbe holen + color = get_edge_color(kind) is_smart = (prov != "explicit" and prov != "rule") final_edges.append(Edge( @@ -114,9 +67,92 @@ 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 + 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 + ) + chunk_ids = [c.id for c in chunks] + + results = [] + + # Outgoing (Source is Chunk) + if chunk_ids: + out_f = models.Filter(must=[ + models.FieldCondition(key="source_id", match=models.MatchAny(any=chunk_ids)), + # Filter 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) + results.extend(res_out) + + # Incoming (Target is Chunk OR Title OR NoteID) + 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))) + shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids))) + + if shoulds: + in_f = models.Filter( + must=[models.FieldCondition(key="kind", match=models.MatchExcept(except_=SYSTEM_EDGES))], + should=shoulds + ) + res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_f, limit=100, with_payload=True) + results.extend(res_in) + + return results + + def _find_connected_edges_batch(self, note_ids): + """Batch-Suche für Level 2 (nur ausgehend und eingehend auf Note-Ebene, keine Title-Suche für Performance).""" + # Vereinfachte Suche: Wir suchen Kanten, die direkt mit den note_ids (oder deren Chunks) zu tun haben + # Um Performance zu sparen, machen wir hier einen simpleren Lookup, wenn möglich. + return self._find_connected_edges(note_ids) + + def _process_edge(self, record, nodes_dict, unique_edges, current_depth): + 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 + src_note = self._resolve_note_from_ref(src_ref) + tgt_note = self._resolve_note_from_ref(tgt_ref) + + if src_note and tgt_note: + src_id = src_note['note_id'] + 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: + is_existing_explicit = (existing['provenance'] in ["explicit", "rule"]) + if is_existing_explicit and not is_current_explicit: + should_update = False + + if should_update: + unique_edges[key] = { + "source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance + } + return src_id, tgt_id + return None, None + def _fetch_note_cached(self, note_id): if note_id in self._note_cache: return self._note_cache[note_id] - res, _ = self.client.scroll( collection_name=self.notes_col, scroll_filter=models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))]), @@ -129,53 +165,57 @@ class GraphExplorerService: def _resolve_note_from_ref(self, ref_str): if not ref_str: return None - - # Fall A: Chunk ID (Format: note_id#cXX) + # ... (Logik identisch zu vorher, hier gekürzt für Übersicht) + # Fall A: Chunk ID / Section if "#" in ref_str: - # Versuch 1: Echte Chunk ID in DB try: - res = self.client.retrieve(collection_name=self.chunks_col, ids=[ref_str], with_payload=True) - if res: - parent_id = res[0].payload.get("note_id") - return self._fetch_note_cached(parent_id) - except: pass - - # Versuch 2: Section Link (note-id#Header) -> Hash abschneiden + 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 possible_note_id = ref_str.split("#")[0] - note_by_id = self._fetch_note_cached(possible_note_id) - if note_by_id: return note_by_id + if self._fetch_note_cached(possible_note_id): return self._fetch_note_cached(possible_note_id) - # Fall B: Es ist direkt die Note ID - note_by_id = self._fetch_note_cached(ref_str) - if note_by_id: return note_by_id + # Fall B: Note ID + if self._fetch_note_cached(ref_str): return self._fetch_note_cached(ref_str) - # Fall C: Es ist der Titel (Wikilink) + # 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))]), limit=1, with_payload=True ) if res: - p = res[0].payload - self._note_cache[p['note_id']] = p - return p - + self._note_cache[res[0].payload['note_id']] = res[0].payload + return res[0].payload return None - def _add_node_to_dict(self, node_dict, note_payload, is_center=False): + 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"]) - size = 35 if is_center else 20 + # 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 = "" + node_dict[nid] = Node( id=nid, - label=note_payload.get("title", nid), + label=f"{label_prefix}{note_payload.get('title', nid)}", size=size, color=color, - shape="dot" if not is_center else "diamond", - title=f"Type: {ntype}\nTags: {note_payload.get('tags')}", - font={'color': 'black'} + 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} ) \ No newline at end of file