WP19 #10

Merged
Lars merged 36 commits from WP19 into main 2025-12-14 20:50:04 +01:00
19 changed files with 1732 additions and 575 deletions

View File

@ -1,533 +1,50 @@
import streamlit as st import streamlit as st
import requests
import uuid import uuid
import os
import json
import re
import yaml
import unicodedata
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
# --- CONFIGURATION --- # --- CONFIG & STYLING ---
load_dotenv() st.set_page_config(page_title="mindnet v2.6", page_icon="🧠", layout="wide")
API_BASE_URL = os.getenv("MINDNET_API_URL", "http://localhost:8002")
CHAT_ENDPOINT = f"{API_BASE_URL}/chat"
FEEDBACK_ENDPOINT = f"{API_BASE_URL}/feedback"
INGEST_ANALYZE_ENDPOINT = f"{API_BASE_URL}/ingest/analyze"
INGEST_SAVE_ENDPOINT = f"{API_BASE_URL}/ingest/save"
HISTORY_FILE = Path("data/logs/search_history.jsonl")
# Timeout Strategy
timeout_setting = os.getenv("MINDNET_API_TIMEOUT") or os.getenv("MINDNET_LLM_TIMEOUT")
API_TIMEOUT = float(timeout_setting) if timeout_setting else 300.0
# --- PAGE SETUP ---
st.set_page_config(page_title="mindnet v2.5", page_icon="🧠", layout="wide")
# --- CSS STYLING ---
st.markdown(""" st.markdown("""
<style> <style>
.block-container { padding-top: 2rem; max_width: 1000px; margin: auto; } .block-container { padding-top: 2rem; max_width: 1200px; margin: auto; }
.intent-badge { background-color: #e8f0fe; color: #1a73e8; padding: 4px 10px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; border: 1px solid #d2e3fc; display: inline-block; margin-bottom: 0.5rem; }
.intent-badge { .draft-box { border: 1px solid #d0d7de; border-radius: 6px; padding: 16px; background-color: #f6f8fa; margin: 10px 0; }
background-color: #e8f0fe; color: #1a73e8; .preview-box { border: 1px solid #e0e0e0; border-radius: 6px; padding: 24px; background-color: white; }
padding: 4px 10px; border-radius: 12px;
font-size: 0.8rem; font-weight: 600;
border: 1px solid #d2e3fc; display: inline-block; margin-bottom: 0.5rem;
}
.draft-box {
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 16px;
background-color: #f6f8fa;
margin-top: 10px;
margin-bottom: 10px;
}
.preview-box {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 24px;
background-color: white;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
}
</style> </style>
""", unsafe_allow_html=True) """, unsafe_allow_html=True)
# --- MODULE IMPORTS ---
try:
from ui_config import QDRANT_URL, QDRANT_KEY, COLLECTION_PREFIX
from ui_graph_service import GraphExplorerService
# Komponenten
from ui_sidebar import render_sidebar
from ui_chat import render_chat_interface
from ui_editor import render_manual_editor
# Die beiden Graph-Engines
from ui_graph import render_graph_explorer as render_graph_agraph
from ui_graph_cytoscape import render_graph_explorer_cytoscape # <-- Import
except ImportError as e:
st.error(f"Import Error: {e}. Bitte stelle sicher, dass alle UI-Dateien im Ordner liegen und 'streamlit-cytoscapejs' installiert ist.")
st.stop()
# --- SESSION STATE --- # --- SESSION STATE ---
if "messages" not in st.session_state: st.session_state.messages = [] if "messages" not in st.session_state: st.session_state.messages = []
if "user_id" not in st.session_state: st.session_state.user_id = str(uuid.uuid4()) if "user_id" not in st.session_state: st.session_state.user_id = str(uuid.uuid4())
# --- HELPER FUNCTIONS --- # --- SERVICE INIT ---
graph_service = GraphExplorerService(QDRANT_URL, QDRANT_KEY, COLLECTION_PREFIX)
def slugify(value): # --- MAIN ROUTING ---
if not value: return ""
value = str(value).lower()
replacements = {'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss', '&': 'und', '+': 'und'}
for k, v in replacements.items():
value = value.replace(k, v)
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
value = re.sub(r'[^\w\s-]', '', value).strip()
return re.sub(r'[-\s]+', '-', value)
def normalize_meta_and_body(meta, body):
ALLOWED_KEYS = {"title", "type", "status", "tags", "id", "created", "updated", "aliases", "lang"}
clean_meta = {}
extra_content = []
if "titel" in meta and "title" not in meta:
meta["title"] = meta.pop("titel")
tag_candidates = ["tags", "emotionale_keywords", "keywords", "schluesselwoerter"]
all_tags = []
for key in tag_candidates:
if key in meta:
val = meta[key]
if isinstance(val, list): all_tags.extend(val)
elif isinstance(val, str): all_tags.extend([t.strip() for t in val.split(",")])
for key, val in meta.items():
if key in ALLOWED_KEYS:
clean_meta[key] = val
elif key in tag_candidates:
pass
else:
if val and isinstance(val, str):
header = key.replace("_", " ").title()
extra_content.append(f"## {header}\n{val}\n")
if all_tags:
clean_tags = []
for t in all_tags:
t_clean = str(t).replace("#", "").strip()
if t_clean: clean_tags.append(t_clean)
clean_meta["tags"] = list(set(clean_tags))
if extra_content:
new_section = "\n".join(extra_content)
final_body = f"{new_section}\n{body}"
else:
final_body = body
return clean_meta, final_body
def parse_markdown_draft(full_text):
"""
HEALING PARSER: Repariert kaputten LLM Output (z.B. fehlendes schließendes '---').
"""
clean_text = full_text.strip()
# 1. Code-Block Wrapper entfernen
pattern_block = r"```(?:markdown|md|yaml)?\s*(.*?)\s*```"
match_block = re.search(pattern_block, clean_text, re.DOTALL | re.IGNORECASE)
if match_block:
clean_text = match_block.group(1).strip()
meta = {}
body = clean_text
yaml_str = ""
# 2. Versuch A: Standard Split (Idealfall)
parts = re.split(r"^---+\s*$", clean_text, maxsplit=2, flags=re.MULTILINE)
if len(parts) >= 3:
yaml_str = parts[1]
body = parts[2]
# 3. Versuch B: Healing (Wenn LLM das schließende --- vergessen hat)
elif clean_text.startswith("---"):
# Wir suchen die erste Überschrift '#', da Frontmatter davor sein muss
# Pattern: Suche --- am Anfang, dann nimm alles bis zum ersten # am Zeilenanfang
fallback_match = re.search(r"^---\s*(.*?)(?=\n#)", clean_text, re.DOTALL | re.MULTILINE)
if fallback_match:
yaml_str = fallback_match.group(1)
# Der Body ist alles NACH dem YAML String (inklusive dem #)
body = clean_text.replace(f"---{yaml_str}", "", 1).strip()
# 4. YAML Parsing
if yaml_str:
yaml_str_clean = yaml_str.replace("#", "") # Tags cleanen
try:
parsed = yaml.safe_load(yaml_str_clean)
if isinstance(parsed, dict):
meta = parsed
except Exception as e:
print(f"YAML Parsing Warning: {e}")
# Fallback: Titel aus H1
if not meta.get("title"):
h1_match = re.search(r"^#\s+(.*)$", body, re.MULTILINE)
if h1_match:
meta["title"] = h1_match.group(1).strip()
# Correction: type/status swap
if meta.get("type") == "draft":
meta["status"] = "draft"
meta["type"] = "experience"
return normalize_meta_and_body(meta, body)
def build_markdown_doc(meta, body):
"""Baut das finale Dokument zusammen."""
if "id" not in meta or meta["id"] == "generated_on_save":
raw_title = meta.get('title', 'note')
clean_slug = slugify(raw_title)[:50] or "note"
meta["id"] = f"{datetime.now().strftime('%Y%m%d')}-{clean_slug}"
meta["updated"] = datetime.now().strftime("%Y-%m-%d")
ordered_meta = {}
prio_keys = ["id", "type", "title", "status", "tags"]
for k in prio_keys:
if k in meta: ordered_meta[k] = meta.pop(k)
ordered_meta.update(meta)
try:
yaml_str = yaml.dump(ordered_meta, default_flow_style=None, sort_keys=False, allow_unicode=True).strip()
except:
yaml_str = "error: generating_yaml"
return f"---\n{yaml_str}\n---\n\n{body}"
def load_history_from_logs(limit=10):
queries = []
if HISTORY_FILE.exists():
try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
lines = f.readlines()
for line in reversed(lines):
try:
entry = json.loads(line)
q = entry.get("query_text")
if q and q not in queries:
queries.append(q)
if len(queries) >= limit: break
except: continue
except: pass
return queries
# --- API CLIENT ---
def send_chat_message(message: str, top_k: int, explain: bool):
try:
response = requests.post(
CHAT_ENDPOINT,
json={"message": message, "top_k": top_k, "explain": explain},
timeout=API_TIMEOUT
)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e)}
def analyze_draft_text(text: str, n_type: str):
try:
response = requests.post(
INGEST_ANALYZE_ENDPOINT,
json={"text": text, "type": n_type},
timeout=15
)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e)}
def save_draft_to_vault(markdown_content: str, filename: str = None):
try:
response = requests.post(
INGEST_SAVE_ENDPOINT,
json={"markdown_content": markdown_content, "filename": filename},
timeout=API_TIMEOUT
)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e)}
def submit_feedback(query_id, node_id, score, comment=None):
try:
requests.post(FEEDBACK_ENDPOINT, json={"query_id": query_id, "node_id": node_id, "score": score, "comment": comment}, timeout=2)
st.toast(f"Feedback ({score}) gesendet!")
except: pass
# --- UI COMPONENTS ---
def render_sidebar():
with st.sidebar:
st.title("🧠 mindnet")
st.caption("v2.5 | Healing Parser")
mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor"], 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")
for q in load_history_from_logs(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):
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
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"
# --- 1. 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"
if "title" not in meta: meta["title"] = ""
tags = meta.get("tags", [])
meta["tags_str"] = ", ".join(tags) if isinstance(tags, list) else str(tags)
# Persistent Data
st.session_state[data_meta_key] = meta
st.session_state[data_sugg_key] = []
st.session_state[data_body_key] = body.strip()
# Init Widgets Keys
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
# --- 2. RESURRECTION ---
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 ---
def _sync_meta():
meta = st.session_state[data_meta_key]
meta["title"] = st.session_state.get(f"{key_base}_wdg_title", "")
meta["type"] = st.session_state.get(f"{key_base}_wdg_type", "default")
meta["tags_str"] = st.session_state.get(f"{key_base}_wdg_tags", "")
st.session_state[data_meta_key] = meta
def _sync_body():
st.session_state[data_body_key] = st.session_state[widget_body_key]
def _insert_text(text_to_insert):
current = st.session_state.get(widget_body_key, "")
new_text = f"{current}\n\n{text_to_insert}"
st.session_state[widget_body_key] = new_text
st.session_state[data_body_key] = new_text
def _remove_text(text_to_remove):
current = st.session_state.get(widget_body_key, "")
new_text = current.replace(text_to_remove, "").strip()
st.session_state[widget_body_key] = new_text
st.session_state[data_body_key] = new_text
# --- UI LAYOUT ---
st.markdown(f'<div class="draft-box">', unsafe_allow_html=True)
st.markdown("### 📝 Entwurf bearbeiten")
meta_ref = st.session_state[data_meta_key]
c1, c2 = st.columns([2, 1])
with c1:
st.text_input("Titel", key=f"{key_base}_wdg_title", on_change=_sync_meta)
with c2:
known_types = ["concept", "project", "decision", "experience", "journal", "value", "goal", "principle", "risk", "belief"]
curr_type = st.session_state.get(f"{key_base}_wdg_type", meta_ref["type"])
if curr_type not in known_types: known_types.append(curr_type)
st.selectbox("Typ", known_types, key=f"{key_base}_wdg_type", on_change=_sync_meta)
st.text_input("Tags", key=f"{key_base}_wdg_tags", on_change=_sync_meta)
tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"])
# --- TAB 1: EDITOR ---
with tab_edit:
st.text_area(
"Body",
key=widget_body_key,
height=500,
on_change=_sync_body,
label_visibility="collapsed"
)
# --- TAB 2: INTELLIGENCE ---
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, ""))
current_doc_type = st.session_state.get(f"{key_base}_wdg_type", "concept")
with st.spinner("Analysiere..."):
analysis = analyze_draft_text(text_to_analyze, current_doc_type)
if "error" in analysis:
st.error(f"Fehler: {analysis['error']}")
else:
suggestions = analysis.get("suggestions", [])
st.session_state[data_sugg_key] = suggestions
if not suggestions:
st.warning("Keine Vorschläge gefunden.")
else:
st.success(f"{len(suggestions)} Vorschläge gefunden.")
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
bg_color = "#e6fffa" if is_inserted else "#ffffff"
border = "3px solid #28a745" if is_inserted else "3px solid #1a73e8"
st.markdown(f"""
<div style="border-left: {border}; background-color: {bg_color}; padding: 10px; margin-bottom: 8px; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<b>{sugg.get('target_title')}</b> <small>({sugg.get('type')})</small><br>
<i>{sugg.get('reason')}</i><br>
<code>{link_text}</code>
</div>
""", 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,))
# --- TAB 3: SAVE ---
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"),
"title": st.session_state.get(f"{key_base}_wdg_title", "").strip(),
"status": "draft",
"tags": final_tags
}
final_body = st.session_state.get(widget_body_key, st.session_state[data_body_key])
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()
final_doc = build_markdown_doc(final_meta, final_body)
with tab_view:
st.markdown('<div class="preview-box">', unsafe_allow_html=True)
st.markdown(final_doc)
st.markdown('</div>', unsafe_allow_html=True)
st.markdown("---")
b1, b2 = st.columns([1, 1])
with b1:
if st.button("💾 Speichern & Indizieren", type="primary", key=f"{key_base}_save"):
with st.spinner("Speichere im Vault..."):
raw_title = final_meta.get("title", "")
if not raw_title:
clean_body = re.sub(r"[#*_\[\]()]", "", final_body).strip()
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:
st.success(f"Gespeichert: {result.get('file_path')}")
st.balloons()
with b2:
if st.button("📋 Code anzeigen", key=f"{key_base}_btn_copy"):
st.code(final_doc, language="markdown")
st.markdown("</div>", unsafe_allow_html=True)
def render_chat_interface(top_k, explain):
for idx, msg in enumerate(st.session_state.messages):
with st.chat_message(msg["role"]):
if msg["role"] == "assistant":
intent = msg.get("intent", "UNKNOWN")
src = msg.get("intent_source", "?")
icon = {"EMPATHY":"❤️", "DECISION":"⚖️", "CODING":"💻", "FACT":"📚", "INTERVIEW":"📝"}.get(intent, "🧠")
st.markdown(f'<div class="intent-badge">{icon} Intent: {intent} <span style="opacity:0.6; font-size:0.8em">({src})</span></div>', unsafe_allow_html=True)
with st.expander("🐞 Debug Raw Payload", expanded=False):
st.json(msg)
if intent == "INTERVIEW":
render_draft_editor(msg)
else:
st.markdown(msg["content"])
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']}")
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)
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"])
if prompt := st.chat_input("Frage Mindnet..."):
st.session_state.messages.append({"role": "user", "content": prompt})
st.rerun()
if len(st.session_state.messages) > 0 and st.session_state.messages[-1]["role"] == "user":
with st.chat_message("assistant"):
with st.spinner("Thinking..."):
resp = send_chat_message(st.session_state.messages[-1]["content"], top_k, explain)
if "error" in resp:
st.error(resp["error"])
else:
st.session_state.messages.append({
"role": "assistant",
"content": resp.get("answer"),
"intent": resp.get("intent", "FACT"),
"intent_source": resp.get("intent_source", "Unknown"),
"sources": resp.get("sources", []),
"query_id": resp.get("query_id")
})
st.rerun()
def render_manual_editor():
mock_msg = {
"content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n",
"query_id": "manual_mode_v2"
}
render_draft_editor(mock_msg)
# --- MAIN ---
mode, top_k, explain = render_sidebar() mode, top_k, explain = render_sidebar()
if mode == "💬 Chat": if mode == "💬 Chat":
render_chat_interface(top_k, explain) render_chat_interface(top_k, explain)
else: elif mode == "📝 Manueller Editor":
render_manual_editor() render_manual_editor()
elif mode == "🕸️ Graph (Agraph)":
render_graph_agraph(graph_service)
elif mode == "🕸️ Graph (Cytoscape)":
render_graph_explorer_cytoscape(graph_service)

