mindnet/app/frontend/ui.py
2025-12-11 13:05:05 +01:00

469 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import streamlit as st
import requests
import uuid
import os
import json
import re
import yaml
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
# --- 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")
# 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.3.7", page_icon="🧠", layout="wide")
# --- CSS STYLING ---
st.markdown("""
<style>
.block-container { padding-top: 2rem; max_width: 1000px; 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;
}
.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;
}
.suggestion-card {
border-left: 3px solid #1a73e8;
background-color: #ffffff;
padding: 10px;
margin-bottom: 8px;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
</style>
""", unsafe_allow_html=True)
# --- SESSION STATE ---
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())
# --- HELPER FUNCTIONS ---
def normalize_meta_and_body(meta, body):
"""Sanitizer: Stellt sicher, dass nur erlaubte Felder im Frontmatter bleiben."""
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_meta["tags"] = list(set(all_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):
"""Robustes Parsing + Sanitization."""
clean_text = full_text
pattern_block = r"```(?:markdown|md)?\s*(.*?)\s*```"
match_block = re.search(pattern_block, full_text, re.DOTALL | re.IGNORECASE)
if match_block:
clean_text = match_block.group(1).strip()
parts = re.split(r"^---+\s*$", clean_text, maxsplit=2, flags=re.MULTILINE)
meta = {}
body = clean_text
if len(parts) >= 3:
yaml_str = parts[1]
body_candidate = parts[2]
try:
parsed = yaml.safe_load(yaml_str)
if isinstance(parsed, dict):
meta = parsed
body = body_candidate.strip()
except Exception:
pass
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":
safe_title = re.sub(r'[^a-zA-Z0-9]', '-', meta.get('title', 'note')).lower()[:30]
meta["id"] = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}-{uuid.uuid4().hex[:4]}"
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=60
)
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.3.7 | Stable State")
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):
qid = msg.get('query_id', str(uuid.uuid4()))
key_base = f"draft_{qid}"
# State Keys
data_body_key = f"{key_base}_data_body" # Persistenter Speicher
data_meta_key = f"{key_base}_data_meta" # Metadaten
data_sugg_key = f"{key_base}_data_suggestions" # Vorschläge
widget_body_key = f"{key_base}_widget_body" # Das Textfeld selbst
# --- 1. INIT STATE ---
if f"{key_base}_init" not in st.session_state:
meta, body = parse_markdown_draft(msg["content"])
# Metadata Defaults
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)
# Init Session State
st.session_state[data_meta_key] = meta
st.session_state[data_body_key] = body.strip()
st.session_state[data_sugg_key] = []
st.session_state[f"{key_base}_init"] = True
# --- CALLBACKS ---
def _sync_body():
# Schreibt Widget-Inhalt in den Speicher
st.session_state[data_body_key] = st.session_state[widget_body_key]
def _insert_text(text_to_insert):
# Liest vom Widget (aktuellster Stand!), fügt an, schreibt zurück
current = st.session_state[widget_body_key]
st.session_state[data_body_key] = f"{current}\n\n{text_to_insert}"
# Wichtig: Leere Vorschläge nach Insert, damit man nicht doppelt klickt
# st.session_state[data_sugg_key] = []
def _remove_text(text_to_remove):
current = st.session_state[widget_body_key]
st.session_state[data_body_key] = current.replace(text_to_remove, "").strip()
# --- UI LAYOUT ---
st.markdown(f'<div class="draft-box">', unsafe_allow_html=True)
st.markdown("### 📝 Entwurf bearbeiten")
# Metadata Form
meta_ref = st.session_state[data_meta_key]
c1, c2 = st.columns([2, 1])
with c1:
meta_ref["title"] = st.text_input("Titel", key=f"{key_base}_wdg_title", value=meta_ref["title"])
with c2:
known_types = ["concept", "project", "decision", "experience", "journal", "value", "goal", "principle"]
curr = meta_ref["type"]
if curr not in known_types: known_types.append(curr)
meta_ref["type"] = st.selectbox("Typ", known_types, index=known_types.index(curr), key=f"{key_base}_wdg_type")
meta_ref["tags_str"] = st.text_input("Tags", key=f"{key_base}_wdg_tags", value=meta_ref.get("tags_str", ""))
# Tabs
tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"])
# --- TAB 1: EDITOR ---
with tab_edit:
# Value kommt aus Data, Änderungen gehen via Callback zurück in Data
st.text_area(
"Body",
key=widget_body_key,
value=st.session_state[data_body_key],
on_change=_sync_body,
height=500,
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"):
# 1. Reset
st.session_state[data_sugg_key] = []
# 2. Text holen (HIER WAR DER FEHLER)
# Wir holen ihn direkt aus dem Widget-Key, da dieser immer aktuell ist,
# auch wenn on_change noch nicht gefeuert hat.
text_to_analyze = st.session_state.get(widget_body_key, st.session_state[data_body_key])
# Debug (optional, damit du siehst was passiert)
# st.caption(f"Sende {len(text_to_analyze)} Zeichen an API...")
with st.spinner("Analysiere..."):
analysis = analyze_draft_text(text_to_analyze, meta_ref["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 (Text zu kurz oder keine Matches).")
else:
st.success(f"{len(suggestions)} Vorschläge gefunden.")
# Render List
suggestions = st.session_state[data_sugg_key]
if suggestions:
# Hole aktuellen Text für Vergleich
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 = [t.strip() for t in meta_ref["tags_str"].split(",") if t.strip()]
final_meta = {
"id": "generated_on_save",
"type": meta_ref["type"],
"title": meta_ref["title"],
"status": "draft",
"tags": final_tags
}
# Auch hier: Nimm den aktuellsten Text für die Vorschau
final_body = st.session_state.get(widget_body_key, st.session_state[data_body_key])
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("---")
if st.button("💾 Speichern & Indizieren", type="primary", key=f"{key_base}_save"):
with st.spinner("Speichere..."):
safe_title = re.sub(r'[^a-zA-Z0-9]', '-', meta_ref["title"]).lower()[:30] or "draft"
fname = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}.md"
res = save_draft_to_vault(final_doc, filename=fname)
if "error" in res: st.error(f"Fehler: {res['error']}")
else: st.success(f"Gespeichert: {res.get('file_path')}")
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":
# Header
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)
# Logic
if intent == "INTERVIEW":
render_draft_editor(msg)
else:
st.markdown(msg["content"])
# Sources
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():
# Wir nutzen dieselbe Logik wie beim Interview, aber mit einem "leeren" Mock-Objekt
# Wichtig: Feste Query-ID für Manuellen Modus, damit der State persistent bleibt
mock_msg = {
"content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n",
"query_id": "manual_editor_fixed_v1"
}
render_draft_editor(mock_msg)
# --- MAIN ---
mode, top_k, explain = render_sidebar()
if mode == "💬 Chat":
render_chat_interface(top_k, explain)
else:
render_manual_editor()