aufteilung ui auf mehrere dateien

This commit is contained in:
Lars 2025-12-14 08:34:23 +01:00
parent 772a202d6e
commit 19f8d76e21
6 changed files with 719 additions and 702 deletions

View File

@ -1,42 +1,15 @@
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
# --- WP-19 GRAPH IMPORTS --- # --- MODULE IMPORTS ---
try: try:
from streamlit_agraph import agraph, Node, Edge, Config from ui_config import QDRANT_URL, QDRANT_KEY, COLLECTION_PREFIX
from qdrant_client import QdrantClient, models from ui_graph_service import GraphExplorerService
except ImportError: from ui_components import render_sidebar, render_chat_interface, render_manual_editor, render_graph_explorer
st.error("Fehlende Bibliotheken! Bitte installiere: pip install streamlit-agraph qdrant-client") except ImportError as e:
st.error(f"Import Error: {e}. Bitte stelle sicher, dass alle UI-Dateien im selben Ordner liegen.")
st.stop() st.stop()
# --- CONFIGURATION ---
load_dotenv()
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")
# Qdrant Config (Direct Access for Graph)
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")
# 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 --- # --- PAGE SETUP ---
st.set_page_config(page_title="mindnet v2.6", page_icon="🧠", layout="wide") st.set_page_config(page_title="mindnet v2.6", page_icon="🧠", layout="wide")
@ -75,676 +48,11 @@ st.markdown("""
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())
# --- GRAPH STYLING CONFIG (WP-19) --- # --- SERVICE INIT ---
# Colors based on types.yaml and standard conventions # Initialisiert den Graph Service einmalig
GRAPH_COLORS = {
"project": "#ff9f43", # Orange
"concept": "#54a0ff", # Blue
"decision": "#5f27cd", # Purple
"risk": "#ff6b6b", # Red
"person": "#1dd1a1", # Green
"experience": "#feca57",# Yellow
"value": "#00d2d3", # Cyan
"goal": "#ff9ff3", # Pink
"default": "#8395a7" # Grey
}
# Colors based on edge 'kind'
EDGE_COLORS = {
"depends_on": "#ff6b6b", # Red (Blocker)
"blocks": "#ee5253", # Dark Red
"caused_by": "#ff9ff3", # Pink
"related_to": "#c8d6e5", # Light Grey
"similar_to": "#c8d6e5", # Light Grey
"next": "#54a0ff", # Blue
"derived_from": "#ff9ff3",# Pink
"references": "#bdc3c7", # Grey
"belongs_to": "#2e86de" # Dark Blue
"contributes_to": "#1dd1a1"
}
# --- HELPER FUNCTIONS ---
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(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
# --- WP-19 GRAPH SERVICE (Advanced) ---
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_ego_graph(self, center_note_id: str):
nodes_dict = {}
unique_edges = {}
center_note = self._fetch_note_cached(center_note_id)
if not center_note: return [], []
self._add_node_to_dict(nodes_dict, center_note, is_center=True)
center_title = center_note.get("title")
# Chunks laden
scroll_filter = models.Filter(
must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=center_note_id))]
)
chunks, _ = self.client.scroll(
collection_name=self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True
)
center_chunk_ids = [c.id for c in chunks]
raw_edges = []
# 1. OUTGOING: Source ist einer unserer Chunks
if center_chunk_ids:
out_filter = models.Filter(
must=[models.FieldCondition(key="source_id", match=models.MatchAny(any=center_chunk_ids))]
)
res_out, _ = self.client.scroll(
collection_name=self.edges_col, scroll_filter=out_filter, limit=100, with_payload=True
)
raw_edges.extend(res_out)
# 2. INCOMING: Target ist Chunk, Titel oder exakte Note-ID
# Hinweis: Target mit #Section (z.B. 'note#header') kann via Keyword-Index schwer gefunden werden,
# wenn wir den Header-Teil nicht kennen.
must_conditions = []
if center_chunk_ids:
must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=center_chunk_ids)))
if center_title:
must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=center_title)))
# NEU: Auch exakte Note-ID als Target prüfen
must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=center_note_id)))
if must_conditions:
in_filter = models.Filter(should=must_conditions) # 'should' wirkt wie OR
res_in, _ = self.client.scroll(
collection_name=self.edges_col, scroll_filter=in_filter, limit=100, with_payload=True
)
raw_edges.extend(res_in)
# Verarbeitung
for record in raw_edges:
payload = record.payload
src_ref = payload.get("source_id")
tgt_ref = payload.get("target_id")
kind = payload.get("kind", "related_to")
provenance = payload.get("provenance", "explicit")
src_note = self._resolve_note_from_ref(src_ref)
tgt_note = self._resolve_note_from_ref(tgt_ref)
if src_note and tgt_note:
src_id = src_note['note_id']
tgt_id = tgt_note['note_id']
if src_id != tgt_id:
self._add_node_to_dict(nodes_dict, src_note)
self._add_node_to_dict(nodes_dict, tgt_note)
key = (src_id, tgt_id)
existing = unique_edges.get(key)
is_current_explicit = (provenance == "explicit" or provenance == "rule")
should_update = True
if existing:
is_existing_explicit = (existing['provenance'] == "explicit" or existing['provenance'] == "rule")
if is_existing_explicit and not is_current_explicit:
should_update = False
if should_update:
unique_edges[key] = {
"source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance
}
final_edges = []
for (src, tgt), data in unique_edges.items():
kind = data['kind']
prov = data['provenance']
color = EDGE_COLORS.get(kind, "#bdc3c7")
is_smart = (prov != "explicit" and prov != "rule")
final_edges.append(Edge(
source=src, target=tgt, label=kind, color=color, dashes=is_smart,
title=f"Provenance: {prov}\nType: {kind}"
))
return list(nodes_dict.values()), final_edges
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):
if not ref_str: return None
# Fall A: Chunk ID (Format: note_id#cXX)
if "#" in ref_str:
# 1. Versuch: Echte Chunk ID in DB suchen
try:
res = self.client.retrieve(collection_name=self.chunks_col, ids=[ref_str], with_payload=True)
if res:
parent_id = res[0].payload.get("note_id")
return self._fetch_note_cached(parent_id)
except: pass
# 2. Versuch (NEU): Es ist ein Link auf eine Section (z.B. "note-id#Header")
# Wir entfernen den Hash-Teil und suchen die Basis-Notiz
possible_note_id = ref_str.split("#")[0]
note_by_id = self._fetch_note_cached(possible_note_id)
if note_by_id: return note_by_id
# Fall B: Es ist direkt die Note ID
note_by_id = self._fetch_note_cached(ref_str)
if note_by_id: return note_by_id
# Fall C: Es ist der Titel (Wikilink)
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchValue(value=ref_str))]),
limit=1, with_payload=True
)
if res:
p = res[0].payload
self._note_cache[p['note_id']] = p
return p
return None
def _add_node_to_dict(self, node_dict, note_payload, is_center=False):
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"])
size = 35 if is_center else 20
node_dict[nid] = Node(
id=nid, label=note_payload.get("title", nid), size=size, color=color, shape="dot",
title=f"Type: {ntype}\nTags: {note_payload.get('tags')}", font={'color': 'black'}
)
# Init Graph Service
graph_service = GraphExplorerService(QDRANT_URL, QDRANT_KEY, COLLECTION_PREFIX) graph_service = GraphExplorerService(QDRANT_URL, QDRANT_KEY, COLLECTION_PREFIX)
# --- API CLIENT --- # --- MAIN ROUTING ---
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.6 | WP-19 Graph View")
mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor", "🕸️ Graph Explorer"], index=0)
st.divider()
st.subheader("⚙️ Settings")
top_k = st.slider("Quellen (Top-K)", 1, 10, 5)
explain = st.toggle("Explanation Layer", True)
st.divider()
st.subheader("🕒 Verlauf")
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"
# 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"]
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]
# 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"])
with tab_edit:
st.text_area("Body", key=widget_body_key, height=500, on_change=_sync_body, label_visibility="collapsed")
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,))
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)
def render_graph_explorer():
st.header("🕸️ Graph Explorer (WP-19)")
col_ctrl, col_graph = st.columns([1, 3])
with col_ctrl:
st.subheader("Fokus setzen")
search_term = st.text_input("Suche Notiz (Titel)", placeholder="z.B. Project Alpha")
selected_note_id = None
if search_term:
# Suche nach Titel für Autocomplete
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("Wähle Notiz:", list(options.keys()))
selected_note_id = options[selected_title]
else:
st.warning("Keine Notiz gefunden.")
st.markdown("---")
st.markdown("**Legende:**")
st.markdown(f"🔴 **Blocker** (Risk/Block)")
st.markdown(f"🔵 **Konzept/Struktur**")
st.markdown(f"🟣 **Entscheidung**")
st.markdown(f"--- **Solid**: Explicit Link")
st.markdown(f"- - **Dashed**: Smart/AI Link")
with col_graph:
if selected_note_id:
with st.spinner(f"Lade Graph für {selected_note_id}..."):
nodes, edges = graph_service.get_ego_graph(selected_note_id)
if not nodes:
st.error("Knoten konnte nicht geladen werden.")
else:
config = Config(
width=900,
height=700,
directed=True,
physics=True,
hierarchical=False,
nodeHighlightBehavior=True,
highlightColor="#F7A7A6",
collapsible=False
)
# Rendering the Graph
st.caption(f"Graph zeigt {len(nodes)} Knoten und {len(edges)} Kanten.")
return_value = agraph(nodes=nodes, edges=edges, config=config)
if return_value:
st.info(f"Auswahl: {return_value}")
else:
st.info("👈 Bitte wähle links eine Notiz aus, um den Graphen zu starten.")
# --- MAIN ---
mode, top_k, explain = render_sidebar() mode, top_k, explain = render_sidebar()
if mode == "💬 Chat": if mode == "💬 Chat":
@ -752,4 +60,4 @@ if mode == "💬 Chat":
elif mode == "📝 Manueller Editor": elif mode == "📝 Manueller Editor":
render_manual_editor() render_manual_editor()
elif mode == "🕸️ Graph Explorer": elif mode == "🕸️ Graph Explorer":
render_graph_explorer() render_graph_explorer(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,305 @@
import streamlit as st
import uuid
import re
from datetime import datetime
from streamlit_agraph import agraph, Config
from qdrant_client import models
from ui_utils import parse_markdown_draft, build_markdown_doc, load_history_from_logs, slugify
from ui_api import save_draft_to_vault, analyze_draft_text, send_chat_message, submit_feedback
from ui_config import HISTORY_FILE, COLLECTION_PREFIX
def render_sidebar():
with st.sidebar:
st.title("🧠 mindnet")
st.caption("v2.6 | WP-19 Graph View")
mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor", "🕸️ Graph Explorer"], index=0)
st.divider()
st.subheader("⚙️ Settings")
top_k = st.slider("Quellen (Top-K)", 1, 10, 5)
explain = st.toggle("Explanation Layer", True)
st.divider()
st.subheader("🕒 Verlauf")
for q in load_history_from_logs(HISTORY_FILE, 8):
if st.button(f"🔎 {q[:25]}...", key=f"hist_{q}", use_container_width=True):
st.session_state.messages.append({"role": "user", "content": q})
st.rerun()
return mode, top_k, explain
def render_draft_editor(msg):
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"]
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]
# 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"])
with tab_edit:
st.text_area("Body", key=widget_body_key, height=500, on_change=_sync_body, label_visibility="collapsed")
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,))
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)
def render_graph_explorer(graph_service):
st.header("🕸️ Graph Explorer (WP-19)")
col_ctrl, col_graph = st.columns([1, 3])
with col_ctrl:
st.subheader("Fokus setzen")
search_term = st.text_input("Suche Notiz (Titel)", placeholder="z.B. Project Alpha")
selected_note_id = None
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("Wähle Notiz:", list(options.keys()))
selected_note_id = options[selected_title]
else:
st.warning("Keine Notiz gefunden.")
st.markdown("---")
st.markdown("**Legende:**")
st.markdown(f"🔴 **Blocker** (Risk/Block)")
st.markdown(f"🔵 **Konzept/Struktur**")
st.markdown(f"🟣 **Entscheidung**")
st.markdown(f"🟢 **Beitrag**")
st.markdown(f"--- **Solid**: Explicit Link")
st.markdown(f"- - **Dashed**: Smart/AI Link")
with col_graph:
if selected_note_id:
with st.spinner(f"Lade Graph für {selected_note_id}..."):
nodes, edges = graph_service.get_ego_graph(selected_note_id)
if not nodes:
st.error("Knoten konnte nicht geladen werden.")
else:
config = Config(
width=900,
height=700,
directed=True,
physics=True,
hierarchical=False,
nodeHighlightBehavior=True,
highlightColor="#F7A7A6",
collapsible=False
)
st.caption(f"Graph zeigt {len(nodes)} Knoten und {len(edges)} Kanten.")
return_value = agraph(nodes=nodes, edges=edges, config=config)
if return_value:
st.info(f"Auswahl: {return_value}")
else:
st.info("👈 Bitte wähle links eine Notiz aus, um den Graphen zu starten.")

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

@ -0,0 +1,49 @@
import os
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 ---
GRAPH_COLORS = {
"project": "#ff9f43", # Orange
"concept": "#54a0ff", # Blau
"decision": "#5f27cd", # Lila
"risk": "#ff6b6b", # Rot
"person": "#1dd1a1", # Grün
"experience": "#feca57",# Gelb
"value": "#00d2d3", # Cyan
"goal": "#ff9ff3", # Pink
"default": "#8395a7" # Grau
}
EDGE_COLORS = {
"depends_on": "#ff6b6b", # Rot (Blocker)
"blocks": "#ee5253", # Dunkelrot
"caused_by": "#ff9ff3", # Pink
"related_to": "#c8d6e5", # Hellgrau
"similar_to": "#c8d6e5", # Hellgrau
"next": "#54a0ff", # Blau
"derived_from": "#ff9ff3", # Pink
"references": "#bdc3c7", # Grau
"belongs_to": "#2e86de", # Dunkelblau
"contributes_to": "#1dd1a1" # Grün (Neu!)
}

View File

@ -0,0 +1,181 @@
from qdrant_client import QdrantClient, models
from streamlit_agraph import Node, Edge
from ui_config import GRAPH_COLORS, EDGE_COLORS
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_ego_graph(self, center_note_id: str):
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, is_center=True)
center_title = center_note.get("title")
# 2. Chunks der Center Note finden
scroll_filter = models.Filter(
must=[models.FieldCondition(key="note_id", match=models.MatchValue(value=center_note_id))]
)
chunks, _ = self.client.scroll(
collection_name=self.chunks_col, scroll_filter=scroll_filter, limit=100, with_payload=True
)
center_chunk_ids = [c.id for c in chunks]
raw_edges = []
# 3. OUTGOING EDGES: Source = einer meiner Chunks
if center_chunk_ids:
out_filter = models.Filter(
must=[models.FieldCondition(key="source_id", match=models.MatchAny(any=center_chunk_ids))]
)
res_out, _ = self.client.scroll(
collection_name=self.edges_col, scroll_filter=out_filter, limit=100, with_payload=True
)
raw_edges.extend(res_out)
# 4. INCOMING EDGES: Target = Chunk, Titel oder Note-ID
must_conditions = []
if center_chunk_ids:
must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchAny(any=center_chunk_ids)))
if center_title:
must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=center_title)))
# FIX: Auch exakte Note-ID als Target prüfen
must_conditions.append(models.FieldCondition(key="target_id", match=models.MatchValue(value=center_note_id)))
if must_conditions:
in_filter = models.Filter(should=must_conditions) # 'should' = OR
res_in, _ = self.client.scroll(
collection_name=self.edges_col, scroll_filter=in_filter, limit=100, with_payload=True
)
raw_edges.extend(res_in)
# 5. Verarbeitung & Auflösung
for record in raw_edges:
payload = record.payload
src_ref = payload.get("source_id")
tgt_ref = payload.get("target_id")
kind = payload.get("kind", "related_to")
provenance = payload.get("provenance", "explicit")
src_note = self._resolve_note_from_ref(src_ref)
tgt_note = self._resolve_note_from_ref(tgt_ref)
if src_note and tgt_note:
src_id = src_note['note_id']
tgt_id = tgt_note['note_id']
# Keine Self-Loops und valide Verbindung
if src_id != tgt_id:
self._add_node_to_dict(nodes_dict, src_note)
self._add_node_to_dict(nodes_dict, tgt_note)
key = (src_id, tgt_id)
existing = unique_edges.get(key)
# Deduplizierung: Explizite Kanten überschreiben Smart Edges
is_current_explicit = (provenance == "explicit" or provenance == "rule")
should_update = True
if existing:
is_existing_explicit = (existing['provenance'] == "explicit" or existing['provenance'] == "rule")
if is_existing_explicit and not is_current_explicit:
should_update = False
if should_update:
unique_edges[key] = {
"source": src_id, "target": tgt_id, "kind": kind, "provenance": provenance
}
# 6. Agraph Objekte bauen
final_edges = []
for (src, tgt), data in unique_edges.items():
kind = data['kind']
prov = data['provenance']
color = EDGE_COLORS.get(kind, "#bdc3c7")
is_smart = (prov != "explicit" and prov != "rule")
final_edges.append(Edge(
source=src, target=tgt, label=kind, color=color, dashes=is_smart,
title=f"Provenance: {prov}\nType: {kind}"
))
return list(nodes_dict.values()), final_edges
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):
if not ref_str: return None
# Fall A: Chunk ID (Format: note_id#cXX)
if "#" in ref_str:
# Versuch 1: Echte Chunk ID in DB
try:
res = self.client.retrieve(collection_name=self.chunks_col, ids=[ref_str], with_payload=True)
if res:
parent_id = res[0].payload.get("note_id")
return self._fetch_note_cached(parent_id)
except: pass
# Versuch 2: Section Link (note-id#Header) -> Hash abschneiden
possible_note_id = ref_str.split("#")[0]
note_by_id = self._fetch_note_cached(possible_note_id)
if note_by_id: return note_by_id
# Fall B: Es ist direkt die Note ID
note_by_id = self._fetch_note_cached(ref_str)
if note_by_id: return note_by_id
# Fall C: Es ist der Titel (Wikilink)
res, _ = self.client.scroll(
collection_name=self.notes_col,
scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchValue(value=ref_str))]),
limit=1, with_payload=True
)
if res:
p = res[0].payload
self._note_cache[p['note_id']] = p
return p
return None
def _add_node_to_dict(self, node_dict, note_payload, is_center=False):
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"])
size = 35 if is_center else 20
node_dict[nid] = Node(
id=nid,
label=note_payload.get("title", nid),
size=size,
color=color,
shape="dot" if not is_center else "diamond",
title=f"Type: {ntype}\nTags: {note_payload.get('tags')}",
font={'color': 'black'}
)

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