37
app/frontend/ui_api.py Normal file
View File

@ -0,0 +1,37 @@
import requests
import streamlit as st
from ui_config import CHAT_ENDPOINT, INGEST_ANALYZE_ENDPOINT, INGEST_SAVE_ENDPOINT, FEEDBACK_ENDPOINT, API_TIMEOUT
def send_chat_message(message: str, top_k: int, explain: bool):
try:
response = requests.post(
CHAT_ENDPOINT,
json={"message": message, "top_k": top_k, "explain": explain},
timeout=API_TIMEOUT
)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e)}
def analyze_draft_text(text: str, n_type: str):
try:
response = requests.post(INGEST_ANALYZE_ENDPOINT, json={"text": text, "type": n_type}, timeout=15)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e)}
def save_draft_to_vault(markdown_content: str, filename: str = None):
try:
response = requests.post(INGEST_SAVE_ENDPOINT, json={"markdown_content": markdown_content, "filename": filename}, timeout=API_TIMEOUT)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e)}
def submit_feedback(query_id, node_id, score, comment=None):
try:
requests.post(FEEDBACK_ENDPOINT, json={"query_id": query_id, "node_id": node_id, "score": score, "comment": comment}, timeout=2)
st.toast(f"Feedback ({score}) gesendet!")
except: pass

View File

@ -0,0 +1,61 @@
import streamlit as st
import os
from ui_utils import build_markdown_doc
def switch_to_editor_callback(note_payload):
"""
Callback für den 'Bearbeiten'-Button im Graphen.
Versucht, die Datei direkt aus dem Vault (Dateisystem) zu lesen.
Das garantiert, dass Frontmatter und Inhalt vollständig sind (Single Source of Truth).
"""
# 1. Pfad ermitteln (Priorität auf 'path' aus Qdrant)
origin_fname = note_payload.get('path')
# Fallback für Legacy-Datenfelder
if not origin_fname:
origin_fname = note_payload.get('file_path') or note_payload.get('filename')
content = ""
file_loaded = False
# 2. Versuch: Direkt von der Festplatte lesen
# Wir prüfen, ob der Pfad existiert und lesen den aktuellen Stand der Datei.
if origin_fname and os.path.exists(origin_fname):
try:
with open(origin_fname, "r", encoding="utf-8") as f:
content = f.read()
file_loaded = True
except Exception as e:
# Fehler im Terminal loggen, aber UI nicht crashen lassen
print(f"Fehler beim Lesen von {origin_fname}: {e}")
# 3. Fallback: Inhalt aus Qdrant nehmen (wenn Datei nicht zugreifbar)
if not file_loaded:
# Wir nehmen 'fulltext' aus dem Payload
content = note_payload.get('fulltext', '')
if not content:
# Letzter Ausweg: Metadaten nehmen und Dummy-Content bauen
content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (Datei nicht gefunden).")
else:
# Check: Hat der Text ein Frontmatter? Wenn nein, rekonstruieren wir es.
if not content.strip().startswith("---"):
content = build_markdown_doc(note_payload, content)
# Notfall-Pfad Konstruktion (falls gar kein Pfad im System ist)
if not origin_fname and 'note_id' in note_payload:
origin_fname = f"{note_payload['note_id']}.md"
# 4. Daten an den Editor übergeben
# Wir nutzen den Chat-Verlauf als Transportmittel für den State
st.session_state.messages.append({
"role": "assistant",
"intent": "INTERVIEW",
"content": content,
"query_id": f"edit_{note_payload.get('note_id', 'unknown')}", # Trigger für den Editor
"origin_filename": origin_fname,
"origin_note_id": note_payload.get('note_id')
})
# 5. Modus umschalten (wechselt den Tab beim nächsten Rerun)
st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor"

78
app/frontend/ui_chat.py Normal file
View File

@ -0,0 +1,78 @@
import streamlit as st
from ui_api import send_chat_message, submit_feedback
from ui_editor import render_draft_editor
def render_chat_interface(top_k, explain):
"""
Rendert das Chat-Interface.
Zeigt Nachrichten an und behandelt User-Input.
"""
# 1. Verlauf anzeigen
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")
st.markdown(f'<div class="intent-badge">Intent: {intent}</div>', unsafe_allow_html=True)
# Debugging (optional, gut für Entwicklung)
with st.expander("🐞 Payload", expanded=False):
st.json(msg)
# Unterscheidung: Normaler Text oder Editor-Modus (Interview)
if intent == "INTERVIEW":
render_draft_editor(msg)
else:
st.markdown(msg["content"])
# Quellen anzeigen
if "sources" in msg and msg["sources"]:
for hit in msg["sources"]:
score = hit.get('total_score', 0)
# Wenn score None ist, 0.0 annehmen
if score is None: score = 0.0
with st.expander(f"📄 {hit.get('note_id', '?')} ({score:.2f})"):
st.markdown(f"_{hit.get('source', {}).get('text', '')[:300]}..._")
# Explanation Layer
if hit.get('explanation'):
st.caption(f"Grund: {hit['explanation']['reasons'][0]['message']}")
# Feedback Buttons pro Source
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)
# Globales Feedback für die Antwort
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:
# User Nachricht
st.markdown(msg["content"])
# 2. Input Feld
if prompt := st.chat_input("Frage Mindnet..."):
st.session_state.messages.append({"role": "user", "content": prompt})
st.rerun()
# 3. Antwort generieren (wenn letzte Nachricht vom User ist)
if len(st.session_state.messages) > 0 and st.session_state.messages[-1]["role"] == "user":
with st.chat_message("assistant"):
with st.spinner("Thinking..."):
resp = send_chat_message(st.session_state.messages[-1]["content"], top_k, explain)
if "error" in resp:
st.error(resp["error"])
else:
st.session_state.messages.append({
"role": "assistant",
"content": resp.get("answer"),
"intent": resp.get("intent", "FACT"),
"sources": resp.get("sources", []),
"query_id": resp.get("query_id")
})
st.rerun()

79
app/frontend/ui_config.py Normal file
View File

