mindnet/app/frontend/ui.py
2025-12-14 08:27:15 +01:00

755 lines
30 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
import unicodedata
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
# --- WP-19 GRAPH IMPORTS ---
try:
from streamlit_agraph import agraph, Node, Edge, Config
from qdrant_client import QdrantClient, models
except ImportError:
st.error("Fehlende Bibliotheken! Bitte installiere: pip install streamlit-agraph qdrant-client")
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 ---
st.set_page_config(page_title="mindnet v2.6", page_icon="🧠", layout="wide")
# --- CSS STYLING ---
st.markdown("""
<style>
.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;
}
.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>
""", 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())
# --- GRAPH STYLING CONFIG (WP-19) ---
# Colors based on types.yaml and standard conventions
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)
# --- 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.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()
if mode == "💬 Chat":
render_chat_interface(top_k, explain)
elif mode == "📝 Manueller Editor":
render_manual_editor()
elif mode == "🕸️ Graph Explorer":
render_graph_explorer()