mindnet/app/frontend/ui.py
2025-12-11 12:56:16 +01:00

483 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 MANAGEMENT KEYS ===
# Wir nutzen getrennte Keys für Widget und Daten, um Streamlit's Bereinigung zu umgehen.
# Persistent Keys (bleiben erhalten auch beim Tab-Wechsel)
data_body_key = f"{key_base}_data_body"
data_meta_key = f"{key_base}_data_meta"
data_sugg_key = f"{key_base}_data_suggestions"
# Widget Keys (können sich ändern/neu gezeichnet werden)
widget_body_key = f"{key_base}_widget_body"
# --- 1. INIT STATE (Einmalig pro Nachricht) ---
if f"{key_base}_init" not in st.session_state:
meta, body = parse_markdown_draft(msg["content"])
# Defaults setzen
if "type" not in meta: meta["type"] = "default"
if "title" not in meta: meta["title"] = ""
if "tags" not in meta: meta["tags"] = []
# Tags Listen-Check
if isinstance(meta["tags"], list):
meta["tags_str"] = ", ".join(meta["tags"])
else:
meta["tags_str"] = str(meta.get("tags", ""))
# Persistent speichern
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
# --- HELPER CALLBACKS ---
# Sync Widget -> Data
def _sync_body():
st.session_state[data_body_key] = st.session_state[widget_body_key]
# Insert Text (Daten ändern)
def _insert_text(text_to_insert):
current = st.session_state[data_body_key]
st.session_state[data_body_key] = f"{current}\n\n{text_to_insert}"
def _remove_text(text_to_remove):
current = st.session_state[data_body_key]
st.session_state[data_body_key] = current.replace(text_to_remove, "").strip()
# --- 2. UI LAYOUT ---
st.markdown(f'<div class="draft-box">', unsafe_allow_html=True)
st.markdown("### 📝 Entwurf bearbeiten")
# Load Data Reference
meta_ref = st.session_state[data_meta_key]
# Metadata Form
c1, c2 = st.columns([2, 1])
with c1:
new_title = st.text_input("Titel", key=f"{key_base}_wdg_title", value=meta_ref["title"])
meta_ref["title"] = new_title # Direct Sync
with c2:
known_types = ["concept", "project", "decision", "experience", "journal", "person", "value", "goal", "principle", "default"]
curr_type = meta_ref["type"]
if curr_type not in known_types: known_types.append(curr_type)
new_type = st.selectbox("Typ", known_types, index=known_types.index(curr_type), key=f"{key_base}_wdg_type")
meta_ref["type"] = new_type # Direct Sync
new_tags = st.text_input("Tags", key=f"{key_base}_wdg_tags", value=meta_ref.get("tags_str", ""))
meta_ref["tags_str"] = new_tags # Direct Sync
# Tabs
tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"])
# --- TAB 1: EDITOR ---
with tab_edit:
# Hier ist der Trick: Value kommt aus 'data_body_key',
# Änderungen triggern '_sync_body', der zurück in 'data_body_key' schreibt.
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. Alte Ergebnisse löschen für Feedback
st.session_state[data_sugg_key] = []
with st.spinner("Analysiere..."):
# Aktuellen Text nehmen
text_to_analyze = st.session_state[data_body_key]
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.")
else:
st.success(f"{len(suggestions)} Vorschläge gefunden.")
suggestions = st.session_state[data_sugg_key]
if suggestions:
for idx, sugg in enumerate(suggestions):
link_text = sugg.get('suggested_markdown', '')
is_inserted = link_text in st.session_state[data_body_key]
card_style = "border-left: 3px solid #28a745;" if is_inserted else "border-left: 3px solid #1a73e8;"
bg_color = "#e6fffa" if is_inserted else "#ffffff"
st.markdown(f"""
<div style="{card_style} 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', 'Unbekannt')}</b> <small>({sugg.get('type', 'semantic')})</small><br>
<i>{sugg.get('reason', 'N/A')}</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: PREVIEW & SAVE ---
# Final Assembly
final_tags_list = [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_list
}
final_doc = build_markdown_doc(final_meta, st.session_state[data_body_key])
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..."):
safe_title = re.sub(r'[^a-zA-Z0-9]', '-', meta_ref["title"]).lower()[:30]
if not safe_title: safe_title = "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":
# 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()