bug fixing
This commit is contained in:
parent
cf91730e45
commit
0b47ffdcb6
|
|
@ -11,7 +11,7 @@ from ui_api import save_draft_to_vault, analyze_draft_text, send_chat_message, s
|
||||||
from ui_config import HISTORY_FILE, COLLECTION_PREFIX, GRAPH_COLORS
|
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.
|
# Diese müssen zwingend VOR dem Aufruf definiert sein.
|
||||||
|
|
||||||
def switch_to_editor_callback(note_payload):
|
def switch_to_editor_callback(note_payload):
|
||||||
"""
|
"""
|
||||||
|
|
@ -28,17 +28,17 @@ def switch_to_editor_callback(note_payload):
|
||||||
# Priorität 1: Der absolute Pfad aus dem Ingest-Prozess ('path')
|
# Priorität 1: Der absolute Pfad aus dem Ingest-Prozess ('path')
|
||||||
origin_fname = note_payload.get('path')
|
origin_fname = note_payload.get('path')
|
||||||
|
|
||||||
# Priorität 2: 'file_path' oder 'filename' (Legacy Felder)
|
# Priorität 2: 'file_path' oder 'filename' (Legacy Felder, Fallback)
|
||||||
if not origin_fname:
|
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')
|
||||||
|
|
||||||
# Priorität 3: Konstruktion aus ID (Notlösung)
|
# Priorität 3: Konstruktion aus ID (Notlösung, falls Metadaten unvollständig)
|
||||||
if not origin_fname and 'note_id' in note_payload:
|
if not origin_fname and 'note_id' in note_payload:
|
||||||
# Annahme: Datei heißt {note_id}.md im Vault Root
|
# 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"
|
origin_fname = f"{note_payload['note_id']}.md"
|
||||||
|
|
||||||
# 3. Message in den Chat-Verlauf injecten (dient als Datencontainer für den Editor)
|
# 3. Message in den Chat-Verlauf injecten (dient als Datencontainer für den Editor)
|
||||||
|
# Wir fügen eine "künstliche" Assistant-Nachricht hinzu, die der Editor dann ausliest.
|
||||||
st.session_state.messages.append({
|
st.session_state.messages.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"intent": "INTERVIEW",
|
"intent": "INTERVIEW",
|
||||||
|
|
@ -49,6 +49,7 @@ def switch_to_editor_callback(note_payload):
|
||||||
})
|
})
|
||||||
|
|
||||||
# 4. Modus umschalten
|
# 4. Modus umschalten
|
||||||
|
# Wir setzen den Key des Radio-Buttons im Session State
|
||||||
st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor"
|
st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor"
|
||||||
|
|
||||||
# --- UI RENDERER ---
|
# --- UI RENDERER ---
|
||||||
|
|
@ -161,9 +162,7 @@ def render_draft_editor(msg):
|
||||||
if origin_fname:
|
if origin_fname:
|
||||||
# Update Modus
|
# Update Modus
|
||||||
display_name = str(origin_fname).split("/")[-1] # Nur Dateiname anzeigen
|
display_name = str(origin_fname).split("/")[-1] # Nur Dateiname anzeigen
|
||||||
st.info(f"📝 **Update-Modus**: Du bearbeitest `{display_name}`")
|
st.success(f"📂 **Datei-Modus**: `{origin_fname}`") # Voller Pfad zur Sicherheit
|
||||||
# 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:
|
||||||
# Create Modus
|
# Create Modus
|
||||||
|
|
@ -269,7 +268,7 @@ def render_draft_editor(msg):
|
||||||
b1, b2 = st.columns([1, 1])
|
b1, b2 = st.columns([1, 1])
|
||||||
with b1:
|
with b1:
|
||||||
# Label dynamisch
|
# Label dynamisch
|
||||||
save_label = "💾 Update speichern" if origin_fname else "💾 Neu anlegen & Indizieren"
|
save_label = "💾 Speichern (Überschreiben)" if origin_fname else "💾 Neu anlegen & Indizieren"
|
||||||
|
|
||||||
if st.button(save_label, type="primary", key=f"{key_base}_save"):
|
if st.button(save_label, type="primary", key=f"{key_base}_save"):
|
||||||
with st.spinner("Speichere im Vault..."):
|
with st.spinner("Speichere im Vault..."):
|
||||||
|
|
@ -280,12 +279,9 @@ def render_draft_editor(msg):
|
||||||
target_filename = origin_fname
|
target_filename = origin_fname
|
||||||
else:
|
else:
|
||||||
# NEU: Wir generieren einen Namen
|
# NEU: Wir generieren einen Namen
|
||||||
raw_title = final_meta.get("title", "")
|
raw_title = final_meta.get("title", "draft")
|
||||||
if not raw_title:
|
target_file = f"{datetime.now().strftime('%Y%m%d')}-{slugify(raw_title)[:60]}.md"
|
||||||
clean_body = re.sub(r"[#*_\[\]()]", "", final_body).strip()
|
target_filename = target_file
|
||||||
raw_title = clean_body[:40] if clean_body else "draft"
|
|
||||||
safe_title = slugify(raw_title)[:60] or "draft"
|
|
||||||
target_filename = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}.md"
|
|
||||||
|
|
||||||
# Senden an API
|
# Senden an API
|
||||||
result = save_draft_to_vault(final_doc, filename=target_filename)
|
result = save_draft_to_vault(final_doc, filename=target_filename)
|
||||||
|
|
@ -382,7 +378,7 @@ def render_graph_explorer(graph_service):
|
||||||
# Defaults speichern für Persistenz
|
# 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)
|
||||||
# Defaults angepasst für BarnesHut (andere Skala!)
|
# Defaults angepasst für BarnesHut (Skalierung angepasst)
|
||||||
st.session_state.setdefault("graph_spacing", 150)
|
st.session_state.setdefault("graph_spacing", 150)
|
||||||
st.session_state.setdefault("graph_gravity", -3000)
|
st.session_state.setdefault("graph_gravity", -3000)
|
||||||
|
|
||||||
|
|
@ -435,74 +431,89 @@ def render_graph_explorer(graph_service):
|
||||||
center_id = st.session_state.graph_center_id
|
center_id = st.session_state.graph_center_id
|
||||||
|
|
||||||
if center_id:
|
if center_id:
|
||||||
# Action Bar
|
# Container für Action Bar OBERHALB des Graphen (Layout Fix)
|
||||||
c_action1, c_action2 = st.columns([3, 1])
|
action_container = st.container()
|
||||||
with c_action1:
|
|
||||||
st.caption(f"Aktives Zentrum: **{center_id}**")
|
|
||||||
with c_action2:
|
|
||||||
# Bearbeiten Button mit Callback (on_click)
|
|
||||||
# Holt die Daten aus dem Cache des Services
|
|
||||||
note_data = graph_service._fetch_note_cached(center_id)
|
|
||||||
if note_data:
|
|
||||||
st.button("📝 Bearbeiten",
|
|
||||||
use_container_width=True,
|
|
||||||
on_click=switch_to_editor_callback,
|
|
||||||
args=(note_data,))
|
|
||||||
else:
|
|
||||||
st.error("Datenfehler: Notiz nicht gefunden.")
|
|
||||||
|
|
||||||
|
# Graph Laden
|
||||||
with st.spinner(f"Lade Graph..."):
|
with st.spinner(f"Lade Graph..."):
|
||||||
# Daten laden (Nutzt den verbesserten Service mit Hover-Texten)
|
# Daten laden (Cache wird genutzt)
|
||||||
nodes, edges = graph_service.get_ego_graph(
|
nodes, edges = graph_service.get_ego_graph(
|
||||||
center_id,
|
center_id,
|
||||||
depth=st.session_state.graph_depth,
|
depth=st.session_state.graph_depth,
|
||||||
show_labels=st.session_state.graph_show_labels
|
show_labels=st.session_state.graph_show_labels
|
||||||
)
|
)
|
||||||
|
|
||||||
if not nodes:
|
# Fetch Note Data für Button & Debug
|
||||||
st.warning("Keine Daten gefunden. (Notiz existiert evtl. nicht mehr)")
|
note_data = graph_service._fetch_note_cached(center_id)
|
||||||
else:
|
|
||||||
# --- CONFIGURATION FÜR AGRAH (BARNES HUT) ---
|
|
||||||
# Wir nutzen KEIN 'key' Argument, da dies Fehler verursacht.
|
|
||||||
# Stattdessen vertrauen wir darauf, dass das Config-Objekt neu ist.
|
|
||||||
# TRICK: Wir ändern die Höhe minimal basierend auf der Gravity, um Re-Render zu erzwingen.
|
|
||||||
dyn_height = 800 + (abs(st.session_state.graph_gravity) % 5)
|
|
||||||
|
|
||||||
config = Config(
|
# --- ACTION BAR RENDEREN ---
|
||||||
width=1000,
|
with action_container:
|
||||||
height=dyn_height,
|
c_act1, c_act2 = st.columns([3, 1])
|
||||||
directed=True,
|
with c_act1:
|
||||||
physics={
|
st.caption(f"Aktives Zentrum: **{center_id}**")
|
||||||
"enabled": True,
|
with c_act2:
|
||||||
# BarnesHut ist der Standard und stabilste Solver für Agraph
|
if note_data:
|
||||||
"solver": "barnesHut",
|
st.button("📝 Bearbeiten",
|
||||||
"barnesHut": {
|
use_container_width=True,
|
||||||
"gravitationalConstant": st.session_state.graph_gravity,
|
on_click=switch_to_editor_callback,
|
||||||
"centralGravity": 0.3,
|
args=(note_data,))
|
||||||
"springLength": st.session_state.graph_spacing,
|
else:
|
||||||
"springConstant": 0.04,
|
st.error("Daten nicht verfügbar")
|
||||||
"damping": 0.09,
|
|
||||||
"avoidOverlap": 0.1
|
|
||||||
},
|
|
||||||
"stabilization": {"enabled": True, "iterations": 600}
|
|
||||||
},
|
|
||||||
hierarchical=False,
|
|
||||||
nodeHighlightBehavior=True,
|
|
||||||
highlightColor="#F7A7A6",
|
|
||||||
collapsible=False
|
|
||||||
)
|
|
||||||
|
|
||||||
return_value = agraph(nodes=nodes, edges=edges, config=config)
|
# DATA INSPECTOR (Payload Debug)
|
||||||
|
with st.expander("🕵️ Data Inspector (Payload Debug)", expanded=False):
|
||||||
# Interaktions-Logik
|
if note_data:
|
||||||
if return_value:
|
st.json(note_data)
|
||||||
if return_value != center_id:
|
if 'path' not in note_data:
|
||||||
# Navigation: Neues Zentrum setzen
|
st.error("ACHTUNG: Feld 'path' fehlt im Qdrant-Payload! Update-Modus wird nicht funktionieren.")
|
||||||
st.session_state.graph_center_id = return_value
|
|
||||||
st.rerun()
|
|
||||||
else:
|
else:
|
||||||
# Klick auf das Zentrum selbst
|
st.success(f"Pfad gefunden: {note_data['path']}")
|
||||||
st.toast(f"Zentrum: {return_value}")
|
else:
|
||||||
|
st.info("Keine Daten geladen.")
|
||||||
|
|
||||||
|
if not nodes:
|
||||||
|
st.warning("Keine Daten gefunden.")
|
||||||
|
else:
|
||||||
|
# --- CONFIGURATION (BarnesHut) ---
|
||||||
|
# Height-Trick für Re-Render (da key-Parameter nicht funktioniert)
|
||||||
|
# Ändere Height minimal basierend auf Gravity
|
||||||
|
dyn_height = 800 + (abs(st.session_state.graph_gravity) % 3)
|
||||||
|
|
||||||
|
config = Config(
|
||||||
|
width=1000,
|
||||||
|
height=dyn_height,
|
||||||
|
directed=True,
|
||||||
|
physics={
|
||||||
|
"enabled": True,
|
||||||
|
# BarnesHut ist der Standard und stabilste Solver für Agraph
|
||||||
|
"solver": "barnesHut",
|
||||||
|
"barnesHut": {
|
||||||
|
"gravitationalConstant": st.session_state.graph_gravity,
|
||||||
|
"centralGravity": 0.3,
|
||||||
|
"springLength": st.session_state.graph_spacing,
|
||||||
|
"springConstant": 0.04,
|
||||||
|
"damping": 0.09,
|
||||||
|
"avoidOverlap": 0.1
|
||||||
|
},
|
||||||
|
"stabilization": {"enabled": True, "iterations": 600}
|
||||||
|
},
|
||||||
|
hierarchical=False,
|
||||||
|
nodeHighlightBehavior=True,
|
||||||
|
highlightColor="#F7A7A6",
|
||||||
|
collapsible=False
|
||||||
|
)
|
||||||
|
|
||||||
|
return_value = agraph(nodes=nodes, edges=edges, config=config)
|
||||||
|
|
||||||
|
# Interaktions-Logik
|
||||||
|
if return_value:
|
||||||
|
if return_value != center_id:
|
||||||
|
# Navigation: Neues Zentrum setzen
|
||||||
|
st.session_state.graph_center_id = return_value
|
||||||
|
st.rerun()
|
||||||
|
else:
|
||||||
|
# Klick auf das Zentrum selbst
|
||||||
|
st.toast(f"Zentrum: {return_value}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
st.info("👈 Bitte wähle links eine Notiz aus, um den Graphen zu starten.")
|
st.info("👈 Bitte wähle links eine Notiz aus.")
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import re
|
||||||
from qdrant_client import QdrantClient, models
|
from qdrant_client import QdrantClient, models
|
||||||
from streamlit_agraph import Node, Edge
|
from streamlit_agraph import Node, Edge
|
||||||
from ui_config import GRAPH_COLORS, get_edge_color, SYSTEM_EDGES
|
from ui_config import GRAPH_COLORS, get_edge_color, SYSTEM_EDGES
|
||||||
|
|
@ -13,8 +14,8 @@ class GraphExplorerService:
|
||||||
|
|
||||||
def get_ego_graph(self, center_note_id: str, depth=2, show_labels=True):
|
def get_ego_graph(self, center_note_id: str, depth=2, show_labels=True):
|
||||||
"""
|
"""
|
||||||
Erstellt den Graphen.
|
Erstellt den Ego-Graphen um eine zentrale Notiz.
|
||||||
show_labels=False versteckt die Kantenbeschriftung für mehr Übersicht.
|
Lädt Volltext für das Zentrum und Snippets für Nachbarn.
|
||||||
"""
|
"""
|
||||||
nodes_dict = {}
|
nodes_dict = {}
|
||||||
unique_edges = {}
|
unique_edges = {}
|
||||||
|
|
@ -26,7 +27,7 @@ class GraphExplorerService:
|
||||||
|
|
||||||
level_1_ids = {center_note_id}
|
level_1_ids = {center_note_id}
|
||||||
|
|
||||||
# Suche Kanten für Center
|
# Suche Kanten für Center (L1)
|
||||||
l1_edges = self._find_connected_edges([center_note_id], center_note.get("title"))
|
l1_edges = self._find_connected_edges([center_note_id], center_note.get("title"))
|
||||||
|
|
||||||
for edge_data in l1_edges:
|
for edge_data in l1_edges:
|
||||||
|
|
@ -43,23 +44,26 @@ class GraphExplorerService:
|
||||||
self._process_edge(edge_data, nodes_dict, unique_edges, current_depth=2)
|
self._process_edge(edge_data, nodes_dict, unique_edges, current_depth=2)
|
||||||
|
|
||||||
# --- SMART CONTENT LOADING ---
|
# --- SMART CONTENT LOADING ---
|
||||||
# 1. Fulltext für Center Node holen (Chunks stitchen)
|
|
||||||
|
# A. Fulltext für Center Node holen (Chunks zusammenfügen)
|
||||||
center_text = self._fetch_full_text_stitched(center_note_id)
|
center_text = self._fetch_full_text_stitched(center_note_id)
|
||||||
if center_note_id in nodes_dict:
|
if center_note_id in nodes_dict:
|
||||||
orig_title = nodes_dict[center_note_id].title
|
orig_title = nodes_dict[center_note_id].title
|
||||||
# Titel im Objekt manipulieren für Hover
|
clean_full = self._clean_markdown(center_text[:2000])
|
||||||
nodes_dict[center_note_id].title = f"{orig_title}\n\n📄 VOLLTEXT:\n{center_text[:2000]}..."
|
# Wir packen den Text in den Tooltip (title attribute)
|
||||||
|
nodes_dict[center_note_id].title = f"{orig_title}\n\n📄 INHALT:\n{clean_full}..."
|
||||||
|
|
||||||
# 2. Previews für alle anderen Nodes holen
|
# B. Previews für alle Nachbarn holen (Batch)
|
||||||
all_ids = list(nodes_dict.keys())
|
all_ids = list(nodes_dict.keys())
|
||||||
previews = self._fetch_previews_for_nodes(all_ids)
|
previews = self._fetch_previews_for_nodes(all_ids)
|
||||||
|
|
||||||
for nid, node_obj in nodes_dict.items():
|
for nid, node_obj in nodes_dict.items():
|
||||||
if nid != center_note_id:
|
if nid != center_note_id:
|
||||||
prev = previews.get(nid, "Kein Vorschau-Text.")
|
prev_raw = previews.get(nid, "Kein Vorschau-Text.")
|
||||||
node_obj.title = f"{node_obj.title}\n\n🔍 VORSCHAU:\n{prev[:600]}..."
|
clean_prev = self._clean_markdown(prev_raw[:600])
|
||||||
|
node_obj.title = f"{node_obj.title}\n\n🔍 VORSCHAU:\n{clean_prev}..."
|
||||||
|
|
||||||
# Graphen bauen
|
# Graphen bauen (Nodes & Edges finalisieren)
|
||||||
final_edges = []
|
final_edges = []
|
||||||
for (src, tgt), data in unique_edges.items():
|
for (src, tgt), data in unique_edges.items():
|
||||||
kind = data['kind']
|
kind = data['kind']
|
||||||
|
|
@ -67,24 +71,38 @@ class GraphExplorerService:
|
||||||
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 Logik
|
||||||
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 list(nodes_dict.values()), final_edges
|
||||||
|
|
||||||
|
def _clean_markdown(self, text):
|
||||||
|
"""Entfernt Markdown-Sonderzeichen für saubere Tooltips im Browser."""
|
||||||
|
if not text: return ""
|
||||||
|
# Entferne Header Marker (## )
|
||||||
|
text = re.sub(r'#+\s', '', text)
|
||||||
|
# Entferne Bold/Italic (** oder *)
|
||||||
|
text = re.sub(r'\*\*|__|\*|_', '', text)
|
||||||
|
# Entferne Links [Text](Url) -> Text
|
||||||
|
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
|
||||||
|
# Entferne Wikilinks [[Link]] -> Link
|
||||||
|
text = re.sub(r'\[\[([^\]]+)\]\]', r'\1', text)
|
||||||
|
return text
|
||||||
|
|
||||||
def _fetch_full_text_stitched(self, note_id):
|
def _fetch_full_text_stitched(self, note_id):
|
||||||
"""Holt ALLE Chunks einer Note, sortiert sie und baut den Text zusammen."""
|
"""Lädt alle Chunks einer Note und baut den Text zusammen."""
|
||||||
try:
|
try:
|
||||||
scroll_filter = models.Filter(
|
scroll_filter = models.Filter(
|
||||||
must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))]
|
must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))]
|
||||||
)
|
)
|
||||||
|
# Limit hoch genug setzen
|
||||||
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True)
|
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True)
|
||||||
# Sortieren nach 'ord'
|
# Sortieren nach 'ord' (Reihenfolge im Dokument)
|
||||||
chunks.sort(key=lambda x: x.payload.get('ord', 999))
|
chunks.sort(key=lambda x: x.payload.get('ord', 999))
|
||||||
|
|
||||||
full_text = []
|
full_text = []
|
||||||
|
|
@ -97,51 +115,49 @@ class GraphExplorerService:
|
||||||
return "Fehler beim Laden des Volltexts."
|
return "Fehler beim Laden des Volltexts."
|
||||||
|
|
||||||
def _fetch_previews_for_nodes(self, node_ids):
|
def _fetch_previews_for_nodes(self, node_ids):
|
||||||
"""Holt den ersten Chunk ('window' oder 'text') für eine Liste von Nodes."""
|
"""Holt Batch-weise den ersten Chunk für eine Liste von Nodes."""
|
||||||
if not node_ids: return {}
|
if not node_ids: return {}
|
||||||
previews = {}
|
previews = {}
|
||||||
try:
|
try:
|
||||||
scroll_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))])
|
scroll_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))])
|
||||||
|
# Limit = Anzahl Nodes * 3 (Puffer)
|
||||||
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=len(node_ids)*3, with_payload=True)
|
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=len(node_ids)*3, with_payload=True)
|
||||||
|
|
||||||
for c in chunks:
|
for c in chunks:
|
||||||
nid = c.payload.get("note_id")
|
nid = c.payload.get("note_id")
|
||||||
|
# Nur den ersten gefundenen Chunk pro Note nehmen
|
||||||
if nid and nid not in previews:
|
if nid and nid not in previews:
|
||||||
previews[nid] = c.payload.get("window") or c.payload.get("text") or ""
|
previews[nid] = c.payload.get("window") or c.payload.get("text") or ""
|
||||||
except: pass
|
except: pass
|
||||||
return previews
|
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."""
|
"""Findet eingehende und ausgehende Kanten für Nodes."""
|
||||||
# Chunks finden
|
# 1. Chunk IDs ermitteln (da Edges oft an Chunks hängen)
|
||||||
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))]
|
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=200)
|
||||||
)
|
|
||||||
chunks, _ = self.client.scroll(
|
|
||||||
collection_name=self.chunks_col, scroll_filter=scroll_filter, limit=200
|
|
||||||
)
|
|
||||||
chunk_ids = [c.id for c in chunks]
|
chunk_ids = [c.id for c in chunks]
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# --- OUTGOING SEARCH (Quelle = Chunk ODER Note) ---
|
# 2. Outgoing Edges suchen
|
||||||
# Wir suchen jetzt auch nach der note_id als source_id, falls Edges direkt an der Note hängen
|
# Source kann sein: Eine Chunk ID ODER die Note ID selbst (bei direkten Links)
|
||||||
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 in Pydantic
|
# FIX: MatchExcept Workaround für Pydantic
|
||||||
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) ---
|
# 3. Incoming Edges suchen
|
||||||
|
# Target kann sein: Chunk ID, Note ID, oder Note Titel (Wikilinks)
|
||||||
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:
|
||||||
|
|
@ -151,21 +167,21 @@ class GraphExplorerService:
|
||||||
)
|
)
|
||||||
res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_f, limit=100, with_payload=True)
|
res_in, _ = self.client.scroll(self.edges_col, scroll_filter=in_f, limit=100, with_payload=True)
|
||||||
results.extend(res_in)
|
results.extend(res_in)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _find_connected_edges_batch(self, note_ids):
|
def _find_connected_edges_batch(self, note_ids):
|
||||||
"""Batch-Suche für Level 2."""
|
# Wrapper für Level 2 Suche
|
||||||
return self._find_connected_edges(note_ids)
|
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):
|
||||||
|
"""Verarbeitet eine rohe Edge, löst IDs auf und fügt sie den Dictionaries hinzu."""
|
||||||
payload = record.payload
|
payload = record.payload
|
||||||
src_ref = payload.get("source_id")
|
src_ref = payload.get("source_id")
|
||||||
tgt_ref = payload.get("target_id")
|
tgt_ref = payload.get("target_id")
|
||||||
kind = payload.get("kind")
|
kind = payload.get("kind")
|
||||||
provenance = payload.get("provenance", "explicit")
|
provenance = payload.get("provenance", "explicit")
|
||||||
|
|
||||||
# Resolve
|
# IDs zu Notes auflösen
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -174,15 +190,16 @@ 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
|
# Nodes hinzufügen
|
||||||
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)
|
# Kante hinzufügen (mit Deduplizierung)
|
||||||
key = (src_id, tgt_id)
|
key = (src_id, tgt_id)
|
||||||
existing = unique_edges.get(key)
|
existing = unique_edges.get(key)
|
||||||
|
|
||||||
should_update = True
|
should_update = True
|
||||||
|
# Bevorzuge explizite Kanten vor Smart Kanten
|
||||||
is_current_explicit = (provenance in ["explicit", "rule"])
|
is_current_explicit = (provenance in ["explicit", "rule"])
|
||||||
if existing:
|
if existing:
|
||||||
is_existing_explicit = (existing['provenance'] in ["explicit", "rule"])
|
is_existing_explicit = (existing['provenance'] in ["explicit", "rule"])
|
||||||
|
|
@ -190,9 +207,7 @@ class GraphExplorerService:
|
||||||
should_update = False
|
should_update = False
|
||||||
|
|
||||||
if should_update:
|
if should_update:
|
||||||
unique_edges[key] = {
|
unique_edges[key] = {"source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance}
|
||||||
"source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance
|
|
||||||
}
|
|
||||||
return src_id, tgt_id
|
return src_id, tgt_id
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
@ -209,18 +224,22 @@ class GraphExplorerService:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _resolve_note_from_ref(self, ref_str):
|
def _resolve_note_from_ref(self, ref_str):
|
||||||
|
"""Löst eine ID (Chunk, Note oder Titel) zu einer Note Payload auf."""
|
||||||
if not ref_str: return None
|
if not ref_str: return None
|
||||||
|
|
||||||
# Fall A: Chunk ID
|
# Fall A: Chunk ID (enthält #)
|
||||||
if "#" in ref_str:
|
if "#" in ref_str:
|
||||||
try:
|
try:
|
||||||
|
# Versuch 1: Chunk ID direkt
|
||||||
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)
|
||||||
if res: return self._fetch_note_cached(res[0].payload.get("note_id"))
|
if res: return self._fetch_note_cached(res[0].payload.get("note_id"))
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
|
# Versuch 2: NoteID#Section (Hash abtrennen)
|
||||||
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
|
# Fall B: Note ID direkt
|
||||||
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
|
# Fall C: Titel
|
||||||
|
|
@ -241,7 +260,7 @@ 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"])
|
||||||
|
|
||||||
# Tooltip wird später durch smart content angereichert
|
# Basis-Tooltip (wird später erweitert)
|
||||||
tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}"
|
tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}"
|
||||||
|
|
||||||
if level == 0: size = 45
|
if level == 0: size = 45
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user