@ -0,0 +1,79 @@
import os
import hashlib
from dotenv import load_dotenv
from pathlib import Path
load_dotenv()
# --- API & PORTS ---
API_BASE_URL = os.getenv("MINDNET_API_URL", "http://localhost:8002")
CHAT_ENDPOINT = f"{API_BASE_URL}/chat"
FEEDBACK_ENDPOINT = f"{API_BASE_URL}/feedback"
INGEST_ANALYZE_ENDPOINT = f"{API_BASE_URL}/ingest/analyze"
INGEST_SAVE_ENDPOINT = f"{API_BASE_URL}/ingest/save"
# --- QDRANT ---
QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
QDRANT_KEY = os.getenv("QDRANT_API_KEY", None)
if QDRANT_KEY == "": QDRANT_KEY = None
COLLECTION_PREFIX = os.getenv("COLLECTION_PREFIX", "mindnet")
# --- FILES & TIMEOUTS ---
HISTORY_FILE = Path("data/logs/search_history.jsonl")
timeout_setting = os.getenv("MINDNET_API_TIMEOUT") or os.getenv("MINDNET_LLM_TIMEOUT")
API_TIMEOUT = float(timeout_setting) if timeout_setting else 300.0
# --- STYLING CONSTANTS ---
# Basierend auf types.yaml
GRAPH_COLORS = {
# 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
}
# 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%)"

213
app/frontend/ui_editor.py Normal file
View File

@ -0,0 +1,213 @@
import streamlit as st
import uuid
import re
from datetime import datetime
from ui_utils import parse_markdown_draft, build_markdown_doc, slugify
from ui_api import save_draft_to_vault, analyze_draft_text
def render_draft_editor(msg):
"""
Rendert den Markdown-Editor.
Nutzt 'origin_filename' aus der Message, um zwischen Update und Neu zu unterscheiden.
"""
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
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 ---
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"
if "title" not in meta: meta["title"] = ""
tags = meta.get("tags", [])
meta["tags_str"] = ", ".join(tags) if isinstance(tags, list) else str(tags)
st.session_state[data_meta_key] = meta
st.session_state[data_sugg_key] = []
st.session_state[data_body_key] = body.strip()
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"]
# Pfad übernehmen (Source of Truth)
st.session_state[f"{key_base}_origin_filename"] = msg.get("origin_filename")
st.session_state[f"{key_base}_init"] = True
# --- RESURRECTION ---
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]
# --- SYNC HELPER ---
def _sync_meta():
meta = st.session_state[data_meta_key]
meta["title"] = st.session_state.get(f"{key_base}_wdg_title", "")
meta["type"] = st.session_state.get(f"{key_base}_wdg_type", "default")
meta["tags_str"] = st.session_state.get(f"{key_base}_wdg_tags", "")
st.session_state[data_meta_key] = meta
def _sync_body():
st.session_state[data_body_key] = st.session_state[widget_body_key]
def _insert_text(t):
st.session_state[widget_body_key] = f"{st.session_state.get(widget_body_key, '')}\n\n{t}"
st.session_state[data_body_key] = st.session_state[widget_body_key]
def _remove_text(t):
st.session_state[widget_body_key] = st.session_state.get(widget_body_key, '').replace(t, "").strip()
st.session_state[data_body_key] = st.session_state[widget_body_key]
# --- UI LAYOUT ---
# Header Info (Debug Pfad anzeigen, damit wir sicher sind)
origin_fname = st.session_state.get(f"{key_base}_origin_filename")
if origin_fname:
# Dateiname extrahieren für saubere Anzeige
display_name = str(origin_fname).split("/")[-1]
st.success(f"📂 **Update-Modus**: `{display_name}`")
# Debugging: Zeige vollen Pfad im Expander
with st.expander("Dateipfad Details", expanded=False):
st.code(origin_fname)
st.markdown(f'<div class="draft-box" style="border-left: 5px solid #ff9f43;">', unsafe_allow_html=True)
else:
st.info("✨ **Erstell-Modus**: Neue Datei wird angelegt.")
st.markdown(f'<div class="draft-box">', unsafe_allow_html=True)
st.markdown("### Editor")
# Meta Felder
meta_ref = st.session_state[data_meta_key]
c1, c2 = st.columns([2, 1])
with c1:
st.text_input("Titel", key=f"{key_base}_wdg_title", on_change=_sync_meta)
with c2:
known_types = ["concept", "project", "decision", "experience", "journal", "value", "goal", "principle", "risk", "belief"]
curr_type = st.session_state.get(f"{key_base}_wdg_type", meta_ref["type"])
if curr_type not in known_types: known_types.append(curr_type)
st.selectbox("Typ", known_types, key=f"{key_base}_wdg_type", 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"])
with tab_edit:
st.text_area("Body", key=widget_body_key, height=600, on_change=_sync_body, label_visibility="collapsed")
with tab_intel:
st.info("Analysiert den Text auf Verknüpfungsmöglichkeiten.")
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, ""))
with st.spinner("Analysiere..."):
analysis = analyze_draft_text(text_to_analyze, st.session_state.get(f"{key_base}_wdg_type", "concept"))
if "error" in analysis:
st.error(f"Fehler: {analysis['error']}")
else:
suggestions = analysis.get("suggestions", [])
st.session_state[data_sugg_key] = suggestions
if not suggestions: st.warning("Keine Vorschläge.")
else: st.success(f"{len(suggestions)} Vorschläge gefunden.")
suggestions = st.session_state[data_sugg_key]
if suggestions:
current_text = 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
bg_color = "#e6fffa" if is_inserted else "#ffffff"
border = "3px solid #28a745" if is_inserted else "3px solid #1a73e8"
st.markdown(f"<div style='border-left: {border}; background-color: {bg_color}; padding: 10px; margin-bottom: 8px;'><b>{sugg.get('target_title')}</b> <small>({sugg.get('type')})</small><br><i>{sugg.get('reason')}</i><br><code>{link_text}</code></div>", 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,))
# Save Logic Preparation
final_tags = [t.strip() for t in st.session_state.get(f"{key_base}_wdg_tags", "").split(",") if t.strip()]
final_meta = {
"id": "generated_on_save",
"type": st.session_state.get(f"{key_base}_wdg_type", "default"),
"title": st.session_state.get(f"{key_base}_wdg_title", "").strip(),
"status": "draft",
"tags": final_tags
}
if "origin_note_id" in msg:
final_meta["id"] = msg["origin_note_id"]
final_body = st.session_state.get(widget_body_key, st.session_state[data_body_key])
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()
final_doc = build_markdown_doc(final_meta, final_body)
with tab_view:
st.markdown('<div class="preview-box">', unsafe_allow_html=True)
st.markdown(final_doc)
st.markdown('</div>', unsafe_allow_html=True)
st.markdown("---")
# Save Actions
b1, b2 = st.columns([1, 1])
with b1:
save_label = "💾 Update speichern" if origin_fname else "💾 Neu anlegen & Indizieren"
if st.button(save_label, type="primary", key=f"{key_base}_save"):
with st.spinner("Speichere im Vault..."):
if origin_fname:
# UPDATE: Ziel ist der exakte Pfad
target_file = origin_fname
else:
# CREATE: Neuer Dateiname
raw_title = final_meta.get("title", "draft")
target_file = f"{datetime.now().strftime('%Y%m%d')}-{slugify(raw_title)[:60]}.md"
result = save_draft_to_vault(final_doc, filename=target_file)
if "error" in result:
st.error(f"Fehler: {result['error']}")
else:
st.success(f"Gespeichert: {result.get('file_path')}")
st.balloons()
with b2:
if st.button("📋 Code anzeigen", key=f"{key_base}_btn_copy"):
st.code(final_doc, language="markdown")
st.markdown("</div>", unsafe_allow_html=True)
def render_manual_editor():
"""
Rendert den manuellen Editor.
PRÜFT, ob eine Edit-Anfrage aus dem Graphen vorliegt!
"""
target_msg = None
# 1. Prüfen: Gibt es Nachrichten im Verlauf?
if st.session_state.messages:
last_msg = st.session_state.messages[-1]
# 2. Ist die letzte Nachricht eine Edit-Anfrage? (Erkennbar am query_id prefix 'edit_')
qid = str(last_msg.get("query_id", ""))
if qid.startswith("edit_"):
target_msg = last_msg
# 3. Fallback: Leeres Template, falls keine Edit-Anfrage vorliegt
if not target_msg:
target_msg = {
"content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n",
"query_id": f"manual_{uuid.uuid4()}" # Eigene ID, damit neuer State entsteht
}
render_draft_editor(target_msg)

153
app/frontend/ui_graph.py Normal file
View File

