neue visualisierung
This commit is contained in:
parent
ae0768e4de
commit
cf91730e45
|
|
@ -382,7 +382,8 @@ def render_graph_explorer(graph_service):
|
|||
# Defaults speichern für Persistenz
|
||||
st.session_state.setdefault("graph_depth", 2)
|
||||
st.session_state.setdefault("graph_show_labels", True)
|
||||
st.session_state.setdefault("graph_spacing", 200)
|
||||
# Defaults angepasst für BarnesHut (andere Skala!)
|
||||
st.session_state.setdefault("graph_spacing", 150)
|
||||
st.session_state.setdefault("graph_gravity", -3000)
|
||||
|
||||
col_ctrl, col_graph = st.columns([1, 4])
|
||||
|
|
@ -415,12 +416,13 @@ def render_graph_explorer(graph_service):
|
|||
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.markdown("**Dynamisches Layout**")
|
||||
st.session_state.graph_spacing = st.slider("Abstand (Feder)", 50, 400, st.session_state.graph_spacing)
|
||||
st.session_state.graph_gravity = st.slider("Abstoßung (Gravity)", -8000, -100, st.session_state.graph_gravity)
|
||||
st.markdown("**Physik (BarnesHut)**")
|
||||
# ACHTUNG: BarnesHut reagiert anders. Spring Length ist wichtig.
|
||||
st.session_state.graph_spacing = st.slider("Federlänge (Abstand)", 50, 500, st.session_state.graph_spacing, help="Wie lang sollen die Verbindungen sein?")
|
||||
st.session_state.graph_gravity = st.slider("Abstoßung (Gravity)", -20000, -1000, st.session_state.graph_gravity, help="Wie stark sollen sich Knoten abstoßen?")
|
||||
|
||||
if st.button("Standard wiederherstellen"):
|
||||
st.session_state.graph_spacing = 200
|
||||
if st.button("Reset Layout"):
|
||||
st.session_state.graph_spacing = 150
|
||||
st.session_state.graph_gravity = -3000
|
||||
st.rerun()
|
||||
|
||||
|
|
@ -439,7 +441,7 @@ def render_graph_explorer(graph_service):
|
|||
st.caption(f"Aktives Zentrum: **{center_id}**")
|
||||
with c_action2:
|
||||
# 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)
|
||||
# Holt die Daten aus dem Cache des Services
|
||||
note_data = graph_service._fetch_note_cached(center_id)
|
||||
if note_data:
|
||||
st.button("📝 Bearbeiten",
|
||||
|
|
@ -460,30 +462,34 @@ def render_graph_explorer(graph_service):
|
|||
if not nodes:
|
||||
st.warning("Keine Daten gefunden. (Notiz existiert evtl. nicht mehr)")
|
||||
else:
|
||||
# CONFIGURATION für agraph
|
||||
# --- 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(
|
||||
width=1000,
|
||||
height=800,
|
||||
height=dyn_height,
|
||||
directed=True,
|
||||
physics=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,
|
||||
# Physik: ForceAtlas2Based für beste Entzerrung
|
||||
solver="forceAtlas2Based",
|
||||
forceAtlas2Based={
|
||||
"theta": 0.5,
|
||||
"gravitationalConstant": st.session_state.graph_gravity,
|
||||
"centralGravity": 0.005,
|
||||
"springConstant": 0.08,
|
||||
"springLength": st.session_state.graph_spacing,
|
||||
"damping": 0.4,
|
||||
"avoidOverlap": 1
|
||||
},
|
||||
stabilization={"enabled": True, "iterations": 800}
|
||||
collapsible=False
|
||||
)
|
||||
|
||||
return_value = agraph(nodes=nodes, edges=edges, config=config)
|
||||
|
|
|
|||
|
|
@ -12,13 +12,16 @@ class GraphExplorerService:
|
|||
self._note_cache = {}
|
||||
|
||||
def get_ego_graph(self, center_note_id: str, depth=2, show_labels=True):
|
||||
"""
|
||||
Erstellt den Graphen.
|
||||
show_labels=False versteckt die Kantenbeschriftung für mehr Übersicht.
|
||||
"""
|
||||
nodes_dict = {}
|
||||
unique_edges = {}
|
||||
|
||||
# 1. Center Note laden
|
||||
center_note = self._fetch_note_cached(center_note_id)
|
||||
if not center_note: return [], []
|
||||
# Node vorerst ohne Vorschau hinzufügen
|
||||
self._add_node_to_dict(nodes_dict, center_note, level=0)
|
||||
|
||||
level_1_ids = {center_note_id}
|
||||
|
|
@ -31,7 +34,7 @@ class GraphExplorerService:
|
|||
if src_id: level_1_ids.add(src_id)
|
||||
if tgt_id: level_1_ids.add(tgt_id)
|
||||
|
||||
# Level 2 Suche
|
||||
# Level 2 Suche (begrenzt für Performance)
|
||||
if depth > 1 and len(level_1_ids) > 1 and len(level_1_ids) < 60:
|
||||
l1_subset = list(level_1_ids - {center_note_id})
|
||||
if l1_subset:
|
||||
|
|
@ -39,19 +42,22 @@ class GraphExplorerService:
|
|||
for edge_data in l2_edges:
|
||||
self._process_edge(edge_data, nodes_dict, unique_edges, current_depth=2)
|
||||
|
||||
# --- NEU: Content Previews (Chunks) laden ---
|
||||
# Wir holen für alle gesammelten Nodes den ersten Chunk als Vorschau
|
||||
all_node_ids = list(nodes_dict.keys())
|
||||
previews = self._fetch_previews_for_nodes(all_node_ids)
|
||||
# --- SMART CONTENT LOADING ---
|
||||
# 1. Fulltext für Center Node holen (Chunks stitchen)
|
||||
center_text = self._fetch_full_text_stitched(center_note_id)
|
||||
if center_note_id in nodes_dict:
|
||||
orig_title = nodes_dict[center_note_id].title
|
||||
# Titel im Objekt manipulieren für Hover
|
||||
nodes_dict[center_note_id].title = f"{orig_title}\n\n📄 VOLLTEXT:\n{center_text[:2000]}..."
|
||||
|
||||
# 2. Previews für alle anderen Nodes holen
|
||||
all_ids = list(nodes_dict.keys())
|
||||
previews = self._fetch_previews_for_nodes(all_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)
|
||||
if nid != center_note_id:
|
||||
prev = previews.get(nid, "Kein Vorschau-Text.")
|
||||
node_obj.title = f"{node_obj.title}\n\n🔍 VORSCHAU:\n{prev[:600]}..."
|
||||
|
||||
# Graphen bauen
|
||||
final_edges = []
|
||||
|
|
@ -60,49 +66,53 @@ class GraphExplorerService:
|
|||
prov = data['provenance']
|
||||
color = get_edge_color(kind)
|
||||
is_smart = (prov != "explicit" and prov != "rule")
|
||||
|
||||
# Label Logik: Wenn show_labels False ist, zeigen wir keinen Text an
|
||||
label_text = kind if show_labels else " "
|
||||
|
||||
final_edges.append(Edge(
|
||||
source=src, target=tgt, label=label_text, color=color, dashes=is_smart,
|
||||
title=f"Relation: {kind}\nProvenance: {prov}"
|
||||
title=f"Relation: {kind}\nProvenance: {prov}" # Tooltip bleibt immer
|
||||
))
|
||||
|
||||
return final_nodes, final_edges
|
||||
return list(nodes_dict.values()), 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 = {}
|
||||
def _fetch_full_text_stitched(self, note_id):
|
||||
"""Holt ALLE Chunks einer Note, sortiert sie und baut den Text zusammen."""
|
||||
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
|
||||
must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=note_id))]
|
||||
)
|
||||
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True)
|
||||
# Sortieren nach 'ord'
|
||||
chunks.sort(key=lambda x: x.payload.get('ord', 999))
|
||||
|
||||
full_text = []
|
||||
for c in chunks:
|
||||
txt = c.payload.get('text', '')
|
||||
if txt: full_text.append(txt)
|
||||
|
||||
return "\n\n".join(full_text)
|
||||
except:
|
||||
return "Fehler beim Laden des Volltexts."
|
||||
|
||||
def _fetch_previews_for_nodes(self, node_ids):
|
||||
"""Holt den ersten Chunk ('window' oder 'text') für eine Liste von Nodes."""
|
||||
if not node_ids: return {}
|
||||
previews = {}
|
||||
try:
|
||||
scroll_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=node_ids))])
|
||||
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=scroll_filter, limit=len(node_ids)*3, 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}")
|
||||
|
||||
previews[nid] = c.payload.get("window") or c.payload.get("text") or ""
|
||||
except: pass
|
||||
return previews
|
||||
|
||||
def _find_connected_edges(self, note_ids, note_title=None):
|
||||
"""Findet In- und Outgoing Edges."""
|
||||
# Chunks finden
|
||||
scroll_filter = models.Filter(
|
||||
must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))]
|
||||
|
|
@ -113,19 +123,25 @@ class GraphExplorerService:
|
|||
chunk_ids = [c.id for c in chunks]
|
||||
|
||||
results = []
|
||||
|
||||
# --- OUTGOING SEARCH (Quelle = Chunk ODER Note) ---
|
||||
# Wir suchen jetzt auch nach der note_id als source_id, falls Edges direkt an der Note hängen
|
||||
source_candidates = chunk_ids + note_ids
|
||||
|
||||
if source_candidates:
|
||||
out_f = models.Filter(must=[
|
||||
models.FieldCondition(key="source_id", match=models.MatchAny(any=source_candidates)),
|
||||
# FIX: MatchExcept mit **kwargs nutzen wegen 'except' Keyword in Pydantic
|
||||
models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))
|
||||
])
|
||||
res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_f, limit=100, with_payload=True)
|
||||
results.extend(res_out)
|
||||
|
||||
# --- INCOMING SEARCH (Ziel = Chunk ODER Title ODER Note) ---
|
||||
shoulds = []
|
||||
if chunk_ids: shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=chunk_ids)))
|
||||
if note_title: shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=note_title)))
|
||||
# Target = Note ID
|
||||
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids)))
|
||||
|
||||
if shoulds:
|
||||
|
|
@ -139,6 +155,7 @@ class GraphExplorerService:
|
|||
return results
|
||||
|
||||
def _find_connected_edges_batch(self, note_ids):
|
||||
"""Batch-Suche für Level 2."""
|
||||
return self._find_connected_edges(note_ids)
|
||||
|
||||
def _process_edge(self, record, nodes_dict, unique_edges, current_depth):
|
||||
|
|
@ -148,6 +165,7 @@ class GraphExplorerService:
|
|||
kind = payload.get("kind")
|
||||
provenance = payload.get("provenance", "explicit")
|
||||
|
||||
# Resolve
|
||||
src_note = self._resolve_note_from_ref(src_ref)
|
||||
tgt_note = self._resolve_note_from_ref(tgt_ref)
|
||||
|
||||
|
|
@ -156,9 +174,11 @@ class GraphExplorerService:
|
|||
tgt_id = tgt_note['note_id']
|
||||
|
||||
if src_id != tgt_id:
|
||||
# Add Nodes
|
||||
self._add_node_to_dict(nodes_dict, src_note, level=current_depth)
|
||||
self._add_node_to_dict(nodes_dict, tgt_note, level=current_depth)
|
||||
|
||||
# Add Edge (Deduplication Logic)
|
||||
key = (src_id, tgt_id)
|
||||
existing = unique_edges.get(key)
|
||||
|
||||
|
|
@ -190,6 +210,8 @@ class GraphExplorerService:
|
|||
|
||||
def _resolve_note_from_ref(self, ref_str):
|
||||
if not ref_str: return None
|
||||
|
||||
# Fall A: Chunk ID
|
||||
if "#" in ref_str:
|
||||
try:
|
||||
res = self.client.retrieve(self.chunks_col, ids=[ref_str], with_payload=True)
|
||||
|
|
@ -198,8 +220,10 @@ class GraphExplorerService:
|
|||
possible_note_id = ref_str.split("#")[0]
|
||||
if self._fetch_note_cached(possible_note_id): return self._fetch_note_cached(possible_note_id)
|
||||
|
||||
# Fall B: Note ID
|
||||
if self._fetch_note_cached(ref_str): return self._fetch_note_cached(ref_str)
|
||||
|
||||
# Fall C: Titel
|
||||
res, _ = self.client.scroll(
|
||||
collection_name=self.notes_col,
|
||||
scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchValue(value=ref_str))]),
|
||||
|
|
@ -217,7 +241,7 @@ class GraphExplorerService:
|
|||
ntype = note_payload.get("type", "default")
|
||||
color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"])
|
||||
|
||||
# Basis-Tooltip (wird später erweitert)
|
||||
# Tooltip wird später durch smart content angereichert
|
||||
tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}"
|
||||
|
||||
if level == 0: size = 45
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user