diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 5f87fcd..3cf9c27 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -2,240 +2,214 @@ import streamlit as st import requests import uuid import os -import time +import json +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" +HISTORY_FILE = Path("data/logs/search_history.jsonl") -# Timeout-Strategie +# 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.1", - page_icon="🧠", - layout="centered" -) +st.set_page_config(page_title="mindnet v2.3.1", page_icon="🧠", layout="wide") +# --- CSS STYLING (VISUAL POLISH) --- st.markdown(""" """, 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()) +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 "draft_note" not in st.session_state: st.session_state.draft_note = {"title": "", "content": "", "type": "concept"} -# --- API FUNCTIONS --- +# --- HELPER FUNCTIONS --- + +def load_history_from_logs(limit=10): + """Liest die letzten N Queries aus dem Logfile (WP-04c Data Flywheel).""" + queries = [] + if HISTORY_FILE.exists(): + try: + with open(HISTORY_FILE, "r", encoding="utf-8") as f: + # Datei rückwärts oder komplett lesen (bei großen Logs besser `tail`) + 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 Exception as e: + st.sidebar.warning(f"Log-Fehler: {e}") + return queries def send_chat_message(message: str, top_k: int, explain: bool): - payload = {"message": message, "top_k": top_k, "explain": explain} try: - response = requests.post(CHAT_ENDPOINT, json=payload, timeout=API_TIMEOUT) + 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 requests.exceptions.ReadTimeout: - return {"error": f"Timeout ({int(API_TIMEOUT)}s). Das lokale LLM rechnet noch."} except Exception as e: return {"error": str(e)} -def submit_feedback(query_id: str, node_id: str, score: int, comment: str = None): - """Sendet Feedback asynchron.""" - payload = { - "query_id": query_id, - "node_id": node_id, - "score": score, - "comment": comment - } +def submit_feedback(query_id, node_id, score, comment=None): try: - requests.post(FEEDBACK_ENDPOINT, json=payload, timeout=5) - # Wir nutzen st.toast für dezentes Feedback ohne Rerun + requests.post(FEEDBACK_ENDPOINT, json={"query_id": query_id, "node_id": node_id, "score": score, "comment": comment}, timeout=2) target = "Antwort" if node_id == "generated_answer" else "Quelle" st.toast(f"Feedback für {target} gespeichert! (Score: {score})") - except Exception as e: - st.error(f"Feedback-Fehler: {e}") + except: pass # --- UI COMPONENTS --- def render_sidebar(): with st.sidebar: - st.header("⚙️ Konfiguration") - st.caption(f"Backend: `{API_BASE_URL}`") + st.title("🧠 mindnet") + st.caption("v2.3.1 | WP-10 UI") - st.subheader("Retrieval") - top_k = st.slider("Quellen Anzahl", 1, 10, 5) - explain_mode = st.toggle("Explanation Layer", value=True) + mode = st.radio("Modus", ["💬 Chat", "📝 Neuer Eintrag (WP-07)"], index=0) st.divider() - st.info("WP-10: Advanced Feedback Loop Active") - if st.button("Reset Chat"): - st.session_state.messages = [] - st.rerun() - return top_k, explain_mode + st.subheader("⚙️ Settings") + top_k = st.slider("Quellen (Top-K)", 1, 10, 5) + explain = st.toggle("Explanation Layer", True) + + st.divider() + st.subheader("🕒 Verlauf") + history = load_history_from_logs(8) + if not history: + st.caption("Noch keine Einträge.") + for q in history: + if st.button(f"🔎 {q[:30]}...", key=f"hist_{q}", help=q, use_container_width=True): + # Trick: Query in Input 'injecten' geht schwer, wir feuern direkt ab + st.session_state.messages.append({"role": "user", "content": q}) + st.rerun() -def render_intent_badge(intent, source): - icon = "🧠" - if intent == "EMPATHY": icon = "❤️" - elif intent == "DECISION": icon = "⚖️" - elif intent == "CODING": icon = "💻" - elif intent == "FACT": icon = "📚" - return f"""
{icon} Intent: {intent} ({source})
""" + return mode, top_k, explain -def render_sources(sources, query_id): - """ - Rendert Quellen inklusive granularem Feedback-Mechanismus (1-5 via Faces). - """ - if not sources: - return +def render_chat_interface(top_k, explain): + # Render History + for msg in st.session_state.messages: + with st.chat_message(msg["role"]): + if msg["role"] == "assistant": + # Intent Badge + if "intent" in msg: + icon = {"EMPATHY": "❤️", "DECISION": "⚖️", "CODING": "💻", "FACT": "📚"}.get(msg["intent"], "🧠") + st.markdown(f'
{icon} Intent: {msg["intent"]}
', unsafe_allow_html=True) + + st.markdown(msg["content"]) + + # Sources + if "sources" in msg: + for hit in msg["sources"]: + score = hit.get('total_score', 0) + icon = "🟢" if score > 0.8 else "🟡" if score > 0.5 else "⚪" + with st.expander(f"{icon} {hit.get('note_id', '?')} ({score:.2f})"): + st.markdown(f"_{hit.get('source', {}).get('text', '')[:300]}..._") + if hit.get('explanation'): + st.caption(f"Grund: {hit['explanation']['reasons'][0]['message']}") + + # Granular Feedback + def _cb(qid=msg["query_id"], nid=hit['node_id']): + val = st.session_state.get(f"fb_src_{qid}_{nid}") + if val is not None: submit_feedback(qid, nid, val+1, "Faces UI") + + st.feedback("faces", key=f"fb_src_{msg['query_id']}_{hit['node_id']}", on_change=_cb) + + # Global Feedback + 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"]) + + # Input Logic + # Prüfen ob wir aus der History kommen (letzte Nachricht User und noch keine Antwort?) + last_msg_is_user = len(st.session_state.messages) > 0 and st.session_state.messages[-1]["role"] == "user" + # Da st.rerun() die ganze App neu lädt, müssen wir prüfen, ob wir auf eine Antwort warten + # Aber Streamlit Flow ist: Input -> Rerun -> Code läuft -> Render. + # Wir brauchen einen Trigger. - st.markdown("#### 📚 Verwendete Quellen") + if prompt := st.chat_input("Frage Mindnet..."): + st.session_state.messages.append({"role": "user", "content": prompt}) + st.rerun() + + # Wenn die letzte Nachricht vom User ist (egal ob via Input oder History Button), generiere Antwort + if last_msg_is_user: + last_prompt = st.session_state.messages[-1]["content"] + with st.chat_message("assistant"): + with st.spinner("Thinking..."): + resp = send_chat_message(last_prompt, top_k, explain) + + if "error" in resp: + st.error(resp["error"]) + # Entferne die User Nachricht, damit man es nochmal probieren kann? Optional. + else: + # Speichern und Rerun für sauberes Rendering + msg_data = { + "role": "assistant", + "content": resp.get("answer"), + "intent": resp.get("intent", "FACT"), + "sources": resp.get("sources", []), + "query_id": resp.get("query_id") + } + st.session_state.messages.append(msg_data) + st.rerun() + +def render_creation_interface(): + st.header("📝 Neuer Wissens-Eintrag (WP-07/11)") + st.info("Hier kannst du strukturierte Notizen erstellen, die direkt in den Obsidian Vault gespeichert werden.") - for idx, hit in enumerate(sources): - score = hit.get('total_score', 0) - node_id = hit.get('node_id') - title = hit.get('note_id', 'Unbekannt') - payload = hit.get('payload', {}) - note_type = payload.get('type', 'unknown') + with st.form("new_entry"): + col1, col2 = st.columns([3, 1]) + title = col1.text_input("Titel der Notiz", placeholder="z.B. Projekt Gamma Meeting") + n_type = col2.selectbox("Typ", ["concept", "meeting", "person", "project", "decision"]) - # Icon basierend auf Score - score_icon = "🟢" if score > 0.8 else "🟡" if score > 0.5 else "⚪" - expander_title = f"{score_icon} {title} (Typ: {note_type}, Score: {score:.2f})" + content = st.text_area("Inhalt (Markdown)", height=300, placeholder="# Protokoll\n\n- Punkt 1...") - with st.expander(expander_title): - # 1. Inhalt - text = hit.get('source', {}).get('text', 'Kein Text') - st.markdown(f"_{text[:300]}..._") - - # 2. Explanation (Why-Layer) - if 'explanation' in hit and hit['explanation']: - st.caption("**Warum gefunden?**") - for r in hit['explanation'].get('reasons', []): - st.caption(f"- {r.get('message')}") - - # 3. Granulares Feedback (Source Level) - JETZT MIT NUANCEN - st.markdown("---") - c1, c2 = st.columns([2, 2]) - with c1: - st.caption("Relevanz dieser Quelle:") - with c2: - # Callback Wrapper für Source-Feedback - def on_source_fb(qid=query_id, nid=node_id, k=f"fb_src_{node_id}"): - val = st.session_state.get(k) - # Mapping: - # Faces liefert 0 (😞) bis 4 (😀). - # Wir mappen das auf 1-5 für das Backend. - if val is not None: - submit_feedback(qid, nid, val + 1, comment="Source Feedback (Faces)") - - # 'faces' bietet 5 Stufen: 😞(1) 🙁(2) 😐(3) 🙂(4) 😀(5) - st.feedback( - "faces", - key=f"fb_src_{query_id}_{node_id}", - on_change=on_source_fb, - kwargs={"qid": query_id, "nid": node_id, "k": f"fb_src_{query_id}_{node_id}"} - ) - -# --- MAIN APP --- - -top_k, show_explain = render_sidebar() -st.title("mindnet v2.3.1") - -# 1. Chat History Rendern -for msg in st.session_state.messages: - with st.chat_message(msg["role"]): - if msg["role"] == "assistant": - # Meta-Daten - if "intent" in msg: - st.markdown(render_intent_badge(msg["intent"], msg.get("intent_source", "?")), unsafe_allow_html=True) - - # Antwort-Text - st.markdown(msg["content"]) - - # Quellen (mit Feedback-Option, aber Status ist readonly für alte Nachrichten in Streamlit oft schwierig, - # daher rendern wir Feedback-Controls idealerweise nur für die letzte Nachricht oder speichern Status) - # In dieser Version rendern wir sie immer, Streamlit State managed das. - if "sources" in msg: - render_sources(msg["sources"], msg["query_id"]) - - # Globales Feedback (Sterne) - qid = msg["query_id"] - - def on_global_fb(q=qid, k=f"fb_glob_{qid}"): - val = st.session_state.get(k) # Liefert 0-4 - if val is not None: - submit_feedback(q, "generated_answer", val + 1, comment="Global Star Rating") - - st.caption("Wie gut war diese Antwort?") - st.feedback( - "stars", - key=f"fb_glob_{qid}", - on_change=on_global_fb - ) - - else: - st.markdown(msg["content"]) - -# 2. User Input -if prompt := st.chat_input("Deine Frage an das System..."): - # User Message anzeigen - st.session_state.messages.append({"role": "user", "content": prompt}) - with st.chat_message("user"): - st.markdown(prompt) - - # API Call - with st.chat_message("assistant"): - with st.spinner("Thinking..."): - resp = send_chat_message(prompt, top_k, show_explain) + st.markdown("**Automatische Vernetzung:**") + st.caption("Verwende `[[Link]]` für Referenzen und `[[rel:depends_on X]]` für logische Kanten.") - if "error" in resp: - st.error(resp["error"]) - else: - # Daten extrahieren - answer = resp.get("answer", "") - intent = resp.get("intent", "FACT") - source = resp.get("intent_source", "Unknown") - query_id = resp.get("query_id") - hits = resp.get("sources", []) - - # Sofort rendern (damit User nicht auf Rerun warten muss) - st.markdown(render_intent_badge(intent, source), unsafe_allow_html=True) - st.markdown(answer) - render_sources(hits, query_id) - - # Feedback Slot für die NEUE Nachricht vorbereiten - st.caption("Wie gut war diese Antwort?") - st.feedback("stars", key=f"fb_glob_{query_id}", on_change=lambda: submit_feedback(query_id, "generated_answer", st.session_state[f"fb_glob_{query_id}"] + 1)) + submitted = st.form_submit_button("💾 Speichern & Indizieren") + if submitted: + # TODO: Hier müsste der POST Request an eine /ingest API gehen + # Da diese API in v2.3.1 noch fehlt, simulieren wir es. + st.success(f"Mockup: Notiz '{title}' ({n_type}) wäre jetzt gespeichert worden!") + st.balloons() - # In History speichern - st.session_state.messages.append({ - "role": "assistant", - "content": answer, - "intent": intent, - "intent_source": source, - "sources": hits, - "query_id": query_id - }) \ No newline at end of file +# --- MAIN LOOP --- +mode, top_k, explain = render_sidebar() + +if mode == "💬 Chat": + render_chat_interface(top_k, explain) +else: + render_creation_interface() \ No newline at end of file