@ -0,0 +1,153 @@
import streamlit as st
from streamlit_agraph import agraph, Config
from qdrant_client import models
from ui_config import COLLECTION_PREFIX, GRAPH_COLORS
from ui_callbacks import switch_to_editor_callback
def render_graph_explorer(graph_service):
st.header("🕸️ Graph Explorer")
# Session State initialisieren
if "graph_center_id" not in st.session_state:
st.session_state.graph_center_id = None
# Defaults für View & Physik setzen
st.session_state.setdefault("graph_depth", 2)
st.session_state.setdefault("graph_show_labels", True)
# Höhere Default-Werte für Abstand
st.session_state.setdefault("graph_spacing", 250)
st.session_state.setdefault("graph_gravity", -4000)
col_ctrl, col_graph = st.columns([1, 4])
# --- LINKE SPALTE: CONTROLS ---
with col_ctrl:
st.subheader("Fokus")
# Sucheingabe
search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...")
# Suchlogik Qdrant
if search_term:
hits, _ = graph_service.client.scroll(
collection_name=f"{COLLECTION_PREFIX}_notes",
scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))]),
limit=10
)
options = {h.payload['title']: h.payload['note_id'] for h in hits}
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()
# Layout & Physik Einstellungen
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_show_labels = st.checkbox("Kanten-Beschriftung", st.session_state.graph_show_labels)
st.markdown("**Physik (BarnesHut)**")
st.session_state.graph_spacing = st.slider("Federlänge (Abstand)", 50, 800, st.session_state.graph_spacing)
st.session_state.graph_gravity = st.slider("Abstoßung (Gravity)", -20000, -500, st.session_state.graph_gravity)
if st.button("Reset Layout"):
st.session_state.graph_spacing = 250
st.session_state.graph_gravity = -4000
st.rerun()
st.divider()
st.caption("Legende (Top Typen)")
for k, v in list(GRAPH_COLORS.items())[:8]:
st.markdown(f"<span style='color:{v}'>●</span> {k}", unsafe_allow_html=True)
# --- RECHTE SPALTE: GRAPH & ACTION BAR ---
with col_graph:
center_id = st.session_state.graph_center_id
if center_id:
# Action Container oben fixieren (Layout Fix)
action_container = st.container()
# Graph und Daten laden
with st.spinner(f"Lade Graph..."):
nodes, edges = graph_service.get_ego_graph(
center_id,
depth=st.session_state.graph_depth,
show_labels=st.session_state.graph_show_labels
)
# WICHTIG: Daten für Editor holen (inkl. Pfad)
note_data = graph_service.get_note_with_full_content(center_id)
# Action Bar rendern
with action_container:
c1, c2 = st.columns([3, 1])
with c1:
st.caption(f"Aktives Zentrum: **{center_id}**")
with c2:
if note_data:
st.button("📝 Bearbeiten",
use_container_width=True,
on_click=switch_to_editor_callback,
args=(note_data,))
else:
st.error("Datenfehler: Note nicht gefunden")
# Debug Inspector
with st.expander("🕵️ Data Inspector", expanded=False):
if note_data:
st.json(note_data)
if 'path' in note_data:
st.success(f"Pfad OK: {note_data['path']}")
else:
st.error("Pfad fehlt!")
else:
st.info("Leer.")
if not nodes:
st.warning("Keine Daten gefunden.")
else:
# --- CONFIGURATION (BarnesHut) ---
# Height-Trick für Re-Render (da key-Parameter manchmal crasht)
dyn_height = 800 + (abs(st.session_state.graph_gravity) % 5)
config = Config(
width=1000,
height=dyn_height,
directed=True,
physics={
"enabled": True,
"solver": "barnesHut",
"barnesHut": {
"gravitationalConstant": st.session_state.graph_gravity,
"centralGravity": 0.005, # Extrem wichtig für die Ausbreitung!
"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 (Klick auf Node)
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:
st.info("👈 Bitte wähle links eine Notiz aus, um den Graphen zu starten.")

View File

@ -0,0 +1,325 @@
import streamlit as st
from st_cytoscape import cytoscape
from qdrant_client import models
from ui_config import COLLECTION_PREFIX, GRAPH_COLORS
from ui_callbacks import switch_to_editor_callback
def update_url_params():
"""Callback: Schreibt Slider-Werte in die URL und synchronisiert den State."""
# Werte aus den Slider-Keys in die Logik-Variablen übertragen
if "cy_depth_slider" in st.session_state:
st.session_state.cy_depth = st.session_state.cy_depth_slider
if "cy_len_slider" in st.session_state:
st.session_state.cy_ideal_edge_len = st.session_state.cy_len_slider
if "cy_rep_slider" in st.session_state:
st.session_state.cy_node_repulsion = st.session_state.cy_rep_slider
# In URL schreiben
st.query_params["depth"] = st.session_state.cy_depth
st.query_params["len"] = st.session_state.cy_ideal_edge_len
st.query_params["rep"] = st.session_state.cy_node_repulsion
def render_graph_explorer_cytoscape(graph_service):
st.header("🕸️ Graph Explorer (Cytoscape)")
# ---------------------------------------------------------
# 1. STATE & PERSISTENZ
# ---------------------------------------------------------
if "graph_center_id" not in st.session_state:
st.session_state.graph_center_id = None
if "graph_inspected_id" not in st.session_state:
st.session_state.graph_inspected_id = None
# Lade Einstellungen aus der URL (falls vorhanden), sonst Defaults
params = st.query_params
# Helper um sicher int zu parsen
def get_param(key, default):
try: return int(params.get(key, default))
except: return default
# Initialisiere Session State Variablen, falls noch nicht vorhanden
if "cy_depth" not in st.session_state:
st.session_state.cy_depth = get_param("depth", 2)
if "cy_ideal_edge_len" not in st.session_state:
st.session_state.cy_ideal_edge_len = get_param("len", 150)
if "cy_node_repulsion" not in st.session_state:
st.session_state.cy_node_repulsion = get_param("rep", 1000000)
col_ctrl, col_graph = st.columns([1, 4])
# ---------------------------------------------------------
# 2. LINKES PANEL (Steuerung)
# ---------------------------------------------------------
with col_ctrl:
st.subheader("Fokus")
search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search")
if search_term:
hits, _ = graph_service.client.scroll(
collection_name=f"{COLLECTION_PREFIX}_notes",
limit=10,
scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))])
)
options = {h.payload['title']: h.payload['note_id'] for h in hits}
if options:
selected_title = st.selectbox("Ergebnisse:", list(options.keys()), key="cy_select")
if st.button("Laden", use_container_width=True, key="cy_load"):
new_id = options[selected_title]
st.session_state.graph_center_id = new_id
st.session_state.graph_inspected_id = new_id
st.rerun()
st.divider()
# LAYOUT EINSTELLUNGEN (Mit URL Sync)
with st.expander("👁️ Layout Einstellungen", expanded=True):
st.slider("Tiefe (Tier)", 1, 3,
value=st.session_state.cy_depth,
key="cy_depth_slider",
on_change=update_url_params)
st.markdown("**COSE Layout**")
st.slider("Kantenlänge", 50, 600,
value=st.session_state.cy_ideal_edge_len,
key="cy_len_slider",
on_change=update_url_params)
st.slider("Knoten-Abstoßung", 100000, 5000000, step=100000,
value=st.session_state.cy_node_repulsion,
key="cy_rep_slider",
on_change=update_url_params)
if st.button("Neu berechnen", key="cy_rerun"):
st.rerun()
st.divider()
st.caption("Legende")
for k, v in list(GRAPH_COLORS.items())[:8]:
st.markdown(f"<span style='color:{v}'>●</span> {k}", unsafe_allow_html=True)
# ---------------------------------------------------------
# 3. RECHTES PANEL (GRAPH & INSPECTOR)
# ---------------------------------------------------------
with col_graph:
center_id = st.session_state.graph_center_id
# Fallback Init
if not center_id and st.session_state.graph_inspected_id:
center_id = st.session_state.graph_inspected_id
st.session_state.graph_center_id = center_id
if center_id:
# Sync Inspection
if not st.session_state.graph_inspected_id:
st.session_state.graph_inspected_id = center_id
inspected_id = st.session_state.graph_inspected_id
# --- DATEN LADEN ---
with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."):
# 1. Graph Daten
nodes_data, edges_data = graph_service.get_ego_graph(
center_id,
depth=st.session_state.cy_depth
)
# 2. Detail Daten (Inspector)
inspected_data = graph_service.get_note_with_full_content(inspected_id)
# --- ACTION BAR ---
action_container = st.container()
with action_container:
c1, c2, c3 = st.columns([2, 1, 1])
with c1:
title_show = inspected_data.get('title', inspected_id) if inspected_data else inspected_id
st.info(f"**Ausgewählt:** {title_show}")
with c2:
# NAVIGATION
if inspected_id != center_id:
if st.button("🎯 Als Zentrum setzen", use_container_width=True, key="cy_nav_btn"):
st.session_state.graph_center_id = inspected_id
st.rerun()
else:
st.caption("_(Ist aktuelles Zentrum)_")
with c3:
# EDITIEREN
if inspected_data:
st.button("📝 Bearbeiten",
use_container_width=True,
on_click=switch_to_editor_callback,
args=(inspected_data,),
key="cy_edit_btn")
# --- DATA INSPECTOR ---
with st.expander("🕵️ Data Inspector (Details)", expanded=False):
if inspected_data:
col_i1, col_i2 = st.columns(2)
with col_i1:
st.markdown(f"**ID:** `{inspected_data.get('note_id')}`")
st.markdown(f"**Typ:** `{inspected_data.get('type')}`")
with col_i2:
st.markdown(f"**Tags:** {', '.join(inspected_data.get('tags', []))}")
path_check = "" if inspected_data.get('path') else ""
st.markdown(f"**Pfad:** {path_check}")
st.caption("Inhalt (Vorschau):")
st.text_area("Content Preview", inspected_data.get('fulltext', '')[:1000], height=200, disabled=True, label_visibility="collapsed")
with st.expander("📄 Raw JSON anzeigen"):
st.json(inspected_data)
else:
st.warning("Keine Daten geladen.")
# --- GRAPH ELEMENTS ---
cy_elements = []
for n in nodes_data:
is_center = (n.id == center_id)
is_inspected = (n.id == inspected_id)
tooltip_text = n.title if n.title else n.label
display_label = n.label
if len(display_label) > 15 and " " in display_label:
display_label = display_label.replace(" ", "\n", 1)
cy_node = {
"data": {
"id": n.id,
"label": display_label,
"bg_color": n.color,
"tooltip": tooltip_text
},
# Wir steuern das Aussehen rein über Klassen (.inspected / .center)
"classes": " ".join([c for c in ["center" if is_center else "", "inspected" if is_inspected else ""] if c]),
"selected": False
}
cy_elements.append(cy_node)
for e in edges_data:
target_id = getattr(e, "to", getattr(e, "target", None))
if target_id:
cy_edge = {
"data": {
"source": e.source,
"target": target_id,
"label": e.label,
"line_color": e.color
}
}
cy_elements.append(cy_edge)
# --- STYLESHEET ---
stylesheet = [
{
"selector": "node",
"style": {
"label": "data(label)",
"width": "30px", "height": "30px",
"background-color": "data(bg_color)",
"color": "#333", "font-size": "12px",
"text-valign": "center", "text-halign": "center",
"text-wrap": "wrap", "text-max-width": "90px",
"border-width": 2, "border-color": "#fff",
"title": "data(tooltip)"
}
},
# Inspiziert (Gelber Rahmen)
{
"selector": ".inspected",
"style": {
"border-width": 6,
"border-color": "#FFC300",
"width": "50px", "height": "50px",
"font-weight": "bold",
"z-index": 999
}
},
# Zentrum (Roter Rahmen)
{
"selector": ".center",
"style": {
"border-width": 4,
"border-color": "#FF5733",
"width": "40px", "height": "40px"
}
},
# Mix
{
"selector": ".center.inspected",
"style": {
"border-width": 6,
"border-color": "#FF5733",
"width": "55px", "height": "55px"
}
},
# Default Selection unterdrücken
{
"selector": "node:selected",
"style": {
"border-width": 0,
"overlay-opacity": 0
}
},
{
"selector": "edge",
"style": {
"width": 2,
"line-color": "data(line_color)",
"target-arrow-color": "data(line_color)",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
"label": "data(label)",
"font-size": "10px", "color": "#666",
"text-background-opacity": 0.8, "text-background-color": "#fff"
}
}
]
# --- RENDER ---
graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}"
clicked_elements = cytoscape(
elements=cy_elements,
stylesheet=stylesheet,
layout={
"name": "cose",
"idealEdgeLength": st.session_state.cy_ideal_edge_len,
"nodeOverlap": 20,
"refresh": 20,
"fit": True,
"padding": 50,
"randomize": False,
"componentSpacing": 100,
"nodeRepulsion": st.session_state.cy_node_repulsion,
"edgeElasticity": 100,
"nestingFactor": 5,
"gravity": 80,
"numIter": 1000,
"initialTemp": 200,
"coolingFactor": 0.95,
"minTemp": 1.0,
"animate": False
},
key=graph_key,
height="700px"
)
# --- EVENT HANDLING ---
if clicked_elements:
clicked_nodes = clicked_elements.get("nodes", [])
if clicked_nodes:
clicked_id = clicked_nodes[0]
if clicked_id != st.session_state.graph_inspected_id:
st.session_state.graph_inspected_id = clicked_id
st.rerun()
else:
st.info("👈 Bitte wähle links eine Notiz aus.")

View File

