Smart Editor Logik: Update bestehender Dateien via path-Parameter (Single Source of Truth).
Graph Explorer: Volle Konfiguration für Physik (Abstand/Gravity), Chunk-Vorschau und Navigation. Chat & Manual Editor: Alle Funktionen komplett enthalten.
This commit is contained in:
parent
ccc848f2e2
commit
ae0768e4de
|
|
@ -5,48 +5,63 @@ from datetime import datetime
|
||||||
from streamlit_agraph import agraph, Config
|
from streamlit_agraph import agraph, Config
|
||||||
from qdrant_client import models
|
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_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_api import save_draft_to_vault, analyze_draft_text, send_chat_message, submit_feedback
|
||||||
from ui_config import HISTORY_FILE, COLLECTION_PREFIX, GRAPH_COLORS
|
from ui_config import HISTORY_FILE, COLLECTION_PREFIX, GRAPH_COLORS
|
||||||
|
|
||||||
# --- CALLBACKS ---
|
# --- CALLBACKS ---
|
||||||
|
# Diese Funktion muss oben stehen, damit sie vor dem Re-Run bekannt ist.
|
||||||
|
|
||||||
def switch_to_editor_callback(note_payload):
|
def switch_to_editor_callback(note_payload):
|
||||||
"""
|
"""
|
||||||
Lädt eine Note in den Editor.
|
Callback für den Edit-Button im Graphen.
|
||||||
Versucht, den Original-Dateinamen zu erraten oder zu finden, um Duplikate zu vermeiden.
|
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', '')
|
content = note_payload.get('fulltext', '')
|
||||||
if not content:
|
if not content:
|
||||||
content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (nur Metadaten verfügbar).")
|
content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (nur Metadaten verfügbar).")
|
||||||
|
|
||||||
# 2. Dateinamen-Heuristik (Single Source of Truth)
|
# 2. Single Source of Truth Bestimmung (Dateipfad)
|
||||||
# Idealfall: Qdrant hat das Feld 'file_path' oder 'filename' gespeichert.
|
# Priorität 1: Der absolute Pfad aus dem Ingest-Prozess ('path')
|
||||||
# Fallback: Wir nutzen die note_id oder den Titel, müssen aber beim Speichern aufpassen.
|
origin_fname = note_payload.get('path')
|
||||||
|
|
||||||
|
# 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')
|
origin_fname = note_payload.get('file_path') or note_payload.get('filename')
|
||||||
|
|
||||||
# Nachricht simulieren, die Daten in den Editor trägt
|
# 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({
|
st.session_state.messages.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"intent": "INTERVIEW",
|
"intent": "INTERVIEW",
|
||||||
"content": content,
|
"content": content,
|
||||||
"query_id": f"edit_{note_payload['note_id']}",
|
"query_id": f"edit_{note_payload['note_id']}",
|
||||||
"origin_filename": origin_fname, # WICHTIG: Pfad mitschleifen
|
"origin_filename": origin_fname, # Pfad für Speicher-Logik
|
||||||
"origin_note_id": note_payload['note_id'] # ID für Fallback mitschleifen
|
"origin_note_id": note_payload['note_id']
|
||||||
})
|
})
|
||||||
|
|
||||||
# 3. Modus umschalten
|
# 4. Modus umschalten
|
||||||
st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor"
|
st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor"
|
||||||
|
|
||||||
# --- UI RENDERER ---
|
# --- UI RENDERER ---
|
||||||
|
|
||||||
def render_sidebar():
|
def render_sidebar():
|
||||||
|
"""
|
||||||
|
Rendert die Seitenleiste mit Navigation und Einstellungen.
|
||||||
|
"""
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
st.title("🧠 mindnet")
|
st.title("🧠 mindnet")
|
||||||
st.caption("v2.6 | WP-19 Graph View")
|
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:
|
if "sidebar_mode_selection" not in st.session_state:
|
||||||
st.session_state["sidebar_mode_selection"] = "💬 Chat"
|
st.session_state["sidebar_mode_selection"] = "💬 Chat"
|
||||||
|
|
||||||
|
|
@ -63,64 +78,61 @@ def render_sidebar():
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.subheader("🕒 Verlauf")
|
st.subheader("🕒 Verlauf")
|
||||||
|
# Suchhistorie laden
|
||||||
for q in load_history_from_logs(HISTORY_FILE, 8):
|
for q in load_history_from_logs(HISTORY_FILE, 8):
|
||||||
if st.button(f"🔎 {q[:25]}...", key=f"hist_{q}", use_container_width=True):
|
if st.button(f"🔎 {q[:25]}...", key=f"hist_{q}", use_container_width=True):
|
||||||
st.session_state.messages.append({"role": "user", "content": q})
|
st.session_state.messages.append({"role": "user", "content": q})
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
return mode, top_k, explain
|
return mode, top_k, explain
|
||||||
|
|
||||||
def render_draft_editor(msg):
|
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"]:
|
if "query_id" not in msg or not msg["query_id"]:
|
||||||
msg["query_id"] = str(uuid.uuid4())
|
msg["query_id"] = str(uuid.uuid4())
|
||||||
|
|
||||||
qid = msg["query_id"]
|
qid = msg["query_id"]
|
||||||
key_base = f"draft_{qid}"
|
key_base = f"draft_{qid}"
|
||||||
|
|
||||||
# State Keys
|
# State Keys definieren
|
||||||
data_meta_key = f"{key_base}_data_meta"
|
data_meta_key = f"{key_base}_data_meta"
|
||||||
data_sugg_key = f"{key_base}_data_suggestions"
|
data_sugg_key = f"{key_base}_data_suggestions"
|
||||||
widget_body_key = f"{key_base}_widget_body"
|
widget_body_key = f"{key_base}_widget_body"
|
||||||
data_body_key = f"{key_base}_data_body"
|
data_body_key = f"{key_base}_data_body"
|
||||||
|
|
||||||
# --- INIT STATE ---
|
# --- INITIALISIERUNG ---
|
||||||
if f"{key_base}_init" not in st.session_state:
|
if f"{key_base}_init" not in st.session_state:
|
||||||
# Metadaten parsen
|
# Markdown parsen
|
||||||
meta, body = parse_markdown_draft(msg["content"])
|
meta, body = parse_markdown_draft(msg["content"])
|
||||||
if "type" not in meta: meta["type"] = "default"
|
if "type" not in meta: meta["type"] = "default"
|
||||||
if "title" not in meta: meta["title"] = ""
|
if "title" not in meta: meta["title"] = ""
|
||||||
tags = meta.get("tags", [])
|
tags = meta.get("tags", [])
|
||||||
meta["tags_str"] = ", ".join(tags) if isinstance(tags, list) else str(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_meta_key] = meta
|
||||||
st.session_state[data_sugg_key] = []
|
st.session_state[data_sugg_key] = []
|
||||||
st.session_state[data_body_key] = body.strip()
|
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_title"] = meta["title"]
|
||||||
st.session_state[f"{key_base}_wdg_type"] = meta["type"]
|
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}_wdg_tags"] = meta["tags_str"]
|
||||||
|
|
||||||
# --- EDITOR LOGIK: Origin Filename ---
|
# WICHTIG: Original-Pfad aus der Message übernehmen (für Update-Logik)
|
||||||
# Wir speichern den Original-Namen im State, um beim Speichern zu wissen, ob wir überschreiben müssen.
|
st.session_state[f"{key_base}_origin_filename"] = msg.get("origin_filename")
|
||||||
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
|
|
||||||
st.session_state[f"{key_base}_init"] = True
|
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:
|
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]
|
st.session_state[widget_body_key] = st.session_state[data_body_key]
|
||||||
|
|
||||||
# --- SYNC FUNCTIONS ---
|
# --- SYNC FUNKTIONEN ---
|
||||||
def _sync_meta():
|
def _sync_meta():
|
||||||
meta = st.session_state[data_meta_key]
|
meta = st.session_state[data_meta_key]
|
||||||
meta["title"] = st.session_state.get(f"{key_base}_wdg_title", "")
|
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[widget_body_key] = new_text
|
||||||
st.session_state[data_body_key] = new_text
|
st.session_state[data_body_key] = new_text
|
||||||
|
|
||||||
# --- UI LAYOUT ---
|
# --- LAYOUT HEADER ---
|
||||||
|
|
||||||
# Header: Status anzeigen
|
|
||||||
origin_fname = st.session_state.get(f"{key_base}_origin_filename")
|
origin_fname = st.session_state.get(f"{key_base}_origin_filename")
|
||||||
|
|
||||||
if origin_fname:
|
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'<div class="draft-box" style="border-left: 5px solid #ff9f43;">', unsafe_allow_html=True)
|
st.markdown(f'<div class="draft-box" style="border-left: 5px solid #ff9f43;">', unsafe_allow_html=True)
|
||||||
else:
|
else:
|
||||||
st.info("✨ Neuer Entwurf (Wird als neue Datei angelegt)")
|
# Create Modus
|
||||||
|
st.info("✨ **Erstell-Modus**: Neue Datei wird angelegt.")
|
||||||
st.markdown(f'<div class="draft-box">', unsafe_allow_html=True)
|
st.markdown(f'<div class="draft-box">', unsafe_allow_html=True)
|
||||||
|
|
||||||
st.markdown("### Editor")
|
st.markdown("### Editor")
|
||||||
|
|
||||||
|
# Metadaten-Editor
|
||||||
meta_ref = st.session_state[data_meta_key]
|
meta_ref = st.session_state[data_meta_key]
|
||||||
c1, c2 = st.columns([2, 1])
|
c1, c2 = st.columns([2, 1])
|
||||||
with c1:
|
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)
|
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"])
|
tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"])
|
||||||
|
|
||||||
with tab_edit:
|
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:
|
with tab_intel:
|
||||||
st.info("Klicke auf 'Analysieren', um Verknüpfungen für den AKTUELLEN Text zu finden.")
|
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.")
|
if not suggestions: st.warning("Keine Vorschläge gefunden.")
|
||||||
else: st.success(f"{len(suggestions)} Vorschläge gefunden.")
|
else: st.success(f"{len(suggestions)} Vorschläge gefunden.")
|
||||||
|
|
||||||
|
# Vorschläge rendern
|
||||||
suggestions = st.session_state[data_sugg_key]
|
suggestions = st.session_state[data_sugg_key]
|
||||||
if suggestions:
|
if suggestions:
|
||||||
current_text_state = st.session_state.get(widget_body_key, "")
|
current_text_state = st.session_state.get(widget_body_key, "")
|
||||||
for idx, sugg in enumerate(suggestions):
|
for idx, sugg in enumerate(suggestions):
|
||||||
link_text = sugg.get('suggested_markdown', '')
|
link_text = sugg.get('suggested_markdown', '')
|
||||||
is_inserted = link_text in current_text_state
|
is_inserted = link_text in current_text_state
|
||||||
|
|
||||||
bg_color = "#e6fffa" if is_inserted else "#ffffff"
|
bg_color = "#e6fffa" if is_inserted else "#ffffff"
|
||||||
border = "3px solid #28a745" if is_inserted else "3px solid #1a73e8"
|
border = "3px solid #28a745" if is_inserted else "3px solid #1a73e8"
|
||||||
|
|
||||||
|
|
@ -213,6 +233,7 @@ def render_draft_editor(msg):
|
||||||
else:
|
else:
|
||||||
st.button("➕ Einfügen", key=f"add_{idx}_{key_base}", on_click=_insert_text, args=(link_text,))
|
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_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_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
|
"tags": final_tags
|
||||||
}
|
}
|
||||||
|
|
||||||
# ID wiederherstellen, falls vorhanden
|
# ID behalten wenn vorhanden (Wichtig für Source of Truth)
|
||||||
if "origin_note_id" in msg:
|
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])
|
final_body = st.session_state.get(widget_body_key, st.session_state[data_body_key])
|
||||||
|
|
||||||
|
# Fallback Titel aus H1
|
||||||
if not final_meta["title"]:
|
if not final_meta["title"]:
|
||||||
h1_match = re.search(r"^#\s+(.*)$", final_body, re.MULTILINE)
|
h1_match = re.search(r"^#\s+(.*)$", final_body, re.MULTILINE)
|
||||||
if h1_match: final_meta["title"] = h1_match.group(1).strip()
|
if h1_match: final_meta["title"] = h1_match.group(1).strip()
|
||||||
|
|
@ -243,17 +265,18 @@ def render_draft_editor(msg):
|
||||||
|
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Footer Actions
|
||||||
b1, b2 = st.columns([1, 1])
|
b1, b2 = st.columns([1, 1])
|
||||||
with b1:
|
with b1:
|
||||||
# Button Text dynamisch machen
|
# Label dynamisch
|
||||||
btn_label = "💾 Update speichern" if origin_fname else "💾 Neu anlegen & Indizieren"
|
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..."):
|
with st.spinner("Speichere im Vault..."):
|
||||||
|
|
||||||
# ENTSCHEIDUNG: Update oder Neu?
|
# ENTSCHEIDUNG: Update oder Neu?
|
||||||
if origin_fname:
|
if origin_fname:
|
||||||
# UPDATE: Wir nutzen den existierenden Dateinamen
|
# UPDATE: Wir nutzen den exakten Pfad aus Qdrant
|
||||||
target_filename = origin_fname
|
target_filename = origin_fname
|
||||||
else:
|
else:
|
||||||
# NEU: Wir generieren einen Namen
|
# NEU: Wir generieren einen Namen
|
||||||
|
|
@ -264,6 +287,7 @@ def render_draft_editor(msg):
|
||||||
safe_title = slugify(raw_title)[:60] or "draft"
|
safe_title = slugify(raw_title)[:60] or "draft"
|
||||||
target_filename = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}.md"
|
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)
|
result = save_draft_to_vault(final_doc, filename=target_filename)
|
||||||
|
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
|
|
@ -279,6 +303,9 @@ def render_draft_editor(msg):
|
||||||
st.markdown("</div>", unsafe_allow_html=True)
|
st.markdown("</div>", unsafe_allow_html=True)
|
||||||
|
|
||||||
def render_chat_interface(top_k, explain):
|
def render_chat_interface(top_k, explain):
|
||||||
|
"""
|
||||||
|
Rendert das Chat-Interface.
|
||||||
|
"""
|
||||||
for idx, msg in enumerate(st.session_state.messages):
|
for idx, msg in enumerate(st.session_state.messages):
|
||||||
with st.chat_message(msg["role"]):
|
with st.chat_message(msg["role"]):
|
||||||
if msg["role"] == "assistant":
|
if msg["role"] == "assistant":
|
||||||
|
|
@ -335,6 +362,9 @@ def render_chat_interface(top_k, explain):
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
def render_manual_editor():
|
def render_manual_editor():
|
||||||
|
"""
|
||||||
|
Wrapper für den manuellen Editor-Modus (Startet mit leerem Template).
|
||||||
|
"""
|
||||||
mock_msg = {
|
mock_msg = {
|
||||||
"content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n",
|
"content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n",
|
||||||
"query_id": "manual_mode_v2"
|
"query_id": "manual_mode_v2"
|
||||||
|
|
@ -346,8 +376,10 @@ def render_manual_editor():
|
||||||
def render_graph_explorer(graph_service):
|
def render_graph_explorer(graph_service):
|
||||||
st.header("🕸️ Graph Explorer")
|
st.header("🕸️ Graph Explorer")
|
||||||
|
|
||||||
|
# Session State initialisieren
|
||||||
if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None
|
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_depth", 2)
|
||||||
st.session_state.setdefault("graph_show_labels", True)
|
st.session_state.setdefault("graph_show_labels", True)
|
||||||
st.session_state.setdefault("graph_spacing", 200)
|
st.session_state.setdefault("graph_spacing", 200)
|
||||||
|
|
@ -358,6 +390,7 @@ def render_graph_explorer(graph_service):
|
||||||
with col_ctrl:
|
with col_ctrl:
|
||||||
st.subheader("Fokus")
|
st.subheader("Fokus")
|
||||||
|
|
||||||
|
# Suche
|
||||||
search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...")
|
search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...")
|
||||||
|
|
||||||
options = {}
|
options = {}
|
||||||
|
|
@ -377,6 +410,7 @@ def render_graph_explorer(graph_service):
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
|
# View Settings
|
||||||
with st.expander("👁️ Ansicht & Layout", expanded=True):
|
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_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)
|
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
|
center_id = st.session_state.graph_center_id
|
||||||
|
|
||||||
if center_id:
|
if center_id:
|
||||||
|
# Action Bar
|
||||||
c_action1, c_action2 = st.columns([3, 1])
|
c_action1, c_action2 = st.columns([3, 1])
|
||||||
with c_action1:
|
with c_action1:
|
||||||
st.caption(f"Aktives Zentrum: **{center_id}**")
|
st.caption(f"Aktives Zentrum: **{center_id}**")
|
||||||
with c_action2:
|
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)
|
note_data = graph_service._fetch_note_cached(center_id)
|
||||||
if note_data:
|
if note_data:
|
||||||
st.button("📝 Bearbeiten",
|
st.button("📝 Bearbeiten",
|
||||||
|
|
@ -411,9 +447,10 @@ def render_graph_explorer(graph_service):
|
||||||
on_click=switch_to_editor_callback,
|
on_click=switch_to_editor_callback,
|
||||||
args=(note_data,))
|
args=(note_data,))
|
||||||
else:
|
else:
|
||||||
st.error("Datenfehler")
|
st.error("Datenfehler: Notiz nicht gefunden.")
|
||||||
|
|
||||||
with st.spinner(f"Lade Graph..."):
|
with st.spinner(f"Lade Graph..."):
|
||||||
|
# Daten laden (Nutzt den verbesserten Service mit Hover-Texten)
|
||||||
nodes, edges = graph_service.get_ego_graph(
|
nodes, edges = graph_service.get_ego_graph(
|
||||||
center_id,
|
center_id,
|
||||||
depth=st.session_state.graph_depth,
|
depth=st.session_state.graph_depth,
|
||||||
|
|
@ -421,10 +458,11 @@ def render_graph_explorer(graph_service):
|
||||||
)
|
)
|
||||||
|
|
||||||
if not nodes:
|
if not nodes:
|
||||||
st.warning("Keine Daten gefunden.")
|
st.warning("Keine Daten gefunden. (Notiz existiert evtl. nicht mehr)")
|
||||||
else:
|
else:
|
||||||
# FIX: Key entfernt, da er in deiner Version TypeError verursacht.
|
# CONFIGURATION für agraph
|
||||||
# Wir verlassen uns auf Config-Update.
|
# Wir nutzen KEIN 'key' Argument, da dies Fehler verursacht.
|
||||||
|
# Stattdessen vertrauen wir darauf, dass das Config-Objekt neu ist.
|
||||||
config = Config(
|
config = Config(
|
||||||
width=1000,
|
width=1000,
|
||||||
height=800,
|
height=800,
|
||||||
|
|
@ -434,6 +472,7 @@ def render_graph_explorer(graph_service):
|
||||||
nodeHighlightBehavior=True,
|
nodeHighlightBehavior=True,
|
||||||
highlightColor="#F7A7A6",
|
highlightColor="#F7A7A6",
|
||||||
collapsible=False,
|
collapsible=False,
|
||||||
|
# Physik: ForceAtlas2Based für beste Entzerrung
|
||||||
solver="forceAtlas2Based",
|
solver="forceAtlas2Based",
|
||||||
forceAtlas2Based={
|
forceAtlas2Based={
|
||||||
"theta": 0.5,
|
"theta": 0.5,
|
||||||
|
|
@ -449,11 +488,14 @@ def render_graph_explorer(graph_service):
|
||||||
|
|
||||||
return_value = agraph(nodes=nodes, edges=edges, config=config)
|
return_value = agraph(nodes=nodes, edges=edges, config=config)
|
||||||
|
|
||||||
|
# Interaktions-Logik
|
||||||
if return_value:
|
if return_value:
|
||||||
if return_value != center_id:
|
if return_value != center_id:
|
||||||
|
# Navigation: Neues Zentrum setzen
|
||||||
st.session_state.graph_center_id = return_value
|
st.session_state.graph_center_id = return_value
|
||||||
st.rerun()
|
st.rerun()
|
||||||
else:
|
else:
|
||||||
|
# Klick auf das Zentrum selbst
|
||||||
st.toast(f"Zentrum: {return_value}")
|
st.toast(f"Zentrum: {return_value}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,13 @@ class GraphExplorerService:
|
||||||
self._note_cache = {}
|
self._note_cache = {}
|
||||||
|
|
||||||
def get_ego_graph(self, center_note_id: str, depth=2, show_labels=True):
|
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 = {}
|
nodes_dict = {}
|
||||||
unique_edges = {}
|
unique_edges = {}
|
||||||
|
|
||||||
# 1. Center Note laden
|
# 1. Center Note laden
|
||||||
center_note = self._fetch_note_cached(center_note_id)
|
center_note = self._fetch_note_cached(center_note_id)
|
||||||
if not center_note: return [], []
|
if not center_note: return [], []
|
||||||
|
# Node vorerst ohne Vorschau hinzufügen
|
||||||
self._add_node_to_dict(nodes_dict, center_note, level=0)
|
self._add_node_to_dict(nodes_dict, center_note, level=0)
|
||||||
|
|
||||||
level_1_ids = {center_note_id}
|
level_1_ids = {center_note_id}
|
||||||
|
|
@ -34,7 +31,7 @@ class GraphExplorerService:
|
||||||
if src_id: level_1_ids.add(src_id)
|
if src_id: level_1_ids.add(src_id)
|
||||||
if tgt_id: level_1_ids.add(tgt_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:
|
if depth > 1 and len(level_1_ids) > 1 and len(level_1_ids) < 60:
|
||||||
l1_subset = list(level_1_ids - {center_note_id})
|
l1_subset = list(level_1_ids - {center_note_id})
|
||||||
if l1_subset:
|
if l1_subset:
|
||||||
|
|
@ -42,6 +39,20 @@ class GraphExplorerService:
|
||||||
for edge_data in l2_edges:
|
for edge_data in l2_edges:
|
||||||
self._process_edge(edge_data, nodes_dict, unique_edges, current_depth=2)
|
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
|
# Graphen bauen
|
||||||
final_edges = []
|
final_edges = []
|
||||||
for (src, tgt), data in unique_edges.items():
|
for (src, tgt), data in unique_edges.items():
|
||||||
|
|
@ -49,19 +60,49 @@ class GraphExplorerService:
|
||||||
prov = data['provenance']
|
prov = data['provenance']
|
||||||
color = get_edge_color(kind)
|
color = get_edge_color(kind)
|
||||||
is_smart = (prov != "explicit" and prov != "rule")
|
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 " "
|
label_text = kind if show_labels else " "
|
||||||
|
|
||||||
final_edges.append(Edge(
|
final_edges.append(Edge(
|
||||||
source=src, target=tgt, label=label_text, color=color, dashes=is_smart,
|
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):
|
def _find_connected_edges(self, note_ids, note_title=None):
|
||||||
"""Findet In- und Outgoing Edges."""
|
|
||||||
# Chunks finden
|
# Chunks finden
|
||||||
scroll_filter = models.Filter(
|
scroll_filter = models.Filter(
|
||||||
must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))]
|
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]
|
chunk_ids = [c.id for c in chunks]
|
||||||
|
|
||||||
results = []
|
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
|
source_candidates = chunk_ids + note_ids
|
||||||
|
|
||||||
if source_candidates:
|
if source_candidates:
|
||||||
out_f = models.Filter(must=[
|
out_f = models.Filter(must=[
|
||||||
models.FieldCondition(key="source_id", match=models.MatchAny(any=source_candidates)),
|
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}))
|
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)
|
res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_f, limit=100, with_payload=True)
|
||||||
results.extend(res_out)
|
results.extend(res_out)
|
||||||
|
|
||||||
# --- INCOMING SEARCH (Ziel = Chunk ODER Title ODER Note) ---
|
|
||||||
shoulds = []
|
shoulds = []
|
||||||
if chunk_ids: shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=chunk_ids)))
|
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)))
|
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)))
|
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids)))
|
||||||
|
|
||||||
if shoulds:
|
if shoulds:
|
||||||
in_f = models.Filter(
|
in_f = models.Filter(
|
||||||
# FIX: MatchExcept mit **kwargs nutzen wegen 'except' Keyword
|
|
||||||
must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))],
|
must=[models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))],
|
||||||
should=shoulds
|
should=shoulds
|
||||||
)
|
)
|
||||||
|
|
@ -105,7 +139,6 @@ class GraphExplorerService:
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _find_connected_edges_batch(self, note_ids):
|
def _find_connected_edges_batch(self, note_ids):
|
||||||
"""Batch-Suche für Level 2."""
|
|
||||||
return self._find_connected_edges(note_ids)
|
return self._find_connected_edges(note_ids)
|
||||||
|
|
||||||
def _process_edge(self, record, nodes_dict, unique_edges, current_depth):
|
def _process_edge(self, record, nodes_dict, unique_edges, current_depth):
|
||||||
|
|
@ -115,7 +148,6 @@ class GraphExplorerService:
|
||||||
kind = payload.get("kind")
|
kind = payload.get("kind")
|
||||||
provenance = payload.get("provenance", "explicit")
|
provenance = payload.get("provenance", "explicit")
|
||||||
|
|
||||||
# Resolve
|
|
||||||
src_note = self._resolve_note_from_ref(src_ref)
|
src_note = self._resolve_note_from_ref(src_ref)
|
||||||
tgt_note = self._resolve_note_from_ref(tgt_ref)
|
tgt_note = self._resolve_note_from_ref(tgt_ref)
|
||||||
|
|
||||||
|
|
@ -124,15 +156,12 @@ class GraphExplorerService:
|
||||||
tgt_id = tgt_note['note_id']
|
tgt_id = tgt_note['note_id']
|
||||||
|
|
||||||
if src_id != tgt_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, src_note, level=current_depth)
|
||||||
self._add_node_to_dict(nodes_dict, tgt_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)
|
key = (src_id, tgt_id)
|
||||||
existing = unique_edges.get(key)
|
existing = unique_edges.get(key)
|
||||||
|
|
||||||
# Update logic: Explicit > Smart
|
|
||||||
should_update = True
|
should_update = True
|
||||||
is_current_explicit = (provenance in ["explicit", "rule"])
|
is_current_explicit = (provenance in ["explicit", "rule"])
|
||||||
if existing:
|
if existing:
|
||||||
|
|
@ -161,8 +190,6 @@ class GraphExplorerService:
|
||||||
|
|
||||||
def _resolve_note_from_ref(self, ref_str):
|
def _resolve_note_from_ref(self, ref_str):
|
||||||
if not ref_str: return None
|
if not ref_str: return None
|
||||||
|
|
||||||
# Fall A: Chunk ID / Section
|
|
||||||
if "#" in ref_str:
|
if "#" in ref_str:
|
||||||
try:
|
try:
|
||||||
res = self.client.retrieve(self.chunks_col, ids=[ref_str], with_payload=True)
|
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]
|
possible_note_id = ref_str.split("#")[0]
|
||||||
if self._fetch_note_cached(possible_note_id): return self._fetch_note_cached(possible_note_id)
|
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)
|
if self._fetch_note_cached(ref_str): return self._fetch_note_cached(ref_str)
|
||||||
|
|
||||||
# Fall C: Titel
|
|
||||||
res, _ = self.client.scroll(
|
res, _ = self.client.scroll(
|
||||||
collection_name=self.notes_col,
|
collection_name=self.notes_col,
|
||||||
scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchValue(value=ref_str))]),
|
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")
|
ntype = note_payload.get("type", "default")
|
||||||
color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"])
|
color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"])
|
||||||
|
|
||||||
# HOVER TEXT: Vorschau bauen
|
# Basis-Tooltip (wird später erweitert)
|
||||||
hover_text = f"Titel: {note_payload.get('title')}\nTyp: {ntype}\nTags: {note_payload.get('tags', [])}"
|
tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}"
|
||||||
|
|
||||||
if level == 0: size = 45
|
if level == 0: size = 45
|
||||||
elif level == 1: size = 25
|
elif level == 1: size = 25
|
||||||
|
|
@ -205,6 +230,6 @@ class GraphExplorerService:
|
||||||
size=size,
|
size=size,
|
||||||
color=color,
|
color=color,
|
||||||
shape="dot" if level > 0 else "diamond",
|
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}
|
font={'color': 'black', 'face': 'arial', 'size': 14 if level < 2 else 0}
|
||||||
)
|
)
|
||||||
Loading…
Reference in New Issue
Block a user