@ -0,0 +1,312 @@
import re
from qdrant_client import QdrantClient, models
from streamlit_agraph import Node, Edge
from ui_config import GRAPH_COLORS, get_edge_color, SYSTEM_EDGES
class GraphExplorerService:
def __init__(self, url, api_key=None, prefix="mindnet"):
self.client = QdrantClient(url=url, api_key=api_key)
self.prefix = prefix
self.notes_col = f"{prefix}_notes"
self.chunks_col = f"{prefix}_chunks"
self.edges_col = f"{prefix}_edges"
self._note_cache = {}
def get_note_with_full_content(self, note_id):
"""
Lädt die Metadaten der Note und rekonstruiert den gesamten Text
aus den Chunks (Stitching). Wichtig für den Editor-Fallback.
"""
# 1. Metadaten holen
meta = self._fetch_note_cached(note_id)
if not meta: return None
# 2. Volltext aus Chunks bauen
full_text = self._fetch_full_text_stitched(note_id)
# 3. Ergebnis kombinieren (Wir überschreiben das 'fulltext' Feld mit dem frischen Stitching)
# Wir geben eine Kopie zurück, um den Cache nicht zu verfälschen
complete_note = meta.copy()
if full_text:
complete_note['fulltext'] = full_text
return complete_note
def get_ego_graph(self, center_note_id: str, depth=2, show_labels=True):
"""
Erstellt den Ego-Graphen um eine zentrale Notiz.
Lädt Volltext für das Zentrum und Snippets für Nachbarn.
"""
nodes_dict = {}
unique_edges = {}
# 1. Center Note laden
center_note = self._fetch_note_cached(center_note_id)
if not center_note: return [], []
self._add_node_to_dict(nodes_dict, center_note, level=0)
# Initialset für Suche
level_1_ids = {center_note_id}
# Suche Kanten für Center (L1)
l1_edges = self._find_connected_edges([center_note_id], center_note.get("title"))
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)
# Level 2 Suche (begrenzt für Performance)
if depth > 1 and len(level_1_ids) > 1 and len(level_1_ids) < 80:
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)
# --- SMART CONTENT LOADING ---
# A. Fulltext für Center Node holen (Chunks zusammenfügen)
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
clean_full = self._clean_markdown(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}..."
# B. Previews für alle Nachbarn holen (Batch)
all_ids = list(nodes_dict.keys())
previews = self._fetch_previews_for_nodes(all_ids)
for nid, node_obj in nodes_dict.items():
if nid != center_note_id:
prev_raw = previews.get(nid, "Kein Vorschau-Text.")
clean_prev = self._clean_markdown(prev_raw[:600])
node_obj.title = f"{node_obj.title}\n\n🔍 VORSCHAU:\n{clean_prev}..."
# Graphen bauen (Nodes & Edges finalisieren)
final_edges = []
for (src, tgt), data in unique_edges.items():
kind = data['kind']
prov = data['provenance']
color = get_edge_color(kind)
is_smart = (prov != "explicit" and prov != "rule")
# Label Logik
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}"
))
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):
"""Lädt alle Chunks einer Note und baut den Text zusammen."""
try:
scroll_filter = models.Filter(
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)
# Sortieren nach 'ord' (Reihenfolge im Dokument)
chunks.sort(key=lambda x: x.payload.get('ord', 999))
full_text = []
for c in chunks:
# 'text' ist der reine Inhalt ohne Overlap
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 Batch-weise den ersten Chunk 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))])
# Limit = Anzahl Nodes * 3 (Puffer)
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 gefundenen Chunk pro Note nehmen
if nid and nid not in previews:
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 eingehende und ausgehende Kanten."""
results = []
# 1. OUTGOING EDGES (Der "Owner"-Fix)
# Wir suchen Kanten, die im Feld 'note_id' (Owner) eine unserer Notizen haben.
# Das findet ALLE ausgehenden Kanten, egal ob sie an einem Chunk oder der Note hängen.
if note_ids:
out_filter = models.Filter(must=[
models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids)),
models.FieldCondition(key="kind", match=models.MatchExcept(**{"except": SYSTEM_EDGES}))
])
# Limit hoch, um alles zu finden
res_out, _ = self.client.scroll(self.edges_col, scroll_filter=out_filter, limit=500, with_payload=True)
results.extend(res_out)
# 2. INCOMING EDGES (Ziel = Chunk ID oder Titel oder Note ID)
# Hier müssen wir Chunks auflösen, um Treffer auf Chunks zu finden.
# Chunk IDs der aktuellen Notes holen
chunk_ids = []
if note_ids:
c_filter = models.Filter(must=[models.FieldCondition(key="note_id", match=models.MatchAny(any=note_ids))])
chunks, _ = self.client.scroll(self.chunks_col, scroll_filter=c_filter, limit=300)
chunk_ids = [c.id for c in chunks]
shoulds = []
# Case A: Edge zeigt auf einen unserer Chunks
if chunk_ids:
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=chunk_ids)))
# Case B: Edge zeigt direkt auf unsere Note ID
if note_ids:
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=note_ids)))
# Case C: Edge zeigt auf unseren Titel (Wikilinks)
if note_title:
shoulds.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=note_title)))
if shoulds:
in_filter = 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_filter, limit=500, with_payload=True)
results.extend(res_in)
return results
def _find_connected_edges_batch(self, note_ids):
# Wrapper für Level 2 Suche
return self._find_connected_edges(note_ids)
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
src_ref = payload.get("source_id")
tgt_ref = payload.get("target_id")
kind = payload.get("kind")
provenance = payload.get("provenance", "explicit")
# IDs zu Notes auflösen
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:
# Nodes hinzufügen
self._add_node_to_dict(nodes_dict, src_note, level=current_depth)
self._add_node_to_dict(nodes_dict, tgt_note, level=current_depth)
# Kante hinzufügen (mit Deduplizierung)
key = (src_id, tgt_id)
existing = unique_edges.get(key)
should_update = True
# Bevorzuge explizite Kanten vor Smart Kanten
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))]),
limit=1, with_payload=True
)
if res:
self._note_cache[note_id] = res[0].payload
return res[0].payload
return None
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
# Fall A: Chunk ID (enthält #)
if "#" in ref_str:
try:
# Versuch 1: Chunk ID direkt
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
# Versuch 2: NoteID#Section (Hash abtrennen)
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 direkt
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))]),
limit=1, with_payload=True
)
if res:
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, level=1):
nid = note_payload.get("note_id")
if nid in node_dict: return
ntype = note_payload.get("type", "default")
color = GRAPH_COLORS.get(ntype, GRAPH_COLORS["default"])
# Basis-Tooltip (wird später erweitert)
tooltip = f"Titel: {note_payload.get('title')}\nTyp: {ntype}"
if level == 0: size = 45
elif level == 1: size = 25
else: size = 15
node_dict[nid] = Node(
id=nid,
label=note_payload.get('title', nid),
size=size,
color=color,
shape="dot" if level > 0 else "diamond",
title=tooltip,
font={'color': 'black', 'face': 'arial', 'size': 14 if level < 2 else 0}
)

View File

@ -0,0 +1,36 @@
import streamlit as st
from ui_utils import load_history_from_logs
from ui_config import HISTORY_FILE
def render_sidebar():
with st.sidebar:
st.title("🧠 mindnet")
st.caption("v2.6 | WP-19 Graph View")
if "sidebar_mode_selection" not in st.session_state:
st.session_state["sidebar_mode_selection"] = "💬 Chat"
mode = st.radio(
"Modus",
[
"💬 Chat",
"📝 Manueller Editor",
"🕸️ Graph (Agraph)",
"🕸️ Graph (Cytoscape)" # <-- Neuer Punkt
],
key="sidebar_mode_selection"
)
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")
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

137
app/frontend/ui_utils.py Normal file
View File

@ -0,0 +1,137 @@
import re
import yaml
import unicodedata
import json
from datetime import datetime
def slugify(value):
if not value: return ""
value = str(value).lower()
replacements = {'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss', '&': 'und', '+': 'und'}
for k, v in replacements.items():
value = value.replace(k, v)
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
value = re.sub(r'[^\w\s-]', '', value).strip()
return re.sub(r'[-\s]+', '-', value)
def normalize_meta_and_body(meta, body):
ALLOWED_KEYS = {"title", "type", "status", "tags", "id", "created", "updated", "aliases", "lang"}
clean_meta = {}
extra_content = []
if "titel" in meta and "title" not in meta:
meta["title"] = meta.pop("titel")
tag_candidates = ["tags", "emotionale_keywords", "keywords", "schluesselwoerter"]
all_tags = []
for key in tag_candidates:
if key in meta:
val = meta[key]
if isinstance(val, list): all_tags.extend(val)
elif isinstance(val, str): all_tags.extend([t.strip() for t in val.split(",")])
for key, val in meta.items():
if key in ALLOWED_KEYS:
clean_meta[key] = val
elif key in tag_candidates:
pass
else:
if val and isinstance(val, str):
header = key.replace("_", " ").title()
extra_content.append(f"## {header}\n{val}\n")
if all_tags:
clean_tags = []
for t in all_tags:
t_clean = str(t).replace("#", "").strip()
if t_clean: clean_tags.append(t_clean)
clean_meta["tags"] = list(set(clean_tags))
if extra_content:
new_section = "\n".join(extra_content)
final_body = f"{new_section}\n{body}"
else:
final_body = body
return clean_meta, final_body
def parse_markdown_draft(full_text):
clean_text = full_text.strip()
pattern_block = r"```(?:markdown|md|yaml)?\s*(.*?)\s*```"
match_block = re.search(pattern_block, clean_text, re.DOTALL | re.IGNORECASE)
if match_block:
clean_text = match_block.group(1).strip()
meta = {}
body = clean_text
yaml_str = ""
parts = re.split(r"^---+\s*$", clean_text, maxsplit=2, flags=re.MULTILINE)
if len(parts) >= 3:
yaml_str = parts[1]
body = parts[2]
elif clean_text.startswith("---"):
fallback_match = re.search(r"^---\s*(.*?)(?=\n#)", clean_text, re.DOTALL | re.MULTILINE)
if fallback_match:
yaml_str = fallback_match.group(1)
body = clean_text.replace(f"---{yaml_str}", "", 1).strip()
if yaml_str:
yaml_str_clean = yaml_str.replace("#", "")
try:
parsed = yaml.safe_load(yaml_str_clean)
if isinstance(parsed, dict):
meta = parsed
except Exception as e:
print(f"YAML Parsing Warning: {e}")
if not meta.get("title"):
h1_match = re.search(r"^#\s+(.*)$", body, re.MULTILINE)
if h1_match:
meta["title"] = h1_match.group(1).strip()
if meta.get("type") == "draft":
meta["status"] = "draft"
meta["type"] = "experience"
return normalize_meta_and_body(meta, body)
def build_markdown_doc(meta, body):
if "id" not in meta or meta["id"] == "generated_on_save":
raw_title = meta.get('title', 'note')
clean_slug = slugify(raw_title)[:50] or "note"
meta["id"] = f"{datetime.now().strftime('%Y%m%d')}-{clean_slug}"
meta["updated"] = datetime.now().strftime("%Y-%m-%d")
ordered_meta = {}
prio_keys = ["id", "type", "title", "status", "tags"]
for k in prio_keys:
if k in meta: ordered_meta[k] = meta.pop(k)
ordered_meta.update(meta)
try:
yaml_str = yaml.dump(ordered_meta, default_flow_style=None, sort_keys=False, allow_unicode=True).strip()
except:
yaml_str = "error: generating_yaml"
return f"---\n{yaml_str}\n---\n\n{body}"
def load_history_from_logs(filepath, limit=10):
queries = []
if filepath.exists():
try:
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
for line in reversed(lines):
try:
entry = json.loads(line)
q = entry.get("query_text")
if q and q not in queries:
queries.append(q)
if len(queries) >= limit: break
except: continue
except: pass
return queries

View File

@ -44,6 +44,7 @@ Das Repository ist in **logische Domänen** unterteilt.
| `03_tech_ingestion_pipeline.md`| **Import.** Ablauflogik (13 Schritte), Chunker-Profile, Smart Edge Allocation. | | `03_tech_ingestion_pipeline.md`| **Import.** Ablauflogik (13 Schritte), Chunker-Profile, Smart Edge Allocation. |
| `03_tech_retrieval_scoring.md` | **Suche.** Die mathematischen Formeln für Scoring, Hybrid Search und Explanation Layer. | | `03_tech_retrieval_scoring.md` | **Suche.** Die mathematischen Formeln für Scoring, Hybrid Search und Explanation Layer. |
| `03_tech_chat_backend.md` | **API & LLM.** Implementation des Routers, Traffic Control (Semaphore) und Feedback-Traceability. | | `03_tech_chat_backend.md` | **API & LLM.** Implementation des Routers, Traffic Control (Semaphore) und Feedback-Traceability. |
| `03_tech_frontend.md` | **UI & Graph.** Architektur des Streamlit-Frontends, State-Management, Cytoscape-Integration und Editor-Logik. |
| `03_tech_configuration.md` | **Config.** Referenztabellen für `.env`, `types.yaml` und `retriever.yaml`. | | `03_tech_configuration.md` | **Config.** Referenztabellen für `.env`, `types.yaml` und `retriever.yaml`. |
### 📂 04_Operations (Betrieb) ### 📂 04_Operations (Betrieb)
@ -77,6 +78,7 @@ Nutze diese Matrix, wenn du ein Workpackage bearbeitest, um die Dokumentation ko
| **Importer / Parsing** | `03_tech_ingestion_pipeline.md` | | **Importer / Parsing** | `03_tech_ingestion_pipeline.md` |
| **Datenbank-Schema** | `03_tech_data_model.md` (Payloads anpassen) | | **Datenbank-Schema** | `03_tech_data_model.md` (Payloads anpassen) |
| **Retrieval / Scoring** | `03_tech_retrieval_scoring.md` (Formeln anpassen) | | **Retrieval / Scoring** | `03_tech_retrieval_scoring.md` (Formeln anpassen) |
| **Frontend / Visualisierung** | 1. `03_tech_frontend.md` (Technische Details)<br>2. `01_chat_usage_guide.md` (Bedienung) |
| **Chat-Logik / Prompts**| 1. `02_concept_ai_personality.md` (Konzept)<br>2. `03_tech_chat_backend.md` (Tech)<br>3. `01_chat_usage_guide.md` (User-Sicht) | | **Chat-Logik / Prompts**| 1. `02_concept_ai_personality.md` (Konzept)<br>2. `03_tech_chat_backend.md` (Tech)<br>3. `01_chat_usage_guide.md` (User-Sicht) |
| **Deployment / Server** | `04_admin_operations.md` | | **Deployment / Server** | `04_admin_operations.md` |
| **Neuen Features (Allg.)**| `06_active_roadmap.md` (Status Update) | | **Neuen Features (Allg.)**| `06_active_roadmap.md` (Status Update) |

View File

@ -1,13 +1,13 @@
--- ---
doc_type: user_manual doc_type: user_manual
audience: user, mindmaster audience: user, mindmaster
scope: chat, ui, feedback scope: chat, ui, feedback, graph
status: active status: active
version: 2.6 version: 2.6
context: "Anleitung zur Nutzung der Web-Oberfläche, der Chat-Personas und des Feedbacks." context: "Anleitung zur Nutzung der Web-Oberfläche, der Chat-Personas und des Graph Explorers."
--- ---
# Chat Usage Guide # Chat & Graph Usage Guide
**Quellen:** `user_guide.md` **Quellen:** `user_guide.md`
@ -36,10 +36,25 @@ Seit Version 2.3.1 bedienst du Mindnet über eine grafische Oberfläche im Brows
* **Grüner Punkt:** Hohe Relevanz (Score > 0.8). * **Grüner Punkt:** Hohe Relevanz (Score > 0.8).
* **Klick darauf:** Zeigt den Textauszug und die **Begründung** (Explanation Layer). * **Klick darauf:** Zeigt den Textauszug und die **Begründung** (Explanation Layer).
### 2.2 Die Sidebar ### 2.2 Modus: 🕸️ Graph Explorer (Cytoscape)
* **Modus-Wahl:** Umschalten zwischen "💬 Chat" und "📝 Manueller Editor". *Neu in v2.6*: Eine interaktive Karte deines Wissens.
* *Feature:* Der Editor nutzt ein "Resurrection Pattern" deine Eingaben bleiben erhalten, auch wenn du den Tab wechselst.
* **Settings:** Hier kannst du `Top-K` (Anzahl der Quellen) und `Explanation Layer` steuern. **Die Farb-Logik:**
* 🔴 **Roter Rahmen:** Das aktuelle **Zentrum** (Ego). Alle geladenen Knoten sind Nachbarn dieses Knotens.
* 🟡 **Gelber Rahmen:** Der **inspizierte** Knoten. Dessen Details siehst du im "Data Inspector" und in der Action-Bar.
**Bedienung:**
1. **Klick auf einen Knoten:** Wählt ihn aus (Gelb). Der Graph bleibt stabil, aber die Info-Leiste oben aktualisiert sich.
2. **Button "🎯 Als Zentrum setzen":** Lädt den Graphen neu und macht den ausgewählten Knoten zum roten Zentrum.
3. **Button "📝 Bearbeiten":** Springt mit dem Inhalt dieses Knotens direkt in den Editor.
**Layout & Persistenz:**
Du kannst in der linken Spalte die Physik (Abstoßung, Kantenlänge) einstellen. Diese Einstellungen werden in der **URL gespeichert**. Du kannst den Link als Lesezeichen speichern, um genau diese Ansicht wiederzufinden.
### 2.3 Modus: 📝 Manueller Editor
Ein Editor mit **"File System First"** Garantie.
* Wenn du eine Datei bearbeitest, liest Mindnet sie direkt von der Festplatte.
* **Resurrection:** Wenn du zwischendurch in den Graph wechselst und zurückkommst, ist dein getippter Text noch da.
--- ---

View File

@ -0,0 +1,160 @@
---
doc_type: technical_reference
audience: developer, frontend_architect
scope: architecture, graph_viz, state_management
status: active
version: 2.6
context: "Technische Dokumentation des modularen Streamlit-Frontends, der Graph-Engines und des Editors."
---
# Technical Reference: Frontend & Visualization
**Kontext:** Mindnet nutzt Streamlit nicht nur als einfaches UI, sondern als komplexe Applikation mit eigenem State-Management, Routing und Persistenz.
---
## 1. Architektur & Modularisierung (WP19)
Seit Version 2.6 ist das Frontend (`app/frontend/`) kein Monolith mehr, sondern in funktionale Module unterteilt.
### 1.1 Dateistruktur & Aufgaben
| Modul | Funktion |
| :--- | :--- |
| `ui.py` | **Router.** Der Entrypoint. Initialisiert Session-State und entscheidet anhand der Sidebar-Auswahl, welche View gerendert wird. |
| `ui_config.py` | **Konstanten.** Zentraler Ort für Farben (`GRAPH_COLORS`), API-Endpunkte und Timeouts. |
| `ui_api.py` | **Backend-Bridge.** Kapselt alle `requests`-Aufrufe an die FastAPI. |
| `ui_callbacks.py` | **State Transitions.** Logik für View-Wechsel (z.B. Sprung vom Graph in den Editor). |
| `ui_utils.py` | **Helper.** Markdown-Parsing (`parse_markdown_draft`) und String-Normalisierung. |
| `ui_graph_service.py`| **Data Logic.** Holt Daten aus Qdrant und bereitet Nodes/Edges auf (unabhängig von der Vis-Library). |
| `ui_graph_cytoscape.py`| **View: Graph.** Implementierung mit `st-cytoscape` (COSE Layout). |
| `ui_editor.py` | **View: Editor.** Logik für Drafts und manuelles Editieren. |
### 1.2 Konfiguration (`ui_config.py`)
Zentrale Steuerung der visuellen Semantik.
# Mapping von Node-Typen zu Farben (Hex)
GRAPH_COLORS = {
"project": "#ff9f43", # Orange
"decision": "#5f27cd", # Lila
"experience": "#feca57", # Gelb (Empathie)
"concept": "#54a0ff", # Blau
"risk": "#ff6b6b" # Rot
}
---
## 2. Graph Visualisierung
Mindnet nutzt primär **Cytoscape** für Stabilität bei großen Graphen.
### 2.1 Engine: Cytoscape (`st-cytoscape`)
* **Algorithmus:** `COSE` (Compound Spring Embedder).
* **Vorteil:** Verhindert Überlappungen aktiv (`nodeRepulsion`).
### 2.2 Architektur-Pattern: "Active Inspector, Passive Graph"
Ein häufiges Problem bei Streamlit-Graphen ist das "Flackern" (Re-Render) bei Klicks. Wir lösen das durch Entkopplung:
1. **Stable Key:** Der React-Key der Komponente hängt *nicht* von der Selektion ab, sondern nur vom Zentrum (`center_id`) und Layout-Settings.
2. **CSS-Selektion:** Wir nutzen **nicht** den nativen `:selected` State (buggy bei Single-Select), sondern injizieren eine CSS-Klasse `.inspected`.
**Stylesheet Implementierung (`ui_graph_cytoscape.py`):**
stylesheet = [
{
"selector": "node",
"style": { "background-color": "data(bg_color)" }
},
# Wir steuern das Highlight manuell über eine Klasse
{
"selector": ".inspected",
"style": {
"border-width": 6,
"border-color": "#FFC300", # Gelb/Gold
"z-index": 999
}
},
# Native Selektion wird unterdrückt/unsichtbar gemacht
{
"selector": "node:selected",
"style": { "overlay-opacity": 0, "border-width": 0 }
}
]
---
## 3. Editor & Single Source of Truth
Ein kritisches Design-Pattern ist der Umgang mit Datenkonsistenz beim Editieren ("File System First").
### 3.1 Das Problem
Qdrant speichert Metadaten und Chunks, aber das Feld `fulltext` im Payload kann veraltet sein oder Formatierungen verlieren.
### 3.2 Die Lösung (Logic Flow)
Der `switch_to_editor_callback` in `ui_callbacks.py` implementiert folgende Kaskade:
def switch_to_editor_callback(note_payload):
# 1. Pfad aus Qdrant Payload lesen
origin_fname = note_payload.get('path')
content = ""
# 2. Versuch: Hard Read von der Festplatte (Source of Truth)
if origin_fname and os.path.exists(origin_fname):
with open(origin_fname, "r", encoding="utf-8") as f:
content = f.read()
else:
# 3. Fallback: Rekonstruktion aus der DB ("Stitching")
# Nur Notfall, falls Docker-Volume fehlt
content = note_payload.get('fulltext', '')
# State setzen (Transport via Message-Bus)
st.session_state.messages.append({
"role": "assistant",
"intent": "INTERVIEW",
"content": content,
"origin_filename": origin_fname
})
st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor"
Dies garantiert, dass der Editor immer den **echten, aktuellen Stand** der Markdown-Datei anzeigt.
---
## 4. State Management Patterns
### 4.1 URL Persistenz (Deep Linking)
Layout-Einstellungen werden in der URL gespeichert, damit sie einen Page-Refresh (F5) überleben.
# ui_graph_cytoscape.py
def update_url_params():
st.query_params["depth"] = st.session_state.cy_depth
st.query_params["rep"] = st.session_state.cy_node_repulsion
# Init
if "cy_depth" not in st.session_state:
st.session_state.cy_depth = int(st.query_params.get("depth", 2))
### 4.2 Resurrection Pattern
Verhindert Datenverlust, wenn der Nutzer während des Tippens den Tab wechselt. Der Editor-Inhalt wird bei jedem Keystroke (`on_change`) in `st.session_state` gespiegelt und beim Neuladen der Komponente von dort wiederhergestellt.
### 4.3 Healing Parser (`ui_utils.py`)
Das LLM liefert oft invalides YAML oder Markdown. Der Parser (`parse_markdown_draft`):
* Repariert fehlende Frontmatter-Trenner (`---`).
* Extrahiert JSON/YAML aus Code-Blöcken.
* Normalisiert Tags (entfernt `#`).
---
## 5. Constraints & Security (Known Limitations)
### 5.1 File System Security
Der Editor ("File System First") vertraut dem Pfad im Qdrant-Feld `path`.
* **Risiko:** Path Traversal (z.B. `../../etc/passwd`).
* **Mitigation:** Aktuell findet keine strikte Prüfung statt, ob der Pfad innerhalb des `./vault` Ordners liegt. Das System setzt voraus, dass die Vektor-Datenbank eine **Trusted Source** ist und nur vom internen Importer befüllt wird.
* **ToDo:** Bei Öffnung der API für Dritte muss hier eine `Path.resolve().is_relative_to(VAULT_ROOT)` Prüfung implementiert werden.
### 5.2 Browser Performance
Die Graph-Visualisierung (`st-cytoscape`) rendert Client-seitig im Browser.
* **Limit:** Ab ca. **500 Knoten/Kanten** kann das Rendering träge werden.
* **Design-Entscheidung:** Das UI ist auf **Ego-Graphen** (Nachbarn eines Knotens) und gefilterte Ansichten ausgelegt, nicht auf die Darstellung des gesamten Knowledge-Graphs ("Whole Brain Visualization").

View File

@ -85,6 +85,7 @@ Environment="STREAMLIT_SERVER_PORT=8501"
Environment="STREAMLIT_SERVER_ADDRESS=0.0.0.0" Environment="STREAMLIT_SERVER_ADDRESS=0.0.0.0"
Environment="STREAMLIT_SERVER_HEADLESS=true" Environment="STREAMLIT_SERVER_HEADLESS=true"
# WICHTIG: Pfad zur neuen Router-Datei (ui.py)
ExecStart=/home/llmadmin/mindnet/.venv/bin/streamlit run app/frontend/ui.py ExecStart=/home/llmadmin/mindnet/.venv/bin/streamlit run app/frontend/ui.py
Restart=always Restart=always
RestartSec=5 RestartSec=5
@ -107,21 +108,28 @@ Führt den Sync stündlich durch. Nutzt `--purge-before-upsert` für Sauberkeit.
### 3.2 Troubleshooting Guide ### 3.2 Troubleshooting Guide
**Fehler: "ModuleNotFoundError: No module named 'st_cytoscape'"**
* Ursache: Alte Dependencies oder falsches Paket installiert.
* Lösung: Environment aktualisieren.
```bash
source .venv/bin/activate
pip uninstall streamlit-cytoscapejs
pip install st-cytoscape
# Oder sicherheitshalber:
pip install -r requirements.txt
```
**Fehler: "Vector dimension error: expected 768, got 384"** **Fehler: "Vector dimension error: expected 768, got 384"**
* Ursache: Alte DB (v2.2), neues Modell (v2.4). * Ursache: Alte DB (v2.2), neues Modell (v2.4).
* Lösung: **Full Reset** (siehe Kap. 4.2). * Lösung: **Full Reset** (siehe Kap. 4.2).
**Fehler: "500 Internal Server Error" (Ollama)**
* Ursache: Timeout bei Cold-Start des Modells.
* Lösung: `MINDNET_LLM_TIMEOUT=300.0` in `.env` setzen.
**Fehler: Import sehr langsam** **Fehler: Import sehr langsam**
* Ursache: Smart Edges sind aktiv und analysieren jeden Chunk. * Ursache: Smart Edges sind aktiv und analysieren jeden Chunk.
* Lösung: `MINDNET_LLM_BACKGROUND_LIMIT` prüfen oder Feature in `types.yaml` deaktivieren. * Lösung: `MINDNET_LLM_BACKGROUND_LIMIT` prüfen oder Feature in `types.yaml` deaktivieren.
**Fehler: UI "Read timed out"** **Fehler: UI "Read timed out"**
* Ursache: Backend braucht für Smart Edges länger als 60s. * Ursache: Backend braucht für Smart Edges länger als 60s.
* Lösung: `MINDNET_API_TIMEOUT=300.0` setzen. * Lösung: `MINDNET_API_TIMEOUT=300.0` in `.env` setzen (oder im Systemd Service).
--- ---

View File

@ -32,6 +32,7 @@ Mindnet läuft in einer verteilten Umgebung (Post-WP15 Setup).
Der Code ist modular in `app` (Logik), `scripts` (CLI) und `config` (Steuerung) getrennt. Der Code ist modular in `app` (Logik), `scripts` (CLI) und `config` (Steuerung) getrennt.
### 2.1 Verzeichnisbaum ### 2.1 Verzeichnisbaum
```text ```text
mindnet/ mindnet/
├── app/ ├── app/
@ -50,8 +51,15 @@ mindnet/
│ │ ├── semantic_analyzer.py# LLM-Filter für Edges (WP15) │ │ ├── semantic_analyzer.py# LLM-Filter für Edges (WP15)
│ │ ├── embeddings_client.py# Async Embeddings (HTTPX) │ │ ├── embeddings_client.py# Async Embeddings (HTTPX)
│ │ └── discovery.py # Intelligence Logic (WP11) │ │ └── discovery.py # Intelligence Logic (WP11)
│ ├── frontend/ │ ├── frontend/ # UI Logic (WP19 Modularisierung)
│ │ └── ui.py # Streamlit App inkl. Healing Parser │ │ ├── ui.py # Main Entrypoint & Routing
│ │ ├── ui_config.py # Styles & Constants
│ │ ├── ui_api.py # Backend Connector
│ │ ├── ui_callbacks.py # State Transitions
│ │ ├── ui_utils.py # Helper & Parsing
│ │ ├── ui_graph_service.py # Graph Data Logic
│ │ ├── ui_graph_cytoscape.py # Modern Graph View (St-Cytoscape)
│ │ └── ui_editor.py # Editor View
│ └── main.py # Entrypoint der API │ └── main.py # Entrypoint der API
├── config/ # YAML-Konfigurationen (Single Source of Truth) ├── config/ # YAML-Konfigurationen (Single Source of Truth)
├── scripts/ # CLI-Tools (Import, Diagnose, Reset) ├── scripts/ # CLI-Tools (Import, Diagnose, Reset)
@ -61,6 +69,12 @@ mindnet/
### 2.2 Modul-Details (Wie es funktioniert) ### 2.2 Modul-Details (Wie es funktioniert)
**Das Frontend (`app.frontend`) - *Neu in v2.6***
* **Router (`ui.py`):** Entscheidet, welche View geladen wird.
* **Service-Layer (`ui_graph_service.py`):** Kapselt die Qdrant-Abfragen. Liefert rohe Nodes/Edges, die dann von den Views visualisiert werden.
* **Graph Engine:** Wir nutzen `st-cytoscape` für das Layout. Die Logik zur Vermeidung von Re-Renders (Stable Keys, CSS-Selektion) ist essentiell.
* **Data Consistency:** Der Editor (`ui_editor.py`) liest Dateien bevorzugt direkt vom Dateisystem ("File System First"), um Datenverlust durch veraltete DB-Einträge zu vermeiden.
**Der Importer (`scripts.import_markdown`)** **Der Importer (`scripts.import_markdown`)**
* Das komplexeste Modul. * Das komplexeste Modul.
* Nutzt `app.core.chunker` und `app.services.semantic_analyzer` (Smart Edges). * Nutzt `app.core.chunker` und `app.services.semantic_analyzer` (Smart Edges).
@ -76,10 +90,6 @@ mindnet/
* Implementiert die Scoring-Formel (`Semantik + Graph + Typ`). * Implementiert die Scoring-Formel (`Semantik + Graph + Typ`).
* **Hybrid Search:** Lädt dynamisch den Subgraphen (`graph_adapter.expand`). * **Hybrid Search:** Lädt dynamisch den Subgraphen (`graph_adapter.expand`).
**Das Frontend (`app.frontend.ui`)**
* **Resurrection Pattern:** Nutzt `st.session_state`, um Eingaben bei Tab-Wechseln zu erhalten.
* **Healing Parser:** Die Funktion `parse_markdown_draft` repariert defekte YAML-Frontmatter vom LLM automatisch.
**Traffic Control (`app.services.llm_service`)** **Traffic Control (`app.services.llm_service`)**
* Stellt sicher, dass der Chat responsive bleibt, auch wenn ein Import läuft. * Stellt sicher, dass der Chat responsive bleibt, auch wenn ein Import läuft.
* Nutzt `asyncio.Semaphore` (`MINDNET_LLM_BACKGROUND_LIMIT`), um Hintergrund-Jobs zu drosseln. * Nutzt `asyncio.Semaphore` (`MINDNET_LLM_BACKGROUND_LIMIT`), um Hintergrund-Jobs zu drosseln.
@ -91,6 +101,7 @@ mindnet/
**Voraussetzungen:** Python 3.10+, Docker, Ollama. **Voraussetzungen:** Python 3.10+, Docker, Ollama.
**Installation:** **Installation:**
```bash ```bash
# 1. Repo & Venv # 1. Repo & Venv
git clone <repo> mindnet git clone <repo> mindnet
@ -98,7 +109,7 @@ cd mindnet
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
# 2. Dependencies # 2. Dependencies (inkl. st-cytoscape)
pip install -r requirements.txt pip install -r requirements.txt
# 3. Ollama (Nomic ist Pflicht!) # 3. Ollama (Nomic ist Pflicht!)
@ -107,6 +118,7 @@ ollama pull nomic-embed-text
``` ```
**Konfiguration (.env):** **Konfiguration (.env):**
```ini ```ini
QDRANT_URL="http://localhost:6333" QDRANT_URL="http://localhost:6333"
COLLECTION_PREFIX="mindnet_dev" COLLECTION_PREFIX="mindnet_dev"
@ -164,7 +176,11 @@ Mindnet lernt durch Konfiguration, nicht durch Training.
DECISION: DECISION:
inject_types: ["value", "risk"] # Füge 'risk' hinzu inject_types: ["value", "risk"] # Füge 'risk' hinzu
``` ```
3. **Kognition (`prompts.yaml`):** (Optional) Passe den System-Prompt an, falls nötig. 3. **Visualisierung (`ui_config.py`):**
Füge dem `GRAPH_COLORS` Dictionary einen Eintrag hinzu:
```python
"risk": "#ff6b6b"
```
### Workflow B: Interview-Schema anpassen (WP07) ### Workflow B: Interview-Schema anpassen (WP07)
Wenn Mindnet neue Fragen stellen soll (z.B. "Budget" bei Projekten): Wenn Mindnet neue Fragen stellen soll (z.B. "Budget" bei Projekten):
@ -183,12 +199,14 @@ Wenn Mindnet neue Fragen stellen soll (z.B. "Budget" bei Projekten):
## 6. Tests & Debugging ## 6. Tests & Debugging
**Unit Tests:** **Unit Tests:**
```bash ```bash
pytest tests/test_retriever_basic.py pytest tests/test_retriever_basic.py
pytest tests/test_chunking.py pytest tests/test_chunking.py
``` ```
**Pipeline Tests:** **Pipeline Tests:**
```bash ```bash
# JSON-Schema prüfen # JSON-Schema prüfen
python3 -m scripts.payload_dryrun --vault ./test_vault python3 -m scripts.payload_dryrun --vault ./test_vault
@ -198,6 +216,7 @@ python3 -m scripts.edges_full_check
``` ```
**E2E Smoke Tests:** **E2E Smoke Tests:**
```bash ```bash
# Decision Engine # Decision Engine
python tests/test_wp06_decision.py -p 8002 -e DECISION -q "Soll ich X tun?" python tests/test_wp06_decision.py -p 8002 -e DECISION -q "Soll ich X tun?"
@ -211,21 +230,24 @@ python tests/test_feedback_smoke.py --url http://localhost:8002/query
## 7. Troubleshooting & One-Liners ## 7. Troubleshooting & One-Liners
**DB komplett zurücksetzen (Vorsicht!):** **DB komplett zurücksetzen (Vorsicht!):**
```bash ```bash
python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet_dev" --yes python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet_dev" --yes
``` ```
**Einen einzelnen File inspizieren (Parser-Sicht):** **Einen einzelnen File inspizieren (Parser-Sicht):**
```bash ```bash
python3 tests/inspect_one_note.py --file ./vault/MeinFile.md python3 tests/inspect_one_note.py --file ./vault/MeinFile.md
``` ```
**Live-Logs sehen:** **Live-Logs sehen:**
```bash ```bash
journalctl -u mindnet-dev -f journalctl -u mindnet-dev -f
journalctl -u mindnet-ui-dev -f journalctl -u mindnet-ui-dev -f
``` ```
**"Read timed out" im Frontend:** **"Read timed out" im Frontend:**
* Ursache: Smart Edges brauchen länger als 60s. * Ursache: Backend braucht für Smart Edges länger als 60s.
* Lösung: `MINDNET_API_TIMEOUT=300.0` in `.env`. * Lösung: `MINDNET_API_TIMEOUT=300.0` in `.env` setzen.

View File

@ -8,20 +8,20 @@ context: "Aktuelle Planung für kommende Features (ab WP16), Release-Strategie u
# Mindnet Active Roadmap # Mindnet Active Roadmap
**Aktueller Stand:** v2.6.0 (Post-WP15) **Aktueller Stand:** v2.6.0 (Post-WP19)
**Fokus:** Usability, Memory & Visualisierung. **Fokus:** Visualisierung, Exploration & Deep Search.
## 1. Programmstatus ## 1. Programmstatus
Wir haben Phase D (Interaktion) weitgehend abgeschlossen. Das System ist stabil, verfügt über Traffic Control und Smart Edges. Der Fokus verschiebt sich nun auf **Phase E (Maintenance & Scaling)** sowie die Vertiefung der KI-Fähigkeiten. Wir haben mit der Implementierung des Graph Explorers (WP19) einen Meilenstein in **Phase E (Maintenance & Scaling)** erreicht. Die Architektur ist nun modular. Der nächste Schritt (WP19a) vertieft die Analyse-Fähigkeiten.
| Phase | Fokus | Status | | Phase | Fokus | Status |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| **Phase A** | Fundament & Import | ✅ Fertig | | **Phase A** | Fundament & Import | ✅ Fertig |
| **Phase B** | Semantik & Graph | ✅ Fertig | | **Phase B** | Semantik & Graph | ✅ Fertig |
| **Phase C** | Persönlichkeit | ✅ Fertig | | **Phase C** | Persönlichkeit | ✅ Fertig |
| **Phase D** | Interaktion & Tools | 🟡 Abschlussphase | | **Phase D** | Interaktion & Tools | ✅ Fertig |
| **Phase E** | Maintenance & Visualisierung | 🚀 Start | | **Phase E** | Maintenance & Visualisierung | 🚀 Aktiv |
--- ---
@ -44,6 +44,7 @@ Eine Übersicht der implementierten Features zum schnellen Auffinden von Funktio
| **WP-10a**| Draft Editor | GUI-Komponente zum Bearbeiten und Speichern generierter Notizen. | | **WP-10a**| Draft Editor | GUI-Komponente zum Bearbeiten und Speichern generierter Notizen. |
| **WP-11** | Backend Intelligence | `nomic-embed-text` (768d) und Matrix-Logik für Kanten-Typisierung. | | **WP-11** | Backend Intelligence | `nomic-embed-text` (768d) und Matrix-Logik für Kanten-Typisierung. |
| **WP-15** | Smart Edge Allocation | LLM-Filter für Kanten in Chunks + Traffic Control (Semaphore). | | **WP-15** | Smart Edge Allocation | LLM-Filter für Kanten in Chunks + Traffic Control (Semaphore). |
| **WP-19** | Graph Visualisierung | **Frontend Modularisierung:** Umbau auf `ui_*.py`.<br>**Graph Engines:** Parallelbetrieb von Cytoscape (COSE) und Agraph.<br>**Tools:** "Single Source of Truth" Editor, Persistenz via URL. |
--- ---
@ -51,19 +52,24 @@ Eine Übersicht der implementierten Features zum schnellen Auffinden von Funktio
Diese Features stehen als nächstes an. Diese Features stehen als nächstes an.
### WP-19a Graph Intelligence & Discovery (Sprint-Fokus)
**Status:** 🚀 Startklar
**Ziel:** Vom "Anschauen" zum "Verstehen". Deep-Dive Werkzeuge für den Graphen.
* **Discovery Screen:** Neuer Tab für semantische Suche ("Finde Notizen über Vaterschaft") und Wildcard-Filter.
* **Filter-Logik:** "Zeige nur Wege, die zu `type:decision` führen".
* **Chunk Inspection:** Umschaltbare Granularität (Notiz vs. Chunk) zur Validierung des Smart Chunkers.
### WP-16 Auto-Discovery & Enrichment ### WP-16 Auto-Discovery & Enrichment
**Status:** 🟡 Geplant **Status:** 🟡 Geplant
**Ziel:** Automatisches Erkennen von fehlenden Kanten in "dummem" Text *vor* der Speicherung. **Ziel:** Automatisches Erkennen von fehlenden Kanten in "dummem" Text *vor* der Speicherung.
* **Problem:** Nutzer vergessen Wikilinks. * **Problem:** Nutzer vergessen Wikilinks.
* **Lösung:** Ein "Enricher" scannt Text vor dem Import, findet Keywords (z.B. "Mindnet") und schlägt Links vor (`[[Mindnet]]`). * **Lösung:** Ein "Enricher" scannt Text vor dem Import, findet Keywords (z.B. "Mindnet") und schlägt Links vor (`[[Mindnet]]`).
* **Abgrenzung:** Anders als *Active Intelligence* (WP11, UI-basiert) läuft dies im Backend (Importer).
### WP-17 Conversational Memory (Gedächtnis) ### WP-17 Conversational Memory (Gedächtnis)
**Status:** 🟡 Geplant **Status:** 🟡 Geplant
**Ziel:** Echte Dialoge statt Request-Response. **Ziel:** Echte Dialoge statt Request-Response.
* **Tech:** Erweiterung des `ChatRequest` DTO um `history`. * **Tech:** Erweiterung des `ChatRequest` DTO um `history`.
* **Logik:** Token-Management (Context Window Balancing zwischen RAG-Doks und Chat-Verlauf). * **Logik:** Token-Management (Context Window Balancing zwischen RAG-Doks und Chat-Verlauf).
* **Nutzen:** Rückfragen ("Was meinst du damit?") werden möglich.
### WP-18 Graph Health & Maintenance ### WP-18 Graph Health & Maintenance
**Status:** 🟡 Geplant (Prio 2) **Status:** 🟡 Geplant (Prio 2)
@ -71,17 +77,10 @@ Diese Features stehen als nächstes an.
* **Feature:** Cronjob `check_graph_integrity.py`. * **Feature:** Cronjob `check_graph_integrity.py`.
* **Funktion:** Findet "Dangling Edges" (Links auf gelöschte Notizen) und repariert/löscht sie. * **Funktion:** Findet "Dangling Edges" (Links auf gelöschte Notizen) und repariert/löscht sie.
### WP-19 Graph Visualisierung & Explorer
**Status:** 🟡 Geplant (Prio 1)
**Ziel:** Vertrauen durch Transparenz.
* **UI:** Neuer Tab "🕸️ Graph" in Streamlit.
* **Tech:** `streamlit-agraph` oder `pyvis`.
* **Nutzen:** Visuelle Kontrolle der *Smart Edge Allocation* ("Hat das LLM die Kante wirklich hierhin gesetzt?").
### WP-20 Cloud Hybrid Mode (Optional) ### WP-20 Cloud Hybrid Mode (Optional)
**Status:** ⚪ Optional **Status:** ⚪ Optional
**Ziel:** "Turbo-Modus" für Massen-Imports. **Ziel:** "Turbo-Modus" für Massen-Imports.
* **Konzept:** Switch in `.env`, um statt Ollama (Lokal) auf Google Gemini (Cloud) umzuschalten, wenn Datenschutz-unkritische Daten verarbeitet werden. * **Konzept:** Switch in `.env`, um statt Ollama (Lokal) auf Google Gemini (Cloud) umzuschalten.
--- ---
@ -89,20 +88,7 @@ Diese Features stehen als nächstes an.
```mermaid ```mermaid
graph TD graph TD
WP15(Smart Edges) --> WP19(Visualisierung) WP19(Graph Viz) --> WP19a(Discovery)
WP15 --> WP16(Auto-Discovery) WP19a --> WP17(Memory)
WP10(Chat UI) --> WP17(Memory) WP15(Smart Edges) --> WP16(Auto-Discovery)
WP03(Import) --> WP18(Health Check) WP03(Import) --> WP18(Health Check)
```
**Nächstes Release (v2.7):**
* Ziel: Visualisierung (WP19) + Conversational Memory (WP17).
* ETA: Q1 2026.
---
## 5. Governance
* **Versionierung:** Semantic Versioning via Gitea Tags.
* **Feature-Branches:** Jedes WP erhält einen Branch `feature/wpXX-name`.
* **Sync First:** Bevor ein neuer Branch erstellt wird, muss `main` gepullt werden.

View File

@ -2,12 +2,12 @@
doc_type: archive doc_type: archive
audience: historian, architect audience: historian, architect
status: archived status: archived
context: "Archivierte Details zu abgeschlossenen Workpackages (WP01-WP15). Referenz für historische Design-Entscheidungen." context: "Archivierte Details zu abgeschlossenen Workpackages (WP01-WP19). Referenz für historische Design-Entscheidungen."
--- ---
# Legacy Workpackages (Archiv) # Legacy Workpackages (Archiv)
**Quellen:** `Programmplan_V2.2.md` **Quellen:** `Programmplan_V2.2.md`, `Active Roadmap`
**Status:** Abgeschlossen / Implementiert. **Status:** Abgeschlossen / Implementiert.
Dieses Dokument dient als Referenz für die Entstehungsgeschichte von Mindnet v2.6. Dieses Dokument dient als Referenz für die Entstehungsgeschichte von Mindnet v2.6.
@ -79,4 +79,16 @@ Dieses Dokument dient als Referenz für die Entstehungsgeschichte von Mindnet v2
### WP-15 Smart Edge Allocation (Meilenstein) ### WP-15 Smart Edge Allocation (Meilenstein)
* **Problem:** "Broadcasting". Ein Chunk erbte alle Links der Note, auch irrelevante. Das verwässerte die Suchergebnisse. * **Problem:** "Broadcasting". Ein Chunk erbte alle Links der Note, auch irrelevante. Das verwässerte die Suchergebnisse.
* **Lösung:** LLM prüft jeden Chunk auf Link-Relevanz. * **Lösung:** LLM prüft jeden Chunk auf Link-Relevanz.
* **Tech:** Einführung von **Traffic Control** (Semaphore), um Import und Chat zu parallelisieren, ohne die Hardware zu überlasten. * **Tech:** Einführung von **Traffic Control** (Semaphore), um Import und Chat zu parallelisieren, ohne die Hardware zu überlasten.
---
## Phase E: Visualisierung & Maintenance (WP19)
### WP-19 Graph Visualisierung & Modularisierung
* **Ziel:** Transparenz über die Datenstruktur schaffen und technische Schulden (Monolith) abbauen.
* **Ergebnis:**
* **Modularisierung:** Aufsplittung der `ui.py` in Router, Services und Views (`ui_*.py`).
* **Graph Explorer:** Einführung von `st-cytoscape` für stabile, nicht-überlappende Layouts (COSE) als Ergänzung zur Legacy-Engine (Agraph).
* **Single Source of Truth:** Der Editor lädt Inhalte nun direkt vom Dateisystem statt aus (potenziell veralteten) Vektor-Payloads.
* **UX:** Einführung von URL-Persistenz für Layout-Settings und CSS-basiertes Highlighting zur Vermeidung von Re-Renders.

View File

@ -30,4 +30,8 @@ tqdm>=4.67.1
pytest>=8.4.2 pytest>=8.4.2
# --- Frontend (WP-10) --- # --- Frontend (WP-10) ---
streamlit>=1.39.0 streamlit>=1.39.0
# Visualization (Parallelbetrieb)
streamlit-agraph>=0.0.45
st-cytoscape>=1.0.0