From 865b2612946d19aba8f32cba1a4f2af20f0d4eca Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 Dec 2025 21:41:43 +0100 Subject: [PATCH 01/30] neuer Importer mit eigenem Service --- app/core/ingestion.py | 264 ++++++++++++++++ scripts/import_markdown.py | 620 +++---------------------------------- 2 files changed, 309 insertions(+), 575 deletions(-) create mode 100644 app/core/ingestion.py diff --git a/app/core/ingestion.py b/app/core/ingestion.py new file mode 100644 index 0000000..32c82e3 --- /dev/null +++ b/app/core/ingestion.py @@ -0,0 +1,264 @@ +""" +app/core/ingestion.py + +Zentraler Service für die Transformation von Markdown-Dateien in Qdrant-Objekte (Notes, Chunks, Edges). +Dient als Shared Logic für: +1. CLI-Imports (scripts/import_markdown.py) +2. API-Uploads (WP-11) +""" +import os +import json +from typing import Dict, List, Optional, Tuple, Any, Set + +from app.core.parser import ( + read_markdown, + normalize_frontmatter, + validate_required_frontmatter, +) +from app.core.note_payload import make_note_payload +from app.core.chunker import assemble_chunks +from app.core.chunk_payload import make_chunk_payloads +# Fallback Imports wie im Original-Skript +try: + from app.core.derive_edges import build_edges_for_note +except ImportError: + from app.core.edges import build_edges_for_note # type: ignore + +from app.core.qdrant import QdrantConfig, get_client, ensure_collections, ensure_payload_indexes +from app.core.qdrant_points import ( + points_for_chunks, + points_for_note, + points_for_edges, + upsert_batch, +) + +# Optionales Embedding +try: + from app.core.embed import embed_texts +except ImportError: + embed_texts = None + +# --- Helper für Type-Registry (ausgelagert aus Script) --- +def load_type_registry(custom_path: Optional[str] = None) -> dict: + import yaml + path = custom_path or os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") + if not os.path.exists(path): + # Fallback auf Root-Ebene (für Tests/CLI) + if os.path.exists("types.yaml"): + path = "types.yaml" + else: + return {} + try: + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + except Exception: + return {} + +def resolve_note_type(requested: Optional[str], reg: dict) -> str: + types = reg.get("types", {}) + if requested and requested in types: + return requested + return "concept" # Default Fallback + +def effective_chunk_profile(note_type: str, reg: dict) -> str: + # 1. Specific Type + t_cfg = reg.get("types", {}).get(note_type, {}) + if t_cfg and t_cfg.get("chunk_profile"): + return t_cfg.get("chunk_profile") + # 2. Defaults + return reg.get("defaults", {}).get("chunk_profile", "default") + +def effective_retriever_weight(note_type: str, reg: dict) -> float: + t_cfg = reg.get("types", {}).get(note_type, {}) + if t_cfg and "retriever_weight" in t_cfg: + return float(t_cfg["retriever_weight"]) + return float(reg.get("defaults", {}).get("retriever_weight", 1.0)) + + +class IngestionService: + def __init__(self, collection_prefix: str = "mindnet"): + self.prefix = collection_prefix + self.cfg = QdrantConfig.from_env() + self.cfg.prefix = collection_prefix # Override env if needed + self.client = get_client(self.cfg) + self.dim = self.cfg.dim + + # Registry laden + self.registry = load_type_registry() + + # Init DB Checks + ensure_collections(self.client, self.prefix, self.dim) + ensure_payload_indexes(self.client, self.prefix) + + def process_file( + self, + file_path: str, + vault_root: str, + force_replace: bool = False, + apply: bool = False, + purge_before: bool = False, + note_scope_refs: bool = False, + hash_mode: str = "body", + hash_source: str = "parsed", + hash_normalize: str = "canonical" + ) -> Dict[str, Any]: + """ + Verarbeitet eine einzelne Datei. + Return: Summary Dict (Erfolg, Änderungen, Stats). + """ + result = { + "path": file_path, + "status": "skipped", + "changed": False, + "error": None + } + + # 1. Parse & Frontmatter + try: + parsed = read_markdown(file_path) + if not parsed: + return {**result, "error": "Empty or unreadable file"} + + fm = normalize_frontmatter(parsed.frontmatter) + validate_required_frontmatter(fm) + except Exception as e: + return {**result, "error": f"Validation failed: {str(e)}"} + + # 2. Type & Config Resolution + note_type = resolve_note_type(fm.get("type"), self.registry) + fm["type"] = note_type + fm["chunk_profile"] = effective_chunk_profile(note_type, self.registry) + + # Weight Resolution (Frontmatter override > Registry) + weight = fm.get("retriever_weight") + if weight is None: + weight = effective_retriever_weight(note_type, self.registry) + fm["retriever_weight"] = float(weight) + + # 3. Build Note Payload + try: + note_pl = make_note_payload( + parsed, + vault_root=vault_root, + hash_mode=hash_mode, + hash_normalize=hash_normalize, + hash_source=hash_source, + file_path=file_path + ) + # Ensure fulltext & weight + if not note_pl.get("fulltext"): + note_pl["fulltext"] = getattr(parsed, "body", "") or "" + note_pl["retriever_weight"] = fm["retriever_weight"] + + note_id = note_pl["note_id"] + except Exception as e: + return {**result, "error": f"Payload build failed: {str(e)}"} + + # 4. Change Detection (Hash Check) + # Wir holen den alten Payload aus Qdrant, wenn wir nicht forcen + old_payload = None + if not force_replace: + old_payload = self._fetch_note_payload(note_id) + + has_old = old_payload is not None + key_current = f"{hash_mode}:{hash_source}:{hash_normalize}" + + old_hash = (old_payload or {}).get("hashes", {}).get(key_current) + new_hash = note_pl.get("hashes", {}).get(key_current) + + hash_changed = (old_hash != new_hash) + + # Artefakte prüfen (Chunks/Edges) + chunks_missing, edges_missing = self._artifacts_missing(note_id) + + should_write = force_replace or (not has_old) or hash_changed or chunks_missing or edges_missing + + if not should_write: + return {**result, "status": "unchanged", "note_id": note_id} + + if not apply: + return {**result, "status": "dry-run", "changed": True, "note_id": note_id} + + # 5. Processing (Chunking, Embedding, Edges) + try: + body_text = getattr(parsed, "body", "") or "" + chunks = assemble_chunks(fm["id"], body_text, fm["type"]) + chunk_pls = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text) + + # Embeddings + vecs = [] + if embed_texts and chunk_pls: + texts = [c.get("window") or c.get("text") or "" for c in chunk_pls] + vecs = embed_texts(texts) + else: + vecs = [[0.0] * self.dim for _ in chunk_pls] + + # Edges + note_refs = note_pl.get("references") or [] + edges = build_edges_for_note( + note_id, + chunk_pls, + note_level_references=note_refs, + include_note_scope_refs=note_scope_refs + ) + except Exception as e: + return {**result, "error": f"Processing failed: {str(e)}"} + + # 6. Upsert Action + if purge_before and has_old: + self._purge_artifacts(note_id) + + # Upsert Note + n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim) + upsert_batch(self.client, n_name, n_pts) + + # Upsert Chunks + if chunk_pls: + c_name, c_pts = points_for_chunks(self.prefix, chunk_pls, vecs) + upsert_batch(self.client, c_name, c_pts) + + # Upsert Edges + if edges: + e_name, e_pts = points_for_edges(self.prefix, edges) + upsert_batch(self.client, e_name, e_pts) + + return { + "path": file_path, + "status": "success", + "changed": True, + "note_id": note_id, + "chunks_count": len(chunk_pls), + "edges_count": len(edges) + } + + # --- Interne Qdrant Helper --- + + def _fetch_note_payload(self, note_id: str) -> Optional[dict]: + from qdrant_client.http import models as rest + col = f"{self.prefix}_notes" + f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) + pts, _ = self.client.scroll(collection_name=col, scroll_filter=f, limit=1, with_payload=True) + return pts[0].payload if pts else None + + def _artifacts_missing(self, note_id: str) -> Tuple[bool, bool]: + from qdrant_client.http import models as rest + c_col = f"{self.prefix}_chunks" + e_col = f"{self.prefix}_edges" + f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) + + # Check Chunks + c_pts, _ = self.client.scroll(collection_name=c_col, scroll_filter=f, limit=1) + # Check Edges + e_pts, _ = self.client.scroll(collection_name=e_col, scroll_filter=f, limit=1) + + return (not bool(c_pts)), (not bool(e_pts)) + + def _purge_artifacts(self, note_id: str): + from qdrant_client.http import models as rest + f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) + selector = rest.FilterSelector(filter=f) + for suffix in ["chunks", "edges"]: + try: + self.client.delete(collection_name=f"{self.prefix}_{suffix}", points_selector=selector) + except Exception: + pass \ No newline at end of file diff --git a/scripts/import_markdown.py b/scripts/import_markdown.py index ac04459..5640077 100644 --- a/scripts/import_markdown.py +++ b/scripts/import_markdown.py @@ -1,593 +1,63 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ scripts/import_markdown.py - -Zweck ------ -- Liest Markdown-Notizen aus einem Vault ein -- Erzeugt Note-Payload, Chunk-Payloads (+ optionale Embeddings) und Edges -- Schreibt alles idempotent in Qdrant (Notes, Chunks, Edges) -- Integriert eine optionale Type-Registry (types.yaml), um z. B. chunk_profile - und retriever_weight pro Notiz-Typ zu steuern. - -Wesentliche Fixes ggü. vorherigen fehlerhaften Ständen ------------------------------------------------------- -- `embed_texts` wird optional importiert und defensiv geprüft (kein NameError mehr) -- `effective_chunk_profile` / `effective_retriever_weight` und Registry-Helfer - sind VOR `main()` definiert (kein NameError mehr) -- `retriever_weight` wird in Note- und Chunk-Payload zuverlässig gesetzt -- Robuste Kantenbildung; Fehler bei Edges blockieren Notes/Chunks nicht -- Korrekte Verwendung von `scroll_filter` beim Qdrant-Client -- `--purge-before-upsert` entfernt alte Chunks/Edges einer Note vor dem Upsert - -Qdrant / ENV ------------- -- QDRANT_URL | QDRANT_HOST/QDRANT_PORT | QDRANT_API_KEY -- COLLECTION_PREFIX (Default: mindnet), via --prefix überschreibbar -- VECTOR_DIM (Default: 384) -- MINDNET_NOTE_SCOPE_REFS: true|false (Default: false) -- MINDNET_TYPES_FILE: Pfad zu types.yaml (optional; Default: ./types.yaml) - -Beispiele ---------- - # Standard (Body, parsed, canonical) - python3 -m scripts.import_markdown --vault ./vault - - # Erstimport nach truncate (Create-Fall) - python3 -m scripts.import_markdown --vault ./vault --apply --purge-before-upsert - - # Nur eine Datei (Diagnose) - python3 -m scripts.import_markdown --vault ./vault --only-path ./vault/30_projects/project-demo.md --apply - - # Sync-Deletes (Dry-Run → Apply) - python3 -m scripts.import_markdown --vault ./vault --sync-deletes - python3 -m scripts.import_markdown --vault ./vault --sync-deletes --apply +Refactored CLI-Wrapper für den IngestionService. """ -from __future__ import annotations - import argparse -import json import os +import json import sys -from typing import Dict, List, Optional, Tuple, Any, Set - from dotenv import load_dotenv -from qdrant_client.http import models as rest +from app.core.ingestion import IngestionService -# --- Projekt-Imports --- -from app.core.parser import ( - read_markdown, - normalize_frontmatter, - validate_required_frontmatter, -) -from app.core.note_payload import make_note_payload -from app.core.chunker import assemble_chunks -from app.core.chunk_payload import make_chunk_payloads -try: - from app.core.derive_edges import build_edges_for_note -except Exception: # pragma: no cover - from app.core.edges import build_edges_for_note # type: ignore -from app.core.qdrant import ( - QdrantConfig, - get_client, - ensure_collections, - ensure_payload_indexes, -) -from app.core.qdrant_points import ( - points_for_chunks, - points_for_note, - points_for_edges, - upsert_batch, -) - -# embeddings sind optional (z. B. im reinen Payload-Backfill) -try: - from app.core.embed import embed_texts # optional -except Exception: # pragma: no cover - embed_texts = None - - -# --------------------------------------------------------------------- -# Type-Registry (types.yaml) – Helper (robust, optional) -# --------------------------------------------------------------------- - -def _env(name: str, default: Optional[str] = None) -> Optional[str]: - v = os.getenv(name) - return v if v is not None else default - -def _load_json_or_yaml(path: str) -> dict: - import io - data: dict = {} - if not path or not os.path.exists(path): - return data - try: - import yaml # type: ignore - with io.open(path, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) or {} - if not isinstance(data, dict): - return {} - return data - except Exception: - # YAML evtl. nicht installiert – versuche JSON - try: - with io.open(path, "r", encoding="utf-8") as f: - data = json.load(f) - if not isinstance(data, dict): - return {} - return data - except Exception: - return {} - -def load_type_registry() -> dict: - # Reihenfolge: ENV > ./types.yaml (im aktuellen Arbeitsverzeichnis) - p = _env("MINDNET_TYPES_FILE", None) - if p and os.path.exists(p): - return _load_json_or_yaml(p) - fallback = os.path.abspath("./config/types.yaml") if os.path.exists("./config/types.yaml") else os.path.abspath("./types.yaml") - return _load_json_or_yaml(fallback) - -def get_type_config(note_type: Optional[str], reg: dict) -> dict: - if not reg or not isinstance(reg, dict): - return {} - types = reg.get("types", {}) if isinstance(reg.get("types"), dict) else {} - if note_type and isinstance(note_type, str) and note_type in types: - return types[note_type] or {} - # Fallback: concept - return types.get("concept", {}) or {} - -def resolve_note_type(requested: Optional[str], reg: dict) -> str: - if requested and isinstance(requested, str): - return requested - # Fallback wenn nichts gesetzt ist - types = reg.get("types", {}) if isinstance(reg.get("types"), dict) else {} - return "concept" if "concept" in types else (requested or "concept") - -def effective_chunk_profile(note_type: str, reg: dict) -> Optional[str]: - """Resolve chunk_profile for type or from defaults/global. - Accepts symbolic profiles: short|medium|long|default. - """ - cfg = get_type_config(note_type, reg) - prof = (cfg.get("chunk_profile") if isinstance(cfg, dict) else None) - if isinstance(prof, str) and prof: - return prof - # defaults fallbacks - for key in ("defaults", "default", "global"): - dcfg = reg.get(key) if isinstance(reg, dict) else None - if isinstance(dcfg, dict): - dprof = dcfg.get("chunk_profile") - if isinstance(dprof, str) and dprof: - return dprof - return "default" - -def effective_retriever_weight(note_type: str, reg: dict) -> Optional[float]: - """Resolve retriever_weight for type or defaults; returns float. - """ - cfg = get_type_config(note_type, reg) - w = (cfg.get("retriever_weight") if isinstance(cfg, dict) else None) - try: - if w is not None: - return float(w) - except Exception: - pass - # defaults fallbacks - for key in ("defaults", "default", "global"): - dcfg = reg.get(key) if isinstance(reg, dict) else None - if isinstance(dcfg, dict): - dw = dcfg.get("retriever_weight") - try: - if dw is not None: - return float(dw) - except Exception: - pass - return 1.0 - - -# --------------------------------------------------------------------- -# Sonstige Helper -# --------------------------------------------------------------------- - -def iter_md(root: str) -> List[str]: - out: List[str] = [] - for dirpath, _, filenames in os.walk(root): - for fn in filenames: - if not fn.lower().endswith(".md"): - continue - p = os.path.join(dirpath, fn) - pn = p.replace("\\", "/") - if any(ex in pn for ex in ["/.obsidian/", "/_backup_frontmatter/", "/_imported/"]): - continue - out.append(p) +def iter_md(root: str): + out = [] + for dp, _, fns in os.walk(root): + for fn in fns: + if fn.endswith(".md") and "/.obsidian/" not in dp: + out.append(os.path.join(dp, fn).replace("\\", "/")) return sorted(out) -def collections(prefix: str) -> Tuple[str, str, str]: - return f"{prefix}_notes", f"{prefix}_chunks", f"{prefix}_edges" - -def fetch_existing_note_payload(client, prefix: str, note_id: str) -> Optional[Dict]: - notes_col, _, _ = collections(prefix) - f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - points, _ = client.scroll( - collection_name=notes_col, - scroll_filter=f, # wichtig: scroll_filter (nicht: filter) - with_payload=True, - with_vectors=False, - limit=1, - ) - if not points: - return None - return points[0].payload or {} - -def list_qdrant_note_ids(client, prefix: str) -> Set[str]: - notes_col, _, _ = collections(prefix) - out: Set[str] = set() - next_page = None - while True: - pts, next_page = client.scroll( - collection_name=notes_col, - with_payload=True, - with_vectors=False, - limit=256, - offset=next_page, - ) - if not pts: - break - for p in pts: - pl = p.payload or {} - nid = pl.get("note_id") - if isinstance(nid, str): - out.add(nid) - if next_page is None: - break - return out - -def purge_note_artifacts(client, prefix: str, note_id: str) -> None: - _, chunks_col, edges_col = collections(prefix) - filt = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - for col in (chunks_col, edges_col): - try: - client.delete( - collection_name=col, - points_selector=rest.FilterSelector(filter=filt), - wait=True - ) - except Exception as e: - print(json.dumps({"note_id": note_id, "warn": f"delete in {col} via filter failed: {e}"})) - -def delete_note_everywhere(client, prefix: str, note_id: str) -> None: - notes_col, chunks_col, edges_col = collections(prefix) - filt = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - for col in (edges_col, chunks_col, notes_col): - try: - client.delete( - collection_name=col, - points_selector=rest.FilterSelector(filter=filt), - wait=True - ) - except Exception as e: - print(json.dumps({"note_id": note_id, "warn": f"delete in {col} failed: {e}"})) - - -# --- Neu: Existenz-Checks für Artefakte (fehlertoleranter Rebuild) --- - -def _has_any_point(client, collection: str, note_id: str) -> bool: - """Prüft, ob es mind. einen Punkt mit note_id in der Collection gibt.""" - filt = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - pts, _ = client.scroll( - collection_name=collection, - scroll_filter=filt, - with_payload=False, - with_vectors=False, - limit=1, - ) - return bool(pts) - -def artifacts_missing(client, prefix: str, note_id: str) -> Tuple[bool, bool]: - """Gibt (chunks_missing, edges_missing) zurück.""" - _, chunks_col, edges_col = collections(prefix) - chunks_missing = not _has_any_point(client, chunks_col, note_id) - edges_missing = not _has_any_point(client, edges_col, note_id) - return chunks_missing, edges_missing - - -# --------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------- - -def _resolve_mode(m: Optional[str]) -> str: - m = (m or "body").strip().lower() - return m if m in {"body", "frontmatter", "full"} else "body" - -def main() -> None: +def main(): load_dotenv() - - ap = argparse.ArgumentParser( - prog="scripts.import_markdown", - description="Importiert Markdown-Notizen in Qdrant (Notes/Chunks/Edges)." - ) - ap.add_argument("--vault", required=True, help="Pfad zum Vault (Ordner mit .md-Dateien)") - ap.add_argument("--only-path", help="Nur diese Datei verarbeiten (absolut oder relativ)") - ap.add_argument("--apply", action="store_true", help="Schreibt nach Qdrant (sonst Dry-Run)") - ap.add_argument("--purge-before-upsert", action="store_true", help="Alte Chunks/Edges der Note vorher löschen") - ap.add_argument("--force-replace", action="store_true", help="Note/Chunks/Edges unabhängig von Hash neu schreiben") - ap.add_argument("--note-id", help="Nur Notes mit dieser ID verarbeiten (Filter)") - ap.add_argument("--note-scope-refs", action="store_true", help="Note-scope References/Backlinks erzeugen") - ap.add_argument("--hash-mode", help="body|frontmatter|full (Default body)") - ap.add_argument("--hash-source", help="parsed|raw (Default parsed)") - ap.add_argument("--hash-normalize", help="canonical|none (Default canonical)") - ap.add_argument("--compare-text", action="store_true", help="Parsed fulltext zusätzlich direkt vergleichen") - ap.add_argument("--baseline-modes", action="store_true", help="Fehlende Hash-Varianten still nachtragen (Notes)") - ap.add_argument("--sync-deletes", action="store_true", help="Qdrant->Vault Lösch-Sync (Dry-Run; mit --apply ausführen)") - ap.add_argument("--prefix", help="Collection-Prefix (überschreibt ENV COLLECTION_PREFIX)") + ap = argparse.ArgumentParser() + ap.add_argument("--vault", required=True) + ap.add_argument("--apply", action="store_true") + ap.add_argument("--purge-before-upsert", action="store_true") + ap.add_argument("--force-replace", action="store_true") + ap.add_argument("--prefix", default="mindnet") + # Weitere Argumente (compat) können hier hinzugefügt werden, wenn nötig args = ap.parse_args() - mode = _resolve_mode(args.hash_mode) # body|frontmatter|full - src = _env("MINDNET_HASH_SOURCE", args.hash_source or "parsed") # parsed|raw - norm = _env("MINDNET_HASH_NORMALIZE", args.hash_normalize or "canonical") # canonical|none - note_scope_refs_env = (_env("MINDNET_NOTE_SCOPE_REFS", "false") == "true") - note_scope_refs = args.note_scope_refs or note_scope_refs_env - compare_text = args.compare_text or (_env("MINDNET_COMPARE_TEXT", "false") == "true") - - # Qdrant - cfg = QdrantConfig.from_env() - if args.prefix: - cfg.prefix = args.prefix.strip() - client = get_client(cfg) - ensure_collections(client, cfg.prefix, cfg.dim) - ensure_payload_indexes(client, cfg.prefix) - - # Type-Registry laden (optional) - reg = load_type_registry() - - root = os.path.abspath(args.vault) - - # Dateiliste - if args.only_path: - only = os.path.abspath(args.only_path) - files = [only] - else: - files = iter_md(root) - if not files: - print("Keine Markdown-Dateien gefunden.", file=sys.stderr) - sys.exit(2) - - # Optional: Sync-Deletes vorab - if args.sync_deletes: - vault_note_ids: Set[str] = set() - for path in files: - try: - parsed = read_markdown(path) - if not parsed: - continue - fm = normalize_frontmatter(parsed.frontmatter) - nid = fm.get("id") - if isinstance(nid, str): - vault_note_ids.add(nid) - except Exception: - continue - qdrant_note_ids = list_qdrant_note_ids(client, cfg.prefix) - to_delete = sorted(qdrant_note_ids - vault_note_ids) - print(json.dumps({ - "action": "sync-deletes", - "prefix": cfg.prefix, - "qdrant_total": len(qdrant_note_ids), - "vault_total": len(vault_note_ids), - "to_delete_count": len(to_delete), - "to_delete": to_delete[:50] + (["…"] if len(to_delete) > 50 else []) - }, ensure_ascii=False)) - if args.apply and to_delete: - for nid in to_delete: - print(json.dumps({"action": "delete", "note_id": nid, "decision": "apply"})) - delete_note_everywhere(client, cfg.prefix, nid) - - key_current = f"{mode}:{src}:{norm}" - + print(f"Init IngestionService (Prefix: {args.prefix})...") + service = IngestionService(collection_prefix=args.prefix) + + files = iter_md(os.path.abspath(args.vault)) + print(f"Found {len(files)} files in vault.") + processed = 0 - for path in files: - try: - parsed = read_markdown(path) - if not parsed: - continue - except Exception as e: - print(json.dumps({"path": path, "error": f"read_markdown failed: {type(e).__name__}: {e}"})) - continue - - # --- Frontmatter prüfen --- - try: - fm = normalize_frontmatter(parsed.frontmatter) - validate_required_frontmatter(fm) - except Exception as e: - print(json.dumps({"path": path, "error": f"Frontmatter invalid: {type(e).__name__}: {e}"})) - continue - - if args.note_id and not args.only_path and fm.get("id") != args.note_id: - continue - - processed += 1 - - # --- Type-Registry anwenden (chunk_profile / retriever_weight) --- - try: - note_type = resolve_note_type(fm.get("type"), reg) - except Exception: - note_type = (fm.get("type") or "concept") - fm["type"] = note_type or fm.get("type") or "concept" - - prof = effective_chunk_profile(note_type, reg) - if prof: - fm["chunk_profile"] = prof - - weight = effective_retriever_weight(note_type, reg) - if weight is not None: - try: - fm["retriever_weight"] = float(weight) - except Exception: - pass # falls FM string-inkonsistent ist - - # --- Payload aufbauen (inkl. Hashes) --- - try: - note_pl = make_note_payload( - parsed, - vault_root=root, - hash_mode=mode, - hash_normalize=norm, - hash_source=src, - file_path=path, - ) - except Exception as e: - print(json.dumps({"path": path, "error": f"make_note_payload failed: {type(e).__name__}: {e}"})) - continue - - if not note_pl.get("fulltext"): - note_pl["fulltext"] = getattr(parsed, "body", "") or "" - - # retriever_weight sicher in Note-Payload spiegeln (für spätere Filter) - if "retriever_weight" not in note_pl and fm.get("retriever_weight") is not None: - try: - note_pl["retriever_weight"] = float(fm.get("retriever_weight")) - except Exception: - pass - - note_id = note_pl.get("note_id") or fm.get("id") - if not note_id: - print(json.dumps({"path": path, "error": "Missing note_id after payload build"})) - continue - - # --- bestehenden Payload laden (zum Diff) --- - old_payload = None if args.force_replace else fetch_existing_note_payload(client, cfg.prefix, note_id) - has_old = old_payload is not None - - old_hashes = (old_payload or {}).get("hashes") or {} - old_hash_exact = old_hashes.get(key_current) - new_hash_exact = (note_pl.get("hashes") or {}).get(key_current) - needs_baseline = (old_hash_exact is None) - - hash_changed = (old_hash_exact is not None and new_hash_exact is not None and old_hash_exact != new_hash_exact) - - text_changed = False - if compare_text: - old_text = (old_payload or {}).get("fulltext") or "" - new_text = note_pl.get("fulltext") or "" - text_changed = (old_text != new_text) - - changed = args.force_replace or (not has_old) or hash_changed or text_changed - do_baseline_only = (args.baseline_modes and has_old and needs_baseline and not changed) - - # --- Chunks + Embeddings vorbereiten --- - try: - body_text = getattr(parsed, "body", "") or "" - chunks = assemble_chunks(fm["id"], body_text, fm.get("type", "concept")) - chunk_pls: List[Dict[str, Any]] = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text) - except Exception as e: - print(json.dumps({"path": path, "note_id": note_id, "error": f"chunk build failed: {type(e).__name__}: {e}"})) - continue - - # retriever_weight auf Chunk-Payload spiegeln - if fm.get("retriever_weight") is not None: - try: - rw = float(fm.get("retriever_weight")) - for pl in chunk_pls: - # Feld nur setzen, wenn noch nicht vorhanden - if "retriever_weight" not in pl: - pl["retriever_weight"] = rw - except Exception: - pass - - # Embeddings (fallback: Nullvektoren) - vecs: List[List[float]] = [[0.0] * int(cfg.dim) for _ in chunk_pls] - if embed_texts and chunk_pls: - try: - texts_for_embed = [(pl.get("window") or pl.get("text") or "") for pl in chunk_pls] - vecs = embed_texts(texts_for_embed) - except Exception as e: - print(json.dumps({"path": path, "note_id": note_id, "warn": f"embed_texts failed, using zeros: {e}"})) - - # --- Fehlende Artefakte in Qdrant ermitteln --- - chunks_missing, edges_missing = artifacts_missing(client, cfg.prefix, note_id) - - # --- Edges (robust) --- - edges: List[Dict[str, Any]] = [] - edges_failed = False - should_build_edges = (changed and (not do_baseline_only)) or edges_missing - if should_build_edges: - try: - note_refs = note_pl.get("references") or [] - edges = build_edges_for_note( - note_id, - chunk_pls, - note_level_references=note_refs, - include_note_scope_refs=note_scope_refs, - ) - except Exception as e: - edges_failed = True - edges = [] - print(json.dumps({"path": path, "note_id": note_id, "warn": f"build_edges_for_note failed, skipping edges: {type(e).__name__}: {e}"})) - - # --- Summary (stdout) --- - summary = { - "note_id": note_id, - "title": fm.get("title"), - "chunks": len(chunk_pls), - "edges": len(edges), - "edges_failed": edges_failed, - "changed": changed, - "chunks_missing": chunks_missing, - "edges_missing": edges_missing, - "needs_baseline_for_mode": needs_baseline, - "decision": ("baseline-only" if args.apply and do_baseline_only else - "apply" if args.apply and (changed or chunks_missing or edges_missing) else - "apply-skip-unchanged" if args.apply and not (changed or chunks_missing or edges_missing) else - "dry-run"), - "path": note_pl["path"], - "hash_mode": mode, - "hash_normalize": norm, - "hash_source": src, - "prefix": cfg.prefix, - } - print(json.dumps(summary, ensure_ascii=False)) - - # --- Writes --- - if not args.apply: - continue - - if do_baseline_only: - merged_hashes = {} - merged_hashes.update(old_hashes) - merged_hashes.update(note_pl.get("hashes") or {}) - if old_payload: - note_pl["hash_fulltext"] = old_payload.get("hash_fulltext", note_pl.get("hash_fulltext")) - note_pl["hash_signature"] = old_payload.get("hash_signature", note_pl.get("hash_signature")) - note_pl["hashes"] = merged_hashes - notes_name, note_pts = points_for_note(cfg.prefix, note_pl, None, cfg.dim) - upsert_batch(client, notes_name, note_pts) - continue - - # Wenn nichts geändert und keine Artefakte fehlen → nichts zu tun - if not changed and not (chunks_missing or edges_missing): - continue - - # Purge nur bei echten Änderungen (unverändert + fehlende Artefakte ≠ Purge) - if args.purge_before_upsert and has_old and changed: - try: - purge_note_artifacts(client, cfg.prefix, note_id) - except Exception as e: - print(json.dumps({"path": path, "note_id": note_id, "warn": f"purge failed: {e}"})) - - # Note nur bei Änderungen neu schreiben - if changed: - notes_name, note_pts = points_for_note(cfg.prefix, note_pl, None, cfg.dim) - upsert_batch(client, notes_name, note_pts) - - # Chunks schreiben, wenn geändert ODER vorher fehlend - if chunk_pls and (changed or chunks_missing): - chunks_name, chunk_pts = points_for_chunks(cfg.prefix, chunk_pls, vecs) - upsert_batch(client, chunks_name, chunk_pts) - - # Edges schreiben, wenn vorhanden und (geändert ODER vorher fehlend) - if edges and (changed or edges_missing): - edges_name, edge_pts = points_for_edges(cfg.prefix, edges) - upsert_batch(client, edges_name, edge_pts) - - print(f"Done. Processed notes: {processed}") + errors = 0 + + for f in files: + # Hier rufen wir den neuen Service + res = service.process_file( + file_path=f, + vault_root=os.path.abspath(args.vault), + apply=args.apply, + force_replace=args.force_replace, + purge_before=args.purge_before_upsert + ) + + # Output nur wenn relevant (nicht skipped/unchanged) + if res.get("status") not in ["skipped", "unchanged"]: + print(json.dumps(res, ensure_ascii=False)) + processed += 1 + + if res.get("error"): + print(json.dumps(res, ensure_ascii=False), file=sys.stderr) + errors += 1 + print(f"Done. Processed/Changed: {processed}. Errors: {errors}") if __name__ == "__main__": - main() + main() \ No newline at end of file From 0170d9291fd3fa20700d7f27013ee7ef758ada3e Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 Dec 2025 22:05:35 +0100 Subject: [PATCH 02/30] erster Stand --- app/core/ingestion.py | 40 +++++++++- app/main.py | 11 ++- app/routers/ingest.py | 89 ++++++++++++++++++++++ app/services/discovery.py | 151 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 app/routers/ingest.py create mode 100644 app/services/discovery.py diff --git a/app/core/ingestion.py b/app/core/ingestion.py index 32c82e3..992c29d 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -261,4 +261,42 @@ class IngestionService: try: self.client.delete(collection_name=f"{self.prefix}_{suffix}", points_selector=selector) except Exception: - pass \ No newline at end of file + pass + + def create_from_text( + self, + markdown_content: str, + filename: str, + vault_root: str, + folder: str = "Inbox" # Standard-Ordner für neue Files + ) -> Dict[str, Any]: + """ + WP-11: Schreibt Text in eine physische Datei und indiziert sie sofort. + """ + # 1. Pfad vorbereiten + target_dir = os.path.join(vault_root, folder) + os.makedirs(target_dir, exist_ok=True) + + # Dateiname bereinigen (Sicherheit) + safe_filename = os.path.basename(filename) + if not safe_filename.endswith(".md"): + safe_filename += ".md" + + file_path = os.path.join(target_dir, safe_filename) + + # 2. Schreiben (Write to Disk - Single Source of Truth) + try: + with open(file_path, "w", encoding="utf-8") as f: + f.write(markdown_content) + except Exception as e: + return {"status": "error", "error": f"Disk write failed: {str(e)}"} + + # 3. Indizieren (Ingest) + # Wir rufen einfach die existierende Logik auf! + return self.process_file( + file_path=file_path, + vault_root=vault_root, + apply=True, # Sofort schreiben + force_replace=True, # Da neu, erzwingen wir Update + purge_before=True # Sauberer Start + ) \ No newline at end of file diff --git a/app/main.py b/app/main.py index 3afd514..98e7ea5 100644 --- a/app/main.py +++ b/app/main.py @@ -11,16 +11,18 @@ from .routers.query import router as query_router from .routers.graph import router as graph_router from .routers.tools import router as tools_router from .routers.feedback import router as feedback_router -# NEU: Chat Router (WP-05) from .routers.chat import router as chat_router +# NEU: Ingest Router (WP-11) +from .routers.ingest import router as ingest_router + try: from .routers.admin import router as admin_router except Exception: admin_router = None def create_app() -> FastAPI: - app = FastAPI(title="mindnet API", version="0.5.0") # Version bump WP-05 + app = FastAPI(title="mindnet API", version="0.6.0") # Version bump WP-11 s = get_settings() @app.get("/healthz") @@ -34,9 +36,10 @@ def create_app() -> FastAPI: app.include_router(graph_router, prefix="/graph", tags=["graph"]) app.include_router(tools_router, prefix="/tools", tags=["tools"]) app.include_router(feedback_router, prefix="/feedback", tags=["feedback"]) - - # NEU: Chat Endpoint app.include_router(chat_router, prefix="/chat", tags=["chat"]) + + # NEU: Registrierung des Ingest-Routers + app.include_router(ingest_router, prefix="/ingest", tags=["ingest"]) if admin_router: app.include_router(admin_router, prefix="/admin", tags=["admin"]) diff --git a/app/routers/ingest.py b/app/routers/ingest.py new file mode 100644 index 0000000..4e9a7a0 --- /dev/null +++ b/app/routers/ingest.py @@ -0,0 +1,89 @@ +""" +app/routers/ingest.py +API-Endpunkte für WP-11 (Discovery & Persistence). +""" +import os +import time +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, List, Dict, Any + +from app.core.ingestion import IngestionService +from app.services.discovery import DiscoveryService + +router = APIRouter() + +# --- DTOs --- + +class AnalyzeRequest(BaseModel): + text: str + type: str = "concept" + +class SaveRequest(BaseModel): + markdown_content: str + filename: Optional[str] = None # Optional, fallback auf Timestamp/Titel + folder: str = "00_Inbox" # Zielordner im Vault + +class SaveResponse(BaseModel): + status: str + file_path: str + note_id: str + stats: Dict[str, Any] + +# --- Services --- +# Instanzierung hier oder via Dependency Injection +discovery_service = DiscoveryService() + +@router.post("/analyze") +async def analyze_draft(req: AnalyzeRequest): + """ + WP-11 Intelligence: Analysiert einen Entwurf und liefert Link-Vorschläge. + """ + try: + result = await discovery_service.analyze_draft(req.text, req.type) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") + +@router.post("/save", response_model=SaveResponse) +async def save_note(req: SaveRequest): + """ + WP-11 Persistence: Speichert Markdown physisch und indiziert es in Qdrant. + """ + # 1. Vault Root ermitteln + vault_root = os.getenv("MINDNET_VAULT_ROOT", "./vault") + if not os.path.exists(vault_root): + # Fallback für Dev-Umgebungen + if os.path.exists("../vault"): + vault_root = "../vault" + else: + raise HTTPException(status_code=500, detail="Vault root not configured or missing") + + # 2. Filename generieren falls fehlend + final_filename = req.filename + if not final_filename: + # Einfacher Fallback: Timestamp + final_filename = f"draft_{int(time.time())}.md" + + # 3. Ingestion Service nutzen + ingest_service = IngestionService() # nutzt Default-Prefix oder aus Env + + result = ingest_service.create_from_text( + markdown_content=req.markdown_content, + filename=final_filename, + vault_root=os.path.abspath(vault_root), + folder=req.folder + ) + + if result.get("status") == "error": + raise HTTPException(status_code=500, detail=result.get("error")) + + return SaveResponse( + status="success", + file_path=result["path"], + note_id=result.get("note_id", "unknown"), + stats={ + "chunks": result.get("chunks_count", 0), + "edges": result.get("edges_count", 0) + } + ) \ No newline at end of file diff --git a/app/services/discovery.py b/app/services/discovery.py new file mode 100644 index 0000000..359a13a --- /dev/null +++ b/app/services/discovery.py @@ -0,0 +1,151 @@ +""" +app/services/discovery.py +Service für Link-Vorschläge und Knowledge-Discovery (WP-11). +Analysiert Drafts auf Keywords und semantische Ähnlichkeiten. +""" +import logging +from typing import List, Dict, Any, Set +from qdrant_client.http import models as rest + +from app.core.qdrant import QdrantConfig, get_client +from app.models.dto import QueryRequest +from app.core.retriever import hybrid_retrieve + +logger = logging.getLogger(__name__) + +class DiscoveryService: + def __init__(self, collection_prefix: str = "mindnet"): + self.prefix = collection_prefix + self.cfg = QdrantConfig.from_env() + self.cfg.prefix = collection_prefix + self.client = get_client(self.cfg) + + async def analyze_draft(self, text: str, current_type: str) -> Dict[str, Any]: + """ + Analysiert einen Draft-Text und schlägt Verlinkungen vor. + Kombiniert Exact Match (Titel) und Semantic Match (Vektor). + """ + suggestions = [] + + # 1. Exact Match: Finde Begriffe im Text, die als Notiz-Titel existieren + # (Bei sehr großen Vaults >10k sollte dies gecached werden) + known_entities = self._fetch_all_titles_and_aliases() + found_entities = self._find_entities_in_text(text, known_entities) + + existing_target_ids = set() + + for entity in found_entities: + existing_target_ids.add(entity["id"]) + suggestions.append({ + "type": "exact_match", + "text_found": entity["match"], + "target_title": entity["title"], + "target_id": entity["id"], + "confidence": 1.0, + "reason": "Existierender Notiz-Titel" + }) + + # 2. Semantic Match: Finde inhaltlich ähnliche Notizen via Vektor-Suche + # Wir filtern Ergebnisse heraus, die wir schon per Exact Match gefunden haben. + semantic_hits = self._get_semantic_suggestions(text) + + for hit in semantic_hits: + if hit.node_id in existing_target_ids: + continue + + # Schwellwert: Nur relevante Vorschläge anzeigen (z.B. > 0.65) + # Wir nutzen den total_score, der bereits Typ-Gewichte enthält. + if hit.total_score > 0.65: + suggestions.append({ + "type": "semantic_match", + "text_found": (hit.source.get("text") or "")[:50] + "...", # Snippet + "target_title": hit.payload.get("title", "Unbekannt"), + "target_id": hit.node_id, + "confidence": round(hit.total_score, 2), + "reason": f"Inhaltliche Ähnlichkeit (Score: {round(hit.total_score, 2)})" + }) + + return { + "draft_length": len(text), + "suggestions_count": len(suggestions), + "suggestions": suggestions + } + + def _fetch_all_titles_and_aliases(self) -> List[Dict]: + """Lädt alle Titel und Aliases aus der Notes-Collection.""" + notes = [] + next_page = None + col_name = f"{self.prefix}_notes" + + try: + while True: + # Scroll API nutzen, um effizient alle Metadaten zu laden + res, next_page = self.client.scroll( + collection_name=col_name, + limit=1000, + offset=next_page, + with_payload=True, + with_vectors=False + ) + for point in res: + pl = point.payload or {} + notes.append({ + "id": pl.get("note_id"), + "title": pl.get("title"), + "aliases": pl.get("aliases", []) + }) + + if next_page is None: + break + except Exception as e: + logger.error(f"Error fetching titles: {e}") + return [] + + return notes + + def _find_entities_in_text(self, text: str, entities: List[Dict]) -> List[Dict]: + """ + Sucht Vorkommen von Titeln im Text (Case-Insensitive). + """ + found = [] + text_lower = text.lower() + + for entity in entities: + # 1. Titel prüfen + title = entity.get("title") + if title and title.lower() in text_lower: + found.append({ + "match": title, + "title": title, + "id": entity["id"] + }) + continue # Wenn Titel gefunden, Aliases nicht mehr prüfen (Prio) + + # 2. Aliases prüfen + aliases = entity.get("aliases") + if aliases and isinstance(aliases, list): + for alias in aliases: + if alias and str(alias).lower() in text_lower: + found.append({ + "match": alias, + "title": title, # Target ist immer der Haupt-Titel + "id": entity["id"] + }) + break + return found + + def _get_semantic_suggestions(self, text: str): + """Wrapper um den Hybrid Retriever.""" + # Wir nutzen eine vereinfachte Query + req = QueryRequest( + query=text, + top_k=5, + explain=False + ) + try: + # hybrid_retrieve ist sync, wird aber schnell genug sein für diesen Kontext + res = hybrid_retrieve(req) + return res.results + except Exception as e: + logger.error(f"Semantic suggestion failed: {e}") + return [] \ No newline at end of file From 565d34fb7b50be80ade7f30664dc80ed015a9ba5 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 Dec 2025 22:17:40 +0100 Subject: [PATCH 03/30] =?UTF-8?q?Erg=C3=A4nzung=20und=20Korrektur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/note_payload.py | 14 ++++++++++++-- app/main.py | 8 +++++--- app/routers/ingest.py | 14 +++++++------- app/services/discovery.py | 36 +++++++++++++++++++----------------- 4 files changed, 43 insertions(+), 29 deletions(-) diff --git a/app/core/note_payload.py b/app/core/note_payload.py index 1c5e6bc..285012f 100644 --- a/app/core/note_payload.py +++ b/app/core/note_payload.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- """ Modul: app/core/note_payload.py -Version: 2.0.0 +Version: 2.1.0 (WP-11 Update: Aliases support) Zweck ----- @@ -145,6 +145,7 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: - retriever_weight: effektives Gewicht für den Retriever - chunk_profile: Profil für Chunking (short|medium|long|default|...) - edge_defaults: Liste von Kanten-Typen, die als Defaults gelten + - aliases: Liste von Synonymen (WP-11) """ n = _as_dict(note) path_arg, types_cfg_explicit = _pick_args(*args, **kwargs) @@ -214,13 +215,22 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: if tags: payload["tags"] = _ensure_list(tags) + # WP-11: Aliases übernehmen (für Discovery Service) + aliases = fm.get("aliases") + if aliases: + payload["aliases"] = _ensure_list(aliases) + # Zeitliche Metadaten (sofern vorhanden) for k in ("created", "modified", "date"): v = fm.get(k) or n.get(k) if v: payload[k] = str(v) + + # Fulltext (Fallback, falls body im Input) + if "body" in n and n["body"]: + payload["fulltext"] = str(n["body"]) # JSON-Roundtrip zur harten Validierung (ASCII beibehalten) json.loads(json.dumps(payload, ensure_ascii=False)) - return payload + return payload \ No newline at end of file diff --git a/app/main.py b/app/main.py index 98e7ea5..fa23b73 100644 --- a/app/main.py +++ b/app/main.py @@ -11,8 +11,8 @@ from .routers.query import router as query_router from .routers.graph import router as graph_router from .routers.tools import router as tools_router from .routers.feedback import router as feedback_router +# NEU: Chat Router (WP-05) from .routers.chat import router as chat_router - # NEU: Ingest Router (WP-11) from .routers.ingest import router as ingest_router @@ -36,9 +36,11 @@ def create_app() -> FastAPI: app.include_router(graph_router, prefix="/graph", tags=["graph"]) app.include_router(tools_router, prefix="/tools", tags=["tools"]) app.include_router(feedback_router, prefix="/feedback", tags=["feedback"]) - app.include_router(chat_router, prefix="/chat", tags=["chat"]) - # NEU: Registrierung des Ingest-Routers + # NEU: Chat Endpoint + app.include_router(chat_router, prefix="/chat", tags=["chat"]) + + # NEU: Ingest Endpoint app.include_router(ingest_router, prefix="/ingest", tags=["ingest"]) if admin_router: diff --git a/app/routers/ingest.py b/app/routers/ingest.py index 4e9a7a0..aa97f49 100644 --- a/app/routers/ingest.py +++ b/app/routers/ingest.py @@ -21,8 +21,8 @@ class AnalyzeRequest(BaseModel): class SaveRequest(BaseModel): markdown_content: str - filename: Optional[str] = None # Optional, fallback auf Timestamp/Titel - folder: str = "00_Inbox" # Zielordner im Vault + filename: Optional[str] = None # Optional, fallback auf Timestamp + folder: str = "00_Inbox" # Zielordner class SaveResponse(BaseModel): status: str @@ -31,7 +31,6 @@ class SaveResponse(BaseModel): stats: Dict[str, Any] # --- Services --- -# Instanzierung hier oder via Dependency Injection discovery_service = DiscoveryService() @router.post("/analyze") @@ -53,8 +52,10 @@ async def save_note(req: SaveRequest): # 1. Vault Root ermitteln vault_root = os.getenv("MINDNET_VAULT_ROOT", "./vault") if not os.path.exists(vault_root): - # Fallback für Dev-Umgebungen - if os.path.exists("../vault"): + # Fallback relative paths + if os.path.exists("vault"): + vault_root = "vault" + elif os.path.exists("../vault"): vault_root = "../vault" else: raise HTTPException(status_code=500, detail="Vault root not configured or missing") @@ -62,11 +63,10 @@ async def save_note(req: SaveRequest): # 2. Filename generieren falls fehlend final_filename = req.filename if not final_filename: - # Einfacher Fallback: Timestamp final_filename = f"draft_{int(time.time())}.md" # 3. Ingestion Service nutzen - ingest_service = IngestionService() # nutzt Default-Prefix oder aus Env + ingest_service = IngestionService() result = ingest_service.create_from_text( markdown_content=req.markdown_content, diff --git a/app/services/discovery.py b/app/services/discovery.py index 359a13a..6612e64 100644 --- a/app/services/discovery.py +++ b/app/services/discovery.py @@ -1,7 +1,6 @@ """ app/services/discovery.py Service für Link-Vorschläge und Knowledge-Discovery (WP-11). -Analysiert Drafts auf Keywords und semantische Ähnlichkeiten. """ import logging from typing import List, Dict, Any, Set @@ -14,21 +13,21 @@ from app.core.retriever import hybrid_retrieve logger = logging.getLogger(__name__) class DiscoveryService: - def __init__(self, collection_prefix: str = "mindnet"): - self.prefix = collection_prefix + def __init__(self, collection_prefix: str = None): self.cfg = QdrantConfig.from_env() - self.cfg.prefix = collection_prefix + # Prefix Priorität: Argument > Env > Default + self.prefix = collection_prefix or self.cfg.prefix or "mindnet" self.client = get_client(self.cfg) async def analyze_draft(self, text: str, current_type: str) -> Dict[str, Any]: """ Analysiert einen Draft-Text und schlägt Verlinkungen vor. - Kombiniert Exact Match (Titel) und Semantic Match (Vektor). + Kombiniert Exact Match (Titel/Alias) und Semantic Match (Vektor). """ suggestions = [] # 1. Exact Match: Finde Begriffe im Text, die als Notiz-Titel existieren - # (Bei sehr großen Vaults >10k sollte dies gecached werden) + # (Holt alle Titel aus Qdrant - bei riesigen Vaults später cachen) known_entities = self._fetch_all_titles_and_aliases() found_entities = self._find_entities_in_text(text, known_entities) @@ -42,23 +41,23 @@ class DiscoveryService: "target_title": entity["title"], "target_id": entity["id"], "confidence": 1.0, - "reason": "Existierender Notiz-Titel" + "reason": "Existierender Notiz-Titel/Alias" }) # 2. Semantic Match: Finde inhaltlich ähnliche Notizen via Vektor-Suche - # Wir filtern Ergebnisse heraus, die wir schon per Exact Match gefunden haben. semantic_hits = self._get_semantic_suggestions(text) for hit in semantic_hits: + # Duplikate vermeiden (wenn wir es schon per Titel gefunden haben) if hit.node_id in existing_target_ids: continue - # Schwellwert: Nur relevante Vorschläge anzeigen (z.B. > 0.65) - # Wir nutzen den total_score, der bereits Typ-Gewichte enthält. + # Schwellwert: Nur relevante Vorschläge + # total_score beinhaltet bereits Typ-Gewichte aus dem Retriever if hit.total_score > 0.65: suggestions.append({ "type": "semantic_match", - "text_found": (hit.source.get("text") or "")[:50] + "...", # Snippet + "text_found": (hit.source.get("text") or "")[:50] + "...", "target_title": hit.payload.get("title", "Unbekannt"), "target_id": hit.node_id, "confidence": round(hit.total_score, 2), @@ -79,7 +78,6 @@ class DiscoveryService: try: while True: - # Scroll API nutzen, um effizient alle Metadaten zu laden res, next_page = self.client.scroll( collection_name=col_name, limit=1000, @@ -89,10 +87,15 @@ class DiscoveryService: ) for point in res: pl = point.payload or {} + + # Aliases robust lesen + aliases = pl.get("aliases") or [] + if isinstance(aliases, str): aliases = [aliases] + notes.append({ "id": pl.get("note_id"), "title": pl.get("title"), - "aliases": pl.get("aliases", []) + "aliases": aliases }) if next_page is None: @@ -105,7 +108,7 @@ class DiscoveryService: def _find_entities_in_text(self, text: str, entities: List[Dict]) -> List[Dict]: """ - Sucht Vorkommen von Titeln im Text (Case-Insensitive). + Sucht Vorkommen von Titeln/Alias im Text (Case-Insensitive). """ found = [] text_lower = text.lower() @@ -119,7 +122,7 @@ class DiscoveryService: "title": title, "id": entity["id"] }) - continue # Wenn Titel gefunden, Aliases nicht mehr prüfen (Prio) + continue # Wenn Titel gefunden, Aliases nicht mehr prüfen # 2. Aliases prüfen aliases = entity.get("aliases") @@ -136,14 +139,13 @@ class DiscoveryService: def _get_semantic_suggestions(self, text: str): """Wrapper um den Hybrid Retriever.""" - # Wir nutzen eine vereinfachte Query req = QueryRequest( query=text, top_k=5, explain=False ) try: - # hybrid_retrieve ist sync, wird aber schnell genug sein für diesen Kontext + # hybrid_retrieve nutzen (sync Wrapper) res = hybrid_retrieve(req) return res.results except Exception as e: From 408c4ace930e1fd8d1d250af175bd5b04d651cfa Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 Dec 2025 22:22:56 +0100 Subject: [PATCH 04/30] import auf .env --- scripts/import_markdown.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/import_markdown.py b/scripts/import_markdown.py index 5640077..718d96c 100644 --- a/scripts/import_markdown.py +++ b/scripts/import_markdown.py @@ -20,13 +20,19 @@ def iter_md(root: str): def main(): load_dotenv() + + # FIX: Default Prefix aus Environment holen, sonst Fallback auf "mindnet" + default_prefix = os.getenv("COLLECTION_PREFIX", "mindnet") + ap = argparse.ArgumentParser() ap.add_argument("--vault", required=True) ap.add_argument("--apply", action="store_true") ap.add_argument("--purge-before-upsert", action="store_true") ap.add_argument("--force-replace", action="store_true") - ap.add_argument("--prefix", default="mindnet") - # Weitere Argumente (compat) können hier hinzugefügt werden, wenn nötig + + # Hier nutzen wir jetzt die Variable + ap.add_argument("--prefix", default=default_prefix) + args = ap.parse_args() print(f"Init IngestionService (Prefix: {args.prefix})...") @@ -39,7 +45,6 @@ def main(): errors = 0 for f in files: - # Hier rufen wir den neuen Service res = service.process_file( file_path=f, vault_root=os.path.abspath(args.vault), @@ -48,7 +53,6 @@ def main(): purge_before=args.purge_before_upsert ) - # Output nur wenn relevant (nicht skipped/unchanged) if res.get("status") not in ["skipped", "unchanged"]: print(json.dumps(res, ensure_ascii=False)) processed += 1 From 765fad6a8da713572160e5fd32115ad4a600e0f8 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 07:25:24 +0100 Subject: [PATCH 05/30] neuer discovery mode --- app/services/discovery.py | 137 ++++++++++++++++++++++++-------------- 1 file changed, 86 insertions(+), 51 deletions(-) diff --git a/app/services/discovery.py b/app/services/discovery.py index 6612e64..7036382 100644 --- a/app/services/discovery.py +++ b/app/services/discovery.py @@ -1,10 +1,13 @@ """ app/services/discovery.py Service für Link-Vorschläge und Knowledge-Discovery (WP-11). +Analysiert Drafts auf Keywords und semantische Ähnlichkeiten. +Implementiert 'Late Binding' für Edge-Typen via types.yaml. """ import logging -from typing import List, Dict, Any, Set -from qdrant_client.http import models as rest +import os +from typing import List, Dict, Any, Optional +import yaml from app.core.qdrant import QdrantConfig, get_client from app.models.dto import QueryRequest @@ -14,20 +17,27 @@ logger = logging.getLogger(__name__) class DiscoveryService: def __init__(self, collection_prefix: str = None): + # 1. Config laden self.cfg = QdrantConfig.from_env() # Prefix Priorität: Argument > Env > Default self.prefix = collection_prefix or self.cfg.prefix or "mindnet" self.client = get_client(self.cfg) + + # 2. Registry für Late Binding laden (Edge Defaults) + self.registry = self._load_type_registry() async def analyze_draft(self, text: str, current_type: str) -> Dict[str, Any]: """ Analysiert einen Draft-Text und schlägt Verlinkungen vor. - Kombiniert Exact Match (Titel/Alias) und Semantic Match (Vektor). + Nutzt 'types.yaml' um den passenden Edge-Typ vorzuschlagen. """ suggestions = [] + # Welcher Edge-Typ ist für diesen Draft-Typ (z.B. 'project') der Standard? + # Late Binding: Wir schauen in die Config, statt es zu hardcoden. + default_edge_type = self._get_default_edge_type(current_type) + # 1. Exact Match: Finde Begriffe im Text, die als Notiz-Titel existieren - # (Holt alle Titel aus Qdrant - bei riesigen Vaults später cachen) known_entities = self._fetch_all_titles_and_aliases() found_entities = self._find_entities_in_text(text, known_entities) @@ -35,43 +45,96 @@ class DiscoveryService: for entity in found_entities: existing_target_ids.add(entity["id"]) + + # Vorschlag generieren + target_title = entity["title"] + # Markdown-Vorschlag: [[rel:depends_on Ziel]] + suggested_md = f"[[rel:{default_edge_type} {target_title}]]" + suggestions.append({ "type": "exact_match", "text_found": entity["match"], - "target_title": entity["title"], + "target_title": target_title, "target_id": entity["id"], + "suggested_edge_type": default_edge_type, + "suggested_markdown": suggested_md, "confidence": 1.0, - "reason": "Existierender Notiz-Titel/Alias" + "reason": f"Existierender Titel (Default für '{current_type}': {default_edge_type})" }) - # 2. Semantic Match: Finde inhaltlich ähnliche Notizen via Vektor-Suche + # 2. Semantic Match: Finde inhaltlich ähnliche Notizen semantic_hits = self._get_semantic_suggestions(text) for hit in semantic_hits: - # Duplikate vermeiden (wenn wir es schon per Titel gefunden haben) if hit.node_id in existing_target_ids: continue - # Schwellwert: Nur relevante Vorschläge - # total_score beinhaltet bereits Typ-Gewichte aus dem Retriever if hit.total_score > 0.65: + # Bei semantischen Treffern ist 'related_to' oft sicherer als 'depends_on', + # es sei denn, die Config erzwingt etwas anderes. + # Wir bleiben hier beim Config-Default, um konsistent zu sein. + target_title = hit.payload.get("title", "Unbekannt") + suggested_md = f"[[rel:{default_edge_type} {target_title}]]" + suggestions.append({ "type": "semantic_match", "text_found": (hit.source.get("text") or "")[:50] + "...", - "target_title": hit.payload.get("title", "Unbekannt"), + "target_title": target_title, "target_id": hit.node_id, + "suggested_edge_type": default_edge_type, + "suggested_markdown": suggested_md, "confidence": round(hit.total_score, 2), - "reason": f"Inhaltliche Ähnlichkeit (Score: {round(hit.total_score, 2)})" + "reason": f"Semantische Ähnlichkeit (Score: {round(hit.total_score, 2)})" }) return { "draft_length": len(text), + "draft_type": current_type, + "default_strategy": default_edge_type, "suggestions_count": len(suggestions), "suggestions": suggestions } + # --- Configuration & Late Binding Helpers --- + + def _load_type_registry(self) -> dict: + """Lädt die types.yaml für Konfigurations-Zugriffe.""" + path = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") + if not os.path.exists(path): + # Fallback relative Pfade + if os.path.exists("types.yaml"): path = "types.yaml" + elif os.path.exists("../config/types.yaml"): path = "../config/types.yaml" + else: return {} + + try: + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + except Exception as e: + logger.warning(f"Failed to load types registry: {e}") + return {} + + def _get_default_edge_type(self, note_type: str) -> str: + """ + Ermittelt den bevorzugten Kanten-Typ für einen gegebenen Notiz-Typ. + Logik: types.yaml -> types -> {note_type} -> edge_defaults[0] + Fallback: 'related_to' + """ + # 1. Config für den Typ laden + types_cfg = self.registry.get("types", {}) + type_def = types_cfg.get(note_type, {}) + + # 2. Defaults prüfen + defaults = type_def.get("edge_defaults") + if defaults and isinstance(defaults, list) and len(defaults) > 0: + # Wir nehmen den ersten Default als "Haupt-Beziehung" + return defaults[0] + + # 3. Fallback, falls nichts konfiguriert ist + return "related_to" + + # --- Core Logic (Unverändert) --- + def _fetch_all_titles_and_aliases(self) -> List[Dict]: - """Lädt alle Titel und Aliases aus der Notes-Collection.""" notes = [] next_page = None col_name = f"{self.prefix}_notes" @@ -87,8 +150,6 @@ class DiscoveryService: ) for point in res: pl = point.payload or {} - - # Aliases robust lesen aliases = pl.get("aliases") or [] if isinstance(aliases, str): aliases = [aliases] @@ -97,57 +158,31 @@ class DiscoveryService: "title": pl.get("title"), "aliases": aliases }) - - if next_page is None: - break + if next_page is None: break except Exception as e: logger.error(f"Error fetching titles: {e}") return [] - return notes def _find_entities_in_text(self, text: str, entities: List[Dict]) -> List[Dict]: - """ - Sucht Vorkommen von Titeln/Alias im Text (Case-Insensitive). - """ found = [] text_lower = text.lower() - for entity in entities: - # 1. Titel prüfen title = entity.get("title") if title and title.lower() in text_lower: - found.append({ - "match": title, - "title": title, - "id": entity["id"] - }) - continue # Wenn Titel gefunden, Aliases nicht mehr prüfen - - # 2. Aliases prüfen - aliases = entity.get("aliases") - if aliases and isinstance(aliases, list): - for alias in aliases: - if alias and str(alias).lower() in text_lower: - found.append({ - "match": alias, - "title": title, # Target ist immer der Haupt-Titel - "id": entity["id"] - }) - break + found.append({"match": title, "title": title, "id": entity["id"]}) + continue + aliases = entity.get("aliases", []) + for alias in aliases: + if alias and str(alias).lower() in text_lower: + found.append({"match": alias, "title": title, "id": entity["id"]}) + break return found def _get_semantic_suggestions(self, text: str): - """Wrapper um den Hybrid Retriever.""" - req = QueryRequest( - query=text, - top_k=5, - explain=False - ) + req = QueryRequest(query=text, top_k=5, explain=False) try: - # hybrid_retrieve nutzen (sync Wrapper) res = hybrid_retrieve(req) return res.results - except Exception as e: - logger.error(f"Semantic suggestion failed: {e}") + except Exception: return [] \ No newline at end of file From 86397919140a5cef0bc92bc67410baa5e69fddba Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 07:34:31 +0100 Subject: [PATCH 06/30] payload angepasst --- app/core/chunk_payload.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/core/chunk_payload.py b/app/core/chunk_payload.py index e48493d..1e56eda 100644 --- a/app/core/chunk_payload.py +++ b/app/core/chunk_payload.py @@ -5,6 +5,7 @@ app/core/chunk_payload.py (Mindnet V2 — types.yaml authoritative) - neighbors_prev / neighbors_next sind Listen ([], [id]). - retriever_weight / chunk_profile kommen aus types.yaml (Frontmatter wird ignoriert). - Fallbacks: defaults.* in types.yaml; sonst 1.0 / "default". +- WP-11 Update: Injects 'title' into chunk payload for Discovery Service. """ from __future__ import annotations from typing import Any, Dict, List, Optional @@ -82,6 +83,11 @@ def make_chunk_payloads(note: Dict[str, Any], file_path: Optional[str] = None) -> List[Dict[str, Any]]: fm = (note or {}).get("frontmatter", {}) or {} note_type = fm.get("type") or note.get("type") or "concept" + + # WP-11 FIX: Title Extraction für Discovery Service + # Wir holen den Titel aus Frontmatter oder Fallback ID/Untitled + title = fm.get("title") or note.get("title") or fm.get("id") or "Untitled" + reg = types_cfg if isinstance(types_cfg, dict) else _load_types() # types.yaml authoritative @@ -106,6 +112,7 @@ def make_chunk_payloads(note: Dict[str, Any], pl: Dict[str, Any] = { "note_id": nid, "chunk_id": cid, + "title": title, # <--- HIER: Titel in Payload einfügen "index": int(index), "ord": int(index) + 1, "type": note_type, @@ -126,4 +133,4 @@ def make_chunk_payloads(note: Dict[str, Any], out.append(pl) - return out + return out \ No newline at end of file From d2ee48555aaa7407ae9a01ab1e0baba4e5189d57 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 07:46:27 +0100 Subject: [PATCH 07/30] angepasste UI --- app/frontend/ui.py | 165 ++++++++++++++++++++++++++++++++------------- 1 file changed, 118 insertions(+), 47 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index d3e714b..adb7cdc 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -14,6 +14,8 @@ 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 @@ -52,10 +54,13 @@ st.markdown(""" font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; } - .debug-info { - font-size: 0.7rem; - color: #888; - margin-bottom: 5px; + .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); } """, unsafe_allow_html=True) @@ -67,20 +72,14 @@ 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. - Alles andere wird in den Body verschoben (Repair-Strategie). - """ + """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 = [] - # 1. Title/Titel Normalisierung if "titel" in meta and "title" not in meta: meta["title"] = meta.pop("titel") - # 2. Tags Normalisierung (Synonyme) tag_candidates = ["tags", "emotionale_keywords", "keywords", "schluesselwoerter"] all_tags = [] for key in tag_candidates: @@ -89,14 +88,12 @@ def normalize_meta_and_body(meta, body): if isinstance(val, list): all_tags.extend(val) elif isinstance(val, str): all_tags.extend([t.strip() for t in val.split(",")]) - # 3. Filterung und Verschiebung for key, val in meta.items(): if key in ALLOWED_KEYS: clean_meta[key] = val elif key in tag_candidates: - pass # Schon oben behandelt + pass else: - # Unerlaubtes Feld (z.B. 'situation') -> Ab in den Body! if val and isinstance(val, str): header = key.replace("_", " ").title() extra_content.append(f"## {header}\n{val}\n") @@ -104,7 +101,6 @@ def normalize_meta_and_body(meta, body): if all_tags: clean_meta["tags"] = list(set(all_tags)) - # 4. Body Zusammenbau if extra_content: new_section = "\n".join(extra_content) final_body = f"{new_section}\n{body}" @@ -114,18 +110,14 @@ def normalize_meta_and_body(meta, body): return clean_meta, final_body def parse_markdown_draft(full_text): - """ - Robustes Parsing + Sanitization. - """ + """Robustes Parsing + Sanitization.""" clean_text = full_text - # Codeblock entfernen 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() - # Frontmatter splitten parts = re.split(r"^---+\s*$", clean_text, maxsplit=2, flags=re.MULTILINE) meta = {} @@ -152,7 +144,6 @@ def build_markdown_doc(meta, body): meta["updated"] = datetime.now().strftime("%Y-%m-%d") - # Sortierung für UX ordered_meta = {} prio_keys = ["id", "type", "title", "status", "tags"] for k in prio_keys: @@ -183,6 +174,8 @@ def load_history_from_logs(limit=10): except: pass return queries +# --- API CLIENT --- + def send_chat_message(message: str, top_k: int, explain: bool): try: response = requests.post( @@ -195,6 +188,32 @@ def send_chat_message(message: str, top_k: int, explain: bool): except Exception as e: return {"error": str(e)} +def analyze_draft_text(text: str, n_type: str): + """Ruft den neuen Intelligence-Service (WP-11) auf.""" + try: + response = requests.post( + INGEST_ANALYZE_ENDPOINT, + json={"text": text, "type": n_type}, + timeout=10 + ) + 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): + """Ruft den neuen Persistence-Service (WP-11) auf.""" + try: + response = requests.post( + INGEST_SAVE_ENDPOINT, + json={"markdown_content": markdown_content, "filename": filename}, + timeout=30 # Indizierung kann dauern + ) + 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) @@ -230,15 +249,14 @@ def render_draft_editor(msg): st.session_state[f"{key_base}_type"] = meta.get("type", "default") st.session_state[f"{key_base}_title"] = meta.get("title", "") - tags_raw = meta.get("tags", []) st.session_state[f"{key_base}_tags"] = ", ".join(tags_raw) if isinstance(tags_raw, list) else str(tags_raw) - st.session_state[f"{key_base}_body"] = body.strip() st.session_state[f"{key_base}_meta"] = meta + st.session_state[f"{key_base}_suggestions"] = [] st.session_state[f"{key_base}_init"] = True - # 2. UI + # 2. UI Layout st.markdown(f'
', unsafe_allow_html=True) st.markdown("### 📝 Entwurf bearbeiten") @@ -254,11 +272,21 @@ def render_draft_editor(msg): new_tags = st.text_input("Tags (kommagetrennt)", value=st.session_state.get(f"{key_base}_tags", ""), key=f"{key_base}_inp_tags") - # Tabs - tab_edit, tab_view = st.tabs(["✏️ Inhalt", "👁️ Vorschau"]) + # Tabs (Jetzt mit "Intelligence") + tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"]) + # Live Reassembly für alle Tabs + final_tags_list = [t.strip() for t in new_tags.split(",") if t.strip()] + final_meta = { + "id": "generated_on_save", + "type": new_type, + "title": new_title, + "status": "draft", + "tags": final_tags_list + } + + # --- TAB 1: EDITOR --- with tab_edit: - st.caption("Bearbeite hier den Inhalt. Metadaten (oben) werden automatisch hinzugefügt.") new_body = st.text_area( "Body", value=st.session_state.get(f"{key_base}_body", ""), @@ -267,19 +295,43 @@ def render_draft_editor(msg): label_visibility="collapsed" ) - # Reassembly - final_tags_list = [t.strip() for t in new_tags.split(",") if t.strip()] - final_meta = st.session_state.get(f"{key_base}_meta", {}).copy() - final_meta.update({ - "id": "generated_on_save", - "type": new_type, - "title": new_title, - "status": "draft", - "tags": final_tags_list - }) - - final_doc = build_markdown_doc(final_meta, new_body) - + # --- TAB 2: INTELLIGENCE (WP-11 Features) --- + with tab_intel: + st.info("Klicke auf 'Analysieren', um Verknüpfungen zu finden.") + + if st.button("🔍 Draft Analysieren", key=f"{key_base}_analyze"): + with st.spinner("Analysiere Text und suche Verknüpfungen..."): + analysis = analyze_draft_text(new_body, new_type) + if "error" in analysis: + st.error(f"Fehler: {analysis['error']}") + else: + st.session_state[f"{key_base}_suggestions"] = analysis.get("suggestions", []) + if not analysis.get("suggestions"): + st.warning("Keine offensichtlichen Verknüpfungen gefunden.") + + suggestions = st.session_state.get(f"{key_base}_suggestions", []) + if suggestions: + st.markdown(f"**{len(suggestions)} Vorschläge gefunden:**") + for idx, sugg in enumerate(suggestions): + with st.container(): + st.markdown(f""" +
+ {sugg['target_title']} ({sugg['type']})
+ Grund: {sugg.get('reason', 'N/A')}
+ {sugg['suggested_markdown']} +
+ """, unsafe_allow_html=True) + + if st.button("➕ Einfügen", key=f"{key_base}_add_{idx}"): + # Append to body + current_body = st.session_state[f"{key_base}_body"] + updated_body = f"{current_body}\n\n{sugg['suggested_markdown']}" + st.session_state[f"{key_base}_body"] = updated_body + st.toast(f"Link zu '{sugg['target_title']}' eingefügt!") + st.rerun() + + # --- TAB 3: PREVIEW --- + final_doc = build_markdown_doc(final_meta, st.session_state.get(f"{key_base}_body", "")) with tab_view: st.markdown('
', unsafe_allow_html=True) st.markdown(final_doc) @@ -287,11 +339,23 @@ def render_draft_editor(msg): st.markdown("---") - # Actions + # Actions (SAVE & EXPORT) b1, b2 = st.columns([1, 1]) with b1: - fname = f"{datetime.now().strftime('%Y%m%d')}-{new_type}.md" - st.download_button("💾 Download .md", data=final_doc, file_name=fname, mime="text/markdown") + # Echter Save Button + if st.button("💾 Speichern & Indizieren", type="primary", key=f"{key_base}_save"): + with st.spinner("Speichere im Vault..."): + # Generiere Filename + safe_title = re.sub(r'[^a-zA-Z0-9]', '-', new_title).lower()[:30] + 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 beim Speichern: {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") @@ -303,13 +367,12 @@ 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": - # Meta + # Header intent = msg.get("intent", "UNKNOWN") src = msg.get("intent_source", "?") icon = {"EMPATHY":"❤️", "DECISION":"⚖️", "CODING":"💻", "FACT":"📚", "INTERVIEW":"📝"}.get(intent, "🧠") st.markdown(f'
{icon} Intent: {intent} ({src})
', unsafe_allow_html=True) - # Debugging (Always visible for safety) with st.expander("🐞 Debug Raw Payload", expanded=False): st.json(msg) @@ -364,9 +427,17 @@ def render_manual_editor(): n_type = c1.selectbox("Typ", ["concept", "project", "decision", "experience", "value", "goal"]) tags = c2.text_input("Tags") body = st.text_area("Inhalt", height=400, placeholder="# Titel\n\nText...") - if st.button("Code anzeigen"): + + if st.button("Speichern (Via API)"): meta = {"type": n_type, "status": "draft", "tags": [t.strip() for t in tags.split(",")]} - st.code(build_markdown_doc(meta, body), language="markdown") + doc = build_markdown_doc(meta, body) + + # Test Call + res = save_draft_to_vault(doc, filename=f"manual-{uuid.uuid4().hex[:6]}.md") + if "error" in res: + st.error(res["error"]) + else: + st.success(f"Gespeichert: {res.get('file_path')}") mode, top_k, explain = render_sidebar() if mode == "💬 Chat": From a7cda3f51cd3c0441e29fe681ee15ebad3318481 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 08:18:41 +0100 Subject: [PATCH 08/30] bugfix --- app/core/ingestion.py | 27 +++++----- app/routers/ingest.py | 101 ++++++++++++++++++++++---------------- app/services/discovery.py | 37 ++++++++++---- 3 files changed, 102 insertions(+), 63 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index 992c29d..80f9398 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -268,35 +268,38 @@ class IngestionService: markdown_content: str, filename: str, vault_root: str, - folder: str = "Inbox" # Standard-Ordner für neue Files + folder: str = "00_Inbox" ) -> Dict[str, Any]: """ - WP-11: Schreibt Text in eine physische Datei und indiziert sie sofort. + WP-11 Persistence: Schreibt Text sicher und indiziert ihn. + Erstellt Verzeichnisse automatisch. """ - # 1. Pfad vorbereiten + # 1. Zielordner vorbereiten target_dir = os.path.join(vault_root, folder) - os.makedirs(target_dir, exist_ok=True) + try: + os.makedirs(target_dir, exist_ok=True) + except Exception as e: + return {"status": "error", "error": f"Could not create folder {target_dir}: {e}"} - # Dateiname bereinigen (Sicherheit) + # 2. Dateiname bereinigen safe_filename = os.path.basename(filename) if not safe_filename.endswith(".md"): safe_filename += ".md" file_path = os.path.join(target_dir, safe_filename) - # 2. Schreiben (Write to Disk - Single Source of Truth) + # 3. Schreiben try: with open(file_path, "w", encoding="utf-8") as f: f.write(markdown_content) except Exception as e: - return {"status": "error", "error": f"Disk write failed: {str(e)}"} + return {"status": "error", "error": f"Disk write failed at {file_path}: {str(e)}"} - # 3. Indizieren (Ingest) - # Wir rufen einfach die existierende Logik auf! + # 4. Indizieren (Single File Upsert) return self.process_file( file_path=file_path, vault_root=vault_root, - apply=True, # Sofort schreiben - force_replace=True, # Da neu, erzwingen wir Update - purge_before=True # Sauberer Start + apply=True, + force_replace=True, + purge_before=True ) \ No newline at end of file diff --git a/app/routers/ingest.py b/app/routers/ingest.py index aa97f49..ce729db 100644 --- a/app/routers/ingest.py +++ b/app/routers/ingest.py @@ -1,9 +1,11 @@ """ app/routers/ingest.py API-Endpunkte für WP-11 (Discovery & Persistence). +Robustified for Frontend Integration. """ import os import time +import logging from fastapi import APIRouter, HTTPException from pydantic import BaseModel from typing import Optional, List, Dict, Any @@ -11,9 +13,12 @@ from typing import Optional, List, Dict, Any from app.core.ingestion import IngestionService from app.services.discovery import DiscoveryService +# Logger für Backend-Debugging aktivieren +logger = logging.getLogger("uvicorn.error") + router = APIRouter() -# --- DTOs --- +# --- DTOs (Data Transfer Objects) --- class AnalyzeRequest(BaseModel): text: str @@ -21,8 +26,8 @@ class AnalyzeRequest(BaseModel): class SaveRequest(BaseModel): markdown_content: str - filename: Optional[str] = None # Optional, fallback auf Timestamp - folder: str = "00_Inbox" # Zielordner + filename: Optional[str] = None + folder: str = "00_Inbox" # Standard-Ordner class SaveResponse(BaseModel): status: str @@ -36,54 +41,66 @@ discovery_service = DiscoveryService() @router.post("/analyze") async def analyze_draft(req: AnalyzeRequest): """ - WP-11 Intelligence: Analysiert einen Entwurf und liefert Link-Vorschläge. + WP-11 Intelligence: Liefert Link-Vorschläge basierend auf Text und Typ. """ try: + # Prio 2: Intelligence Service aufrufen result = await discovery_service.analyze_draft(req.text, req.type) return result except Exception as e: + logger.error(f"Analyze failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") @router.post("/save", response_model=SaveResponse) async def save_note(req: SaveRequest): """ - WP-11 Persistence: Speichert Markdown physisch und indiziert es in Qdrant. + WP-11 Persistence: Speichert Markdown physisch und indiziert es sofort. """ - # 1. Vault Root ermitteln - vault_root = os.getenv("MINDNET_VAULT_ROOT", "./vault") - if not os.path.exists(vault_root): - # Fallback relative paths - if os.path.exists("vault"): - vault_root = "vault" - elif os.path.exists("../vault"): - vault_root = "../vault" - else: - raise HTTPException(status_code=500, detail="Vault root not configured or missing") - - # 2. Filename generieren falls fehlend - final_filename = req.filename - if not final_filename: - final_filename = f"draft_{int(time.time())}.md" - - # 3. Ingestion Service nutzen - ingest_service = IngestionService() - - result = ingest_service.create_from_text( - markdown_content=req.markdown_content, - filename=final_filename, - vault_root=os.path.abspath(vault_root), - folder=req.folder - ) - - if result.get("status") == "error": - raise HTTPException(status_code=500, detail=result.get("error")) + try: + # 1. Vault Root sicher ermitteln + vault_root = os.getenv("MINDNET_VAULT_ROOT", "./vault") - return SaveResponse( - status="success", - file_path=result["path"], - note_id=result.get("note_id", "unknown"), - stats={ - "chunks": result.get("chunks_count", 0), - "edges": result.get("edges_count", 0) - } - ) \ No newline at end of file + # Absolute Pfade auflösen, um CWD-Probleme zu vermeiden + abs_vault_root = os.path.abspath(vault_root) + + if not os.path.exists(abs_vault_root): + error_msg = f"Vault root not found at: {abs_vault_root}. Check MINDNET_VAULT_ROOT in .env" + logger.error(error_msg) + raise HTTPException(status_code=500, detail=error_msg) + + # 2. Filename Fallback + final_filename = req.filename + if not final_filename: + final_filename = f"draft_{int(time.time())}.md" + + # 3. Ingestion Service aufrufen + ingest_service = IngestionService() + + logger.info(f"Attempting to save {final_filename} to {req.folder} in {abs_vault_root}") + + result = ingest_service.create_from_text( + markdown_content=req.markdown_content, + filename=final_filename, + vault_root=abs_vault_root, + folder=req.folder + ) + + # Fehler vom Service abfangen + if result.get("status") == "error": + raise HTTPException(status_code=500, detail=result.get("error")) + + return SaveResponse( + status="success", + file_path=result["path"], + note_id=result.get("note_id", "unknown"), + stats={ + "chunks": result.get("chunks_count", 0), + "edges": result.get("edges_count", 0) + } + ) + + except HTTPException as he: + raise he + except Exception as e: + logger.error(f"Save failed: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Save failed: {str(e)}") \ No newline at end of file diff --git a/app/services/discovery.py b/app/services/discovery.py index 7036382..15635ca 100644 --- a/app/services/discovery.py +++ b/app/services/discovery.py @@ -102,9 +102,12 @@ class DiscoveryService: path = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") if not os.path.exists(path): # Fallback relative Pfade - if os.path.exists("types.yaml"): path = "types.yaml" - elif os.path.exists("../config/types.yaml"): path = "../config/types.yaml" - else: return {} + if os.path.exists("types.yaml"): + path = "types.yaml" + elif os.path.exists("../config/types.yaml"): + path = "../config/types.yaml" + else: + return {} try: with open(path, "r", encoding="utf-8") as f: @@ -132,7 +135,7 @@ class DiscoveryService: # 3. Fallback, falls nichts konfiguriert ist return "related_to" - # --- Core Logic (Unverändert) --- + # --- Core Logic --- def _fetch_all_titles_and_aliases(self) -> List[Dict]: notes = [] @@ -150,15 +153,19 @@ class DiscoveryService: ) for point in res: pl = point.payload or {} + + # Aliases robust lesen (kann Liste oder String sein) aliases = pl.get("aliases") or [] - if isinstance(aliases, str): aliases = [aliases] + if isinstance(aliases, str): + aliases = [aliases] notes.append({ "id": pl.get("note_id"), "title": pl.get("title"), "aliases": aliases }) - if next_page is None: break + if next_page is None: + break except Exception as e: logger.error(f"Error fetching titles: {e}") return [] @@ -168,14 +175,25 @@ class DiscoveryService: found = [] text_lower = text.lower() for entity in entities: + # 1. Title Match title = entity.get("title") if title and title.lower() in text_lower: - found.append({"match": title, "title": title, "id": entity["id"]}) + found.append({ + "match": title, + "title": title, + "id": entity["id"] + }) continue + + # 2. Alias Match aliases = entity.get("aliases", []) for alias in aliases: if alias and str(alias).lower() in text_lower: - found.append({"match": alias, "title": title, "id": entity["id"]}) + found.append({ + "match": alias, + "title": title, + "id": entity["id"] + }) break return found @@ -184,5 +202,6 @@ class DiscoveryService: try: res = hybrid_retrieve(req) return res.results - except Exception: + except Exception as e: + logger.error(f"Semantic suggestion error: {e}") return [] \ No newline at end of file From e7cec6acc651ac9e8c5ca973c9edfd3cc16f6bc4 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 10:23:23 +0100 Subject: [PATCH 09/30] new ingestion, asyn writing, robust --- app/core/ingestion.py | 91 ++++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index 80f9398..6790260 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -3,13 +3,14 @@ app/core/ingestion.py Zentraler Service für die Transformation von Markdown-Dateien in Qdrant-Objekte (Notes, Chunks, Edges). Dient als Shared Logic für: -1. CLI-Imports (scripts/import_markdown.py) +1. CLI-Imports (scripts/import_markdown.py) - muss ggf. angepasst werden auf Async! 2. API-Uploads (WP-11) """ import os -import json -from typing import Dict, List, Optional, Tuple, Any, Set +import logging +from typing import Dict, List, Optional, Tuple, Any +# Core Module Imports from app.core.parser import ( read_markdown, normalize_frontmatter, @@ -18,10 +19,12 @@ from app.core.parser import ( from app.core.note_payload import make_note_payload from app.core.chunker import assemble_chunks from app.core.chunk_payload import make_chunk_payloads -# Fallback Imports wie im Original-Skript + +# Fallback für Edges Import try: from app.core.derive_edges import build_edges_for_note except ImportError: + # Fallback falls Dateiname anders ist from app.core.edges import build_edges_for_note # type: ignore from app.core.qdrant import QdrantConfig, get_client, ensure_collections, ensure_payload_indexes @@ -32,18 +35,16 @@ from app.core.qdrant_points import ( upsert_batch, ) -# Optionales Embedding -try: - from app.core.embed import embed_texts -except ImportError: - embed_texts = None +# WICHTIG: Wir nutzen den API-Client für Embeddings +from app.services.embeddings_client import EmbeddingsClient -# --- Helper für Type-Registry (ausgelagert aus Script) --- +logger = logging.getLogger(__name__) + +# --- Helper für Type-Registry --- def load_type_registry(custom_path: Optional[str] = None) -> dict: import yaml path = custom_path or os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") if not os.path.exists(path): - # Fallback auf Root-Ebene (für Tests/CLI) if os.path.exists("types.yaml"): path = "types.yaml" else: @@ -58,14 +59,12 @@ def resolve_note_type(requested: Optional[str], reg: dict) -> str: types = reg.get("types", {}) if requested and requested in types: return requested - return "concept" # Default Fallback + return "concept" def effective_chunk_profile(note_type: str, reg: dict) -> str: - # 1. Specific Type t_cfg = reg.get("types", {}).get(note_type, {}) if t_cfg and t_cfg.get("chunk_profile"): return t_cfg.get("chunk_profile") - # 2. Defaults return reg.get("defaults", {}).get("chunk_profile", "default") def effective_retriever_weight(note_type: str, reg: dict) -> float: @@ -79,18 +78,21 @@ class IngestionService: def __init__(self, collection_prefix: str = "mindnet"): self.prefix = collection_prefix self.cfg = QdrantConfig.from_env() - self.cfg.prefix = collection_prefix # Override env if needed + self.cfg.prefix = collection_prefix self.client = get_client(self.cfg) self.dim = self.cfg.dim # Registry laden self.registry = load_type_registry() + # Embedding Service initialisieren + self.embedder = EmbeddingsClient() + # Init DB Checks ensure_collections(self.client, self.prefix, self.dim) ensure_payload_indexes(self.client, self.prefix) - def process_file( + async def process_file( self, file_path: str, vault_root: str, @@ -103,8 +105,7 @@ class IngestionService: hash_normalize: str = "canonical" ) -> Dict[str, Any]: """ - Verarbeitet eine einzelne Datei. - Return: Summary Dict (Erfolg, Änderungen, Stats). + Verarbeitet eine einzelne Datei (ASYNC). """ result = { "path": file_path, @@ -129,7 +130,6 @@ class IngestionService: fm["type"] = note_type fm["chunk_profile"] = effective_chunk_profile(note_type, self.registry) - # Weight Resolution (Frontmatter override > Registry) weight = fm.get("retriever_weight") if weight is None: weight = effective_retriever_weight(note_type, self.registry) @@ -145,7 +145,6 @@ class IngestionService: hash_source=hash_source, file_path=file_path ) - # Ensure fulltext & weight if not note_pl.get("fulltext"): note_pl["fulltext"] = getattr(parsed, "body", "") or "" note_pl["retriever_weight"] = fm["retriever_weight"] @@ -154,21 +153,17 @@ class IngestionService: except Exception as e: return {**result, "error": f"Payload build failed: {str(e)}"} - # 4. Change Detection (Hash Check) - # Wir holen den alten Payload aus Qdrant, wenn wir nicht forcen + # 4. Change Detection old_payload = None if not force_replace: old_payload = self._fetch_note_payload(note_id) has_old = old_payload is not None key_current = f"{hash_mode}:{hash_source}:{hash_normalize}" - old_hash = (old_payload or {}).get("hashes", {}).get(key_current) new_hash = note_pl.get("hashes", {}).get(key_current) hash_changed = (old_hash != new_hash) - - # Artefakte prüfen (Chunks/Edges) chunks_missing, edges_missing = self._artifacts_missing(note_id) should_write = force_replace or (not has_old) or hash_changed or chunks_missing or edges_missing @@ -185,14 +180,29 @@ class IngestionService: chunks = assemble_chunks(fm["id"], body_text, fm["type"]) chunk_pls = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text) - # Embeddings + # --- EMBEDDING FIX --- vecs = [] - if embed_texts and chunk_pls: + if chunk_pls: texts = [c.get("window") or c.get("text") or "" for c in chunk_pls] - vecs = embed_texts(texts) - else: - vecs = [[0.0] * self.dim for _ in chunk_pls] - + try: + # Async Aufruf des Embedders + if hasattr(self.embedder, 'embed_documents'): + vecs = await self.embedder.embed_documents(texts) + else: + # Fallback Loop falls Client kein Batch unterstützt + for t in texts: + v = await self.embedder.embed_query(t) + vecs.append(v) + + # Validierung der Dimensionen + if vecs and len(vecs) > 0: + dim_got = len(vecs[0]) + if dim_got != self.dim: + raise ValueError(f"Vector dimension mismatch. Expected {self.dim}, got {dim_got}") + except Exception as e: + logger.error(f"Embedding generation failed: {e}") + raise RuntimeError(f"Embedding failed: {e}") + # Edges note_refs = note_pl.get("references") or [] edges = build_edges_for_note( @@ -208,16 +218,13 @@ class IngestionService: if purge_before and has_old: self._purge_artifacts(note_id) - # Upsert Note n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim) upsert_batch(self.client, n_name, n_pts) - # Upsert Chunks if chunk_pls: c_name, c_pts = points_for_chunks(self.prefix, chunk_pls, vecs) upsert_batch(self.client, c_name, c_pts) - # Upsert Edges if edges: e_name, e_pts = points_for_edges(self.prefix, edges) upsert_batch(self.client, e_name, e_pts) @@ -245,12 +252,8 @@ class IngestionService: c_col = f"{self.prefix}_chunks" e_col = f"{self.prefix}_edges" f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - - # Check Chunks c_pts, _ = self.client.scroll(collection_name=c_col, scroll_filter=f, limit=1) - # Check Edges e_pts, _ = self.client.scroll(collection_name=e_col, scroll_filter=f, limit=1) - return (not bool(c_pts)), (not bool(e_pts)) def _purge_artifacts(self, note_id: str): @@ -263,7 +266,7 @@ class IngestionService: except Exception: pass - def create_from_text( + async def create_from_text( self, markdown_content: str, filename: str, @@ -272,20 +275,18 @@ class IngestionService: ) -> Dict[str, Any]: """ WP-11 Persistence: Schreibt Text sicher und indiziert ihn. - Erstellt Verzeichnisse automatisch. """ - # 1. Zielordner vorbereiten + # 1. Zielordner target_dir = os.path.join(vault_root, folder) try: os.makedirs(target_dir, exist_ok=True) except Exception as e: return {"status": "error", "error": f"Could not create folder {target_dir}: {e}"} - # 2. Dateiname bereinigen + # 2. Dateiname safe_filename = os.path.basename(filename) if not safe_filename.endswith(".md"): safe_filename += ".md" - file_path = os.path.join(target_dir, safe_filename) # 3. Schreiben @@ -295,8 +296,8 @@ class IngestionService: except Exception as e: return {"status": "error", "error": f"Disk write failed at {file_path}: {str(e)}"} - # 4. Indizieren (Single File Upsert) - return self.process_file( + # 4. Indizieren (Async Aufruf!) + return await self.process_file( file_path=file_path, vault_root=vault_root, apply=True, From 4ed04039e52d528e5d3abbdbf6c2bcb191acba01 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 10:32:56 +0100 Subject: [PATCH 10/30] async --- app/services/embeddings_client.py | 114 ++++++++++++++++++++++++------ 1 file changed, 94 insertions(+), 20 deletions(-) diff --git a/app/services/embeddings_client.py b/app/services/embeddings_client.py index 4f8636f..ea7a7fb 100644 --- a/app/services/embeddings_client.py +++ b/app/services/embeddings_client.py @@ -1,32 +1,97 @@ """ -app/services/embeddings_client.py — Text→Embedding (WP-04) +app/services/embeddings_client.py — Text→Embedding Service Zweck: - Liefert 384-d Embeddings für Textqueries (lazy load, einmal pro Prozess). - Standard: Sentence-Transformers (MODEL_NAME aus app.config.Settings). - Hinweis: Kein Netz-Zugriff; nutzt lokal installierte Modelle. + Liefert Embeddings für Textqueries. + - Legacy Mode (Sync): Nutzt lokal Sentence-Transformers (CPU). + - Modern Mode (Async/Class): Nutzt Ollama API (HTTP) für Non-Blocking Operations (WP-11). + Kompatibilität: - Python 3.12+, sentence-transformers 5.x + Python 3.12+, sentence-transformers 5.x, httpx Version: - 0.1.0 (Erstanlage) + 0.2.0 (Erweitert um Async EmbeddingsClient) Stand: - 2025-10-07 -Bezug: - - app/core/retriever.py (nutzt embed_text_if_needed) - - app/config.py (MODEL_NAME, VECTOR_SIZE) -Nutzung: - from app.services.embeddings_client import embed_text -Änderungsverlauf: - 0.1.0 (2025-10-07) – Erstanlage. + 2025-12-11 """ from __future__ import annotations -from typing import List +import os +import logging +import httpx +from typing import List, Optional from functools import lru_cache from app.config import get_settings +logger = logging.getLogger(__name__) + +# ============================================================================== +# TEIL 1: NEUE ASYNC KLASSE (Für Ingestion API / WP-11) +# ============================================================================== + +class EmbeddingsClient: + """ + Async Client für Embeddings via Ollama (oder kompatible APIs). + Verhindert das Blockieren des Event-Loops bei schweren Berechnungen. + """ + def __init__(self): + self.settings = get_settings() + # Fallback auf Environment Variablen, falls Settings nicht geladen + self.base_url = os.getenv("MINDNET_OLLAMA_URL", "http://127.0.0.1:11434") + # Nutze explizites Embedding Modell oder Fallback auf LLM Modell + self.model = os.getenv("MINDNET_EMBEDDING_MODEL", os.getenv("MINDNET_LLM_MODEL", "phi3:mini")) + + async def embed_query(self, text: str) -> List[float]: + """Erzeugt Embedding für einen einzelnen Text.""" + return await self._request_embedding(text) + + async def embed_documents(self, texts: List[str]) -> List[List[float]]: + """ + Erzeugt Embeddings für eine Liste von Texten. + Nutzt eine Session für effizientere Requests. + """ + vectors = [] + async with httpx.AsyncClient(timeout=60.0) as client: + for text in texts: + vec = await self._request_embedding_with_client(client, text) + vectors.append(vec) + return vectors + + async def _request_embedding(self, text: str) -> List[float]: + """Interne Hilfsmethode für Single-Request (One-off Client).""" + async with httpx.AsyncClient(timeout=30.0) as client: + return await self._request_embedding_with_client(client, text) + + async def _request_embedding_with_client(self, client: httpx.AsyncClient, text: str) -> List[float]: + """Führt den eigentlichen Request gegen Ollama aus.""" + if not text or not text.strip(): + return [] + + url = f"{self.base_url}/api/embeddings" + try: + response = await client.post( + url, + json={ + "model": self.model, + "prompt": text + } + ) + response.raise_for_status() + data = response.json() + return data.get("embedding", []) + except Exception as e: + logger.error(f"Embedding error (Ollama) for model {self.model}: {e}") + # Fallback: Leere Liste, damit der Prozess nicht crasht (wird vom Caller gefiltert) + return [] + + +# ============================================================================== +# TEIL 2: LEGACY FUNKTIONEN (Für bestehende Sync-Module / CLI) +# ============================================================================== + # Lazy import, damit Testläufe ohne Modell-Laden schnell sind def _load_model(): + # Performance-Warnung loggen, da dies viel RAM braucht + logger.info("Loading local SentenceTransformer model (Legacy Mode)...") from sentence_transformers import SentenceTransformer # import hier, nicht top-level s = get_settings() return SentenceTransformer(s.MODEL_NAME, device="cpu") @@ -37,10 +102,19 @@ def _cached_model(): def embed_text(text: str) -> List[float]: """ - Erzeugt einen 384-d Vektor (oder laut Settings.VECTOR_SIZE) für den gegebenen Text. + LEGACY: Erzeugt einen Vektor synchron via Sentence-Transformers. + Wird u.a. vom Retriever oder alten CLI-Skripten genutzt. """ if not text or not text.strip(): - raise ValueError("embed_text: leerer Text") - model = _cached_model() - vec = model.encode([text], normalize_embeddings=True)[0] - return vec.astype(float).tolist() + # Um Konsistenz mit neuer Klasse zu wahren, loggen wir Warnung statt Error + # raise ValueError("embed_text: leerer Text") -> Veraltet + logger.warning("embed_text called with empty string") + return [] + + try: + model = _cached_model() + vec = model.encode([text], normalize_embeddings=True)[0] + return vec.astype(float).tolist() + except Exception as e: + logger.error(f"Legacy embed_text failed: {e}") + return [] \ No newline at end of file From 5aae33f57865c3cbab543f333facca53d92ad92c Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 10:46:37 +0100 Subject: [PATCH 11/30] bug fixing --- app/routers/ingest.py | 82 ++++++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/app/routers/ingest.py b/app/routers/ingest.py index ce729db..95b2369 100644 --- a/app/routers/ingest.py +++ b/app/routers/ingest.py @@ -1,7 +1,7 @@ """ app/routers/ingest.py API-Endpunkte für WP-11 (Discovery & Persistence). -Robustified for Frontend Integration. +Fixed Async/Await Issues. """ import os import time @@ -11,14 +11,15 @@ from pydantic import BaseModel from typing import Optional, List, Dict, Any from app.core.ingestion import IngestionService -from app.services.discovery import DiscoveryService +from app.core.retriever import Retriever +from app.models.dto import QueryRequest -# Logger für Backend-Debugging aktivieren -logger = logging.getLogger("uvicorn.error") +# Logger Konfiguration +logger = logging.getLogger(__name__) router = APIRouter() -# --- DTOs (Data Transfer Objects) --- +# --- DTOs --- class AnalyzeRequest(BaseModel): text: str @@ -27,7 +28,7 @@ class AnalyzeRequest(BaseModel): class SaveRequest(BaseModel): markdown_content: str filename: Optional[str] = None - folder: str = "00_Inbox" # Standard-Ordner + folder: str = "00_Inbox" class SaveResponse(BaseModel): status: str @@ -35,18 +36,52 @@ class SaveResponse(BaseModel): note_id: str stats: Dict[str, Any] -# --- Services --- -discovery_service = DiscoveryService() +# --- Endpoints --- @router.post("/analyze") async def analyze_draft(req: AnalyzeRequest): """ - WP-11 Intelligence: Liefert Link-Vorschläge basierend auf Text und Typ. + WP-11 Intelligence: Liefert Link-Vorschläge via Retriever. """ try: - # Prio 2: Intelligence Service aufrufen - result = await discovery_service.analyze_draft(req.text, req.type) - return result + # Wir nutzen den Retriever direkt, statt eines extra DiscoveryServices + retriever = Retriever() + suggestions = [] + + # 1. Suche nach ähnlichen Inhalten (Semantic) + query_text = req.text[:400] + if not query_text.strip(): + return {"suggestions": []} + + # Check ob Retriever async ist (in v2.3 oft ja) + if hasattr(retriever.search, '__await__'): + hits_result = await retriever.search(QueryRequest(query=query_text, top_k=5, mode="hybrid")) + else: + # Fallback sync + hits_result = await retriever.search(QueryRequest(query=query_text, top_k=5, mode="hybrid")) + + seen_titles = set() + for hit in hits_result.results: + title = hit.payload.get("note_id") or hit.node_id + if not title or title in seen_titles: continue + seen_titles.add(title) + + # Simple Edge Logic + edge_kind = "related_to" + if req.type == "project": edge_kind = "depends_on" + if req.type == "decision": edge_kind = "references" + + if hit.total_score > 0.65: + suggestions.append({ + "target_title": title, + "target_id": hit.node_id, + "suggested_markdown": f"[[rel:{edge_kind} {title}]]", + "reason": f"Semantisch ähnlich ({hit.total_score:.2f})", + "type": "semantic" + }) + + return {"suggestions": suggestions} + except Exception as e: logger.error(f"Analyze failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") @@ -57,41 +92,40 @@ async def save_note(req: SaveRequest): WP-11 Persistence: Speichert Markdown physisch und indiziert es sofort. """ try: - # 1. Vault Root sicher ermitteln + # 1. Pfad-Setup vault_root = os.getenv("MINDNET_VAULT_ROOT", "./vault") - - # Absolute Pfade auflösen, um CWD-Probleme zu vermeiden abs_vault_root = os.path.abspath(vault_root) if not os.path.exists(abs_vault_root): - error_msg = f"Vault root not found at: {abs_vault_root}. Check MINDNET_VAULT_ROOT in .env" - logger.error(error_msg) - raise HTTPException(status_code=500, detail=error_msg) + # Versuche ihn zu erstellen, falls er fehlt + os.makedirs(abs_vault_root, exist_ok=True) - # 2. Filename Fallback + # 2. Filename final_filename = req.filename if not final_filename: final_filename = f"draft_{int(time.time())}.md" - # 3. Ingestion Service aufrufen + # 3. Ingestion Service ingest_service = IngestionService() - logger.info(f"Attempting to save {final_filename} to {req.folder} in {abs_vault_root}") + logger.info(f"Saving {final_filename} to {req.folder}") - result = ingest_service.create_from_text( + # --- DER FIX: AWAIT HINZUFÜGEN --- + # Da ingestion.py auf async umgestellt wurde, MÜSSEN wir hier warten. + result = await ingest_service.create_from_text( markdown_content=req.markdown_content, filename=final_filename, vault_root=abs_vault_root, folder=req.folder ) - # Fehler vom Service abfangen + # Fehlerprüfung auf dem Dictionary if result.get("status") == "error": raise HTTPException(status_code=500, detail=result.get("error")) return SaveResponse( status="success", - file_path=result["path"], + file_path=result.get("path", "unknown"), note_id=result.get("note_id", "unknown"), stats={ "chunks": result.get("chunks_count", 0), From fd47a954bd833c45b45abde4c0ed14645a457581 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 11:11:01 +0100 Subject: [PATCH 12/30] bug fix --- app/routers/ingest.py | 66 +++++------------- app/services/discovery.py | 137 +++++++++++--------------------------- 2 files changed, 56 insertions(+), 147 deletions(-) diff --git a/app/routers/ingest.py b/app/routers/ingest.py index 95b2369..fb69b54 100644 --- a/app/routers/ingest.py +++ b/app/routers/ingest.py @@ -1,20 +1,19 @@ """ app/routers/ingest.py API-Endpunkte für WP-11 (Discovery & Persistence). -Fixed Async/Await Issues. +Fixed Async/Await Integration with Discovery Service. """ import os import time import logging from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from typing import Optional, List, Dict, Any +from typing import Optional, Dict, Any from app.core.ingestion import IngestionService -from app.core.retriever import Retriever -from app.models.dto import QueryRequest +# WICHTIG: Wir nutzen wieder den spezialisierten DiscoveryService +from app.services.discovery import DiscoveryService -# Logger Konfiguration logger = logging.getLogger(__name__) router = APIRouter() @@ -36,51 +35,20 @@ class SaveResponse(BaseModel): note_id: str stats: Dict[str, Any] +# --- Services --- +discovery_service = DiscoveryService() + # --- Endpoints --- @router.post("/analyze") async def analyze_draft(req: AnalyzeRequest): """ - WP-11 Intelligence: Liefert Link-Vorschläge via Retriever. + WP-11 Intelligence: Liefert Link-Vorschläge (Exact + Semantic). """ try: - # Wir nutzen den Retriever direkt, statt eines extra DiscoveryServices - retriever = Retriever() - suggestions = [] - - # 1. Suche nach ähnlichen Inhalten (Semantic) - query_text = req.text[:400] - if not query_text.strip(): - return {"suggestions": []} - - # Check ob Retriever async ist (in v2.3 oft ja) - if hasattr(retriever.search, '__await__'): - hits_result = await retriever.search(QueryRequest(query=query_text, top_k=5, mode="hybrid")) - else: - # Fallback sync - hits_result = await retriever.search(QueryRequest(query=query_text, top_k=5, mode="hybrid")) - - seen_titles = set() - for hit in hits_result.results: - title = hit.payload.get("note_id") or hit.node_id - if not title or title in seen_titles: continue - seen_titles.add(title) - - # Simple Edge Logic - edge_kind = "related_to" - if req.type == "project": edge_kind = "depends_on" - if req.type == "decision": edge_kind = "references" - - if hit.total_score > 0.65: - suggestions.append({ - "target_title": title, - "target_id": hit.node_id, - "suggested_markdown": f"[[rel:{edge_kind} {title}]]", - "reason": f"Semantisch ähnlich ({hit.total_score:.2f})", - "type": "semantic" - }) - - return {"suggestions": suggestions} + # Wir delegieren an den Service, der Exact Matching, Config und Semantik beherrscht + result = await discovery_service.analyze_draft(req.text, req.type) + return result except Exception as e: logger.error(f"Analyze failed: {e}", exc_info=True) @@ -97,21 +65,22 @@ async def save_note(req: SaveRequest): abs_vault_root = os.path.abspath(vault_root) if not os.path.exists(abs_vault_root): - # Versuche ihn zu erstellen, falls er fehlt - os.makedirs(abs_vault_root, exist_ok=True) + try: + os.makedirs(abs_vault_root, exist_ok=True) + except Exception: + raise HTTPException(status_code=500, detail=f"Vault root missing and cannot create: {abs_vault_root}") # 2. Filename final_filename = req.filename if not final_filename: final_filename = f"draft_{int(time.time())}.md" - # 3. Ingestion Service + # 3. Ingestion Service (Async) ingest_service = IngestionService() logger.info(f"Saving {final_filename} to {req.folder}") - # --- DER FIX: AWAIT HINZUFÜGEN --- - # Da ingestion.py auf async umgestellt wurde, MÜSSEN wir hier warten. + # Async Call zum Ingestion Service result = await ingest_service.create_from_text( markdown_content=req.markdown_content, filename=final_filename, @@ -119,7 +88,6 @@ async def save_note(req: SaveRequest): folder=req.folder ) - # Fehlerprüfung auf dem Dictionary if result.get("status") == "error": raise HTTPException(status_code=500, detail=result.get("error")) diff --git a/app/services/discovery.py b/app/services/discovery.py index 15635ca..7d25941 100644 --- a/app/services/discovery.py +++ b/app/services/discovery.py @@ -1,14 +1,14 @@ """ app/services/discovery.py Service für Link-Vorschläge und Knowledge-Discovery (WP-11). -Analysiert Drafts auf Keywords und semantische Ähnlichkeiten. -Implementiert 'Late Binding' für Edge-Typen via types.yaml. +Adaptiert für Async-Architecture (v2.4). """ import logging import os -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any import yaml +# Wir nutzen hier weiterhin die Low-Level Funktionen, da diese stabil sind from app.core.qdrant import QdrantConfig, get_client from app.models.dto import QueryRequest from app.core.retriever import hybrid_retrieve @@ -17,27 +17,21 @@ logger = logging.getLogger(__name__) class DiscoveryService: def __init__(self, collection_prefix: str = None): - # 1. Config laden self.cfg = QdrantConfig.from_env() - # Prefix Priorität: Argument > Env > Default self.prefix = collection_prefix or self.cfg.prefix or "mindnet" self.client = get_client(self.cfg) - - # 2. Registry für Late Binding laden (Edge Defaults) self.registry = self._load_type_registry() async def analyze_draft(self, text: str, current_type: str) -> Dict[str, Any]: """ Analysiert einen Draft-Text und schlägt Verlinkungen vor. - Nutzt 'types.yaml' um den passenden Edge-Typ vorzuschlagen. + Kombiniert Exact Match (Titel/Alias) und Semantic Match. """ suggestions = [] - - # Welcher Edge-Typ ist für diesen Draft-Typ (z.B. 'project') der Standard? - # Late Binding: Wir schauen in die Config, statt es zu hardcoden. default_edge_type = self._get_default_edge_type(current_type) # 1. Exact Match: Finde Begriffe im Text, die als Notiz-Titel existieren + # (Dies läuft synchron, ist aber sehr schnell durch Qdrant Scroll) known_entities = self._fetch_all_titles_and_aliases() found_entities = self._find_entities_in_text(text, known_entities) @@ -45,10 +39,7 @@ class DiscoveryService: for entity in found_entities: existing_target_ids.add(entity["id"]) - - # Vorschlag generieren target_title = entity["title"] - # Markdown-Vorschlag: [[rel:depends_on Ziel]] suggested_md = f"[[rel:{default_edge_type} {target_title}]]" suggestions.append({ @@ -59,21 +50,21 @@ class DiscoveryService: "suggested_edge_type": default_edge_type, "suggested_markdown": suggested_md, "confidence": 1.0, - "reason": f"Existierender Titel (Default für '{current_type}': {default_edge_type})" + "reason": f"Exakter Treffer (Default für '{current_type}': {default_edge_type})" }) # 2. Semantic Match: Finde inhaltlich ähnliche Notizen - semantic_hits = self._get_semantic_suggestions(text) + # Wir filtern Ergebnisse heraus, die wir schon per Exact Match gefunden haben. + semantic_hits = await self._get_semantic_suggestions_async(text) for hit in semantic_hits: if hit.node_id in existing_target_ids: continue if hit.total_score > 0.65: - # Bei semantischen Treffern ist 'related_to' oft sicherer als 'depends_on', - # es sei denn, die Config erzwingt etwas anderes. - # Wir bleiben hier beim Config-Default, um konsistent zu sein. - target_title = hit.payload.get("title", "Unbekannt") + # FIX: Titel aus Payload lesen, nicht ID! + target_title = hit.payload.get("title") or hit.node_id + suggested_md = f"[[rel:{default_edge_type} {target_title}]]" suggestions.append({ @@ -84,124 +75,74 @@ class DiscoveryService: "suggested_edge_type": default_edge_type, "suggested_markdown": suggested_md, "confidence": round(hit.total_score, 2), - "reason": f"Semantische Ähnlichkeit (Score: {round(hit.total_score, 2)})" + "reason": f"Semantische Ähnlichkeit ({hit.total_score:.2f})" }) return { "draft_length": len(text), - "draft_type": current_type, - "default_strategy": default_edge_type, "suggestions_count": len(suggestions), "suggestions": suggestions } - # --- Configuration & Late Binding Helpers --- + # --- Helpers --- + + async def _get_semantic_suggestions_async(self, text: str): + """Async Wrapper um den Hybrid Retriever.""" + req = QueryRequest(query=text, top_k=5, explain=False) + try: + # Da hybrid_retrieve (noch) sync ist, rufen wir es direkt auf. + # In einer voll-async Umgebung würde man dies in einen Thread-Pool auslagern, + # aber da Qdrant-Client sync ist, ist das hier okay. + res = hybrid_retrieve(req) + return res.results + except Exception as e: + logger.error(f"Semantic suggestion failed: {e}") + return [] def _load_type_registry(self) -> dict: - """Lädt die types.yaml für Konfigurations-Zugriffe.""" path = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") if not os.path.exists(path): - # Fallback relative Pfade - if os.path.exists("types.yaml"): - path = "types.yaml" - elif os.path.exists("../config/types.yaml"): - path = "../config/types.yaml" - else: - return {} - + if os.path.exists("types.yaml"): path = "types.yaml" + else: return {} try: - with open(path, "r", encoding="utf-8") as f: - return yaml.safe_load(f) or {} - except Exception as e: - logger.warning(f"Failed to load types registry: {e}") - return {} + with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} + except Exception: return {} def _get_default_edge_type(self, note_type: str) -> str: - """ - Ermittelt den bevorzugten Kanten-Typ für einen gegebenen Notiz-Typ. - Logik: types.yaml -> types -> {note_type} -> edge_defaults[0] - Fallback: 'related_to' - """ - # 1. Config für den Typ laden types_cfg = self.registry.get("types", {}) type_def = types_cfg.get(note_type, {}) - - # 2. Defaults prüfen defaults = type_def.get("edge_defaults") if defaults and isinstance(defaults, list) and len(defaults) > 0: - # Wir nehmen den ersten Default als "Haupt-Beziehung" return defaults[0] - - # 3. Fallback, falls nichts konfiguriert ist return "related_to" - # --- Core Logic --- - def _fetch_all_titles_and_aliases(self) -> List[Dict]: notes = [] next_page = None col_name = f"{self.prefix}_notes" - try: while True: - res, next_page = self.client.scroll( - collection_name=col_name, - limit=1000, - offset=next_page, - with_payload=True, - with_vectors=False - ) + res, next_page = self.client.scroll(collection_name=col_name, limit=1000, offset=next_page, with_payload=True, with_vectors=False) for point in res: pl = point.payload or {} - - # Aliases robust lesen (kann Liste oder String sein) aliases = pl.get("aliases") or [] - if isinstance(aliases, str): - aliases = [aliases] - - notes.append({ - "id": pl.get("note_id"), - "title": pl.get("title"), - "aliases": aliases - }) - if next_page is None: - break - except Exception as e: - logger.error(f"Error fetching titles: {e}") - return [] + if isinstance(aliases, str): aliases = [aliases] + notes.append({"id": pl.get("note_id"), "title": pl.get("title"), "aliases": aliases}) + if next_page is None: break + except Exception: return [] return notes def _find_entities_in_text(self, text: str, entities: List[Dict]) -> List[Dict]: found = [] text_lower = text.lower() for entity in entities: - # 1. Title Match title = entity.get("title") if title and title.lower() in text_lower: - found.append({ - "match": title, - "title": title, - "id": entity["id"] - }) - continue - - # 2. Alias Match + found.append({"match": title, "title": title, "id": entity["id"]}) + continue aliases = entity.get("aliases", []) for alias in aliases: if alias and str(alias).lower() in text_lower: - found.append({ - "match": alias, - "title": title, - "id": entity["id"] - }) + found.append({"match": alias, "title": title, "id": entity["id"]}) break - return found - - def _get_semantic_suggestions(self, text: str): - req = QueryRequest(query=text, top_k=5, explain=False) - try: - res = hybrid_retrieve(req) - return res.results - except Exception as e: - logger.error(f"Semantic suggestion error: {e}") - return [] \ No newline at end of file + return found \ No newline at end of file From 54e38d58c3646a4abd35ff364f5674f7aa773a09 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 11:28:16 +0100 Subject: [PATCH 13/30] UI um debug Modus --- app/frontend/ui.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index adb7cdc..d5c1706 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -194,7 +194,7 @@ def analyze_draft_text(text: str, n_type: str): response = requests.post( INGEST_ANALYZE_ENDPOINT, json={"text": text, "type": n_type}, - timeout=10 + timeout=15 # Erhöhtes Timeout für Suche ) response.raise_for_status() return response.json() @@ -207,7 +207,7 @@ def save_draft_to_vault(markdown_content: str, filename: str = None): response = requests.post( INGEST_SAVE_ENDPOINT, json={"markdown_content": markdown_content, "filename": filename}, - timeout=30 # Indizierung kann dauern + timeout=60 # Indizierung kann dauern ) response.raise_for_status() return response.json() @@ -225,7 +225,7 @@ def submit_feedback(query_id, node_id, score, comment=None): def render_sidebar(): with st.sidebar: st.title("🧠 mindnet") - st.caption("v2.3.2 | WP-10 UI") + st.caption("v2.3.3 | WP-10b (Intelligence)") mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor"], index=0) st.divider() st.subheader("⚙️ Settings") @@ -299,16 +299,21 @@ def render_draft_editor(msg): with tab_intel: st.info("Klicke auf 'Analysieren', um Verknüpfungen zu finden.") - if st.button("🔍 Draft Analysieren", key=f"{key_base}_analyze"): + if st.button("🔍 Analyse starten", key=f"{key_base}_analyze"): with st.spinner("Analysiere Text und suche Verknüpfungen..."): - analysis = analyze_draft_text(new_body, new_type) + current_text = st.session_state[f"{key_base}_body"] + # API Call + analysis = analyze_draft_text(current_text, new_type) + if "error" in analysis: st.error(f"Fehler: {analysis['error']}") else: - st.session_state[f"{key_base}_suggestions"] = analysis.get("suggestions", []) - if not analysis.get("suggestions"): + suggestions = analysis.get("suggestions", []) + st.session_state[f"{key_base}_suggestions"] = suggestions + if not suggestions: st.warning("Keine offensichtlichen Verknüpfungen gefunden.") + # Anzeige der Vorschläge suggestions = st.session_state.get(f"{key_base}_suggestions", []) if suggestions: st.markdown(f"**{len(suggestions)} Vorschläge gefunden:**") @@ -316,18 +321,17 @@ def render_draft_editor(msg): with st.container(): st.markdown(f"""
- {sugg['target_title']} ({sugg['type']})
+ {sugg.get('target_title', 'Unbekannt')} ({sugg.get('type', 'semantic')})
Grund: {sugg.get('reason', 'N/A')}
- {sugg['suggested_markdown']} + {sugg.get('suggested_markdown', '')}
""", unsafe_allow_html=True) if st.button("➕ Einfügen", key=f"{key_base}_add_{idx}"): - # Append to body current_body = st.session_state[f"{key_base}_body"] updated_body = f"{current_body}\n\n{sugg['suggested_markdown']}" st.session_state[f"{key_base}_body"] = updated_body - st.toast(f"Link zu '{sugg['target_title']}' eingefügt!") + st.toast(f"Link zu '{sugg.get('target_title', '?')}' eingefügt!") st.rerun() # --- TAB 3: PREVIEW --- @@ -342,14 +346,18 @@ def render_draft_editor(msg): # Actions (SAVE & EXPORT) b1, b2 = st.columns([1, 1]) with b1: - # Echter Save Button + # Echter Save Button (Ruft API auf) if st.button("💾 Speichern & Indizieren", type="primary", key=f"{key_base}_save"): with st.spinner("Speichere im Vault..."): # Generiere Filename safe_title = re.sub(r'[^a-zA-Z0-9]', '-', new_title).lower()[:30] fname = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}.md" - result = save_draft_to_vault(final_doc, filename=fname) + # Wir holen den aktuellsten Stand aus dem State (inklusive eingefügter Links) + latest_body = st.session_state.get(f"{key_base}_body", "") + latest_doc = build_markdown_doc(final_meta, latest_body) + + result = save_draft_to_vault(latest_doc, filename=fname) if "error" in result: st.error(f"Fehler beim Speichern: {result['error']}") From 06f77fe8b76b9fe03688f41cc65a3b15aa6798e0 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 11:35:27 +0100 Subject: [PATCH 14/30] debug mode im router --- app/routers/ingest.py | 96 ++++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 37 deletions(-) diff --git a/app/routers/ingest.py b/app/routers/ingest.py index fb69b54..5533005 100644 --- a/app/routers/ingest.py +++ b/app/routers/ingest.py @@ -1,25 +1,21 @@ """ -app/routers/ingest.py -API-Endpunkte für WP-11 (Discovery & Persistence). -Fixed Async/Await Integration with Discovery Service. +app/routers/ingest.py - DEBUG VERSION """ import os import time import logging from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from typing import Optional, Dict, Any +from typing import Optional, List, Dict, Any from app.core.ingestion import IngestionService -# WICHTIG: Wir nutzen wieder den spezialisierten DiscoveryService -from app.services.discovery import DiscoveryService +from app.core.retriever import Retriever +from app.models.dto import QueryRequest logger = logging.getLogger(__name__) - router = APIRouter() # --- DTOs --- - class AnalyzeRequest(BaseModel): text: str type: str = "concept" @@ -35,20 +31,61 @@ class SaveResponse(BaseModel): note_id: str stats: Dict[str, Any] -# --- Services --- -discovery_service = DiscoveryService() - # --- Endpoints --- @router.post("/analyze") async def analyze_draft(req: AnalyzeRequest): """ - WP-11 Intelligence: Liefert Link-Vorschläge (Exact + Semantic). + WP-11 Intelligence: Liefert Link-Vorschläge. + DEBUG MODE: Threshold gesenkt, Logging erhöht. """ try: - # Wir delegieren an den Service, der Exact Matching, Config und Semantik beherrscht - result = await discovery_service.analyze_draft(req.text, req.type) - return result + retriever = Retriever() + suggestions = [] + + query_text = req.text[:400] + logger.info(f"ANALYZING TEXT: '{query_text}' (Type: {req.type})") + + if not query_text.strip(): + return {"suggestions": []} + + # Wir suchen + hits_result = await retriever.search(QueryRequest(query=query_text, top_k=5, mode="hybrid")) + + logger.info(f"RETRIEVER FOUND: {len(hits_result.results)} raw hits") + + seen_titles = set() + for hit in hits_result.results: + # Titel holen + title = hit.payload.get("title") or hit.payload.get("note_id") or hit.node_id + + # Logging für jeden Treffer + logger.info(f" -> CHECK HIT: {title} | Score: {hit.total_score:.4f}") + + if not title or title in seen_titles: + continue + seen_titles.add(title) + + # Edge Logic + edge_kind = "related_to" + if req.type == "project": edge_kind = "depends_on" + if req.type == "decision": edge_kind = "references" + + # --- ÄNDERUNG: THRESHOLD GESENKT --- + # War vorher 0.65. Jetzt 0.3 für Tests. + if hit.total_score > 0.3: + suggestions.append({ + "target_title": title, + "target_id": hit.node_id, + "suggested_markdown": f"[[rel:{edge_kind} {title}]]", + "reason": f"Semantisch ähnlich ({hit.total_score:.2f})", + "type": "semantic" + }) + else: + logger.info(f" -> SKIPPED (Score too low)") + + logger.info(f"RETURNING {len(suggestions)} SUGGESTIONS") + return {"suggestions": suggestions} except Exception as e: logger.error(f"Analyze failed: {e}", exc_info=True) @@ -56,36 +93,24 @@ async def analyze_draft(req: AnalyzeRequest): @router.post("/save", response_model=SaveResponse) async def save_note(req: SaveRequest): - """ - WP-11 Persistence: Speichert Markdown physisch und indiziert es sofort. - """ + """WP-11 Persistence""" try: - # 1. Pfad-Setup vault_root = os.getenv("MINDNET_VAULT_ROOT", "./vault") abs_vault_root = os.path.abspath(vault_root) if not os.path.exists(abs_vault_root): - try: - os.makedirs(abs_vault_root, exist_ok=True) - except Exception: - raise HTTPException(status_code=500, detail=f"Vault root missing and cannot create: {abs_vault_root}") + os.makedirs(abs_vault_root, exist_ok=True) - # 2. Filename final_filename = req.filename if not final_filename: final_filename = f"draft_{int(time.time())}.md" - # 3. Ingestion Service (Async) ingest_service = IngestionService() - - logger.info(f"Saving {final_filename} to {req.folder}") + logger.info(f"Saving {final_filename}") - # Async Call zum Ingestion Service - result = await ingest_service.create_from_text( + result = await ingest_service.save_and_index( markdown_content=req.markdown_content, - filename=final_filename, - vault_root=abs_vault_root, - folder=req.folder + filename=final_filename ) if result.get("status") == "error": @@ -93,12 +118,9 @@ async def save_note(req: SaveRequest): return SaveResponse( status="success", - file_path=result.get("path", "unknown"), + file_path=result.get("file_path", "unknown"), note_id=result.get("note_id", "unknown"), - stats={ - "chunks": result.get("chunks_count", 0), - "edges": result.get("edges_count", 0) - } + stats=result.get("stats", {}) ) except HTTPException as he: From f4299db34732232e2e31806fc0911f0aac8beaff Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 12:04:08 +0100 Subject: [PATCH 15/30] =?UTF-8?q?WP10=20=C3=9Cberarbeitung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/ui.py | 153 ++++++++++++++++++++++++------------------ app/routers/ingest.py | 74 +++++++++++--------- 2 files changed, 127 insertions(+), 100 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index d5c1706..5f04d9d 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -23,7 +23,7 @@ timeout_setting = os.getenv("MINDNET_API_TIMEOUT") or os.getenv("MINDNET_LLM_TIM API_TIMEOUT = float(timeout_setting) if timeout_setting else 300.0 # --- PAGE SETUP --- -st.set_page_config(page_title="mindnet v2.3.2", page_icon="🧠", layout="wide") +st.set_page_config(page_title="mindnet v2.3.4", page_icon="🧠", layout="wide") # --- CSS STYLING --- st.markdown(""" @@ -194,7 +194,7 @@ def analyze_draft_text(text: str, n_type: str): response = requests.post( INGEST_ANALYZE_ENDPOINT, json={"text": text, "type": n_type}, - timeout=15 # Erhöhtes Timeout für Suche + timeout=15 ) response.raise_for_status() return response.json() @@ -225,7 +225,7 @@ def submit_feedback(query_id, node_id, score, comment=None): def render_sidebar(): with st.sidebar: st.title("🧠 mindnet") - st.caption("v2.3.3 | WP-10b (Intelligence)") + st.caption("v2.3.4 | WP-10b (Intelligence)") mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor"], index=0) st.divider() st.subheader("⚙️ Settings") @@ -243,7 +243,7 @@ def render_draft_editor(msg): qid = msg.get('query_id', str(uuid.uuid4())) key_base = f"draft_{qid}" - # 1. Init + # 1. Init (Nur beim allerersten Laden) if f"{key_base}_init" not in st.session_state: meta, body = parse_markdown_draft(msg["content"]) @@ -263,19 +263,89 @@ def render_draft_editor(msg): # Metadata c1, c2 = st.columns([2, 1]) with c1: - new_title = st.text_input("Titel", value=st.session_state.get(f"{key_base}_title", ""), key=f"{key_base}_inp_title") + # Titel immer aus State lesen/schreiben + new_title = st.text_input("Titel", key=f"{key_base}_inp_title", value=st.session_state.get(f"{key_base}_title", "")) with c2: known_types = ["concept", "project", "decision", "experience", "journal", "person", "value", "goal", "principle", "default"] curr_type = st.session_state.get(f"{key_base}_type", "default") 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}_sel_type") - new_tags = st.text_input("Tags (kommagetrennt)", value=st.session_state.get(f"{key_base}_tags", ""), key=f"{key_base}_inp_tags") + new_tags = st.text_input("Tags (kommagetrennt)", key=f"{key_base}_inp_tags", value=st.session_state.get(f"{key_base}_tags", "")) - # Tabs (Jetzt mit "Intelligence") + # Tabs tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"]) - # Live Reassembly für alle Tabs + # --- TAB 1: EDITOR --- + with tab_edit: + # WICHTIG: Das Text-Area ist an session_state gebunden via 'key'. + current_body = st.text_area( + "Body", + key=f"{key_base}_txt_body", # Master-Key + value=st.session_state.get(f"{key_base}_body", ""), + height=500, + label_visibility="collapsed" + ) + # Sync zurück zum generischen Key für andere Tabs + st.session_state[f"{key_base}_body"] = current_body + + # --- 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"): + with st.spinner("Analysiere..."): + # Wir nehmen explizit den Text aus dem Widget-State + text_to_analyze = st.session_state[f"{key_base}_txt_body"] + analysis = analyze_draft_text(text_to_analyze, new_type) + + if "error" in analysis: + st.error(f"Fehler: {analysis['error']}") + else: + suggestions = analysis.get("suggestions", []) + st.session_state[f"{key_base}_suggestions"] = suggestions + if not suggestions: + st.warning("Keine Vorschläge gefunden.") + + suggestions = st.session_state.get(f"{key_base}_suggestions", []) + if suggestions: + st.write(f"**{len(suggestions)} Vorschläge:**") + for idx, sugg in enumerate(suggestions): + link_text = sugg.get('suggested_markdown', '') + + # Check: Ist der Link schon im Text? + is_inserted = link_text in st.session_state[f"{key_base}_txt_body"] + + # Card Styling + 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""" +
+ {sugg.get('target_title', 'Unbekannt')} ({sugg.get('type', 'semantic')})
+ {sugg.get('reason', 'N/A')}
+ {link_text} +
+ """, unsafe_allow_html=True) + + # Button Logik (Toggle) + if is_inserted: + if st.button(f"❌ Entfernen", key=f"del_{idx}_{key_base}"): + new_text = st.session_state[f"{key_base}_txt_body"].replace(link_text, "").strip() + st.session_state[f"{key_base}_txt_body"] = new_text + st.session_state[f"{key_base}_body"] = new_text + st.rerun() + else: + if st.button(f"➕ Einfügen", key=f"add_{idx}_{key_base}"): + old_text = st.session_state[f"{key_base}_txt_body"] + new_text = f"{old_text}\n\n{link_text}" + st.session_state[f"{key_base}_txt_body"] = new_text + st.session_state[f"{key_base}_body"] = new_text + st.rerun() + + # --- TAB 3: PREVIEW & SAVE --- + + # Reassemble Metadata & Body (Always use latest state) final_tags_list = [t.strip() for t in new_tags.split(",") if t.strip()] final_meta = { "id": "generated_on_save", @@ -285,57 +355,10 @@ def render_draft_editor(msg): "tags": final_tags_list } - # --- TAB 1: EDITOR --- - with tab_edit: - new_body = st.text_area( - "Body", - value=st.session_state.get(f"{key_base}_body", ""), - height=500, - key=f"{key_base}_txt_body", - label_visibility="collapsed" - ) + # Wir nehmen den aktuellsten Body aus dem State + final_body_content = st.session_state.get(f"{key_base}_txt_body", "") + final_doc = build_markdown_doc(final_meta, final_body_content) - # --- TAB 2: INTELLIGENCE (WP-11 Features) --- - with tab_intel: - st.info("Klicke auf 'Analysieren', um Verknüpfungen zu finden.") - - if st.button("🔍 Analyse starten", key=f"{key_base}_analyze"): - with st.spinner("Analysiere Text und suche Verknüpfungen..."): - current_text = st.session_state[f"{key_base}_body"] - # API Call - analysis = analyze_draft_text(current_text, new_type) - - if "error" in analysis: - st.error(f"Fehler: {analysis['error']}") - else: - suggestions = analysis.get("suggestions", []) - st.session_state[f"{key_base}_suggestions"] = suggestions - if not suggestions: - st.warning("Keine offensichtlichen Verknüpfungen gefunden.") - - # Anzeige der Vorschläge - suggestions = st.session_state.get(f"{key_base}_suggestions", []) - if suggestions: - st.markdown(f"**{len(suggestions)} Vorschläge gefunden:**") - for idx, sugg in enumerate(suggestions): - with st.container(): - st.markdown(f""" -
- {sugg.get('target_title', 'Unbekannt')} ({sugg.get('type', 'semantic')})
- Grund: {sugg.get('reason', 'N/A')}
- {sugg.get('suggested_markdown', '')} -
- """, unsafe_allow_html=True) - - if st.button("➕ Einfügen", key=f"{key_base}_add_{idx}"): - current_body = st.session_state[f"{key_base}_body"] - updated_body = f"{current_body}\n\n{sugg['suggested_markdown']}" - st.session_state[f"{key_base}_body"] = updated_body - st.toast(f"Link zu '{sugg.get('target_title', '?')}' eingefügt!") - st.rerun() - - # --- TAB 3: PREVIEW --- - final_doc = build_markdown_doc(final_meta, st.session_state.get(f"{key_base}_body", "")) with tab_view: st.markdown('
', unsafe_allow_html=True) st.markdown(final_doc) @@ -343,24 +366,19 @@ def render_draft_editor(msg): st.markdown("---") - # Actions (SAVE & EXPORT) + # Save Action b1, b2 = st.columns([1, 1]) with b1: - # Echter Save Button (Ruft API auf) if st.button("💾 Speichern & Indizieren", type="primary", key=f"{key_base}_save"): with st.spinner("Speichere im Vault..."): - # Generiere Filename safe_title = re.sub(r'[^a-zA-Z0-9]', '-', new_title).lower()[:30] fname = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}.md" - # Wir holen den aktuellsten Stand aus dem State (inklusive eingefügter Links) - latest_body = st.session_state.get(f"{key_base}_body", "") - latest_doc = build_markdown_doc(final_meta, latest_body) - - result = save_draft_to_vault(latest_doc, filename=fname) + # Hier der entscheidende Call mit dem aktuellen Dokument + result = save_draft_to_vault(final_doc, filename=fname) if "error" in result: - st.error(f"Fehler beim Speichern: {result['error']}") + st.error(f"Fehler: {result['error']}") else: st.success(f"Gespeichert: {result.get('file_path')}") st.balloons() @@ -447,6 +465,7 @@ def render_manual_editor(): else: st.success(f"Gespeichert: {res.get('file_path')}") +# --- MAIN --- mode, top_k, explain = render_sidebar() if mode == "💬 Chat": render_chat_interface(top_k, explain) diff --git a/app/routers/ingest.py b/app/routers/ingest.py index 5533005..a1407b3 100644 --- a/app/routers/ingest.py +++ b/app/routers/ingest.py @@ -1,21 +1,27 @@ """ -app/routers/ingest.py - DEBUG VERSION +app/routers/ingest.py +API-Endpunkte für WP-11 (Discovery & Persistence). """ import os import time import logging from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from typing import Optional, List, Dict, Any +from typing import Optional, Dict, Any from app.core.ingestion import IngestionService +# Fallback: Falls DiscoveryService noch fehlt, nutzen wir Ingest Service Features oder Mock +# Wir gehen hier davon aus, dass wir alles im IngestionService oder Router machen können, +# um Importfehler zu vermeiden. from app.core.retriever import Retriever from app.models.dto import QueryRequest logger = logging.getLogger(__name__) + router = APIRouter() # --- DTOs --- + class AnalyzeRequest(BaseModel): text: str type: str = "concept" @@ -37,43 +43,36 @@ class SaveResponse(BaseModel): async def analyze_draft(req: AnalyzeRequest): """ WP-11 Intelligence: Liefert Link-Vorschläge. - DEBUG MODE: Threshold gesenkt, Logging erhöht. + Implementiert direkt hier, um Abhängigkeiten zu reduzieren. """ try: retriever = Retriever() suggestions = [] query_text = req.text[:400] - logger.info(f"ANALYZING TEXT: '{query_text}' (Type: {req.type})") - if not query_text.strip(): return {"suggestions": []} - # Wir suchen - hits_result = await retriever.search(QueryRequest(query=query_text, top_k=5, mode="hybrid")) - - logger.info(f"RETRIEVER FOUND: {len(hits_result.results)} raw hits") + # 1. Semantic Search + # Safe async call check + if hasattr(retriever.search, '__await__'): + hits_result = await retriever.search(QueryRequest(query=query_text, top_k=5, mode="hybrid")) + else: + hits_result = await retriever.search(QueryRequest(query=query_text, top_k=5, mode="hybrid")) seen_titles = set() for hit in hits_result.results: - # Titel holen - title = hit.payload.get("title") or hit.payload.get("note_id") or hit.node_id - - # Logging für jeden Treffer - logger.info(f" -> CHECK HIT: {title} | Score: {hit.total_score:.4f}") - - if not title or title in seen_titles: - continue + # Titel ermitteln + title = hit.payload.get("note_id") or hit.node_id + if not title or title in seen_titles: continue seen_titles.add(title) - # Edge Logic edge_kind = "related_to" if req.type == "project": edge_kind = "depends_on" if req.type == "decision": edge_kind = "references" - # --- ÄNDERUNG: THRESHOLD GESENKT --- - # War vorher 0.65. Jetzt 0.3 für Tests. - if hit.total_score > 0.3: + # Score Threshold + if hit.total_score > 0.4: # Etwas toleranter suggestions.append({ "target_title": title, "target_id": hit.node_id, @@ -81,19 +80,19 @@ async def analyze_draft(req: AnalyzeRequest): "reason": f"Semantisch ähnlich ({hit.total_score:.2f})", "type": "semantic" }) - else: - logger.info(f" -> SKIPPED (Score too low)") - logger.info(f"RETURNING {len(suggestions)} SUGGESTIONS") return {"suggestions": suggestions} except Exception as e: logger.error(f"Analyze failed: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") + # Kein 500er werfen, lieber leere Liste, damit UI nicht crasht + return {"suggestions": [], "error": str(e)} @router.post("/save", response_model=SaveResponse) async def save_note(req: SaveRequest): - """WP-11 Persistence""" + """ + WP-11 Persistence: Speichert Markdown physisch und indiziert es sofort. + """ try: vault_root = os.getenv("MINDNET_VAULT_ROOT", "./vault") abs_vault_root = os.path.abspath(vault_root) @@ -106,19 +105,28 @@ async def save_note(req: SaveRequest): final_filename = f"draft_{int(time.time())}.md" ingest_service = IngestionService() - logger.info(f"Saving {final_filename}") - - result = await ingest_service.save_and_index( - markdown_content=req.markdown_content, - filename=final_filename - ) + logger.info(f"Saving {final_filename} to {req.folder}") + + # --- AWAIT WICHTIG! --- + # Wir rufen save_and_index auf (so hieß es in meiner IngestionService Implementierung) + # Wenn deine Methode create_from_text heißt, ändere es hier entsprechend. + # Ich nutze hier save_and_index als Standard aus WP-11. + + if hasattr(ingest_service, 'save_and_index'): + result = await ingest_service.save_and_index(req.markdown_content, final_filename) + elif hasattr(ingest_service, 'create_from_text'): + # Fallback falls du die alte Version hast + result = await ingest_service.create_from_text(req.markdown_content, final_filename, abs_vault_root, req.folder) + else: + raise RuntimeError("IngestionService hat weder save_and_index noch create_from_text") + if result.get("status") == "error": raise HTTPException(status_code=500, detail=result.get("error")) return SaveResponse( status="success", - file_path=result.get("file_path", "unknown"), + file_path=result.get("file_path") or result.get("path", "unknown"), note_id=result.get("note_id", "unknown"), stats=result.get("stats", {}) ) From aba6f0c38be8bc2ba718d8635250ce03364ae61f Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 12:11:55 +0100 Subject: [PATCH 16/30] render draft_editor in ui.py --- app/frontend/ui.py | 67 ++++++++++++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 5f04d9d..2a537bd 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -242,7 +242,21 @@ def render_sidebar(): def render_draft_editor(msg): qid = msg.get('query_id', str(uuid.uuid4())) key_base = f"draft_{qid}" + body_key = f"{key_base}_txt_body" + # --- CALLBACKS (Lösung für den State-Error) --- + def _append_text(k, text): + current = st.session_state.get(k, "") + st.session_state[k] = f"{current}\n\n{text}" + # Sync auch den generischen Key + st.session_state[f"{key_base}_body"] = st.session_state[k] + + def _remove_text(k, text): + current = st.session_state.get(k, "") + # Einfaches Replace (könnte man robuster machen) + st.session_state[k] = current.replace(text, "").strip() + st.session_state[f"{key_base}_body"] = st.session_state[k] + # 1. Init (Nur beim allerersten Laden) if f"{key_base}_init" not in st.session_state: meta, body = parse_markdown_draft(msg["content"]) @@ -251,7 +265,11 @@ def render_draft_editor(msg): st.session_state[f"{key_base}_title"] = meta.get("title", "") tags_raw = meta.get("tags", []) st.session_state[f"{key_base}_tags"] = ", ".join(tags_raw) if isinstance(tags_raw, list) else str(tags_raw) + + # Initialisiere beide Keys + st.session_state[body_key] = body.strip() st.session_state[f"{key_base}_body"] = body.strip() + st.session_state[f"{key_base}_meta"] = meta st.session_state[f"{key_base}_suggestions"] = [] st.session_state[f"{key_base}_init"] = True @@ -263,7 +281,6 @@ def render_draft_editor(msg): # Metadata c1, c2 = st.columns([2, 1]) with c1: - # Titel immer aus State lesen/schreiben new_title = st.text_input("Titel", key=f"{key_base}_inp_title", value=st.session_state.get(f"{key_base}_title", "")) with c2: known_types = ["concept", "project", "decision", "experience", "journal", "person", "value", "goal", "principle", "default"] @@ -278,15 +295,14 @@ def render_draft_editor(msg): # --- TAB 1: EDITOR --- with tab_edit: - # WICHTIG: Das Text-Area ist an session_state gebunden via 'key'. + # Das Widget rendert HIER. Änderungen am State müssen VORHER (via Callback) passieren. current_body = st.text_area( "Body", - key=f"{key_base}_txt_body", # Master-Key - value=st.session_state.get(f"{key_base}_body", ""), + key=body_key, height=500, label_visibility="collapsed" ) - # Sync zurück zum generischen Key für andere Tabs + # Sync manueller Änderungen in den generischen Key st.session_state[f"{key_base}_body"] = current_body # --- TAB 2: INTELLIGENCE --- @@ -295,8 +311,7 @@ def render_draft_editor(msg): if st.button("🔍 Analyse starten", key=f"{key_base}_analyze"): with st.spinner("Analysiere..."): - # Wir nehmen explizit den Text aus dem Widget-State - text_to_analyze = st.session_state[f"{key_base}_txt_body"] + text_to_analyze = st.session_state[body_key] analysis = analyze_draft_text(text_to_analyze, new_type) if "error" in analysis: @@ -313,8 +328,8 @@ def render_draft_editor(msg): for idx, sugg in enumerate(suggestions): link_text = sugg.get('suggested_markdown', '') - # Check: Ist der Link schon im Text? - is_inserted = link_text in st.session_state[f"{key_base}_txt_body"] + # Prüfe ob Text vorhanden (Case Insensitive Check wäre besser, hier simpel) + is_inserted = link_text in st.session_state[body_key] # Card Styling card_style = "border-left: 3px solid #28a745;" if is_inserted else "border-left: 3px solid #1a73e8;" @@ -328,24 +343,23 @@ def render_draft_editor(msg):
""", unsafe_allow_html=True) - # Button Logik (Toggle) + # Button Logik mit CALLBACKS (on_click) if is_inserted: - if st.button(f"❌ Entfernen", key=f"del_{idx}_{key_base}"): - new_text = st.session_state[f"{key_base}_txt_body"].replace(link_text, "").strip() - st.session_state[f"{key_base}_txt_body"] = new_text - st.session_state[f"{key_base}_body"] = new_text - st.rerun() + st.button( + f"❌ Entfernen", + key=f"del_{idx}_{key_base}", + on_click=_remove_text, # Callback + args=(body_key, link_text) # Argumente für Callback + ) else: - if st.button(f"➕ Einfügen", key=f"add_{idx}_{key_base}"): - old_text = st.session_state[f"{key_base}_txt_body"] - new_text = f"{old_text}\n\n{link_text}" - st.session_state[f"{key_base}_txt_body"] = new_text - st.session_state[f"{key_base}_body"] = new_text - st.rerun() + st.button( + f"➕ Einfügen", + key=f"add_{idx}_{key_base}", + on_click=_append_text, # Callback + args=(body_key, link_text) # Argumente für Callback + ) # --- TAB 3: PREVIEW & SAVE --- - - # Reassemble Metadata & Body (Always use latest state) final_tags_list = [t.strip() for t in new_tags.split(",") if t.strip()] final_meta = { "id": "generated_on_save", @@ -355,8 +369,8 @@ def render_draft_editor(msg): "tags": final_tags_list } - # Wir nehmen den aktuellsten Body aus dem State - final_body_content = st.session_state.get(f"{key_base}_txt_body", "") + # Nimm immer den aktuellsten Text aus dem Widget-State + final_body_content = st.session_state[body_key] final_doc = build_markdown_doc(final_meta, final_body_content) with tab_view: @@ -372,9 +386,10 @@ def render_draft_editor(msg): 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]', '-', new_title).lower()[:30] + if not safe_title: safe_title = "draft" fname = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}.md" - # Hier der entscheidende Call mit dem aktuellen Dokument + # Speichern mit aktuellstem Inhalt result = save_draft_to_vault(final_doc, filename=fname) if "error" in result: From b153571933d665ebcdc5eaa3dd48a506c06201dc Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 12:23:06 +0100 Subject: [PATCH 17/30] hard mode ingestion --- app/core/ingestion.py | 133 +++++++++++++++++++++++++++--------------- 1 file changed, 85 insertions(+), 48 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index 6790260..cd6b293 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -3,8 +3,9 @@ app/core/ingestion.py Zentraler Service für die Transformation von Markdown-Dateien in Qdrant-Objekte (Notes, Chunks, Edges). Dient als Shared Logic für: -1. CLI-Imports (scripts/import_markdown.py) - muss ggf. angepasst werden auf Async! +1. CLI-Imports (scripts/import_markdown.py) 2. API-Uploads (WP-11) +Refactored for Async Embedding Support. """ import os import logging @@ -20,12 +21,19 @@ from app.core.note_payload import make_note_payload from app.core.chunker import assemble_chunks from app.core.chunk_payload import make_chunk_payloads -# Fallback für Edges Import +# Fallback für Edges Import (Robustheit) try: from app.core.derive_edges import build_edges_for_note except ImportError: - # Fallback falls Dateiname anders ist - from app.core.edges import build_edges_for_note # type: ignore + try: + from app.core.derive_edges import derive_edges_for_note as build_edges_for_note + except ImportError: + try: + from app.core.edges import build_edges_for_note + except ImportError: + # Fallback Mock + logging.warning("Could not import edge derivation logic. Edges will be empty.") + def build_edges_for_note(*args, **kwargs): return [] from app.core.qdrant import QdrantConfig, get_client, ensure_collections, ensure_payload_indexes from app.core.qdrant_points import ( @@ -35,7 +43,7 @@ from app.core.qdrant_points import ( upsert_batch, ) -# WICHTIG: Wir nutzen den API-Client für Embeddings +# WICHTIG: Wir nutzen den API-Client für Embeddings (Async Support) from app.services.embeddings_client import EmbeddingsClient logger = logging.getLogger(__name__) @@ -75,22 +83,28 @@ def effective_retriever_weight(note_type: str, reg: dict) -> float: class IngestionService: - def __init__(self, collection_prefix: str = "mindnet"): - self.prefix = collection_prefix + def __init__(self, collection_prefix: str = None): + # Prefix Logik vereinheitlichen + env_prefix = os.getenv("COLLECTION_PREFIX", "mindnet") + self.prefix = collection_prefix or env_prefix + self.cfg = QdrantConfig.from_env() - self.cfg.prefix = collection_prefix + self.cfg.prefix = self.prefix self.client = get_client(self.cfg) self.dim = self.cfg.dim # Registry laden self.registry = load_type_registry() - # Embedding Service initialisieren + # Embedding Service initialisieren (Async Client) self.embedder = EmbeddingsClient() - # Init DB Checks - ensure_collections(self.client, self.prefix, self.dim) - ensure_payload_indexes(self.client, self.prefix) + # Init DB Checks (Fehler abfangen, falls DB nicht erreichbar) + try: + ensure_collections(self.client, self.prefix, self.dim) + ensure_payload_indexes(self.client, self.prefix) + except Exception as e: + logger.warning(f"DB initialization warning: {e}") async def process_file( self, @@ -105,7 +119,7 @@ class IngestionService: hash_normalize: str = "canonical" ) -> Dict[str, Any]: """ - Verarbeitet eine einzelne Datei (ASYNC). + Verarbeitet eine einzelne Datei (ASYNC Version). """ result = { "path": file_path, @@ -123,6 +137,7 @@ class IngestionService: fm = normalize_frontmatter(parsed.frontmatter) validate_required_frontmatter(fm) except Exception as e: + logger.error(f"Validation failed for {file_path}: {e}") return {**result, "error": f"Validation failed: {str(e)}"} # 2. Type & Config Resolution @@ -151,6 +166,7 @@ class IngestionService: note_id = note_pl["note_id"] except Exception as e: + logger.error(f"Payload build failed: {e}") return {**result, "error": f"Payload build failed: {str(e)}"} # 4. Change Detection @@ -180,12 +196,12 @@ class IngestionService: chunks = assemble_chunks(fm["id"], body_text, fm["type"]) chunk_pls = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text) - # --- EMBEDDING FIX --- + # --- EMBEDDING FIX (ASYNC) --- vecs = [] if chunk_pls: texts = [c.get("window") or c.get("text") or "" for c in chunk_pls] try: - # Async Aufruf des Embedders + # Async Aufruf des Embedders (via Batch oder Loop) if hasattr(self.embedder, 'embed_documents'): vecs = await self.embedder.embed_documents(texts) else: @@ -198,63 +214,81 @@ class IngestionService: if vecs and len(vecs) > 0: dim_got = len(vecs[0]) if dim_got != self.dim: - raise ValueError(f"Vector dimension mismatch. Expected {self.dim}, got {dim_got}") + # Wirf keinen Fehler, aber logge Warnung. Qdrant Upsert wird failen wenn 0. + logger.warning(f"Vector dimension mismatch. Expected {self.dim}, got {dim_got}") + if dim_got == 0: + raise ValueError("Embedding returned empty vectors (Dim 0)") except Exception as e: logger.error(f"Embedding generation failed: {e}") raise RuntimeError(f"Embedding failed: {e}") # Edges note_refs = note_pl.get("references") or [] - edges = build_edges_for_note( - note_id, - chunk_pls, - note_level_references=note_refs, - include_note_scope_refs=note_scope_refs - ) + # Versuche flexible Signatur für Edges (V1 vs V2) + try: + edges = build_edges_for_note( + note_id, + chunk_pls, + note_level_references=note_refs, + include_note_scope_refs=note_scope_refs + ) + except TypeError: + # Fallback für ältere Signatur + edges = build_edges_for_note(note_id, chunk_pls) + except Exception as e: + logger.error(f"Processing failed: {e}", exc_info=True) return {**result, "error": f"Processing failed: {str(e)}"} # 6. Upsert Action - if purge_before and has_old: - self._purge_artifacts(note_id) + try: + if purge_before and has_old: + self._purge_artifacts(note_id) - n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim) - upsert_batch(self.client, n_name, n_pts) + n_name, n_pts = points_for_note(self.prefix, note_pl, None, self.dim) + upsert_batch(self.client, n_name, n_pts) - if chunk_pls: - c_name, c_pts = points_for_chunks(self.prefix, chunk_pls, vecs) - upsert_batch(self.client, c_name, c_pts) + if chunk_pls and vecs: + c_name, c_pts = points_for_chunks(self.prefix, chunk_pls, vecs) + upsert_batch(self.client, c_name, c_pts) - if edges: - e_name, e_pts = points_for_edges(self.prefix, edges) - upsert_batch(self.client, e_name, e_pts) + if edges: + e_name, e_pts = points_for_edges(self.prefix, edges) + upsert_batch(self.client, e_name, e_pts) - return { - "path": file_path, - "status": "success", - "changed": True, - "note_id": note_id, - "chunks_count": len(chunk_pls), - "edges_count": len(edges) - } + return { + "path": file_path, + "status": "success", + "changed": True, + "note_id": note_id, + "chunks_count": len(chunk_pls), + "edges_count": len(edges) + } + except Exception as e: + logger.error(f"Upsert failed: {e}", exc_info=True) + return {**result, "error": f"DB Upsert failed: {e}"} # --- Interne Qdrant Helper --- def _fetch_note_payload(self, note_id: str) -> Optional[dict]: from qdrant_client.http import models as rest col = f"{self.prefix}_notes" - f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - pts, _ = self.client.scroll(collection_name=col, scroll_filter=f, limit=1, with_payload=True) - return pts[0].payload if pts else None + try: + f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) + pts, _ = self.client.scroll(collection_name=col, scroll_filter=f, limit=1, with_payload=True) + return pts[0].payload if pts else None + except: return None def _artifacts_missing(self, note_id: str) -> Tuple[bool, bool]: from qdrant_client.http import models as rest c_col = f"{self.prefix}_chunks" e_col = f"{self.prefix}_edges" - f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - c_pts, _ = self.client.scroll(collection_name=c_col, scroll_filter=f, limit=1) - e_pts, _ = self.client.scroll(collection_name=e_col, scroll_filter=f, limit=1) - return (not bool(c_pts)), (not bool(e_pts)) + try: + f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) + c_pts, _ = self.client.scroll(collection_name=c_col, scroll_filter=f, limit=1) + e_pts, _ = self.client.scroll(collection_name=e_col, scroll_filter=f, limit=1) + return (not bool(c_pts)), (not bool(e_pts)) + except: return True, True def _purge_artifacts(self, note_id: str): from qdrant_client.http import models as rest @@ -274,7 +308,8 @@ class IngestionService: folder: str = "00_Inbox" ) -> Dict[str, Any]: """ - WP-11 Persistence: Schreibt Text sicher und indiziert ihn. + WP-11 Persistence API Entrypoint. + Schreibt Text in Vault und indiziert ihn sofort. """ # 1. Zielordner target_dir = os.path.join(vault_root, folder) @@ -293,10 +328,12 @@ class IngestionService: try: with open(file_path, "w", encoding="utf-8") as f: f.write(markdown_content) + logger.info(f"Written file to {file_path}") except Exception as e: return {"status": "error", "error": f"Disk write failed at {file_path}: {str(e)}"} # 4. Indizieren (Async Aufruf!) + # Wir rufen process_file auf, das jetzt ASYNC ist return await self.process_file( file_path=file_path, vault_root=vault_root, From 88e2cbf254becfbf41cab16ee3f61ce00d8c8ce5 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 12:40:39 +0100 Subject: [PATCH 18/30] WP10, neue UI mit echtem manuellem Editor/Import --- app/frontend/ui.py | 27 +++----- scripts/import_markdown.py | 131 +++++++++++++++++++++++-------------- 2 files changed, 91 insertions(+), 67 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 2a537bd..88dbf80 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -23,7 +23,7 @@ timeout_setting = os.getenv("MINDNET_API_TIMEOUT") or os.getenv("MINDNET_LLM_TIM API_TIMEOUT = float(timeout_setting) if timeout_setting else 300.0 # --- PAGE SETUP --- -st.set_page_config(page_title="mindnet v2.3.4", page_icon="🧠", layout="wide") +st.set_page_config(page_title="mindnet v2.3.6", page_icon="🧠", layout="wide") # --- CSS STYLING --- st.markdown(""" @@ -225,7 +225,7 @@ def submit_feedback(query_id, node_id, score, comment=None): def render_sidebar(): with st.sidebar: st.title("🧠 mindnet") - st.caption("v2.3.4 | WP-10b (Intelligence)") + st.caption("v2.3.6 | WP-10b (Full)") mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor"], index=0) st.divider() st.subheader("⚙️ Settings") @@ -463,22 +463,13 @@ def render_chat_interface(top_k, explain): st.rerun() def render_manual_editor(): - st.header("📝 Manueller Editor") - c1, c2 = st.columns([1, 2]) - n_type = c1.selectbox("Typ", ["concept", "project", "decision", "experience", "value", "goal"]) - tags = c2.text_input("Tags") - body = st.text_area("Inhalt", height=400, placeholder="# Titel\n\nText...") - - if st.button("Speichern (Via API)"): - meta = {"type": n_type, "status": "draft", "tags": [t.strip() for t in tags.split(",")]} - doc = build_markdown_doc(meta, body) - - # Test Call - res = save_draft_to_vault(doc, filename=f"manual-{uuid.uuid4().hex[:6]}.md") - if "error" in res: - st.error(res["error"]) - else: - st.success(f"Gespeichert: {res.get('file_path')}") + # Wir nutzen eine Fake-Message, um die render_draft_editor Logik wiederzuverwenden + # Aber mit leeren Defaults + mock_msg = { + "content": "---\ntype: default\nstatus: draft\ntitle: Neue Notiz\ntags: []\n---\n# Titel\n", + "query_id": "manual_mode_v2" # Feste ID für manuellen Modus + } + render_draft_editor(mock_msg) # --- MAIN --- mode, top_k, explain = render_sidebar() diff --git a/scripts/import_markdown.py b/scripts/import_markdown.py index 718d96c..da86fc0 100644 --- a/scripts/import_markdown.py +++ b/scripts/import_markdown.py @@ -1,67 +1,100 @@ #!/usr/bin/env python3 """ scripts/import_markdown.py -Refactored CLI-Wrapper für den IngestionService. +CLI-Tool zum Importieren von Markdown-Dateien in Qdrant. +Updated for Mindnet v2.3.6 (Async Ingestion Support). """ -import argparse +import asyncio import os -import json -import sys +import argparse +import logging +from pathlib import Path from dotenv import load_dotenv + +# Importiere den neuen Async Service +# Stellen wir sicher, dass der Pfad stimmt (Pythonpath) +import sys +sys.path.append(os.getcwd()) + from app.core.ingestion import IngestionService -def iter_md(root: str): - out = [] - for dp, _, fns in os.walk(root): - for fn in fns: - if fn.endswith(".md") and "/.obsidian/" not in dp: - out.append(os.path.join(dp, fn).replace("\\", "/")) - return sorted(out) +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("importer") + +async def main_async(args): + vault_path = Path(args.vault).resolve() + if not vault_path.exists(): + logger.error(f"Vault path does not exist: {vault_path}") + return + + # Service initialisieren (startet Async Clients) + logger.info(f"Initializing IngestionService (Prefix: {args.prefix})") + service = IngestionService(collection_prefix=args.prefix) + + logger.info(f"Scanning {vault_path}...") + files = list(vault_path.rglob("*.md")) + # Exclude .obsidian folder if present + files = [f for f in files if ".obsidian" not in str(f)] + files.sort() + + logger.info(f"Found {len(files)} markdown files.") + + stats = {"processed": 0, "skipped": 0, "errors": 0} + + # Wir nutzen eine Semaphore, um nicht zu viele Files gleichzeitig zu öffnen/embedden + sem = asyncio.Semaphore(5) # Max 5 concurrent files to avoid OOM or Rate Limit + + async def process_with_limit(f_path): + async with sem: + try: + res = await service.process_file( + file_path=str(f_path), + vault_root=str(vault_path), + force_replace=args.force, + apply=args.apply, + purge_before=True + ) + return res + except Exception as e: + return {"status": "error", "error": str(e), "path": str(f_path)} + + # Batch Processing + # Wir verarbeiten in Chunks, um den Progress zu sehen + batch_size = 20 + for i in range(0, len(files), batch_size): + batch = files[i:i+batch_size] + logger.info(f"Processing batch {i} to {i+len(batch)}...") + + tasks = [process_with_limit(f) for f in batch] + results = await asyncio.gather(*tasks) + + for res in results: + if res.get("status") == "success": + stats["processed"] += 1 + elif res.get("status") == "error": + stats["errors"] += 1 + logger.error(f"Error in {res.get('path')}: {res.get('error')}") + else: + stats["skipped"] += 1 + + logger.info(f"Done. Stats: {stats}") + if not args.apply: + logger.info("DRY RUN. Use --apply to write to DB.") def main(): load_dotenv() - - # FIX: Default Prefix aus Environment holen, sonst Fallback auf "mindnet" default_prefix = os.getenv("COLLECTION_PREFIX", "mindnet") - ap = argparse.ArgumentParser() - ap.add_argument("--vault", required=True) - ap.add_argument("--apply", action="store_true") - ap.add_argument("--purge-before-upsert", action="store_true") - ap.add_argument("--force-replace", action="store_true") + parser = argparse.ArgumentParser(description="Import Vault to Qdrant (Async)") + parser.add_argument("--vault", default="./vault", help="Path to vault root") + parser.add_argument("--prefix", default=default_prefix, help="Collection prefix") + parser.add_argument("--force", action="store_true", help="Force re-index all files") + parser.add_argument("--apply", action="store_true", help="Perform writes to Qdrant") - # Hier nutzen wir jetzt die Variable - ap.add_argument("--prefix", default=default_prefix) + args = parser.parse_args() - args = ap.parse_args() - - print(f"Init IngestionService (Prefix: {args.prefix})...") - service = IngestionService(collection_prefix=args.prefix) - - files = iter_md(os.path.abspath(args.vault)) - print(f"Found {len(files)} files in vault.") - - processed = 0 - errors = 0 - - for f in files: - res = service.process_file( - file_path=f, - vault_root=os.path.abspath(args.vault), - apply=args.apply, - force_replace=args.force_replace, - purge_before=args.purge_before_upsert - ) - - if res.get("status") not in ["skipped", "unchanged"]: - print(json.dumps(res, ensure_ascii=False)) - processed += 1 - - if res.get("error"): - print(json.dumps(res, ensure_ascii=False), file=sys.stderr) - errors += 1 - - print(f"Done. Processed/Changed: {processed}. Errors: {errors}") + # Starte den Async Loop + asyncio.run(main_async(args)) if __name__ == "__main__": main() \ No newline at end of file From b4ebbe5c287e21a0e4cbb59ad188946833ecc5ec Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 12:56:16 +0100 Subject: [PATCH 19/30] WP10 ui mit text buffer --- app/frontend/ui.py | 160 +++++++++++++++++++++++---------------------- 1 file changed, 82 insertions(+), 78 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 88dbf80..20bb3f9 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -23,7 +23,7 @@ timeout_setting = os.getenv("MINDNET_API_TIMEOUT") or os.getenv("MINDNET_LLM_TIM API_TIMEOUT = float(timeout_setting) if timeout_setting else 300.0 # --- PAGE SETUP --- -st.set_page_config(page_title="mindnet v2.3.6", page_icon="🧠", layout="wide") +st.set_page_config(page_title="mindnet v2.3.7", page_icon="🧠", layout="wide") # --- CSS STYLING --- st.markdown(""" @@ -112,7 +112,6 @@ def normalize_meta_and_body(meta, 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: @@ -189,7 +188,6 @@ def send_chat_message(message: str, top_k: int, explain: bool): return {"error": str(e)} def analyze_draft_text(text: str, n_type: str): - """Ruft den neuen Intelligence-Service (WP-11) auf.""" try: response = requests.post( INGEST_ANALYZE_ENDPOINT, @@ -202,12 +200,11 @@ def analyze_draft_text(text: str, n_type: str): return {"error": str(e)} def save_draft_to_vault(markdown_content: str, filename: str = None): - """Ruft den neuen Persistence-Service (WP-11) auf.""" try: response = requests.post( INGEST_SAVE_ENDPOINT, json={"markdown_content": markdown_content, "filename": filename}, - timeout=60 # Indizierung kann dauern + timeout=60 ) response.raise_for_status() return response.json() @@ -225,7 +222,7 @@ def submit_feedback(query_id, node_id, score, comment=None): def render_sidebar(): with st.sidebar: st.title("🧠 mindnet") - st.caption("v2.3.6 | WP-10b (Full)") + st.caption("v2.3.7 | Stable State") mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor"], index=0) st.divider() st.subheader("⚙️ Settings") @@ -242,96 +239,119 @@ def render_sidebar(): def render_draft_editor(msg): qid = msg.get('query_id', str(uuid.uuid4())) key_base = f"draft_{qid}" - body_key = f"{key_base}_txt_body" - # --- CALLBACKS (Lösung für den State-Error) --- - def _append_text(k, text): - current = st.session_state.get(k, "") - st.session_state[k] = f"{current}\n\n{text}" - # Sync auch den generischen Key - st.session_state[f"{key_base}_body"] = st.session_state[k] + # === 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" - def _remove_text(k, text): - current = st.session_state.get(k, "") - # Einfaches Replace (könnte man robuster machen) - st.session_state[k] = current.replace(text, "").strip() - st.session_state[f"{key_base}_body"] = st.session_state[k] - - # 1. Init (Nur beim allerersten Laden) + # --- 1. INIT STATE (Einmalig pro Nachricht) --- if f"{key_base}_init" not in st.session_state: meta, body = parse_markdown_draft(msg["content"]) - st.session_state[f"{key_base}_type"] = meta.get("type", "default") - st.session_state[f"{key_base}_title"] = meta.get("title", "") - tags_raw = meta.get("tags", []) - st.session_state[f"{key_base}_tags"] = ", ".join(tags_raw) if isinstance(tags_raw, list) else str(tags_raw) + # Defaults setzen + if "type" not in meta: meta["type"] = "default" + if "title" not in meta: meta["title"] = "" + if "tags" not in meta: meta["tags"] = [] - # Initialisiere beide Keys - st.session_state[body_key] = body.strip() - st.session_state[f"{key_base}_body"] = body.strip() - - st.session_state[f"{key_base}_meta"] = meta - st.session_state[f"{key_base}_suggestions"] = [] + # 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 - # 2. UI Layout + # --- 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'
', unsafe_allow_html=True) st.markdown("### 📝 Entwurf bearbeiten") - # Metadata + # 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}_inp_title", value=st.session_state.get(f"{key_base}_title", "")) + 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 = st.session_state.get(f"{key_base}_type", "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}_sel_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 (kommagetrennt)", key=f"{key_base}_inp_tags", value=st.session_state.get(f"{key_base}_tags", "")) + 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: - # Das Widget rendert HIER. Änderungen am State müssen VORHER (via Callback) passieren. - current_body = st.text_area( + # 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=body_key, + key=widget_body_key, + value=st.session_state[data_body_key], + on_change=_sync_body, height=500, label_visibility="collapsed" ) - # Sync manueller Änderungen in den generischen Key - st.session_state[f"{key_base}_body"] = current_body # --- 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..."): - text_to_analyze = st.session_state[body_key] - analysis = analyze_draft_text(text_to_analyze, new_type) + # 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[f"{key_base}_suggestions"] = 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.get(f"{key_base}_suggestions", []) + suggestions = st.session_state[data_sugg_key] if suggestions: - st.write(f"**{len(suggestions)} Vorschläge:**") for idx, sugg in enumerate(suggestions): link_text = sugg.get('suggested_markdown', '') + is_inserted = link_text in st.session_state[data_body_key] - # Prüfe ob Text vorhanden (Case Insensitive Check wäre besser, hier simpel) - is_inserted = link_text in st.session_state[body_key] - - # Card Styling card_style = "border-left: 3px solid #28a745;" if is_inserted else "border-left: 3px solid #1a73e8;" bg_color = "#e6fffa" if is_inserted else "#ffffff" @@ -343,35 +363,22 @@ def render_draft_editor(msg):
""", unsafe_allow_html=True) - # Button Logik mit CALLBACKS (on_click) if is_inserted: - st.button( - f"❌ Entfernen", - key=f"del_{idx}_{key_base}", - on_click=_remove_text, # Callback - args=(body_key, link_text) # Argumente für Callback - ) + st.button("❌ Entfernen", key=f"del_{idx}_{key_base}", on_click=_remove_text, args=(link_text,)) else: - st.button( - f"➕ Einfügen", - key=f"add_{idx}_{key_base}", - on_click=_append_text, # Callback - args=(body_key, link_text) # Argumente für Callback - ) + st.button("➕ Einfügen", key=f"add_{idx}_{key_base}", on_click=_insert_text, args=(link_text,)) # --- TAB 3: PREVIEW & SAVE --- - final_tags_list = [t.strip() for t in new_tags.split(",") if t.strip()] + # 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": new_type, - "title": new_title, + "type": meta_ref["type"], + "title": meta_ref["title"], "status": "draft", "tags": final_tags_list } - - # Nimm immer den aktuellsten Text aus dem Widget-State - final_body_content = st.session_state[body_key] - final_doc = build_markdown_doc(final_meta, final_body_content) + final_doc = build_markdown_doc(final_meta, st.session_state[data_body_key]) with tab_view: st.markdown('
', unsafe_allow_html=True) @@ -380,18 +387,15 @@ def render_draft_editor(msg): st.markdown("---") - # Save Action 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]', '-', new_title).lower()[:30] + 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" - # Speichern mit aktuellstem Inhalt result = save_draft_to_vault(final_doc, filename=fname) - if "error" in result: st.error(f"Fehler: {result['error']}") else: @@ -463,11 +467,11 @@ def render_chat_interface(top_k, explain): st.rerun() def render_manual_editor(): - # Wir nutzen eine Fake-Message, um die render_draft_editor Logik wiederzuverwenden - # Aber mit leeren Defaults + # 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: default\nstatus: draft\ntitle: Neue Notiz\ntags: []\n---\n# Titel\n", - "query_id": "manual_mode_v2" # Feste ID für manuellen Modus + "content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n", + "query_id": "manual_editor_fixed_v1" } render_draft_editor(mock_msg) From 98c395b5de2ea32c8706055ec1e4b8aaf62c621c Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 13:05:05 +0100 Subject: [PATCH 20/30] Ui bug fix --- app/frontend/ui.py | 132 ++++++++++++++++++++------------------------- 1 file changed, 59 insertions(+), 73 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 20bb3f9..26154ec 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -240,80 +240,67 @@ 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" + # 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 (Einmalig pro Nachricht) --- + # --- 1. INIT STATE --- if f"{key_base}_init" not in st.session_state: meta, body = parse_markdown_draft(msg["content"]) - # Defaults setzen + # Metadata Defaults 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", "")) + tags = meta.get("tags", []) + meta["tags_str"] = ", ".join(tags) if isinstance(tags, list) else str(tags) - # Persistent speichern + # 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 - # --- HELPER CALLBACKS --- - # Sync Widget -> Data + # --- CALLBACKS --- def _sync_body(): + # Schreibt Widget-Inhalt in den Speicher 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] + # 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[data_body_key] + current = st.session_state[widget_body_key] st.session_state[data_body_key] = current.replace(text_to_remove, "").strip() - # --- 2. UI LAYOUT --- + # --- UI LAYOUT --- st.markdown(f'
', unsafe_allow_html=True) st.markdown("### 📝 Entwurf bearbeiten") - # Load Data Reference - meta_ref = st.session_state[data_meta_key] - # Metadata Form + meta_ref = st.session_state[data_meta_key] 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 + 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", "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 + 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") - 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 + 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: - # Hier ist der Trick: Value kommt aus 'data_body_key', - # Änderungen triggern '_sync_body', der zurück in 'data_body_key' schreibt. + # Value kommt aus Data, Änderungen gehen via Callback zurück in Data st.text_area( "Body", key=widget_body_key, @@ -328,12 +315,18 @@ def render_draft_editor(msg): 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 + # 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..."): - # 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: @@ -342,23 +335,27 @@ def render_draft_editor(msg): suggestions = analysis.get("suggestions", []) st.session_state[data_sugg_key] = suggestions if not suggestions: - st.warning("Keine Vorschläge gefunden.") + 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 st.session_state[data_body_key] + is_inserted = link_text in current_text_state - card_style = "border-left: 3px solid #28a745;" if is_inserted else "border-left: 3px solid #1a73e8;" bg_color = "#e6fffa" if is_inserted else "#ffffff" + border = "3px solid #28a745" if is_inserted else "3px solid #1a73e8" st.markdown(f""" -
- {sugg.get('target_title', 'Unbekannt')} ({sugg.get('type', 'semantic')})
- {sugg.get('reason', 'N/A')}
+
+ {sugg.get('target_title')} ({sugg.get('type')})
+ {sugg.get('reason')}
{link_text}
""", unsafe_allow_html=True) @@ -368,17 +365,18 @@ def render_draft_editor(msg): 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()] + # --- 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_list + "tags": final_tags } - final_doc = build_markdown_doc(final_meta, st.session_state[data_body_key]) + # 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('
', unsafe_allow_html=True) @@ -387,25 +385,13 @@ def render_draft_editor(msg): 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("
", unsafe_allow_html=True) + 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): From 2677ad7269b0216939e8530df39cbbd4059cef3a Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 13:15:43 +0100 Subject: [PATCH 21/30] bug fix ui --- app/frontend/ui.py | 96 ++++++++++++++++++++++++---------------------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 26154ec..8be766e 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -23,7 +23,7 @@ timeout_setting = os.getenv("MINDNET_API_TIMEOUT") or os.getenv("MINDNET_LLM_TIM 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") +st.set_page_config(page_title="mindnet v2.3.8", page_icon="🧠", layout="wide") # --- CSS STYLING --- st.markdown(""" @@ -112,6 +112,7 @@ def normalize_meta_and_body(meta, 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: @@ -188,6 +189,7 @@ def send_chat_message(message: str, top_k: int, explain: bool): return {"error": str(e)} def analyze_draft_text(text: str, n_type: str): + """Ruft den neuen Intelligence-Service (WP-11) auf.""" try: response = requests.post( INGEST_ANALYZE_ENDPOINT, @@ -200,11 +202,12 @@ def analyze_draft_text(text: str, n_type: str): return {"error": str(e)} def save_draft_to_vault(markdown_content: str, filename: str = None): + """Ruft den neuen Persistence-Service (WP-11) auf.""" try: response = requests.post( INGEST_SAVE_ENDPOINT, json={"markdown_content": markdown_content, "filename": filename}, - timeout=60 + timeout=60 # Indizierung kann dauern ) response.raise_for_status() return response.json() @@ -222,7 +225,7 @@ def submit_feedback(query_id, node_id, score, comment=None): def render_sidebar(): with st.sidebar: st.title("🧠 mindnet") - st.caption("v2.3.7 | Stable State") + st.caption("v2.3.8 | Fixed State Sync") mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor"], index=0) st.divider() st.subheader("⚙️ Settings") @@ -241,42 +244,38 @@ def render_draft_editor(msg): 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 + data_meta_key = f"{key_base}_data_meta" + data_sugg_key = f"{key_base}_data_suggestions" + widget_body_key = f"{key_base}_widget_body" - # --- 1. INIT STATE --- + # --- 1. INIT STATE (Nur einmalig) --- if f"{key_base}_init" not in st.session_state: meta, body = parse_markdown_draft(msg["content"]) - # Metadata Defaults + # 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] = [] + + # WICHTIG: Wir initialisieren den Widget Key direkt! + st.session_state[widget_body_key] = body.strip() + 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] - + # --- CALLBACKS (Modifizieren direkt den Widget-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] = [] + current = st.session_state[widget_body_key] + st.session_state[widget_body_key] = f"{current}\n\n{text_to_insert}" + # Kein Rerun nötig, Button-Klick macht das implizit 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() + st.session_state[widget_body_key] = current.replace(text_to_remove, "").strip() # --- UI LAYOUT --- st.markdown(f'
', unsafe_allow_html=True) @@ -300,12 +299,11 @@ def render_draft_editor(msg): # --- TAB 1: EDITOR --- with tab_edit: - # Value kommt aus Data, Änderungen gehen via Callback zurück in Data + # State-Fix: Wir übergeben KEIN 'value' Argument, wenn der Key existiert. + # So hat das Widget Vorrang vor veraltetem Code. st.text_area( "Body", key=widget_body_key, - value=st.session_state[data_body_key], - on_change=_sync_body, height=500, label_visibility="collapsed" ) @@ -315,16 +313,14 @@ def render_draft_editor(msg): 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 + # 1. Reset suggestions 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]) + # 2. Text DIREKT aus dem Widget State lesen + text_to_analyze = st.session_state[widget_body_key] - # Debug (optional, damit du siehst was passiert) - # st.caption(f"Sende {len(text_to_analyze)} Zeichen an API...") + # 3. Debug Output (optional) + # st.info(f"Sende {len(text_to_analyze)} Zeichen an API...") with st.spinner("Analysiere..."): analysis = analyze_draft_text(text_to_analyze, meta_ref["type"]) @@ -335,7 +331,7 @@ def render_draft_editor(msg): 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).") + st.warning("Keine Vorschläge gefunden.") else: st.success(f"{len(suggestions)} Vorschläge gefunden.") @@ -343,7 +339,7 @@ def render_draft_editor(msg): 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, "") + current_text_state = st.session_state[widget_body_key] for idx, sugg in enumerate(suggestions): link_text = sugg.get('suggested_markdown', '') @@ -375,7 +371,7 @@ def render_draft_editor(msg): "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_body = st.session_state[widget_body_key] final_doc = build_markdown_doc(final_meta, final_body) with tab_view: @@ -385,13 +381,25 @@ def render_draft_editor(msg): 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')}") + b1, b2 = st.columns([1, 1]) + with b1: + 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" + + # Sende das finale Dokument an die API + 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("
", unsafe_allow_html=True) def render_chat_interface(top_k, explain): @@ -453,11 +461,9 @@ def render_chat_interface(top_k, explain): 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" + "query_id": "manual_mode_v2" } render_draft_editor(mock_msg) From 9d792e11cef86cd8e16d1f1a8159a86ebc863268 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 13:26:14 +0100 Subject: [PATCH 22/30] ui change --- app/frontend/ui.py | 48 +++++++++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 8be766e..b68014c 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -23,7 +23,7 @@ timeout_setting = os.getenv("MINDNET_API_TIMEOUT") or os.getenv("MINDNET_LLM_TIM API_TIMEOUT = float(timeout_setting) if timeout_setting else 300.0 # --- PAGE SETUP --- -st.set_page_config(page_title="mindnet v2.3.8", page_icon="🧠", layout="wide") +st.set_page_config(page_title="mindnet v2.3.9", page_icon="🧠", layout="wide") # --- CSS STYLING --- st.markdown(""" @@ -225,7 +225,7 @@ def submit_feedback(query_id, node_id, score, comment=None): def render_sidebar(): with st.sidebar: st.title("🧠 mindnet") - st.caption("v2.3.8 | Fixed State Sync") + st.caption("v2.3.9 | Stable ID Fix") mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor"], index=0) st.divider() st.subheader("⚙️ Settings") @@ -240,15 +240,23 @@ def render_sidebar(): return mode, top_k, explain def render_draft_editor(msg): - qid = msg.get('query_id', str(uuid.uuid4())) + # --- STABLE ID FIX (Der entscheidende Teil) --- + # Wir prüfen, ob die Nachricht schon eine ID hat. Wenn nicht, erzeugen wir eine + # und SPEICHERN sie zurück in das msg-Objekt (das Teil von session_state ist). + # So bleibt die ID über Reruns hinweg identisch. + 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" - # --- 1. INIT STATE (Nur einmalig) --- + # --- 1. INIT STATE (Nur einmalig pro stabiler ID) --- if f"{key_base}_init" not in st.session_state: meta, body = parse_markdown_draft(msg["content"]) @@ -258,24 +266,32 @@ def render_draft_editor(msg): tags = meta.get("tags", []) meta["tags_str"] = ", ".join(tags) if isinstance(tags, list) else str(tags) + # Persistent Data st.session_state[data_meta_key] = meta st.session_state[data_sugg_key] = [] + st.session_state[data_body_key] = body.strip() - # WICHTIG: Wir initialisieren den Widget Key direkt! + # Widget Init (wichtig: Hier wird der "Default Value" des Widgets gesetzt) st.session_state[widget_body_key] = body.strip() st.session_state[f"{key_base}_init"] = True - # --- CALLBACKS (Modifizieren direkt den Widget-Key) --- - + # --- CALLBACKS --- + def _sync_body(): + # Sync vom Widget zurück in den persistenten Speicher + st.session_state[data_body_key] = st.session_state[widget_body_key] + def _insert_text(text_to_insert): + # Einfügen in Widget State current = st.session_state[widget_body_key] st.session_state[widget_body_key] = f"{current}\n\n{text_to_insert}" - # Kein Rerun nötig, Button-Klick macht das implizit + # Sync auch data_body + st.session_state[data_body_key] = st.session_state[widget_body_key] def _remove_text(text_to_remove): current = st.session_state[widget_body_key] st.session_state[widget_body_key] = current.replace(text_to_remove, "").strip() + st.session_state[data_body_key] = st.session_state[widget_body_key] # --- UI LAYOUT --- st.markdown(f'
', unsafe_allow_html=True) @@ -299,12 +315,13 @@ def render_draft_editor(msg): # --- TAB 1: EDITOR --- with tab_edit: - # State-Fix: Wir übergeben KEIN 'value' Argument, wenn der Key existiert. - # So hat das Widget Vorrang vor veraltetem Code. + # Hier kein 'value=' übergeben, wenn der Key im Session State ist. + # Streamlit nimmt automatisch den Wert aus session_state[widget_body_key]. st.text_area( "Body", key=widget_body_key, height=500, + on_change=_sync_body, label_visibility="collapsed" ) @@ -316,12 +333,9 @@ def render_draft_editor(msg): # 1. Reset suggestions st.session_state[data_sugg_key] = [] - # 2. Text DIREKT aus dem Widget State lesen + # 2. Text DIREKT aus dem Widget State lesen (das ist der aktuellste Stand im Browser) text_to_analyze = st.session_state[widget_body_key] - # 3. Debug Output (optional) - # st.info(f"Sende {len(text_to_analyze)} Zeichen an API...") - with st.spinner("Analysiere..."): analysis = analyze_draft_text(text_to_analyze, meta_ref["type"]) @@ -338,7 +352,6 @@ def render_draft_editor(msg): # Render List suggestions = st.session_state[data_sugg_key] if suggestions: - # Hole aktuellen Text für Vergleich current_text_state = st.session_state[widget_body_key] for idx, sugg in enumerate(suggestions): @@ -370,7 +383,7 @@ def render_draft_editor(msg): "status": "draft", "tags": final_tags } - # Auch hier: Nimm den aktuellsten Text für die Vorschau + # Nimm den Widget Content final_body = st.session_state[widget_body_key] final_doc = build_markdown_doc(final_meta, final_body) @@ -384,11 +397,10 @@ def render_draft_editor(msg): b1, b2 = st.columns([1, 1]) with b1: if st.button("💾 Speichern & Indizieren", type="primary", key=f"{key_base}_save"): - with st.spinner("Speichere..."): + with st.spinner("Speichere im Vault..."): 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" - # Sende das finale Dokument an die API result = save_draft_to_vault(final_doc, filename=fname) if "error" in result: st.error(f"Fehler: {result['error']}") From 52210a91fd693758959d990325392642d08abd09 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 13:50:40 +0100 Subject: [PATCH 23/30] update aus WP11 --- app/routers/ingest.py | 99 ++++++---------------- app/services/discovery.py | 132 ++++++++++++++++++------------ app/services/embeddings_client.py | 97 ++++++++++------------ 3 files changed, 147 insertions(+), 181 deletions(-) diff --git a/app/routers/ingest.py b/app/routers/ingest.py index a1407b3..d40b529 100644 --- a/app/routers/ingest.py +++ b/app/routers/ingest.py @@ -1,6 +1,7 @@ """ app/routers/ingest.py API-Endpunkte für WP-11 (Discovery & Persistence). +Delegiert an Services. """ import os import time @@ -10,17 +11,13 @@ from pydantic import BaseModel from typing import Optional, Dict, Any from app.core.ingestion import IngestionService -# Fallback: Falls DiscoveryService noch fehlt, nutzen wir Ingest Service Features oder Mock -# Wir gehen hier davon aus, dass wir alles im IngestionService oder Router machen können, -# um Importfehler zu vermeiden. -from app.core.retriever import Retriever -from app.models.dto import QueryRequest +from app.services.discovery import DiscoveryService logger = logging.getLogger(__name__) - router = APIRouter() -# --- DTOs --- +# Services Init (Global oder via Dependency Injection) +discovery_service = DiscoveryService() class AnalyzeRequest(BaseModel): text: str @@ -37,102 +34,56 @@ class SaveResponse(BaseModel): note_id: str stats: Dict[str, Any] -# --- Endpoints --- - @router.post("/analyze") async def analyze_draft(req: AnalyzeRequest): """ - WP-11 Intelligence: Liefert Link-Vorschläge. - Implementiert direkt hier, um Abhängigkeiten zu reduzieren. + WP-11 Intelligence: Liefert Link-Vorschläge via DiscoveryService. """ try: - retriever = Retriever() - suggestions = [] - - query_text = req.text[:400] - if not query_text.strip(): - return {"suggestions": []} - - # 1. Semantic Search - # Safe async call check - if hasattr(retriever.search, '__await__'): - hits_result = await retriever.search(QueryRequest(query=query_text, top_k=5, mode="hybrid")) - else: - hits_result = await retriever.search(QueryRequest(query=query_text, top_k=5, mode="hybrid")) - - seen_titles = set() - for hit in hits_result.results: - # Titel ermitteln - title = hit.payload.get("note_id") or hit.node_id - if not title or title in seen_titles: continue - seen_titles.add(title) - - edge_kind = "related_to" - if req.type == "project": edge_kind = "depends_on" - if req.type == "decision": edge_kind = "references" - - # Score Threshold - if hit.total_score > 0.4: # Etwas toleranter - suggestions.append({ - "target_title": title, - "target_id": hit.node_id, - "suggested_markdown": f"[[rel:{edge_kind} {title}]]", - "reason": f"Semantisch ähnlich ({hit.total_score:.2f})", - "type": "semantic" - }) - - return {"suggestions": suggestions} - + # Hier rufen wir jetzt den verbesserten Service auf + result = await discovery_service.analyze_draft(req.text, req.type) + return result except Exception as e: logger.error(f"Analyze failed: {e}", exc_info=True) - # Kein 500er werfen, lieber leere Liste, damit UI nicht crasht return {"suggestions": [], "error": str(e)} @router.post("/save", response_model=SaveResponse) async def save_note(req: SaveRequest): """ - WP-11 Persistence: Speichert Markdown physisch und indiziert es sofort. + WP-11 Persistence: Speichert und indiziert. """ try: vault_root = os.getenv("MINDNET_VAULT_ROOT", "./vault") abs_vault_root = os.path.abspath(vault_root) if not os.path.exists(abs_vault_root): - os.makedirs(abs_vault_root, exist_ok=True) - - final_filename = req.filename - if not final_filename: - final_filename = f"draft_{int(time.time())}.md" + try: os.makedirs(abs_vault_root, exist_ok=True) + except: pass + final_filename = req.filename or f"draft_{int(time.time())}.md" ingest_service = IngestionService() - logger.info(f"Saving {final_filename} to {req.folder}") - - # --- AWAIT WICHTIG! --- - # Wir rufen save_and_index auf (so hieß es in meiner IngestionService Implementierung) - # Wenn deine Methode create_from_text heißt, ändere es hier entsprechend. - # Ich nutze hier save_and_index als Standard aus WP-11. + # Async Call + result = await ingest_service.create_from_text( + markdown_content=req.markdown_content, + filename=final_filename, + vault_root=abs_vault_root, + folder=req.folder + ) - if hasattr(ingest_service, 'save_and_index'): - result = await ingest_service.save_and_index(req.markdown_content, final_filename) - elif hasattr(ingest_service, 'create_from_text'): - # Fallback falls du die alte Version hast - result = await ingest_service.create_from_text(req.markdown_content, final_filename, abs_vault_root, req.folder) - else: - raise RuntimeError("IngestionService hat weder save_and_index noch create_from_text") - if result.get("status") == "error": raise HTTPException(status_code=500, detail=result.get("error")) return SaveResponse( status="success", - file_path=result.get("file_path") or result.get("path", "unknown"), + file_path=result.get("path", "unknown"), note_id=result.get("note_id", "unknown"), - stats=result.get("stats", {}) + stats={ + "chunks": result.get("chunks_count", 0), + "edges": result.get("edges_count", 0) + } ) - - except HTTPException as he: - raise he + except HTTPException as he: raise he except Exception as e: logger.error(f"Save failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Save failed: {str(e)}") \ No newline at end of file diff --git a/app/services/discovery.py b/app/services/discovery.py index 7d25941..adbafc8 100644 --- a/app/services/discovery.py +++ b/app/services/discovery.py @@ -1,14 +1,12 @@ """ app/services/discovery.py -Service für Link-Vorschläge und Knowledge-Discovery (WP-11). -Adaptiert für Async-Architecture (v2.4). +Updated for WP-11: Sliding Window Analysis. """ import logging -import os +import asyncio from typing import List, Dict, Any import yaml -# Wir nutzen hier weiterhin die Low-Level Funktionen, da diese stabil sind from app.core.qdrant import QdrantConfig, get_client from app.models.dto import QueryRequest from app.core.retriever import hybrid_retrieve @@ -24,81 +22,110 @@ class DiscoveryService: async def analyze_draft(self, text: str, current_type: str) -> Dict[str, Any]: """ - Analysiert einen Draft-Text und schlägt Verlinkungen vor. - Kombiniert Exact Match (Titel/Alias) und Semantic Match. + Analysiert den Draft mit Sliding Window Strategie. """ suggestions = [] default_edge_type = self._get_default_edge_type(current_type) - # 1. Exact Match: Finde Begriffe im Text, die als Notiz-Titel existieren - # (Dies läuft synchron, ist aber sehr schnell durch Qdrant Scroll) + # 1. Exact Match (Läuft über gesamten Text, schnell genug) known_entities = self._fetch_all_titles_and_aliases() found_entities = self._find_entities_in_text(text, known_entities) existing_target_ids = set() - for entity in found_entities: existing_target_ids.add(entity["id"]) - target_title = entity["title"] - suggested_md = f"[[rel:{default_edge_type} {target_title}]]" - suggestions.append({ "type": "exact_match", "text_found": entity["match"], - "target_title": target_title, + "target_title": entity["title"], "target_id": entity["id"], "suggested_edge_type": default_edge_type, - "suggested_markdown": suggested_md, + "suggested_markdown": f"[[rel:{default_edge_type} {entity['title']}]]", "confidence": 1.0, - "reason": f"Exakter Treffer (Default für '{current_type}': {default_edge_type})" + "reason": f"Exakter Treffer ({entity['match']})" }) - # 2. Semantic Match: Finde inhaltlich ähnliche Notizen - # Wir filtern Ergebnisse heraus, die wir schon per Exact Match gefunden haben. - semantic_hits = await self._get_semantic_suggestions_async(text) + # 2. Semantic Match (Sliding Window) + # Wir zerlegen den Text in relevante Chunks, um Token-Limits zu umgehen + # und Fokus zu streuen. + search_queries = self._generate_search_queries(text) - for hit in semantic_hits: - if hit.node_id in existing_target_ids: - continue - - if hit.total_score > 0.65: - # FIX: Titel aus Payload lesen, nicht ID! - target_title = hit.payload.get("title") or hit.node_id + # Parallel Execution für alle Queries + tasks = [self._get_semantic_suggestions_async(q) for q in search_queries] + results_list = await asyncio.gather(*tasks) + + # Ergebnisse mergen und deduplizieren + seen_semantic_ids = set() + + for hits in results_list: + for hit in hits: + if hit.node_id in existing_target_ids or hit.node_id in seen_semantic_ids: + continue - suggested_md = f"[[rel:{default_edge_type} {target_title}]]" - - suggestions.append({ - "type": "semantic_match", - "text_found": (hit.source.get("text") or "")[:50] + "...", - "target_title": target_title, - "target_id": hit.node_id, - "suggested_edge_type": default_edge_type, - "suggested_markdown": suggested_md, - "confidence": round(hit.total_score, 2), - "reason": f"Semantische Ähnlichkeit ({hit.total_score:.2f})" - }) + # Threshold Tuning: Bei 'nomic' sind Scores oft niedriger (0.4-0.6 ist schon gut) + # Wir setzen ihn moderat auf 0.50 + if hit.total_score > 0.50: + seen_semantic_ids.add(hit.node_id) + target_title = hit.payload.get("title") or hit.node_id + + suggestions.append({ + "type": "semantic_match", + "text_found": (hit.source.get("text") or "")[:60] + "...", + "target_title": target_title, + "target_id": hit.node_id, + "suggested_edge_type": default_edge_type, + "suggested_markdown": f"[[rel:{default_edge_type} {target_title}]]", + "confidence": round(hit.total_score, 2), + "reason": f"Semantisch ähnlich ({hit.total_score:.2f})" + }) + + # Sortieren nach Confidence + suggestions.sort(key=lambda x: x["confidence"], reverse=True) return { "draft_length": len(text), "suggestions_count": len(suggestions), - "suggestions": suggestions + "suggestions": suggestions[:10] # Limit auf Top 10 } # --- Helpers --- + def _generate_search_queries(self, text: str) -> List[str]: + """ + Zerlegt den Text in bis zu 3 Such-Queries: + 1. Der Anfang (Kontext/Einleitung) + 2. Die Mitte (Details) + 3. Das Ende (Fazit/Zusammenfassung) + """ + if len(text) < 600: + return [text] + + queries = [] + # Query 1: Die ersten 400 Zeichen + queries.append(text[:400]) + + # Query 2: Ein Fenster aus der Mitte + mid = len(text) // 2 + queries.append(text[mid-200 : mid+200]) + + # Query 3: Die letzten 400 Zeichen + if len(text) > 800: + queries.append(text[-400:]) + + return queries + async def _get_semantic_suggestions_async(self, text: str): - """Async Wrapper um den Hybrid Retriever.""" + # Nutzt hybrid_retrieve (sync), aber hier in Async Context okay req = QueryRequest(query=text, top_k=5, explain=False) try: - # Da hybrid_retrieve (noch) sync ist, rufen wir es direkt auf. - # In einer voll-async Umgebung würde man dies in einen Thread-Pool auslagern, - # aber da Qdrant-Client sync ist, ist das hier okay. res = hybrid_retrieve(req) return res.results - except Exception as e: - logger.error(f"Semantic suggestion failed: {e}") - return [] + except Exception: return [] + # ... (Restliche Methoden wie _fetch_all_titles_and_aliases bleiben gleich) ... + # Füge hier die Methoden aus dem vorherigen Artefakt ein (fetch_all..., find_entities..., load_type...) + # Der Kürze halber lasse ich sie im Snippet weg, da sie unverändert sind. + def _load_type_registry(self) -> dict: path = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") if not os.path.exists(path): @@ -112,24 +139,22 @@ class DiscoveryService: types_cfg = self.registry.get("types", {}) type_def = types_cfg.get(note_type, {}) defaults = type_def.get("edge_defaults") - if defaults and isinstance(defaults, list) and len(defaults) > 0: - return defaults[0] - return "related_to" + return defaults[0] if defaults else "related_to" def _fetch_all_titles_and_aliases(self) -> List[Dict]: notes = [] next_page = None - col_name = f"{self.prefix}_notes" + col = f"{self.prefix}_notes" try: while True: - res, next_page = self.client.scroll(collection_name=col_name, limit=1000, offset=next_page, with_payload=True, with_vectors=False) + res, next_page = self.client.scroll(collection_name=col, limit=1000, offset=next_page, with_payload=True, with_vectors=False) for point in res: pl = point.payload or {} aliases = pl.get("aliases") or [] if isinstance(aliases, str): aliases = [aliases] notes.append({"id": pl.get("note_id"), "title": pl.get("title"), "aliases": aliases}) if next_page is None: break - except Exception: return [] + except Exception: pass return notes def _find_entities_in_text(self, text: str, entities: List[Dict]) -> List[Dict]: @@ -140,9 +165,8 @@ class DiscoveryService: if title and title.lower() in text_lower: found.append({"match": title, "title": title, "id": entity["id"]}) continue - aliases = entity.get("aliases", []) - for alias in aliases: - if alias and str(alias).lower() in text_lower: + for alias in entity.get("aliases", []): + if str(alias).lower() in text_lower: found.append({"match": alias, "title": title, "id": entity["id"]}) break return found \ No newline at end of file diff --git a/app/services/embeddings_client.py b/app/services/embeddings_client.py index ea7a7fb..090959e 100644 --- a/app/services/embeddings_client.py +++ b/app/services/embeddings_client.py @@ -1,19 +1,9 @@ """ -app/services/embeddings_client.py — Text→Embedding Service +app/services/embeddings_client.py +Client für die Vektorisierung von Texten via Ollama API. -Zweck: - Liefert Embeddings für Textqueries. - - Legacy Mode (Sync): Nutzt lokal Sentence-Transformers (CPU). - - Modern Mode (Async/Class): Nutzt Ollama API (HTTP) für Non-Blocking Operations (WP-11). - -Kompatibilität: - Python 3.12+, sentence-transformers 5.x, httpx -Version: - 0.2.0 (Erweitert um Async EmbeddingsClient) -Stand: - 2025-12-11 +Version: 2.4.0 (Async + Dedicated Embedding Model Support) """ - from __future__ import annotations import os import logging @@ -24,45 +14,53 @@ from app.config import get_settings logger = logging.getLogger(__name__) -# ============================================================================== -# TEIL 1: NEUE ASYNC KLASSE (Für Ingestion API / WP-11) -# ============================================================================== - class EmbeddingsClient: """ - Async Client für Embeddings via Ollama (oder kompatible APIs). - Verhindert das Blockieren des Event-Loops bei schweren Berechnungen. + Async Client für Embeddings via Ollama. + Trennt Chat-Modell (Generation) von Embedding-Modell (Semantik). """ def __init__(self): self.settings = get_settings() - # Fallback auf Environment Variablen, falls Settings nicht geladen self.base_url = os.getenv("MINDNET_OLLAMA_URL", "http://127.0.0.1:11434") - # Nutze explizites Embedding Modell oder Fallback auf LLM Modell - self.model = os.getenv("MINDNET_EMBEDDING_MODEL", os.getenv("MINDNET_LLM_MODEL", "phi3:mini")) + + # Lese Konfiguration für spezialisiertes Embedding-Modell + self.model = os.getenv("MINDNET_EMBEDDING_MODEL") + + # Fallback auf LLM, falls kein Embedding-Modell gesetzt (nicht empfohlen für Prod) + if not self.model: + self.model = os.getenv("MINDNET_LLM_MODEL", "phi3:mini") + logger.warning(f"No MINDNET_EMBEDDING_MODEL set. Falling back to LLM '{self.model}'. Quality might suffer.") + else: + logger.info(f"EmbeddingsClient initialized with model: {self.model}") async def embed_query(self, text: str) -> List[float]: - """Erzeugt Embedding für einen einzelnen Text.""" + """ + Erzeugt Embedding für einen einzelnen Text (z.B. Suchanfrage). + """ return await self._request_embedding(text) async def embed_documents(self, texts: List[str]) -> List[List[float]]: """ - Erzeugt Embeddings für eine Liste von Texten. - Nutzt eine Session für effizientere Requests. + Erzeugt Embeddings für eine Liste von Texten (z.B. Chunks beim Import). + Nutzt eine persistente Session für Performance. """ vectors = [] - async with httpx.AsyncClient(timeout=60.0) as client: + # Timeout erhöht für Batch-Processing + async with httpx.AsyncClient(timeout=120.0) as client: for text in texts: vec = await self._request_embedding_with_client(client, text) vectors.append(vec) return vectors async def _request_embedding(self, text: str) -> List[float]: - """Interne Hilfsmethode für Single-Request (One-off Client).""" + """Interne Hilfsmethode für Single-Request.""" async with httpx.AsyncClient(timeout=30.0) as client: return await self._request_embedding_with_client(client, text) async def _request_embedding_with_client(self, client: httpx.AsyncClient, text: str) -> List[float]: - """Führt den eigentlichen Request gegen Ollama aus.""" + """ + Führt den eigentlichen HTTP-Request gegen Ollama aus. + """ if not text or not text.strip(): return [] @@ -75,46 +73,39 @@ class EmbeddingsClient: "prompt": text } ) + + if response.status_code == 404: + logger.error(f"Model '{self.model}' not found in Ollama. Run: ollama pull {self.model}") + return [] + response.raise_for_status() data = response.json() return data.get("embedding", []) + except Exception as e: - logger.error(f"Embedding error (Ollama) for model {self.model}: {e}") - # Fallback: Leere Liste, damit der Prozess nicht crasht (wird vom Caller gefiltert) + logger.error(f"Embedding error (Model: {self.model}): {e}") + # Wir geben eine leere Liste zurück, damit der Batch-Prozess nicht komplett crasht. + # Der Aufrufer (IngestionService) muss prüfen, ob Vektor leer ist. return [] - -# ============================================================================== -# TEIL 2: LEGACY FUNKTIONEN (Für bestehende Sync-Module / CLI) -# ============================================================================== - -# Lazy import, damit Testläufe ohne Modell-Laden schnell sind -def _load_model(): - # Performance-Warnung loggen, da dies viel RAM braucht - logger.info("Loading local SentenceTransformer model (Legacy Mode)...") - from sentence_transformers import SentenceTransformer # import hier, nicht top-level - s = get_settings() - return SentenceTransformer(s.MODEL_NAME, device="cpu") +# --- LEGACY SUPPORT (Synchron) --- +# Wird nur noch von alten Skripten oder Tests ohne Async-Support genutzt. @lru_cache(maxsize=1) -def _cached_model(): - return _load_model() +def _cached_legacy_model(): + from sentence_transformers import SentenceTransformer + s = get_settings() + # Hier nutzen wir das Modell aus den Settings, meist CPU-basiert + return SentenceTransformer(s.MODEL_NAME, device="cpu") def embed_text(text: str) -> List[float]: """ - LEGACY: Erzeugt einen Vektor synchron via Sentence-Transformers. - Wird u.a. vom Retriever oder alten CLI-Skripten genutzt. + LEGACY: Synchrones Embedding via SentenceTransformers (CPU). """ if not text or not text.strip(): - # Um Konsistenz mit neuer Klasse zu wahren, loggen wir Warnung statt Error - # raise ValueError("embed_text: leerer Text") -> Veraltet - logger.warning("embed_text called with empty string") return [] - try: - model = _cached_model() - vec = model.encode([text], normalize_embeddings=True)[0] - return vec.astype(float).tolist() + return _cached_legacy_model().encode([text], normalize_embeddings=True)[0].tolist() except Exception as e: logger.error(f"Legacy embed_text failed: {e}") return [] \ No newline at end of file From 1fde4ed72af57d6246a02eec805800180b83dd4f Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 13:57:19 +0100 Subject: [PATCH 24/30] =?UTF-8?q?reset=20qdrant=20=C3=BCberarbeitet=20mit?= =?UTF-8?q?=20.env=20parametern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/reset_qdrant.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/reset_qdrant.py b/scripts/reset_qdrant.py index 015fa2d..de43c08 100644 --- a/scripts/reset_qdrant.py +++ b/scripts/reset_qdrant.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- """ Name: scripts/reset_qdrant.py -Version: v1.2.0 (2025-11-11) +Version: v1.2.1 (2025-12-11) Kurzbeschreibung: Sicheres Zurücksetzen der Qdrant-Collections für EIN Projektpräfix. Das Skript ermittelt zunächst die tatsächlich betroffenen Collections und zeigt eine @@ -39,6 +39,7 @@ Exitcodes: 0 = OK, 1 = abgebrochen/keine Aktion, 2 = Verbindungs-/Konfigurationsfehler Changelog: + v1.2.1: Fix: load_dotenv() hinzugefügt, damit VECTOR_DIM aus .env gelesen wird. v1.2.0: ensure_payload_indexes() nach wipe/truncate standardmäßig ausführen (idempotent); --no-indexes Flag ergänzt. v1.1.1: Stabilisierung & Preview (2025-09-05). v1.1.0: Interaktive Bestätigung, --yes/--dry-run hinzugefügt, Preview der betroffenen Collections. @@ -50,6 +51,9 @@ import os import sys from typing import List +# FIX: Dotenv laden +from dotenv import load_dotenv + from qdrant_client import QdrantClient from qdrant_client.http import models as rest @@ -124,6 +128,9 @@ def wipe_collections(client: QdrantClient, all_col_names: List[str], existing: L def main(): + # FIX: Umgebungsvariablen aus .env laden + load_dotenv() + ap = argparse.ArgumentParser(description="Wipe oder truncate mindnet-Collections in Qdrant (mit Bestätigung & Index-Setup).") ap.add_argument("--mode", choices=["wipe", "truncate"], required=True, help="wipe = Collections löschen & neu anlegen; truncate = nur Inhalte löschen") @@ -135,6 +142,7 @@ def main(): # Qdrant-Konfiguration try: + # Hier wird jetzt VECTOR_DIM=768 korrekt berücksichtigt cfg = QdrantConfig.from_env() except Exception as e: print(f"Konfigurationsfehler: {e}", file=sys.stderr) @@ -156,6 +164,9 @@ def main(): existing = resolve_existing_collections(client, cfg.prefix) nonexisting = [c for c in all_col_names if c not in existing] + # Debug-Info zur Dimension + print(f"Info: Nutze Vektor-Dimension: {cfg.dim}") + # Preview & Bestätigung if not confirm_or_abort(args.mode, existing, nonexisting, args.yes): print("Abgebrochen – keine Änderungen vorgenommen.") @@ -188,4 +199,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file From 5fa02aed2de08c45897ba87c6343770c856dc6a6 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 14:20:00 +0100 Subject: [PATCH 25/30] bug fix --- app/services/discovery.py | 136 ++++++++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 48 deletions(-) diff --git a/app/services/discovery.py b/app/services/discovery.py index adbafc8..74cd57e 100644 --- a/app/services/discovery.py +++ b/app/services/discovery.py @@ -1,72 +1,94 @@ """ app/services/discovery.py -Updated for WP-11: Sliding Window Analysis. +Service für Link-Vorschläge und Knowledge-Discovery (WP-11). +Implementiert Sliding Window für lange Texte und Late Binding für Edge-Typen. """ import logging import asyncio -from typing import List, Dict, Any +import os # <--- Added missing import +from typing import List, Dict, Any, Optional import yaml from app.core.qdrant import QdrantConfig, get_client from app.models.dto import QueryRequest +# Hinweis: hybrid_retrieve ist aktuell synchron. In einer reinen Async-Welt +# würde man dies refactorn, aber hier wrappen wir es. from app.core.retriever import hybrid_retrieve logger = logging.getLogger(__name__) class DiscoveryService: def __init__(self, collection_prefix: str = None): + # 1. Config laden self.cfg = QdrantConfig.from_env() + # Prefix Priorität: Argument > Env > Default self.prefix = collection_prefix or self.cfg.prefix or "mindnet" self.client = get_client(self.cfg) + + # 2. Registry für Late Binding laden (Edge Defaults) self.registry = self._load_type_registry() async def analyze_draft(self, text: str, current_type: str) -> Dict[str, Any]: """ - Analysiert den Draft mit Sliding Window Strategie. + Analysiert einen Draft-Text und schlägt Verlinkungen vor. + Nutzt Sliding Window für Semantik und Full-Text Scan für Entity Recognition. """ suggestions = [] + + # Default Edge Typ aus Config (z.B. 'depends_on' für Projekte) default_edge_type = self._get_default_edge_type(current_type) - # 1. Exact Match (Läuft über gesamten Text, schnell genug) + # --------------------------------------------------------- + # 1. Exact Match: Finde Titel/Aliases im Text + # --------------------------------------------------------- known_entities = self._fetch_all_titles_and_aliases() found_entities = self._find_entities_in_text(text, known_entities) existing_target_ids = set() + for entity in found_entities: existing_target_ids.add(entity["id"]) + target_title = entity["title"] + suggested_md = f"[[rel:{default_edge_type} {target_title}]]" + suggestions.append({ "type": "exact_match", "text_found": entity["match"], - "target_title": entity["title"], + "target_title": target_title, "target_id": entity["id"], "suggested_edge_type": default_edge_type, - "suggested_markdown": f"[[rel:{default_edge_type} {entity['title']}]]", + "suggested_markdown": suggested_md, "confidence": 1.0, - "reason": f"Exakter Treffer ({entity['match']})" + "reason": f"Exakter Treffer: '{entity['match']}'" }) - # 2. Semantic Match (Sliding Window) - # Wir zerlegen den Text in relevante Chunks, um Token-Limits zu umgehen - # und Fokus zu streuen. + # --------------------------------------------------------- + # 2. Semantic Match: Sliding Window Analyse + # --------------------------------------------------------- + # Zerlege Text in sinnvolle Abschnitte für das Embedding search_queries = self._generate_search_queries(text) - # Parallel Execution für alle Queries + # Parallel alle Abschnitte suchen tasks = [self._get_semantic_suggestions_async(q) for q in search_queries] results_list = await asyncio.gather(*tasks) - # Ergebnisse mergen und deduplizieren + # Ergebnisse zusammenführen seen_semantic_ids = set() for hits in results_list: for hit in hits: + # Duplikate filtern (schon als Exact Match oder schon als anderer Semantic Hit) if hit.node_id in existing_target_ids or hit.node_id in seen_semantic_ids: continue - # Threshold Tuning: Bei 'nomic' sind Scores oft niedriger (0.4-0.6 ist schon gut) - # Wir setzen ihn moderat auf 0.50 + # Schwellwert: Mit 'nomic-embed-text' sind Scores oft schärfer. + # 0.50 ist ein guter Startwert für semantische Nähe. if hit.total_score > 0.50: seen_semantic_ids.add(hit.node_id) + + # Titel aus Payload holen (wurde in chunk_payload.py gefixt) target_title = hit.payload.get("title") or hit.node_id + suggested_md = f"[[rel:{default_edge_type} {target_title}]]" suggestions.append({ "type": "semantic_match", @@ -74,99 +96,117 @@ class DiscoveryService: "target_title": target_title, "target_id": hit.node_id, "suggested_edge_type": default_edge_type, - "suggested_markdown": f"[[rel:{default_edge_type} {target_title}]]", + "suggested_markdown": suggested_md, "confidence": round(hit.total_score, 2), "reason": f"Semantisch ähnlich ({hit.total_score:.2f})" }) - # Sortieren nach Confidence + # Sortieren nach Confidence (Höchste zuerst) suggestions.sort(key=lambda x: x["confidence"], reverse=True) return { "draft_length": len(text), + "analyzed_windows": len(search_queries), "suggestions_count": len(suggestions), - "suggestions": suggestions[:10] # Limit auf Top 10 + "suggestions": suggestions[:10] # Top 10 reichen } - # --- Helpers --- + # --- Interne Helfer --- def _generate_search_queries(self, text: str) -> List[str]: - """ - Zerlegt den Text in bis zu 3 Such-Queries: - 1. Der Anfang (Kontext/Einleitung) - 2. Die Mitte (Details) - 3. Das Ende (Fazit/Zusammenfassung) - """ - if len(text) < 600: - return [text] - + """Erzeugt Sliding Windows über den Text.""" + if not text: return [] + if len(text) < 600: return [text] + queries = [] - # Query 1: Die ersten 400 Zeichen - queries.append(text[:400]) + # 1. Anfang (Kontext) + queries.append(text[:500]) - # Query 2: Ein Fenster aus der Mitte + # 2. Mitte mid = len(text) // 2 - queries.append(text[mid-200 : mid+200]) + queries.append(text[mid-250 : mid+250]) - # Query 3: Die letzten 400 Zeichen + # 3. Ende (Fazit) if len(text) > 800: - queries.append(text[-400:]) + queries.append(text[-500:]) return queries async def _get_semantic_suggestions_async(self, text: str): - # Nutzt hybrid_retrieve (sync), aber hier in Async Context okay + """Wrapper um den Retriever (sync).""" req = QueryRequest(query=text, top_k=5, explain=False) try: + # Hier blockieren wir kurz den Loop, da hybrid_retrieve sync ist. + # In High-Load Szenarien müsste das in einen ThreadPoolExecutor. res = hybrid_retrieve(req) return res.results - except Exception: return [] + except Exception as e: + logger.error(f"Semantic suggestion error: {e}") + return [] - # ... (Restliche Methoden wie _fetch_all_titles_and_aliases bleiben gleich) ... - # Füge hier die Methoden aus dem vorherigen Artefakt ein (fetch_all..., find_entities..., load_type...) - # Der Kürze halber lasse ich sie im Snippet weg, da sie unverändert sind. - def _load_type_registry(self) -> dict: path = os.getenv("MINDNET_TYPES_FILE", "config/types.yaml") if not os.path.exists(path): if os.path.exists("types.yaml"): path = "types.yaml" else: return {} try: - with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} - except Exception: return {} + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + except Exception: + return {} def _get_default_edge_type(self, note_type: str) -> str: types_cfg = self.registry.get("types", {}) type_def = types_cfg.get(note_type, {}) defaults = type_def.get("edge_defaults") - return defaults[0] if defaults else "related_to" + if defaults and isinstance(defaults, list) and len(defaults) > 0: + return defaults[0] + return "related_to" def _fetch_all_titles_and_aliases(self) -> List[Dict]: notes = [] next_page = None - col = f"{self.prefix}_notes" + col_name = f"{self.prefix}_notes" + try: while True: - res, next_page = self.client.scroll(collection_name=col, limit=1000, offset=next_page, with_payload=True, with_vectors=False) + res, next_page = self.client.scroll( + collection_name=col_name, + limit=1000, + offset=next_page, + with_payload=True, + with_vectors=False + ) for point in res: pl = point.payload or {} aliases = pl.get("aliases") or [] if isinstance(aliases, str): aliases = [aliases] - notes.append({"id": pl.get("note_id"), "title": pl.get("title"), "aliases": aliases}) + + notes.append({ + "id": pl.get("note_id"), + "title": pl.get("title"), + "aliases": aliases + }) + if next_page is None: break - except Exception: pass + except Exception as e: + logger.error(f"Error fetching titles: {e}") + return [] return notes def _find_entities_in_text(self, text: str, entities: List[Dict]) -> List[Dict]: found = [] text_lower = text.lower() for entity in entities: + # Title title = entity.get("title") if title and title.lower() in text_lower: found.append({"match": title, "title": title, "id": entity["id"]}) continue - for alias in entity.get("aliases", []): - if str(alias).lower() in text_lower: + # Aliases + aliases = entity.get("aliases", []) + for alias in aliases: + if alias and str(alias).lower() in text_lower: found.append({"match": alias, "title": title, "id": entity["id"]}) break return found \ No newline at end of file From b1cf89982b79ffd4542de820023e1cd62974e7c8 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 14:28:46 +0100 Subject: [PATCH 26/30] anpassung an 786 vector --- app/services/embeddings_client.py | 93 ++++++++++++------------------- 1 file changed, 36 insertions(+), 57 deletions(-) diff --git a/app/services/embeddings_client.py b/app/services/embeddings_client.py index 090959e..afad847 100644 --- a/app/services/embeddings_client.py +++ b/app/services/embeddings_client.py @@ -1,15 +1,19 @@ """ -app/services/embeddings_client.py -Client für die Vektorisierung von Texten via Ollama API. +app/services/embeddings_client.py — Text→Embedding Service -Version: 2.4.0 (Async + Dedicated Embedding Model Support) +Zweck: + Einheitlicher Client für Embeddings via Ollama (Nomic). + Stellt sicher, dass sowohl Async (Ingestion) als auch Sync (Retriever) + denselben Vektorraum (768 Dim) nutzen. + +Version: 2.5.0 (Unified Ollama) """ from __future__ import annotations import os import logging import httpx -from typing import List, Optional -from functools import lru_cache +import requests # Für den synchronen Fallback +from typing import List from app.config import get_settings logger = logging.getLogger(__name__) @@ -17,35 +21,22 @@ logger = logging.getLogger(__name__) class EmbeddingsClient: """ Async Client für Embeddings via Ollama. - Trennt Chat-Modell (Generation) von Embedding-Modell (Semantik). """ def __init__(self): self.settings = get_settings() self.base_url = os.getenv("MINDNET_OLLAMA_URL", "http://127.0.0.1:11434") - - # Lese Konfiguration für spezialisiertes Embedding-Modell self.model = os.getenv("MINDNET_EMBEDDING_MODEL") - # Fallback auf LLM, falls kein Embedding-Modell gesetzt (nicht empfohlen für Prod) if not self.model: self.model = os.getenv("MINDNET_LLM_MODEL", "phi3:mini") - logger.warning(f"No MINDNET_EMBEDDING_MODEL set. Falling back to LLM '{self.model}'. Quality might suffer.") - else: - logger.info(f"EmbeddingsClient initialized with model: {self.model}") + logger.warning(f"No MINDNET_EMBEDDING_MODEL set. Fallback to '{self.model}'.") async def embed_query(self, text: str) -> List[float]: - """ - Erzeugt Embedding für einen einzelnen Text (z.B. Suchanfrage). - """ return await self._request_embedding(text) async def embed_documents(self, texts: List[str]) -> List[List[float]]: - """ - Erzeugt Embeddings für eine Liste von Texten (z.B. Chunks beim Import). - Nutzt eine persistente Session für Performance. - """ vectors = [] - # Timeout erhöht für Batch-Processing + # Längeres Timeout für Batches async with httpx.AsyncClient(timeout=120.0) as client: for text in texts: vec = await self._request_embedding_with_client(client, text) @@ -53,59 +44,47 @@ class EmbeddingsClient: return vectors async def _request_embedding(self, text: str) -> List[float]: - """Interne Hilfsmethode für Single-Request.""" async with httpx.AsyncClient(timeout=30.0) as client: return await self._request_embedding_with_client(client, text) async def _request_embedding_with_client(self, client: httpx.AsyncClient, text: str) -> List[float]: - """ - Führt den eigentlichen HTTP-Request gegen Ollama aus. - """ - if not text or not text.strip(): - return [] - + if not text or not text.strip(): return [] url = f"{self.base_url}/api/embeddings" try: - response = await client.post( - url, - json={ - "model": self.model, - "prompt": text - } - ) - - if response.status_code == 404: - logger.error(f"Model '{self.model}' not found in Ollama. Run: ollama pull {self.model}") - return [] - + response = await client.post(url, json={"model": self.model, "prompt": text}) response.raise_for_status() - data = response.json() - return data.get("embedding", []) - + return response.json().get("embedding", []) except Exception as e: - logger.error(f"Embedding error (Model: {self.model}): {e}") - # Wir geben eine leere Liste zurück, damit der Batch-Prozess nicht komplett crasht. - # Der Aufrufer (IngestionService) muss prüfen, ob Vektor leer ist. + logger.error(f"Async embedding failed: {e}") return [] -# --- LEGACY SUPPORT (Synchron) --- -# Wird nur noch von alten Skripten oder Tests ohne Async-Support genutzt. - -@lru_cache(maxsize=1) -def _cached_legacy_model(): - from sentence_transformers import SentenceTransformer - s = get_settings() - # Hier nutzen wir das Modell aus den Settings, meist CPU-basiert - return SentenceTransformer(s.MODEL_NAME, device="cpu") +# ============================================================================== +# TEIL 2: SYNCHRONER FALLBACK (Unified) +# ============================================================================== def embed_text(text: str) -> List[float]: """ - LEGACY: Synchrones Embedding via SentenceTransformers (CPU). + LEGACY/SYNC: Nutzt jetzt ebenfalls OLLAMA via 'requests'. + Ersetzt SentenceTransformers, um Dimensionskonflikte (768 vs 384) zu lösen. """ if not text or not text.strip(): return [] + + base_url = os.getenv("MINDNET_OLLAMA_URL", "http://127.0.0.1:11434") + model = os.getenv("MINDNET_EMBEDDING_MODEL") + + # Fallback logik identisch zur Klasse + if not model: + model = os.getenv("MINDNET_LLM_MODEL", "phi3:mini") + + url = f"{base_url}/api/embeddings" + try: - return _cached_legacy_model().encode([text], normalize_embeddings=True)[0].tolist() + # Synchroner Request (blockierend) + response = requests.post(url, json={"model": model, "prompt": text}, timeout=30) + response.raise_for_status() + data = response.json() + return data.get("embedding", []) except Exception as e: - logger.error(f"Legacy embed_text failed: {e}") + logger.error(f"Sync embedding (Ollama) failed: {e}") return [] \ No newline at end of file From a1a58727fd9595026f5fa921bc1d89d2371ef042 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 14:46:56 +0100 Subject: [PATCH 27/30] discovery opt - deduplicate, last 300 Zeichen --- app/services/discovery.py | 159 +++++++++++++++++--------------------- 1 file changed, 73 insertions(+), 86 deletions(-) diff --git a/app/services/discovery.py b/app/services/discovery.py index 74cd57e..40f731c 100644 --- a/app/services/discovery.py +++ b/app/services/discovery.py @@ -1,143 +1,152 @@ """ app/services/discovery.py Service für Link-Vorschläge und Knowledge-Discovery (WP-11). -Implementiert Sliding Window für lange Texte und Late Binding für Edge-Typen. +Optimiert: Deduplizierung pro Notiz & Footer-Fokus für kurze Texte. """ import logging import asyncio -import os # <--- Added missing import -from typing import List, Dict, Any, Optional +import os +from typing import List, Dict, Any, Optional, Set import yaml from app.core.qdrant import QdrantConfig, get_client from app.models.dto import QueryRequest -# Hinweis: hybrid_retrieve ist aktuell synchron. In einer reinen Async-Welt -# würde man dies refactorn, aber hier wrappen wir es. from app.core.retriever import hybrid_retrieve logger = logging.getLogger(__name__) class DiscoveryService: def __init__(self, collection_prefix: str = None): - # 1. Config laden self.cfg = QdrantConfig.from_env() - # Prefix Priorität: Argument > Env > Default self.prefix = collection_prefix or self.cfg.prefix or "mindnet" self.client = get_client(self.cfg) - - # 2. Registry für Late Binding laden (Edge Defaults) self.registry = self._load_type_registry() async def analyze_draft(self, text: str, current_type: str) -> Dict[str, Any]: - """ - Analysiert einen Draft-Text und schlägt Verlinkungen vor. - Nutzt Sliding Window für Semantik und Full-Text Scan für Entity Recognition. - """ suggestions = [] - - # Default Edge Typ aus Config (z.B. 'depends_on' für Projekte) default_edge_type = self._get_default_edge_type(current_type) + # Tracking-Sets für Deduplizierung (Wir merken uns NOTE-IDs, nicht Chunk-IDs) + seen_target_note_ids = set() + # --------------------------------------------------------- - # 1. Exact Match: Finde Titel/Aliases im Text + # 1. Exact Match: Titel/Aliases # --------------------------------------------------------- known_entities = self._fetch_all_titles_and_aliases() found_entities = self._find_entities_in_text(text, known_entities) - existing_target_ids = set() - for entity in found_entities: - existing_target_ids.add(entity["id"]) - target_title = entity["title"] - suggested_md = f"[[rel:{default_edge_type} {target_title}]]" - + # Duplikate vermeiden + if entity["id"] in seen_target_note_ids: + continue + seen_target_note_ids.add(entity["id"]) + suggestions.append({ "type": "exact_match", "text_found": entity["match"], - "target_title": target_title, + "target_title": entity["title"], "target_id": entity["id"], "suggested_edge_type": default_edge_type, - "suggested_markdown": suggested_md, + "suggested_markdown": f"[[rel:{default_edge_type} {entity['title']}]]", "confidence": 1.0, "reason": f"Exakter Treffer: '{entity['match']}'" }) # --------------------------------------------------------- - # 2. Semantic Match: Sliding Window Analyse + # 2. Semantic Match: Sliding Window & Footer Focus # --------------------------------------------------------- - # Zerlege Text in sinnvolle Abschnitte für das Embedding search_queries = self._generate_search_queries(text) - # Parallel alle Abschnitte suchen + # Async parallel abfragen tasks = [self._get_semantic_suggestions_async(q) for q in search_queries] results_list = await asyncio.gather(*tasks) - # Ergebnisse zusammenführen - seen_semantic_ids = set() - + # Ergebnisse verarbeiten for hits in results_list: for hit in hits: - # Duplikate filtern (schon als Exact Match oder schon als anderer Semantic Hit) - if hit.node_id in existing_target_ids or hit.node_id in seen_semantic_ids: + # WICHTIG: Note ID aus Payload holen (Chunk ID ist hit.node_id) + note_id = hit.payload.get("note_id") + + # Fallback, falls Payload leer (sollte nicht passieren) + if not note_id: + continue + + # 1. Check: Haben wir diese NOTIZ schon? (Egal welcher Chunk) + if note_id in seen_target_note_ids: continue - # Schwellwert: Mit 'nomic-embed-text' sind Scores oft schärfer. - # 0.50 ist ein guter Startwert für semantische Nähe. + # 2. Score Check (Threshold) if hit.total_score > 0.50: - seen_semantic_ids.add(hit.node_id) + seen_target_note_ids.add(note_id) # Blockiere weitere Chunks dieser Notiz - # Titel aus Payload holen (wurde in chunk_payload.py gefixt) - target_title = hit.payload.get("title") or hit.node_id + target_title = hit.payload.get("title") or "Unbekannt" suggested_md = f"[[rel:{default_edge_type} {target_title}]]" suggestions.append({ "type": "semantic_match", "text_found": (hit.source.get("text") or "")[:60] + "...", "target_title": target_title, - "target_id": hit.node_id, + "target_id": note_id, # Wir verlinken auf die Notiz, nicht den Chunk "suggested_edge_type": default_edge_type, "suggested_markdown": suggested_md, "confidence": round(hit.total_score, 2), "reason": f"Semantisch ähnlich ({hit.total_score:.2f})" }) - # Sortieren nach Confidence (Höchste zuerst) + # Sortieren nach Confidence suggestions.sort(key=lambda x: x["confidence"], reverse=True) return { "draft_length": len(text), "analyzed_windows": len(search_queries), "suggestions_count": len(suggestions), - "suggestions": suggestions[:10] # Top 10 reichen + "suggestions": suggestions[:10] } - # --- Interne Helfer --- + # --- Optimierte Sliding Windows --- def _generate_search_queries(self, text: str) -> List[str]: - """Erzeugt Sliding Windows über den Text.""" + """ + Erzeugt intelligente Fenster. + Besonderheit: Erzwingt 'Footer-Scan' auch bei kurzen Texten, + damit "Referenzen am Ende" nicht im Kontext untergehen. + """ + text_len = len(text) if not text: return [] - if len(text) < 600: return [text] queries = [] - # 1. Anfang (Kontext) - queries.append(text[:500]) - # 2. Mitte - mid = len(text) // 2 - queries.append(text[mid-250 : mid+250]) + # A) Der gesamte Text (oder Anfang) für den groben Kontext + # Bei sehr kurzen Texten ist das alles. + queries.append(text[:600]) - # 3. Ende (Fazit) - if len(text) > 800: - queries.append(text[-500:]) - + # B) Der "Footer-Scan" (Das Ende) + # Wenn der Text > 150 Zeichen ist, nehmen wir die letzten 200 Zeichen separat. + # Grund: Oft steht dort "Gehört zu Projekt X". + # Wenn wir das isolieren, ist der Vektor "Projekt X" sehr rein. + if text_len > 150: + footer = text[-250:] + # Nur hinzufügen, wenn es sich signifikant vom Start unterscheidet + if footer not in queries: + queries.append(footer) + + # C) Sliding Window für lange Texte (> 800 Chars) + if text_len > 800: + window_size = 500 + step = 1500 + for i in range(window_size, text_len - window_size, step): + end_pos = min(i + window_size, text_len) + chunk = text[i:end_pos] + if len(chunk) > 100: + queries.append(chunk) + return queries + # --- Standard Helper (Unverändert) --- + async def _get_semantic_suggestions_async(self, text: str): - """Wrapper um den Retriever (sync).""" req = QueryRequest(query=text, top_k=5, explain=False) try: - # Hier blockieren wir kurz den Loop, da hybrid_retrieve sync ist. - # In High-Load Szenarien müsste das in einen ThreadPoolExecutor. res = hybrid_retrieve(req) return res.results except Exception as e: @@ -150,63 +159,41 @@ class DiscoveryService: if os.path.exists("types.yaml"): path = "types.yaml" else: return {} try: - with open(path, "r", encoding="utf-8") as f: - return yaml.safe_load(f) or {} - except Exception: - return {} + with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} + except Exception: return {} def _get_default_edge_type(self, note_type: str) -> str: types_cfg = self.registry.get("types", {}) type_def = types_cfg.get(note_type, {}) defaults = type_def.get("edge_defaults") - if defaults and isinstance(defaults, list) and len(defaults) > 0: - return defaults[0] - return "related_to" + return defaults[0] if defaults else "related_to" def _fetch_all_titles_and_aliases(self) -> List[Dict]: notes = [] next_page = None - col_name = f"{self.prefix}_notes" - + col = f"{self.prefix}_notes" try: while True: - res, next_page = self.client.scroll( - collection_name=col_name, - limit=1000, - offset=next_page, - with_payload=True, - with_vectors=False - ) + res, next_page = self.client.scroll(collection_name=col, limit=1000, offset=next_page, with_payload=True, with_vectors=False) for point in res: pl = point.payload or {} aliases = pl.get("aliases") or [] if isinstance(aliases, str): aliases = [aliases] - - notes.append({ - "id": pl.get("note_id"), - "title": pl.get("title"), - "aliases": aliases - }) - + notes.append({"id": pl.get("note_id"), "title": pl.get("title"), "aliases": aliases}) if next_page is None: break - except Exception as e: - logger.error(f"Error fetching titles: {e}") - return [] + except Exception: pass return notes def _find_entities_in_text(self, text: str, entities: List[Dict]) -> List[Dict]: found = [] text_lower = text.lower() for entity in entities: - # Title title = entity.get("title") if title and title.lower() in text_lower: found.append({"match": title, "title": title, "id": entity["id"]}) continue - # Aliases - aliases = entity.get("aliases", []) - for alias in aliases: - if alias and str(alias).lower() in text_lower: + for alias in entity.get("aliases", []): + if str(alias).lower() in text_lower: found.append({"match": alias, "title": title, "id": entity["id"]}) break return found \ No newline at end of file From b815f6235fe89dde5573c7437a63879500c4fc95 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 14:59:59 +0100 Subject: [PATCH 28/30] =?UTF-8?q?mehrdimensionale=20matrix=20f=C3=BCr=20Ka?= =?UTF-8?q?nten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/discovery.py | 128 +++++++++++++++++++++++++++----------- 1 file changed, 92 insertions(+), 36 deletions(-) diff --git a/app/services/discovery.py b/app/services/discovery.py index 40f731c..995abde 100644 --- a/app/services/discovery.py +++ b/app/services/discovery.py @@ -1,7 +1,12 @@ """ app/services/discovery.py Service für Link-Vorschläge und Knowledge-Discovery (WP-11). -Optimiert: Deduplizierung pro Notiz & Footer-Fokus für kurze Texte. + +Features: +- Sliding Window Analyse für lange Texte. +- Footer-Scan für Projekt-Referenzen. +- 'Matrix-Logic' für intelligente Kanten-Typen (Experience -> Value = based_on). +- Async & Nomic-Embeddings kompatibel. """ import logging import asyncio @@ -23,33 +28,42 @@ class DiscoveryService: self.registry = self._load_type_registry() async def analyze_draft(self, text: str, current_type: str) -> Dict[str, Any]: + """ + Analysiert den Text und liefert Vorschläge mit kontext-sensitiven Kanten-Typen. + """ suggestions = [] + + # Fallback, falls keine spezielle Regel greift default_edge_type = self._get_default_edge_type(current_type) - # Tracking-Sets für Deduplizierung (Wir merken uns NOTE-IDs, nicht Chunk-IDs) + # Tracking-Sets für Deduplizierung (Wir merken uns NOTE-IDs) seen_target_note_ids = set() # --------------------------------------------------------- # 1. Exact Match: Titel/Aliases # --------------------------------------------------------- + # Holt Titel, Aliases UND Typen aus dem Index known_entities = self._fetch_all_titles_and_aliases() found_entities = self._find_entities_in_text(text, known_entities) for entity in found_entities: - # Duplikate vermeiden if entity["id"] in seen_target_note_ids: continue seen_target_note_ids.add(entity["id"]) + # INTELLIGENTE KANTEN-LOGIK (MATRIX) + target_type = entity.get("type", "concept") + smart_edge = self._resolve_edge_type(current_type, target_type) + suggestions.append({ "type": "exact_match", "text_found": entity["match"], "target_title": entity["title"], "target_id": entity["id"], - "suggested_edge_type": default_edge_type, - "suggested_markdown": f"[[rel:{default_edge_type} {entity['title']}]]", + "suggested_edge_type": smart_edge, + "suggested_markdown": f"[[rel:{smart_edge} {entity['title']}]]", "confidence": 1.0, - "reason": f"Exakter Treffer: '{entity['match']}'" + "reason": f"Exakter Treffer: '{entity['match']}' ({target_type})" }) # --------------------------------------------------------- @@ -64,33 +78,33 @@ class DiscoveryService: # Ergebnisse verarbeiten for hits in results_list: for hit in hits: - # WICHTIG: Note ID aus Payload holen (Chunk ID ist hit.node_id) note_id = hit.payload.get("note_id") - - # Fallback, falls Payload leer (sollte nicht passieren) - if not note_id: - continue + if not note_id: continue - # 1. Check: Haben wir diese NOTIZ schon? (Egal welcher Chunk) + # Deduplizierung (Notiz-Ebene) if note_id in seen_target_note_ids: continue - # 2. Score Check (Threshold) + # Score Check (Threshold 0.50 für nomic-embed-text) if hit.total_score > 0.50: - seen_target_note_ids.add(note_id) # Blockiere weitere Chunks dieser Notiz + seen_target_note_ids.add(note_id) target_title = hit.payload.get("title") or "Unbekannt" - suggested_md = f"[[rel:{default_edge_type} {target_title}]]" + + # INTELLIGENTE KANTEN-LOGIK (MATRIX) + # Den Typ der gefundenen Notiz aus dem Payload lesen + target_type = hit.payload.get("type", "concept") + smart_edge = self._resolve_edge_type(current_type, target_type) suggestions.append({ "type": "semantic_match", "text_found": (hit.source.get("text") or "")[:60] + "...", "target_title": target_title, - "target_id": note_id, # Wir verlinken auf die Notiz, nicht den Chunk - "suggested_edge_type": default_edge_type, - "suggested_markdown": suggested_md, + "target_id": note_id, + "suggested_edge_type": smart_edge, + "suggested_markdown": f"[[rel:{smart_edge} {target_title}]]", "confidence": round(hit.total_score, 2), - "reason": f"Semantisch ähnlich ({hit.total_score:.2f})" + "reason": f"Semantisch ähnlich zu {target_type} ({hit.total_score:.2f})" }) # Sortieren nach Confidence @@ -103,34 +117,63 @@ class DiscoveryService: "suggestions": suggestions[:10] } - # --- Optimierte Sliding Windows --- + # --------------------------------------------------------- + # Core Logic: Die Matrix + # --------------------------------------------------------- + + def _resolve_edge_type(self, source_type: str, target_type: str) -> str: + """ + Entscheidungsmatrix für komplexe Verbindungen. + Definiert, wie Typ A auf Typ B verlinken sollte. + """ + st = source_type.lower() + tt = target_type.lower() + + # Regeln für 'experience' (Erfahrungen) + if st == "experience": + if tt == "value": return "based_on" + if tt == "principle": return "derived_from" + if tt == "trip": return "part_of" + if tt == "lesson": return "learned" + if tt == "project": return "related_to" # oder belongs_to + + # Regeln für 'project' + if st == "project": + if tt == "decision": return "depends_on" + if tt == "concept": return "uses" + if tt == "person": return "managed_by" + + # Regeln für 'decision' (ADR) + if st == "decision": + if tt == "principle": return "compliant_with" + if tt == "requirement": return "addresses" + + # Fallback: Standard aus der types.yaml für den Source-Typ + return self._get_default_edge_type(st) + + # --------------------------------------------------------- + # Sliding Windows + # --------------------------------------------------------- def _generate_search_queries(self, text: str) -> List[str]: """ - Erzeugt intelligente Fenster. - Besonderheit: Erzwingt 'Footer-Scan' auch bei kurzen Texten, - damit "Referenzen am Ende" nicht im Kontext untergehen. + Erzeugt intelligente Fenster + Footer Scan. """ text_len = len(text) if not text: return [] queries = [] - # A) Der gesamte Text (oder Anfang) für den groben Kontext - # Bei sehr kurzen Texten ist das alles. + # 1. Start / Gesamtkontext queries.append(text[:600]) - # B) Der "Footer-Scan" (Das Ende) - # Wenn der Text > 150 Zeichen ist, nehmen wir die letzten 200 Zeichen separat. - # Grund: Oft steht dort "Gehört zu Projekt X". - # Wenn wir das isolieren, ist der Vektor "Projekt X" sehr rein. + # 2. Footer-Scan (Wichtig für "Projekt"-Referenzen am Ende) if text_len > 150: footer = text[-250:] - # Nur hinzufügen, wenn es sich signifikant vom Start unterscheidet if footer not in queries: queries.append(footer) - # C) Sliding Window für lange Texte (> 800 Chars) + # 3. Sliding Window für lange Texte if text_len > 800: window_size = 500 step = 1500 @@ -142,7 +185,9 @@ class DiscoveryService: return queries - # --- Standard Helper (Unverändert) --- + # --------------------------------------------------------- + # Standard Helpers + # --------------------------------------------------------- async def _get_semantic_suggestions_async(self, text: str): req = QueryRequest(query=text, top_k=5, explain=False) @@ -174,12 +219,21 @@ class DiscoveryService: col = f"{self.prefix}_notes" try: while True: - res, next_page = self.client.scroll(collection_name=col, limit=1000, offset=next_page, with_payload=True, with_vectors=False) + res, next_page = self.client.scroll( + collection_name=col, limit=1000, offset=next_page, + with_payload=True, with_vectors=False + ) for point in res: pl = point.payload or {} aliases = pl.get("aliases") or [] if isinstance(aliases, str): aliases = [aliases] - notes.append({"id": pl.get("note_id"), "title": pl.get("title"), "aliases": aliases}) + + notes.append({ + "id": pl.get("note_id"), + "title": pl.get("title"), + "aliases": aliases, + "type": pl.get("type", "concept") # WICHTIG: Typ laden für Matrix + }) if next_page is None: break except Exception: pass return notes @@ -188,12 +242,14 @@ class DiscoveryService: found = [] text_lower = text.lower() for entity in entities: + # Title Check title = entity.get("title") if title and title.lower() in text_lower: - found.append({"match": title, "title": title, "id": entity["id"]}) + found.append({"match": title, "title": title, "id": entity["id"], "type": entity["type"]}) continue + # Alias Check for alias in entity.get("aliases", []): if str(alias).lower() in text_lower: - found.append({"match": alias, "title": title, "id": entity["id"]}) + found.append({"match": alias, "title": title, "id": entity["id"], "type": entity["type"]}) break return found \ No newline at end of file From 00aecf692d604e3a13467998688669adc07bdb66 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 15:12:30 +0100 Subject: [PATCH 29/30] UI Texteditor merkt sich den Inhalt bei Umschalten --- app/frontend/ui.py | 73 +++++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/app/frontend/ui.py b/app/frontend/ui.py index b68014c..733bcb4 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -23,7 +23,7 @@ timeout_setting = os.getenv("MINDNET_API_TIMEOUT") or os.getenv("MINDNET_LLM_TIM API_TIMEOUT = float(timeout_setting) if timeout_setting else 300.0 # --- PAGE SETUP --- -st.set_page_config(page_title="mindnet v2.3.9", page_icon="🧠", layout="wide") +st.set_page_config(page_title="mindnet v2.3.10", page_icon="🧠", layout="wide") # --- CSS STYLING --- st.markdown(""" @@ -207,7 +207,7 @@ def save_draft_to_vault(markdown_content: str, filename: str = None): response = requests.post( INGEST_SAVE_ENDPOINT, json={"markdown_content": markdown_content, "filename": filename}, - timeout=60 # Indizierung kann dauern + timeout=60 ) response.raise_for_status() return response.json() @@ -225,7 +225,7 @@ def submit_feedback(query_id, node_id, score, comment=None): def render_sidebar(): with st.sidebar: st.title("🧠 mindnet") - st.caption("v2.3.9 | Stable ID Fix") + st.caption("v2.3.10 | Mode Switch Fix") mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor"], index=0) st.divider() st.subheader("⚙️ Settings") @@ -240,10 +240,7 @@ def render_sidebar(): return mode, top_k, explain def render_draft_editor(msg): - # --- STABLE ID FIX (Der entscheidende Teil) --- - # Wir prüfen, ob die Nachricht schon eine ID hat. Wenn nicht, erzeugen wir eine - # und SPEICHERN sie zurück in das msg-Objekt (das Teil von session_state ist). - # So bleibt die ID über Reruns hinweg identisch. + # Ensure ID Stability if "query_id" not in msg or not msg["query_id"]: msg["query_id"] = str(uuid.uuid4()) @@ -256,42 +253,44 @@ def render_draft_editor(msg): widget_body_key = f"{key_base}_widget_body" data_body_key = f"{key_base}_data_body" - # --- 1. INIT STATE (Nur einmalig pro stabiler ID) --- + # --- 1. INIT STATE (Nur beim allerersten Laden der Message) --- if f"{key_base}_init" not in st.session_state: meta, body = parse_markdown_draft(msg["content"]) - - # 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) - # Persistent Data + # Persistent Data (Source of Truth) st.session_state[data_meta_key] = meta st.session_state[data_sugg_key] = [] st.session_state[data_body_key] = body.strip() - # Widget Init (wichtig: Hier wird der "Default Value" des Widgets gesetzt) - st.session_state[widget_body_key] = body.strip() - st.session_state[f"{key_base}_init"] = True + # --- 2. RESURRECTION FIX (WICHTIG!) --- + # Wenn wir vom Manuellen Editor zurückkommen, wurde der widget_key von Streamlit gelöscht. + # Wir müssen ihn aus dem persistenten data_body_key wiederherstellen. + 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_body(): - # Sync vom Widget zurück in den persistenten Speicher + # Sync Widget -> Data (Source of Truth) st.session_state[data_body_key] = st.session_state[widget_body_key] def _insert_text(text_to_insert): - # Einfügen in Widget State - current = st.session_state[widget_body_key] - st.session_state[widget_body_key] = f"{current}\n\n{text_to_insert}" - # Sync auch data_body - st.session_state[data_body_key] = st.session_state[widget_body_key] + # Insert in Widget Key und Sync Data + 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[widget_body_key] - st.session_state[widget_body_key] = current.replace(text_to_remove, "").strip() - st.session_state[data_body_key] = st.session_state[widget_body_key] + 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'
', unsafe_allow_html=True) @@ -301,22 +300,29 @@ def render_draft_editor(msg): 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"]) + # Auch hier Keys für Widgets nutzen, um Resets zu vermeiden + title_key = f"{key_base}_wdg_title" + if title_key not in st.session_state: st.session_state[title_key] = meta_ref["title"] + meta_ref["title"] = st.text_input("Titel", key=title_key) + 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") + type_key = f"{key_base}_wdg_type" + if type_key not in st.session_state: st.session_state[type_key] = meta_ref["type"] + meta_ref["type"] = st.selectbox("Typ", known_types, index=known_types.index(curr) if curr in known_types else 0, key=type_key) - meta_ref["tags_str"] = st.text_input("Tags", key=f"{key_base}_wdg_tags", value=meta_ref.get("tags_str", "")) + tags_key = f"{key_base}_wdg_tags" + if tags_key not in st.session_state: st.session_state[tags_key] = meta_ref.get("tags_str", "") + meta_ref["tags_str"] = st.text_input("Tags", key=tags_key) # Tabs tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"]) # --- TAB 1: EDITOR --- with tab_edit: - # Hier kein 'value=' übergeben, wenn der Key im Session State ist. - # Streamlit nimmt automatisch den Wert aus session_state[widget_body_key]. + # Hier kein 'value' Argument mehr, da wir den Key oben (Resurrection) initialisiert haben. st.text_area( "Body", key=widget_body_key, @@ -330,11 +336,10 @@ def render_draft_editor(msg): 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 suggestions st.session_state[data_sugg_key] = [] - # 2. Text DIREKT aus dem Widget State lesen (das ist der aktuellste Stand im Browser) - text_to_analyze = st.session_state[widget_body_key] + # Lese vom Widget (aktuell) oder Data (Fallback) + text_to_analyze = st.session_state.get(widget_body_key, st.session_state.get(data_body_key, "")) with st.spinner("Analysiere..."): analysis = analyze_draft_text(text_to_analyze, meta_ref["type"]) @@ -352,7 +357,7 @@ def render_draft_editor(msg): # Render List suggestions = st.session_state[data_sugg_key] if suggestions: - current_text_state = st.session_state[widget_body_key] + current_text_state = st.session_state.get(widget_body_key, "") for idx, sugg in enumerate(suggestions): link_text = sugg.get('suggested_markdown', '') @@ -383,8 +388,8 @@ def render_draft_editor(msg): "status": "draft", "tags": final_tags } - # Nimm den Widget Content - final_body = st.session_state[widget_body_key] + # Final Doc aus Data + 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: From 5bf91d51da1cea0cd8da4885f433c0be08c81248 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Dec 2025 16:58:23 +0100 Subject: [PATCH 30/30] Dokumentation WP11 --- Programmmanagement/Programmplan_V2.2.md | 41 ++++---- docs/Knowledge_Design_Manual.md | 33 +++---- docs/Overview.md | 35 +++---- docs/admin_guide.md | 96 ++++++++++--------- docs/appendix.md | 24 +++-- .../archiv}/ARCHITECTURE_SNAPSHOT_v2.2.1.md | 0 .../archiv}/Überarbeitungshinweise_WP03.md | 0 .../archiv}/Überarbeitungshinweise_WP04.md | 0 docs/dev_workflow.md | 60 +++++------- docs/developer_guide.md | 40 ++++++-- docs/mindnet_functional_architecture.md | 57 ++++++++--- docs/mindnet_technical_architecture.md | 71 ++++++++------ docs/pipeline_playbook.md | 50 +++++++--- docs/user_guide.md | 20 +++- 14 files changed, 319 insertions(+), 208 deletions(-) rename {Programmmanagement => docs/archiv}/ARCHITECTURE_SNAPSHOT_v2.2.1.md (100%) rename {Programmmanagement => docs/archiv}/Überarbeitungshinweise_WP03.md (100%) rename {Programmmanagement => docs/archiv}/Überarbeitungshinweise_WP04.md (100%) diff --git a/Programmmanagement/Programmplan_V2.2.md b/Programmmanagement/Programmplan_V2.2.md index 91ee315..9b09f5f 100644 --- a/Programmmanagement/Programmplan_V2.2.md +++ b/Programmmanagement/Programmplan_V2.2.md @@ -1,10 +1,10 @@ -# mindnet v2.2 — Programmplan -**Version:** 2.4.0 (Inkl. WP-07 Interview & WP-10a Draft Editor) -**Stand:** 2025-12-10 +# mindnet v2.4 — Programmplan +**Version:** 2.4.0 (Inkl. WP-11 Backend Intelligence) +**Stand:** 2025-12-11 **Status:** Aktiv --- -- [mindnet v2.2 — Programmplan](#mindnet-v22--programmplan) +- [mindnet v2.4 — Programmplan](#mindnet-v24--programmplan) - [1. Programmauftrag](#1-programmauftrag) - [2. Vision](#2-vision) - [3. Programmziele](#3-programmziele) @@ -29,7 +29,7 @@ - [WP-09 – Vault-Onboarding \& Migration (geplant)](#wp-09--vault-onboarding--migration-geplant) - [WP-10 – Chat-Interface \& Writeback (abgeschlossen)](#wp-10--chat-interface--writeback-abgeschlossen) - [WP-10a – GUI Evolution: Draft Editor (abgeschlossen)](#wp-10a--gui-evolution-draft-editor-abgeschlossen) - - [WP-11 – Knowledge-Builder \& Vernetzungs-Assistent (geplant)](#wp-11--knowledge-builder--vernetzungs-assistent-geplant) + - [WP-11 – Backend Intelligence \& Persistence (abgeschlossen)](#wp-11--backend-intelligence--persistence-abgeschlossen) - [WP-12 – Knowledge Rewriter (Soft Mode, geplant)](#wp-12--knowledge-rewriter-soft-mode-geplant) - [WP-13 – MCP-Integration \& Agenten-Layer (geplant)](#wp-13--mcp-integration--agenten-layer-geplant) - [WP-14 – Review / Refactoring / Dokumentation (geplant)](#wp-14--review--refactoring--dokumentation-geplant) @@ -51,7 +51,8 @@ mindnet v2.4 entwickelt ein persönliches, wachsendes KI-Gedächtnis, das: - über mehrere Kanäle gefüttert wird: - Obsidian-Markdown (primäre Quelle), - Chat-basierter Agent (Decision Engine & RAG-Chat aktiv), - - **Interview-Assistent (One-Shot Extraction aktiv)**, + - Interview-Assistent (One-Shot Extraction aktiv), + - **Draft Editor (Active Intelligence aktiv)**, - automatisch neue Zusammenhänge erkennt und vernetzt (Edges, Typen, Hinweise), - sich durch Rückmeldungen (Feedback) selbst verbessert (Self-Tuning). @@ -116,7 +117,8 @@ Kernprinzipien der Vision: - **Multi-Persona:** System wechselt den Tonfall (Empathisch vs. Analytisch) situativ (WP-06 abgeschlossen). - **Chat Interface:** Web-basiertes Frontend (Streamlit) für einfache Interaktion und Feedback-Gabe (WP-10 abgeschlossen). - **Interview-Assistent (WP-07):** One-Shot Extraction von Notizen ("Neues Projekt anlegen") ist live. -- Technische Basis: FastAPI, Qdrant, Ollama (Local LLM), Streamlit. +- **Active Intelligence (WP-11):** Automatische Link-Vorschläge (Matrix-Logik) während des Schreibens. +- Technische Basis: FastAPI (Async), Qdrant (768 Dim), Ollama (Phi-3/Nomic), Streamlit. - Automatisierte Erkennung von Beziehungen: - Wikilinks, Inline-Relationen, Callout-Edges, Typ-Defaults. - „Mitwachsendes“ Schema ohne Obsidian-Umstrukturierungen: @@ -126,8 +128,7 @@ Kernprinzipien der Vision: ### 3.2 Mittelfristig (Nächste Schritte) - **Self-Tuning (WP-08):** Optimierung der Gewichte in `retriever.yaml` basierend auf dem gesammelten Feedback. -- **Knowledge-Builder (WP-11):** Assistent zur Analyse und Vernetzung manuell erstellter Notizen. -- Agenten können über MCP-Tools (`mindnet_query`, `mindnet_chat`) auf mindnet zugreifen. +- Agenten können über MCP-Tools (`mindnet_query`, `mindnet_chat`) auf mindnet zugreifen (WP-13). ### 3.3 Langfristig @@ -180,7 +181,7 @@ Die folgenden Prinzipien steuern alle Workpackages und Entscheidungen: - Jeder Importlauf, jede Retriever-Anfrage und jede Policy-Änderung soll prüfbar sein. 10. **Local First & Privacy** - - Nutzung lokaler LLMs (Ollama/Phi-3) für Inference. Keine Daten verlassen den Server. + - Nutzung lokaler LLMs (Ollama) für Inference. Keine Daten verlassen den Server. --- @@ -192,7 +193,7 @@ Die folgenden Prinzipien steuern alle Workpackages und Entscheidungen: Phase D – Agenten, MCP & Interaktion (Aktiv) Phase E – Review, Refactoring, Dokumentation -Alle Workpackages sind einer Phase zugeordnet. WP-01 bis WP-07 und WP-10/10a sind erfolgreich abgeschlossen. +Alle Workpackages sind einer Phase zugeordnet. WP-01 bis WP-07 und WP-10/10a/11 sind erfolgreich abgeschlossen. --- @@ -447,13 +448,20 @@ Anpassung der GUI an komplexe Interaktionsmuster, die durch den Interview-Assist --- -### WP-11 – Knowledge-Builder & Vernetzungs-Assistent (geplant) +### WP-11 – Backend Intelligence & Persistence (abgeschlossen) **Phase:** D -**Status:** 🟡 geplant +**Status:** 🟢 abgeschlossen **Ziel:** -Assistent, der manuell erstellte oder importierte Notizen analysiert und Vorschläge für Typen, Edges und Einordnung macht. +Ermöglichung von "Active Intelligence" durch asynchrone Verarbeitung und semantische Analyse im Hintergrund. + +**Erreichte Ergebnisse:** +- **Async Core:** Umstellung der Pipeline auf `asyncio` und `httpx` (Vermeidung von Blockaden). +- **Nomic Embeddings:** Integration von `nomic-embed-text` (768 Dim) für State-of-the-Art Semantik. +- **Matrix Logic:** Regelwerk für kontextsensitive Kanten (`experience` + `value` -> `based_on`). +- **Sliding Window:** Analyse langer Texte für Link-Vorschläge. +- **Persistence API:** Neuer Endpunkt `/ingest/save` für atomares Speichern & Indizieren. **Aufwand / Komplexität:** - Aufwand: Hoch @@ -557,7 +565,7 @@ Aufräumen, dokumentieren, stabilisieren – insbesondere für Onboarding Dritte | WP09 | 🟡 | | WP10 | 🟢 | | WP10a | 🟢 | -| WP11 | 🟡 | +| WP11 | 🟢 | | WP12 | 🟡 | | WP13 | 🟡 | | WP14 | 🟡 | @@ -585,7 +593,8 @@ mindnet v2.4 ist so aufgesetzt, dass: - ein **Self-Healing- und Self-Tuning-Mechanismus** vorbereitet ist (durch WP-04c Feedback-Daten), - ein **Persönlichkeitsmodell** (Decision Engine, Empathie) existiert und den Tonfall situativ anpasst, - eine **grafische Oberfläche** (WP-10/10a) existiert, die komplexe Zusammenhänge visualisiert und Co-Creation ermöglicht, +- **Active Intelligence** (WP-11) dich beim Schreiben unterstützt, indem es automatisch Verknüpfungen vorschlägt, - langfristig ein **KI-Zwilling** aufgebaut wird, der deine Werte, Erfahrungen und Denkweise spiegelt, -- die technische Architektur (FastAPI, Qdrant, YAML-Policies, MCP-Integration) lokal, nachvollziehbar und erweiterbar bleibt. +- die technische Architektur (AsyncIO, Qdrant 768d, YAML-Policies) lokal, nachvollziehbar und performant bleibt. Dieser Programmplan bildet die konsolidierte Grundlage (v2.4.0) für alle weiteren Arbeiten. \ No newline at end of file diff --git a/docs/Knowledge_Design_Manual.md b/docs/Knowledge_Design_Manual.md index 95de77e..e5d2a44 100644 --- a/docs/Knowledge_Design_Manual.md +++ b/docs/Knowledge_Design_Manual.md @@ -1,7 +1,7 @@ # mindnet v2.4 – Knowledge Design Manual **Datei:** `docs/mindnet_knowledge_design_manual_v2.4.md` -**Stand:** 2025-12-10 -**Status:** **FINAL** (Integrierter Stand WP01–WP10a) +**Stand:** 2025-12-11 +**Status:** **FINAL** (Integrierter Stand WP01–WP11) **Quellen:** `knowledge_design.md`, `TYPE_REGISTRY_MANUAL.md`, `chunking_strategy.md`, `mindnet_functional_architecture.md`. --- @@ -24,11 +24,11 @@ Dieses Handbuch ist die **primäre Arbeitsanweisung** für dich als Mindmaster ( ### 1.1 Zielsetzung Mindnet ist mehr als eine Dokumentablage. Es ist ein vernetztes System, das deine Persönlichkeit, Entscheidungen und Erfahrungen abbildet. -Seit Version 2.3.1 verfügt Mindnet über: +Seit Version 2.4 verfügt Mindnet über: * **Hybrid Router:** Das System erkennt, ob du Fakten, Entscheidungen oder Empathie brauchst. * **Context Intelligence:** Das System lädt je nach Situation unterschiedliche Notiz-Typen (z.B. Werte bei Entscheidungen). * **Web UI (WP10):** Du kannst direkt sehen, welche Quellen genutzt wurden. -* **Interview Modus (WP07):** Du kannst Notizen direkt im Chat entwerfen lassen. +* **Active Intelligence (WP11):** Das System hilft dir beim Schreiben und Vernetzen (Link-Vorschläge). ### 1.2 Der Vault als „Source of Truth“ Die Markdown-Dateien in deinem Vault sind die **einzige Quelle der Wahrheit**. @@ -59,7 +59,7 @@ Jede Datei muss mindestens folgende Felder enthalten, um korrekt verarbeitet zu Diese Felder sind technisch nicht zwingend, aber für bestimmte Typen sinnvoll: lang: de # Sprache (Default: de) - aliases: [Alpha Projekt, Project A] # Synonyme für die Suche + aliases: [Alpha Projekt, Project A] # Synonyme (WICHTIG für Exact Match in Intelligence) visibility: internal # internal (default), public, private > **Hinweis:** Felder wie `retriever_weight` oder `chunk_profile` sollten **nicht** mehr manuell im Frontmatter gesetzt werden. Diese werden zentral über den `type` gesteuert (siehe Kap. 3), um die Wartbarkeit zu sichern. @@ -86,17 +86,17 @@ Der `type` ist der wichtigste Hebel im Knowledge Design. Er steuert nicht nur da Mindnet unterscheidet verschiedene Wissensarten. Wähle den Typ, der die **Rolle** der Notiz am besten beschreibt: -| Typ | Beschreibung & Einsatzzweck | Rolle im Chat (Intent) | Interview Schema (WP07) | -| :--- | :--- | :--- | :--- | -| **`concept`** | Fachbegriffe, Theorien. Zeitloses Wissen. | **FACT** | Titel, Definition, Tags | -| **`project`** | Ein Vorhaben mit Ziel, Dauer und Aufgaben. | **FACT / DECISION** | Ziel, Status, Stakeholder, Steps | -| **`experience`** | Persönliche Erfahrung, Lektion oder Erkenntnis. | **EMPATHY** | Situation, Erkenntnis, Emotionen | -| **`decision`** | Eine bewusst getroffene Entscheidung (ADR). | **DECISION** | Kontext, Entscheidung, Alternativen | -| **`value`** | Ein persönlicher Wert oder ein Prinzip. | **DECISION** | Definition, Anti-Beispiel | -| **`goal`** | Ein strategisches Ziel (kurz- oder langfristig). | **DECISION** | Zeitrahmen, KPIs, Werte | -| **`person`** | Eine reale Person (Netzwerk, Autor). | **FACT** | Rolle, Kontext | -| **`journal`** | Zeitbezogener Log-Eintrag, Daily Note. | **FACT** | Datum, Tags | -| **`source`** | Externe Quelle (Buch, PDF, Artikel). | **FACT** | Autor, URL | +| Typ | Beschreibung & Einsatzzweck | Rolle im Chat (Intent) | Interview Schema (WP07) | Matrix-Logik (WP11) | +| :--- | :--- | :--- | :--- | :--- | +| **`concept`** | Fachbegriffe, Theorien. Zeitloses Wissen. | **FACT** | Titel, Definition, Tags | Ziel für `uses` | +| **`project`** | Ein Vorhaben mit Ziel, Dauer und Aufgaben. | **FACT / DECISION** | Ziel, Status, Stakeholder | Quelle für `uses`, `depends_on` | +| **`experience`** | Persönliche Erfahrung, Lektion oder Erkenntnis. | **EMPATHY** | Situation, Erkenntnis, Emotionen | Quelle für `based_on` | +| **`decision`** | Eine bewusst getroffene Entscheidung (ADR). | **DECISION** | Kontext, Entscheidung, Alternativen | Quelle für `depends_on` | +| **`value`** | Ein persönlicher Wert oder ein Prinzip. | **DECISION** | Definition, Anti-Beispiel | Ziel für `based_on` | +| **`goal`** | Ein strategisches Ziel (kurz- oder langfristig). | **DECISION** | Zeitrahmen, KPIs, Werte | Ziel für `related_to` | +| **`person`** | Eine reale Person (Netzwerk, Autor). | **FACT** | Rolle, Kontext | - | +| **`journal`** | Zeitbezogener Log-Eintrag, Daily Note. | **FACT** | Datum, Tags | - | +| **`source`** | Externe Quelle (Buch, PDF, Artikel). | **FACT** | Autor, URL | - | ### 3.2 Zusammenspiel mit `types.yaml` @@ -139,6 +139,7 @@ Dies ist die **mächtigste** Methode. Du sagst dem System explizit, **wie** Ding * `related_to`: Hat zu tun mit (allgemein). * `caused_by`: Wurde verursacht durch. * `solves`: Löst (Problem). + * **Neu (v2.4):** `based_on`, `uses`, `derived_from` (werden oft automatisch vorgeschlagen). ### 4.3 Callout-Edges (Kuratierte Listen) Für Zusammenfassungen oder "Siehe auch"-Blöcke am Ende einer Notiz. diff --git a/docs/Overview.md b/docs/Overview.md index d8aacdc..b7cc8f0 100644 --- a/docs/Overview.md +++ b/docs/Overview.md @@ -1,7 +1,7 @@ # Mindnet v2.4 – Overview & Einstieg **Datei:** `docs/mindnet_overview_v2.4.md` -**Stand:** 2025-12-10 -**Status:** **FINAL** (Inkl. Interview-Assistent & Web-Editor) +**Stand:** 2025-12-11 +**Status:** **FINAL** (Inkl. Async Intelligence & Editor) **Version:** 2.4.0 --- @@ -13,6 +13,7 @@ Anders als herkömmliche Notiz-Apps (wie Obsidian oder Evernote), die Texte nur passiv speichern, ist Mindnet ein **aktives System**: * Es **versteht** Zusammenhänge über einen Wissensgraphen. * Es **begründet** Antworten ("Warum ist das so?"). +* Es **unterstützt** beim Schreiben: Es schlägt automatisch Verbindungen zu bestehendem Wissen vor ("Active Intelligence"). * Es **antwortet** situativ angepasst: Mal als Strategieberater, mal als empathischer Spiegel, und neu: **als Interviewer, der hilft, Wissen zu erfassen.** ### Die Vision @@ -27,14 +28,14 @@ Mindnet arbeitet auf drei Schichten, die aufeinander aufbauen: ### Ebene 1: Content (Das Gedächtnis) * **Quelle:** Dein lokaler Obsidian-Vault (Markdown). * **Funktion:** Speicherung von Fakten, Projekten und Logs. -* **Technik:** Import-Pipeline, Chunking, Vektor-Datenbank (Qdrant). -* **Status:** 🟢 Live (WP01–WP03). +* **Technik:** Async Import-Pipeline, Chunking, Vektor-Datenbank (Qdrant). +* **Status:** 🟢 Live (WP01–WP03, WP11). ### Ebene 2: Semantik (Das Verstehen) * **Funktion:** Verknüpfung von isolierten Notizen zu einem Netzwerk. * **Logik:** "Projekt A *hängt ab von* Entscheidung B". -* **Technik:** Hybrider Retriever (Graph + Vektor), Explanation Engine. -* **Status:** 🟢 Live (WP04). +* **Technik:** Hybrider Retriever (Graph + Nomic Embeddings), Explanation Engine. +* **Status:** 🟢 Live (WP04, WP11). ### Ebene 3: Identität & Interaktion (Die Persönlichkeit) * **Funktion:** Interaktion, Bewertung und Co-Creation. @@ -45,6 +46,7 @@ Mindnet arbeitet auf drei Schichten, die aufeinander aufbauen: * **Intent Router:** Erkennt Absichten (Fakt vs. Gefühl vs. Entscheidung vs. Interview). * **Strategic Retrieval:** Lädt gezielt Werte oder Erfahrungen nach. * **One-Shot Extraction:** Generiert Entwürfe für neue Notizen. + * **Active Intelligence:** Schlägt Links während des Schreibens vor. * **Status:** 🟢 Live (WP05–WP07, WP10). --- @@ -54,18 +56,19 @@ Mindnet arbeitet auf drei Schichten, die aufeinander aufbauen: Der Datenfluss in Mindnet ist zyklisch ("Data Flywheel"): 1. **Input:** Du schreibst Notizen in Obsidian **ODER** lässt sie von Mindnet im Chat entwerfen. -2. **Ingest:** Ein Python-Skript importiert, zerlegt (Chunking) und vernetzt (Edges) die Daten in Qdrant. -3. **Intent Recognition:** Der Router analysiert deine Frage: Willst du Fakten, Code, Empathie oder etwas dokumentieren? -4. **Retrieval / Action:** +2. **Intelligence (Live):** Während du schreibst, analysiert Mindnet den Text und schlägt Verknüpfungen vor (Sliding Window Analyse). +3. **Ingest:** Ein asynchrones Python-Skript importiert, zerlegt (Chunking) und vernetzt (Edges) die Daten in Qdrant. +4. **Intent Recognition:** Der Router analysiert deine Frage: Willst du Fakten, Code, Empathie oder etwas dokumentieren? +5. **Retrieval / Action:** * Bei Fragen: Das System sucht Inhalte passend zum Intent. * Bei Interviews: Das System wählt das passende Schema (z.B. Projekt-Vorlage). -5. **Generation:** Ein lokales LLM (Ollama) formuliert die Antwort oder den Markdown-Draft. -6. **Feedback:** Du bewertest die Antwort. Das System lernt (langfristig) daraus. +6. **Generation:** Ein lokales LLM (Ollama) formuliert die Antwort oder den Markdown-Draft. +7. **Feedback:** Du bewertest die Antwort. Das System lernt (langfristig) daraus. **Tech-Stack:** -* **Backend:** Python 3.10+, FastAPI. -* **Datenbank:** Qdrant (Vektor & Graph). -* **KI:** Ollama (Phi-3 Mini) – 100% lokal. +* **Backend:** Python 3.10+, FastAPI (Async). +* **Datenbank:** Qdrant (Vektor & Graph, 768 Dim). +* **KI:** Ollama (Phi-3 Mini für Chat, Nomic für Embeddings) – 100% lokal. * **Frontend:** Streamlit Web-UI (v2.4). --- @@ -96,5 +99,5 @@ Wo findest du was? ## 6. Aktueller Fokus -Wir haben den **Interview-Assistenten (WP07)** und den **Draft-Editor (WP10a)** erfolgreich integriert. -Das System kann nun aktiv helfen, Wissen zu strukturieren, anstatt es nur abzurufen. Der Fokus verschiebt sich nun in Richtung **Self-Tuning (WP08)**, um aus dem gesammelten Feedback automatisch zu lernen. \ No newline at end of file +Wir haben den **Interview-Assistenten (WP07)** und die **Backend Intelligence (WP11)** erfolgreich integriert. +Das System kann nun aktiv helfen, Wissen zu strukturieren und zu vernetzen. Der Fokus verschiebt sich nun in Richtung **Self-Tuning (WP08)**, um aus dem gesammelten Feedback automatisch zu lernen. \ No newline at end of file diff --git a/docs/admin_guide.md b/docs/admin_guide.md index c9bafe8..ef87828 100644 --- a/docs/admin_guide.md +++ b/docs/admin_guide.md @@ -1,7 +1,7 @@ # Mindnet v2.4 – Admin Guide **Datei:** `docs/mindnet_admin_guide_v2.4.md` -**Stand:** 2025-12-10 -**Status:** **FINAL** (Inkl. Frontend Deployment & Interview Config) +**Stand:** 2025-12-11 +**Status:** **FINAL** (Inkl. Async Architecture & Nomic Model) **Quellen:** `Handbuch.md`, `mindnet_developer_guide_v2.4.md`. > Dieses Handbuch richtet sich an **Administratoren**. Es beschreibt Installation, Konfiguration, Backup-Strategien, Monitoring und den sicheren Betrieb der Mindnet-Instanz (API + UI + DB). @@ -23,7 +23,7 @@ Wir unterscheiden strikt zwischen: * **OS:** Linux (Ubuntu 22.04+ empfohlen) oder macOS. * **Runtime:** Python 3.10+, Docker (für Qdrant), Ollama (für LLM). * **Hardware:** - * CPU: 4+ Cores empfohlen (für Import & Inference). + * CPU: 4+ Cores empfohlen (für Async Import & Inference). * RAM: Min. 8GB empfohlen (4GB System + 4GB für Phi-3/Qdrant). * Disk: SSD empfohlen für Qdrant-Performance. @@ -37,11 +37,11 @@ Wir unterscheiden strikt zwischen: python3 -m venv .venv source .venv/bin/activate - # 3. Dependencies installieren (inkl. Streamlit) + # 3. Dependencies installieren (inkl. Streamlit, HTTPX) pip install -r requirements.txt # 4. Verzeichnisse anlegen - mkdir -p logs qdrant_storage data/logs + mkdir -p logs qdrant_storage data/logs vault ### 2.3 Qdrant Setup (Docker) Wir nutzen Qdrant als Vektor-Datenbank. Persistenz ist wichtig. @@ -53,36 +53,49 @@ Wir nutzen Qdrant als Vektor-Datenbank. Persistenz ist wichtig. -v $(pwd)/qdrant_storage:/qdrant/storage \ qdrant/qdrant -### 2.4 Ollama Setup (LLM Service) -Mindnet benötigt einen lokalen LLM-Server für den Chat. +### 2.4 Ollama Setup (LLM & Embeddings) +Mindnet benötigt einen lokalen LLM-Server für Chat UND Embeddings. +**WICHTIG (Update v2.3.10):** Es muss zwingend `nomic-embed-text` installiert sein, sonst startet der Import nicht. # 1. Installieren (Linux Script) - curl -fsSL [https://ollama.com/install.sh](https://ollama.com/install.sh) | sh + curl -fsSL https://ollama.com/install.sh | sh - # 2. Modell laden (Phi-3 Mini für CPU-Performance) - ollama pull phi3:mini + # 2. Modelle laden + ollama pull phi3:mini # Für Chat/Reasoning + ollama pull nomic-embed-text # Für Vektoren (768 Dim) # 3. Testen curl http://localhost:11434/api/generate -d '{"model": "phi3:mini", "prompt":"Hi"}' ### 2.5 Konfiguration (ENV) -Erstelle eine `.env` Datei im Root-Verzeichnis. Die neuen Settings für WP-06/WP-07 (Timeout, Decision Config) sind essenziell für stabilen Betrieb auf CPUs. +Erstelle eine `.env` Datei im Root-Verzeichnis. Achte besonders auf `VECTOR_DIM` und `MINDNET_EMBEDDING_MODEL`. + # Server Config + UVICORN_HOST=0.0.0.0 + # Qdrant Verbindung QDRANT_URL="http://localhost:6333" # Mindnet Core Settings COLLECTION_PREFIX="mindnet" MINDNET_TYPES_FILE="./config/types.yaml" + MINDNET_VAULT_ROOT="./vault" - # LLM / RAG Settings - MINDNET_LLM_MODEL="phi3:mini" + # WICHTIG: Dimension auf 768 setzen (für Nomic) + VECTOR_DIM=768 + + # AI Modelle (Ollama) MINDNET_OLLAMA_URL="http://127.0.0.1:11434" + MINDNET_LLM_MODEL="phi3:mini" + MINDNET_EMBEDDING_MODEL="nomic-embed-text" # NEU - # Config & Timeouts + # Timeouts (Erhöht für Async/Nomic) + MINDNET_LLM_TIMEOUT=300.0 + MINDNET_API_TIMEOUT=60.0 + + # Configs MINDNET_PROMPTS_PATH="./config/prompts.yaml" MINDNET_DECISION_CONFIG="./config/decision_engine.yaml" - MINDNET_LLM_TIMEOUT=300.0 ### 2.6 Deployment via Systemd (Backend & Frontend) @@ -98,6 +111,7 @@ Mindnet benötigt zwei Services pro Umgebung: API (Uvicorn) und UI (Streamlit). User=llmadmin Group=llmadmin WorkingDirectory=/home/llmadmin/mindnet + # Async Server Start ExecStart=/home/llmadmin/mindnet/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8001 --env-file .env Restart=always RestartSec=5 @@ -141,11 +155,11 @@ Mindnet benötigt zwei Services pro Umgebung: API (Uvicorn) und UI (Streamlit). ## 3. Betrieb im Alltag ### 3.1 Regelmäßige Importe -Der Vault-Zustand sollte regelmäßig (z.B. stündlich per Cronjob) nach Qdrant synchronisiert werden. +Der Vault-Zustand sollte regelmäßig (z.B. stündlich per Cronjob) nach Qdrant synchronisiert werden. Das Skript nutzt nun **AsyncIO** und eine Semaphore, um Ollama nicht zu überlasten. **Cronjob-Beispiel (stündlich):** - 0 * * * * cd /home/llmadmin/mindnet && .venv/bin/python3 -m scripts.import_markdown --vault /path/to/vault --prefix "mindnet" --apply --purge-before-upsert --sync-deletes >> ./logs/import.log 2>&1 + 0 * * * * cd /home/llmadmin/mindnet && .venv/bin/python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --purge-before-upsert --sync-deletes >> ./logs/import.log 2>&1 ### 3.2 Health-Checks Prüfe regelmäßig, ob alle Komponenten laufen. @@ -157,34 +171,28 @@ Prüfe regelmäßig, ob alle Komponenten laufen. ### 3.3 Logs & Monitoring * **Backend Fehler:** `journalctl -u mindnet-prod -f` * **Frontend Fehler:** `journalctl -u mindnet-ui-prod -f` - * Achte auf "Timeout"-Meldungen im Frontend, wenn das Backend zu langsam antwortet. * **LLM Fehler:** `journalctl -u ollama -f` * **Fachliche Logs:** `data/logs/search_history.jsonl` --- -## 4. Update-Prozess +## 4. Troubleshooting (Update v2.4) -Wenn neue Versionen ausgerollt werden (Deployment): +### "Vector dimension error: expected dim: 768, got 384" +* **Ursache:** Du versuchst, in eine alte Qdrant-Collection (mit 384 Dim aus v2.2) neue Embeddings (mit 768 Dim von Nomic) zu schreiben. +* **Lösung:** Full Reset erforderlich. + 1. `python -m scripts.reset_qdrant --mode wipe --prefix mindnet --yes` (Löscht DB). + 2. `python -m scripts.import_markdown ...` (Baut neu auf). -1. **Code aktualisieren:** - - cd /home/llmadmin/mindnet - git pull origin main +### "500 Internal Server Error" beim Speichern +* **Ursache:** Oft Timeout bei Ollama, wenn `nomic-embed-text` noch nicht im RAM geladen ist ("Cold Start"). +* **Lösung:** + 1. Sicherstellen, dass Modell existiert: `ollama list`. + 2. API neustarten (re-initialisiert Async Clients). -2. **Dependencies prüfen:** - - source .venv/bin/activate - pip install -r requirements.txt - -3. **Dienste neustarten (Zwingend!):** - - sudo systemctl restart mindnet-prod - sudo systemctl restart mindnet-ui-prod - -4. **Schema-Migration (falls nötig):** - - python3 -m scripts.import_markdown ... --apply +### "NameError: name 'os' is not defined" +* **Ursache:** Fehlender Import in Skripten nach Updates. +* **Lösung:** `git pull` (Fix wurde in v2.3.10 deployed). --- @@ -202,16 +210,13 @@ Für schnelle Wiederherstellung des Suchindex. tar -czf qdrant_backup_$(date +%F).tar.gz ./qdrant_storage docker start mindnet_qdrant -### 5.3 Log-Daten (Priorität 3) -Sichere den Ordner `data/logs/`. Verlust dieser Daten bedeutet Verlust des Trainingsmaterials für Self-Tuning. - -### 5.4 Notfall-Wiederherstellung (Rebuild) -Wenn die Datenbank korrupt ist: +### 5.3 Notfall-Wiederherstellung (Rebuild) +Wenn die Datenbank korrupt ist oder Modelle gewechselt werden: # 1. DB komplett leeren (Wipe) python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet" --yes # 2. Alles neu importieren - python3 -m scripts.import_markdown --vault /path/to/vault --prefix "mindnet" --apply + python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force --- @@ -221,7 +226,4 @@ Wenn die Datenbank korrupt ist: Mindnet hat aktuell **keine integrierte Authentifizierung**. * **Frontend:** Streamlit auf Port 8501 ist offen. Nutze Nginx Basic Auth oder VPN. * **API:** Sollte nicht direkt im öffentlichen Netz stehen. -* **Qdrant:** Auf `127.0.0.1` beschränken. - -### 6.2 Typen-Governance -Änderungen an der `types.yaml` (z.B. neue Gewichte) wirken global und erfordern Tests. \ No newline at end of file +* **Qdrant:** Auf `127.0.0.1` beschränken. \ No newline at end of file diff --git a/docs/appendix.md b/docs/appendix.md index d9fc8f4..2244d5d 100644 --- a/docs/appendix.md +++ b/docs/appendix.md @@ -1,7 +1,7 @@ # Mindnet v2.4 – Appendices & Referenzen **Datei:** `docs/mindnet_appendices_v2.4.md` -**Stand:** 2025-12-10 -**Status:** **FINAL** (Integrierter Stand WP01–WP10a) +**Stand:** 2025-12-11 +**Status:** **FINAL** (Integrierter Stand WP01–WP11) **Quellen:** `TYPE_REGISTRY_MANUAL.md`, `chunking_strategy.md`, `mindnet_technical_architecture.md`, `Handbuch.md`. > Dieses Dokument bündelt Tabellen, Schemata und technische Referenzen, die in den Prozess-Dokumenten (Playbook, Guides) den Lesefluss stören würden. @@ -43,7 +43,9 @@ Referenz aller implementierten Kantenarten (`kind`). | `similar_to` | Inline | Ja | Inhaltliche Ähnlichkeit. "Ist wie X". | | `caused_by` | Inline | Nein | Kausalität. "X ist der Grund für Y". | | `solves` | Inline | Nein | Lösung. "Tool X löst Problem Y". | -| `derived_from` | Default (Exp) | Nein | Herkunft. "Erkenntnis stammt aus Quelle X". | +| `derived_from` | Matrix / Default | Nein | Herkunft. "Erkenntnis stammt aus Prinzip X". | +| `based_on` | Matrix | Nein | Fundament. "Erfahrung basiert auf Wert Y". | +| `uses` | Matrix | Nein | Nutzung. "Projekt nutzt Konzept Z". | --- @@ -104,28 +106,35 @@ Diese Variablen steuern das Verhalten der Skripte und Container. | `QDRANT_URL` | `http://localhost:6333` | URL zur Vektor-DB. | | `QDRANT_API_KEY` | *(leer)* | API-Key für Absicherung (optional). | | `COLLECTION_PREFIX` | `mindnet` | Namensraum für Collections (`{prefix}_notes` etc). | +| `VECTOR_DIM` | `768` | **NEU:** Dimension für Embeddings (für Nomic). | | `MINDNET_TYPES_FILE` | `config/types.yaml` | Pfad zur Typ-Registry. | | `MINDNET_RETRIEVER_CONFIG`| `config/retriever.yaml`| Pfad zur Scoring-Konfiguration. | | `MINDNET_PROMPTS_PATH` | `config/prompts.yaml` | Pfad zu LLM-Prompts (Neu in v2.2). | | `MINDNET_DECISION_CONFIG` | `config/decision_engine.yaml` | Router & Interview Config (Neu in v2.3). | -| `MINDNET_LLM_MODEL` | `phi3:mini` | Name des Ollama-Modells (Neu in v2.2). | +| `MINDNET_LLM_MODEL` | `phi3:mini` | Name des Chat-Modells. | +| `MINDNET_EMBEDDING_MODEL` | `nomic-embed-text` | **NEU:** Name des Vektor-Modells. | | `MINDNET_OLLAMA_URL` | `http://127.0.0.1:11434`| URL zum LLM-Server (Neu in v2.2). | | `MINDNET_LLM_TIMEOUT` | `300.0` | Timeout für Ollama (Erhöht für CPU-Inference). | -| `MINDNET_API_TIMEOUT` | `300.0` | Frontend Timeout (Streamlit). | +| `MINDNET_API_TIMEOUT` | `60.0` | **NEU:** Frontend Timeout (Streamlit). | +| `MINDNET_VAULT_ROOT` | `./vault` | **NEU:** Pfad für Write-Back Operationen. | | `MINDNET_HASH_COMPARE` | `Body` | Vergleichsmodus für Import (`Body`, `Frontmatter`, `Full`). | | `MINDNET_HASH_SOURCE` | `parsed` | Quelle für Hash (`parsed`, `raw`, `file`). | -| `VECTOR_DIM` | `384` | Dimension der Embeddings (Modellabhängig). | --- ## Anhang E: Glossar +* **Active Intelligence:** Feature, das während des Schreibens Links vorschlägt. +* **Async Ingestion:** Non-blocking Import-Prozess zur Vermeidung von Timeouts. * **Decision Engine:** Komponente, die den Intent prüft und Strategien wählt (WP06). * **Draft Editor:** Web-Komponente zur Bearbeitung generierter Notizen (WP10a). * **Explanation Layer:** Komponente, die Scores und Graphen als Begründung liefert. * **Hybrid Router:** Kombination aus Keyword-Matching und LLM-Klassifizierung für Intents. +* **Matrix Logic:** Regelwerk, das Kanten-Typen basierend auf Quell- und Ziel-Typ bestimmt. +* **Nomic:** Das neue, hochpräzise Embedding-Modell (768 Dim). * **One-Shot Extractor:** LLM-Strategie zur sofortigen Generierung von Drafts ohne Rückfragen (WP07). * **RAG (Retrieval Augmented Generation):** Kombination aus Suche und Text-Generierung. +* **Resurrection Pattern:** UI-Technik, um Eingaben bei Tab-Wechseln zu erhalten. --- @@ -146,4 +155,5 @@ Aktueller Implementierungsstand der Module. | **WP07** | Interview Assistent | 🟢 Live | **One-Shot Extractor & Schemas aktiv.** | | **WP08** | Self-Tuning | 🔴 Geplant | Auto-Adjustment der Gewichte. | | **WP10** | Chat Interface | 🟢 Live | Web-Interface (Streamlit). | -| **WP10a**| Draft Editor | 🟢 Live | **Interaktives UI für WP07 Drafts.** | \ No newline at end of file +| **WP10a**| Draft Editor | 🟢 Live | **Interaktives UI für WP07 Drafts.** | +| **WP11** | Backend Intelligence | 🟢 Live | **Async Core, Nomic, Matrix.** | \ No newline at end of file diff --git a/Programmmanagement/ARCHITECTURE_SNAPSHOT_v2.2.1.md b/docs/archiv/ARCHITECTURE_SNAPSHOT_v2.2.1.md similarity index 100% rename from Programmmanagement/ARCHITECTURE_SNAPSHOT_v2.2.1.md rename to docs/archiv/ARCHITECTURE_SNAPSHOT_v2.2.1.md diff --git a/Programmmanagement/Überarbeitungshinweise_WP03.md b/docs/archiv/Überarbeitungshinweise_WP03.md similarity index 100% rename from Programmmanagement/Überarbeitungshinweise_WP03.md rename to docs/archiv/Überarbeitungshinweise_WP03.md diff --git a/Programmmanagement/Überarbeitungshinweise_WP04.md b/docs/archiv/Überarbeitungshinweise_WP04.md similarity index 100% rename from Programmmanagement/Überarbeitungshinweise_WP04.md rename to docs/archiv/Überarbeitungshinweise_WP04.md diff --git a/docs/dev_workflow.md b/docs/dev_workflow.md index 70537eb..806f19e 100644 --- a/docs/dev_workflow.md +++ b/docs/dev_workflow.md @@ -1,6 +1,6 @@ # Mindnet v2.4 – Entwickler-Workflow **Datei:** `docs/DEV_WORKFLOW.md` -**Stand:** 2025-12-10 (Aktualisiert: Inkl. Interview-Tests WP07) +**Stand:** 2025-12-11 (Aktualisiert: Inkl. Async Intelligence & Nomic) Dieses Handbuch beschreibt den Entwicklungszyklus zwischen **Windows PC** (IDE), **Raspberry Pi** (Gitea) und **Beelink** (Runtime/Server). @@ -35,14 +35,14 @@ Hier erstellst du die neue Funktion in einer sicheren Umgebung. 2. **Branch erstellen:** * Klicke wieder unten links auf `main`. * Wähle `+ Create new branch...`. - * Gib den Namen ein: `feature/was-ich-tue` (z.B. `feature/wp07-interview`). + * Gib den Namen ein: `feature/was-ich-tue` (z.B. `feature/wp11-async-fix`). * Drücke **Enter**. 3. **Sicherheits-Check:** * Steht unten links jetzt dein Feature-Branch? **Nur dann darfst du Code ändern!** 4. **Coden:** - * Nimm deine Änderungen vor (z.B. neue Schemas in `decision_engine.yaml`). + * Nimm deine Änderungen vor (z.B. neue Schemas in `decision_engine.yaml` oder Async-Logik in `ingestion.py`). 5. **Sichern & Hochladen:** * **Source Control** Icon (Gabel-Symbol) -> Nachricht eingeben -> **Commit**. @@ -64,14 +64,16 @@ Hier prüfst du, ob dein neuer Code auf dem echten Server läuft. ```bash git fetch # Tipp: 'git branch -r' zeigt alle verfügbaren Branches an - git checkout feature/wp07-interview + git checkout feature/wp11-async-fix git pull ``` -4. **Umgebung vorbereiten (bei Bedarf):** +4. **Umgebung vorbereiten (WICHTIG für v2.4):** ```bash source .venv/bin/activate - pip install -r requirements.txt # Nur nötig bei neuen Paketen + pip install -r requirements.txt # HTTPX usw. + # Sicherstellen, dass das neue Embedding-Modell da ist: + ollama pull nomic-embed-text ``` 5. **Test-Server aktualisieren (WICHTIG):** @@ -87,8 +89,6 @@ Hier prüfst du, ob dein neuer Code auf dem echten Server läuft. # Logs prüfen (um Fehler zu sehen): journalctl -u mindnet-dev -f - # Oder Frontend Logs: - journalctl -u mindnet-ui-dev -f ``` **Option B: Manuell Debuggen (Direct Output)** @@ -100,13 +100,6 @@ Hier prüfst du, ob dein neuer Code auf dem echten Server läuft. # 2. Manuell starten (z.B. API) uvicorn app.main:app --host 0.0.0.0 --port 8002 --env-file .env - - # ... Testen ... - - # 3. Wenn fertig: Services wieder anschalten (Optional) - # Strg+C drücken - sudo systemctl start mindnet-dev - sudo systemctl start mindnet-ui-dev ``` 6. **Validieren (Smoke Tests):** @@ -114,16 +107,15 @@ Hier prüfst du, ob dein neuer Code auf dem echten Server läuft. * **Browser:** Öffne `http://:8502` um die UI zu testen (Intent Badge prüfen!). * **CLI:** Führe Testskripte in einem **zweiten Terminal** aus: - **Test A: Decision Engine** + **Test A: Intelligence / Aliases (Neu in WP11)** ```bash - python tests/test_wp06_decision.py -p 8002 -q "Soll ich Qdrant nutzen?" - # Erwartung: Intent DECISION + python debug_analysis.py + # Erwartung: "✅ ALIAS GEFUNDEN" ``` - **Test B: Interview Modus (Neu!)** + **Test B: API Check** ```bash - python tests/test_wp06_decision.py -p 8002 -q "Ich will ein neues Projekt starten" - # Erwartung: Intent INTERVIEW, Output ist Markdown Codeblock + curl -X POST "http://localhost:8002/ingest/analyze" -d '{"text": "mindnet", "type": "journal"}' ``` --- @@ -150,16 +142,18 @@ Jetzt bringen wir die Änderung in das Live-System (Port 8001 / 8501). cd /home/llmadmin/mindnet git pull origin main - # Dependencies updaten + # Dependencies updaten & Modelle checken source .venv/bin/activate pip install -r requirements.txt + ollama pull nomic-embed-text + + # Falls sich die Vektor-Dimension geändert hat (v2.4 Upgrade): + # python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet" --yes + # python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force # Produktions-Services neustarten sudo systemctl restart mindnet-prod sudo systemctl restart mindnet-ui-prod - - # Kurz prüfen, ob er läuft - sudo systemctl status mindnet-prod ``` --- @@ -174,7 +168,7 @@ Damit das Chaos nicht wächst, löschen wir den fertigen Branch. cd ~/mindnet_dev git checkout main git pull - git branch -d feature/wp07-interview + git branch -d feature/wp11-async-fix ``` 3. **VS Code:** * Auf `main` wechseln. @@ -187,27 +181,25 @@ Damit das Chaos nicht wächst, löschen wir den fertigen Branch. | Wo? | Befehl | Was tut es? | | :--- | :--- | :--- | -| **VS Code** | `Sync (auf main)` | **WICHTIG:** Holt neuesten Code vom Server. | -| **Beelink** | `git fetch` | Aktualisiert Liste der Remote-Branches. | | **Beelink** | `sudo systemctl restart mindnet-dev` | **Neustart Dev-Backend (Port 8002).** | -| **Beelink** | `sudo systemctl restart mindnet-ui-dev` | **Neustart Dev-Frontend (Port 8502).** | | **Beelink** | `journalctl -u mindnet-dev -f` | **Live-Logs Backend.** | -| **Beelink** | `journalctl -u mindnet-ui-dev -f` | **Live-Logs Frontend.** | +| **Beelink** | `python debug_analysis.py` | **Prüft Aliases & Scores.** | +| **Beelink** | `python -m scripts.reset_qdrant ...` | **Löscht & Repariert DB.** | --- ## 4. Troubleshooting +**"Vector dimension error: expected 768, got 384"** +* **Ursache:** Du hast `nomic-embed-text` (768) aktiviert, aber die DB ist noch alt (384). +* **Lösung:** `scripts.reset_qdrant` ausführen und neu importieren. + **"Read timed out (300s)" / 500 Error beim Interview** * **Ursache:** Das LLM (Ollama) braucht für den One-Shot Draft länger als das Timeout erlaubt. * **Lösung:** 1. Erhöhe in `.env` den Wert: `MINDNET_LLM_TIMEOUT=300.0`. 2. Starte die Server neu. -**"Port 8002 / 8502 already in use"** -* **Ursache:** Du willst `uvicorn` oder `streamlit` manuell starten, aber der Service läuft noch. -* **Lösung:** `sudo systemctl stop mindnet-dev` bzw. `mindnet-ui-dev`. - **"UnicodeDecodeError in .env"** * **Ursache:** Umlaute oder Sonderzeichen in der `.env` Datei. * **Lösung:** `.env` bereinigen (nur ASCII nutzen) und sicherstellen, dass sie UTF-8 ohne BOM ist. \ No newline at end of file diff --git a/docs/developer_guide.md b/docs/developer_guide.md index e536dcd..ba6abf2 100644 --- a/docs/developer_guide.md +++ b/docs/developer_guide.md @@ -1,7 +1,7 @@ # Mindnet v2.4 – Developer Guide **Datei:** `docs/mindnet_developer_guide_v2.4.md` -**Stand:** 2025-12-10 -**Status:** **FINAL** (Inkl. RAG, Interview Mode & Frontend WP10) +**Stand:** 2025-12-11 +**Status:** **FINAL** (Inkl. Async Core, Nomic & Frontend State) **Quellen:** `mindnet_technical_architecture.md`, `Handbuch.md`, `DEV_WORKFLOW.md`. > **Zielgruppe:** Entwickler:innen. @@ -20,6 +20,7 @@ - [3.2 Der Hybrid Router (`app.routers.chat`)](#32-der-hybrid-router-approuterschat) - [3.3 Der Retriever (`app.core.retriever`)](#33-der-retriever-appcoreretriever) - [3.4 Das Frontend (`app.frontend.ui`)](#34-das-frontend-appfrontendui) + - [3.5 Embedding Service (`app.services.embeddings_client`)](#35-embedding-service-appservicesembeddings_client) - [4. Tests \& Debugging](#4-tests--debugging) - [4.1 Unit Tests (Pytest)](#41-unit-tests-pytest) - [4.2 Integration / Pipeline Tests](#42-integration--pipeline-tests) @@ -40,6 +41,7 @@ Der Code ist modular in `app` (Logik), `scripts` (CLI) und `config` (Steuerung) mindnet/ ├── app/ │ ├── core/ # Kernlogik + │ │ ├── ingestion.py # NEU: Async Ingestion Service (WP11) │ │ ├── chunker.py # Text-Zerlegung │ │ ├── derive_edges.py # Edge-Erzeugung (WP03 Logik) │ │ ├── retriever.py # Scoring & Hybrid Search @@ -49,13 +51,15 @@ Der Code ist modular in `app` (Logik), `scripts` (CLI) und `config` (Steuerung) │ │ └── dto.py # Zentrale DTO-Definition │ ├── routers/ # FastAPI Endpoints │ │ ├── query.py # Suche + │ │ ├── ingest.py # NEU: Save/Analyze (WP11) │ │ ├── chat.py # Hybrid Router & Interview Logic (WP06/WP07) │ │ ├── feedback.py # Feedback (WP04c) │ │ └── ... │ ├── services/ # Interne & Externe Dienste │ │ ├── llm_service.py # Ollama Client (Mit Timeout & Raw-Mode) + │ │ ├── embeddings_client.py# NEU: Async Embeddings (HTTPX) │ │ ├── feedback_service.py # Logging (JSONL Writer) - │ │ └── embeddings_client.py + │ │ └── discovery.py # NEU: Intelligence Logic (WP11) │ ├── frontend/ # NEU (WP10) │ │ └── ui.py # Streamlit Application inkl. Draft-Editor │ └── main.py # Entrypoint der API @@ -77,7 +81,7 @@ Der Code ist modular in `app` (Logik), `scripts` (CLI) und `config` (Steuerung) ### 2.1 Voraussetzungen * **Python:** 3.10 oder höher. * **Docker:** Für Qdrant. -* **Ollama:** Für lokale LLM-Inference (erforderlich für `/chat`). +* **Ollama:** Für lokale LLM-Inference (erforderlich für `/chat` und Embeddings). * **Vault:** Ein Ordner mit Markdown-Dateien (z.B. `./mindnet_v2_test_vault` für Tests). ### 2.2 Installation @@ -93,9 +97,11 @@ Der Code ist modular in `app` (Logik), `scripts` (CLI) und `config` (Steuerung) # 3. Abhängigkeiten installieren (inkl. Streamlit) pip install -r requirements.txt - # 4. Ollama Setup (Modell laden) - # Wir nutzen Phi-3 Mini für schnelle CPU-Inference + # 4. Ollama Setup (Modelle laden) + # Chat-Modell (Phi-3) ollama pull phi3:mini + # Embedding-Modell (Nomic) - PFLICHT für v2.4! + ollama pull nomic-embed-text ### 2.3 Konfiguration (Environment) Erstelle eine `.env` Datei im Root-Verzeichnis. @@ -106,18 +112,21 @@ Erstelle eine `.env` Datei im Root-Verzeichnis. # Mindnet Core Settings COLLECTION_PREFIX="mindnet_dev" + VECTOR_DIM=768 # NEU: 768 für Nomic (vorher 384) MINDNET_TYPES_FILE="./config/types.yaml" MINDNET_RETRIEVER_CONFIG="./config/retriever.yaml" + MINDNET_VAULT_ROOT="./vault" # LLM / RAG Settings (WP06/07) MINDNET_LLM_MODEL="phi3:mini" + MINDNET_EMBEDDING_MODEL="nomic-embed-text" # NEU MINDNET_OLLAMA_URL="http://127.0.0.1:11434" MINDNET_LLM_TIMEOUT=300.0 - MINDNET_PROMPTS_PATH="./config/prompts.yaml" MINDNET_DECISION_CONFIG="./config/decision_engine.yaml" # Frontend Settings (WP10) MINDNET_API_URL="http://localhost:8002" + MINDNET_API_TIMEOUT=60.0 # Import-Strategie MINDNET_HASH_COMPARE="Body" @@ -144,7 +153,8 @@ Wir entwickeln mit zwei Services. Du kannst sie manuell in zwei Terminals starte ### 3.1 Der Importer (`scripts.import_markdown`) Dies ist das komplexeste Modul. -* **Einstieg:** `scripts/import_markdown.py` -> `main()`. +* **Einstieg:** `scripts/import_markdown.py` -> `main_async()`. +* **Async & Semaphore:** Das Skript nutzt nun `asyncio` und eine Semaphore (Limit: 5), um parallele Embeddings zu erzeugen, ohne Ollama zu überlasten. * **Idempotenz:** Der Importer muss mehrfach laufen können, ohne Duplikate zu erzeugen. Wir nutzen deterministische IDs (UUIDv5). * **Debugging:** Nutze `--dry-run` oder `scripts/payload_dryrun.py`. @@ -161,9 +171,15 @@ Hier passiert das Scoring. ### 3.4 Das Frontend (`app.frontend.ui`) Eine Streamlit-App (WP10). +* **Resurrection Pattern:** Das UI nutzt ein spezielles State-Management, um Eingaben bei Tab-Wechseln (Chat <-> Editor) zu erhalten. Widgets synchronisieren sich mit `st.session_state`. * **Draft Editor:** Enthält einen YAML-Sanitizer (`normalize_meta_and_body`), der sicherstellt, dass LLM-Halluzinationen im Frontmatter nicht das File zerstören. -* **State:** Nutzt `st.session_state` für Chat-History und Drafts. -* **Logik:** Ruft `/chat` und `/feedback` Endpoints der API auf. +* **Logik:** Ruft `/chat` und `/feedback` und `/ingest/analyze` Endpoints der API auf. + +### 3.5 Embedding Service (`app.services.embeddings_client`) +**Neu in v2.4:** +* Nutzt `httpx.AsyncClient` für non-blocking Calls an Ollama. +* Unterstützt dediziertes Embedding-Modell (`nomic-embed-text`) getrennt vom Chat-Modell. +* Enthält Legacy-Funktion `embed_text` für synchrone Skripte. --- @@ -201,6 +217,9 @@ Prüfen das laufende System gegen eine echte Qdrant-Instanz und Ollama. # 3. Feedback Test python tests/test_feedback_smoke.py --url http://localhost:8002/query + # 4. Intelligence Test (WP11) + python debug_analysis.py + --- ## 5. Das "Teach-the-AI" Paradigma (Context Intelligence) @@ -263,6 +282,7 @@ Der `One-Shot Extractor` (Prompt Template) liest diese Liste dynamisch und weist **DB komplett zurücksetzen (Vorsicht!):** + # --yes überspringt die Bestätigung python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet_dev" --yes **Einen einzelnen File inspizieren (Parser-Sicht):** diff --git a/docs/mindnet_functional_architecture.md b/docs/mindnet_functional_architecture.md index 25dfff5..32b5dd2 100644 --- a/docs/mindnet_functional_architecture.md +++ b/docs/mindnet_functional_architecture.md @@ -1,9 +1,9 @@ # Mindnet v2.4 – Fachliche Architektur **Datei:** `docs/mindnet_functional_architecture_v2.4.md` -**Stand:** 2025-12-10 -**Status:** **FINAL** (Integrierter Stand WP01–WP10 + WP07) +**Stand:** 2025-12-11 +**Status:** **FINAL** (Integrierter Stand WP01–WP11: Async Intelligence) -> Dieses Dokument beschreibt **was** Mindnet fachlich tut und **warum** – mit Fokus auf die Erzeugung und Nutzung von **Edges** (Kanten), die Logik des Retrievers und den **RAG-Chat** (Decision Engine, Interview-Modus & Persönlichkeit). Die technische Umsetzung wird im technischen Dokument detailliert. +> Dieses Dokument beschreibt **was** Mindnet fachlich tut und **warum** – mit Fokus auf die Erzeugung und Nutzung von **Edges** (Kanten), die Logik des Retrievers und den **RAG-Chat** (Decision Engine, Interview-Modus & Persönlichkeit). ---
@@ -19,6 +19,7 @@ - [2.1 Struktur-Kanten (Das Skelett)](#21-struktur-kanten-das-skelett) - [2.2 Inhalts-Kanten (explizit)](#22-inhalts-kanten-explizit) - [2.3 Typ-basierte Default-Kanten (Regelbasiert)](#23-typ-basierte-default-kanten-regelbasiert) + - [2.4 Matrix-Logik (Kontextsensitive Kanten) – Neu in v2.4](#24-matrix-logik-kontextsensitive-kanten--neu-in-v24) - [3) Edge-Payload – Felder \& Semantik](#3-edge-payload--felder--semantik) - [4) Typ-Registry (`config/types.yaml`)](#4-typ-registry-configtypesyaml) - [4.1 Zweck](#41-zweck) @@ -26,12 +27,13 @@ - [5) Der Retriever (Funktionaler Layer)](#5-der-retriever-funktionaler-layer) - [5.1 Scoring-Modell](#51-scoring-modell) - [5.2 Erklärbarkeit (Explainability) – WP04b](#52-erklärbarkeit-explainability--wp04b) - - [6) Context Intelligence \& Intent Router (WP06/WP07)](#6-context-intelligence--intent-router-wp06wp07) + - [6) Context Intelligence \& Intent Router (WP06–WP11)](#6-context-intelligence--intent-router-wp06wp11) - [6.1 Das Problem: Statische vs. Dynamische Antworten](#61-das-problem-statische-vs-dynamische-antworten) - [6.2 Der Intent-Router (Keyword \& Semantik)](#62-der-intent-router-keyword--semantik) - [6.3 Strategic Retrieval (Injektion von Werten)](#63-strategic-retrieval-injektion-von-werten) - [6.4 Reasoning (Das Gewissen)](#64-reasoning-das-gewissen) - - [6.5 Der Interview-Modus (One-Shot Extraction) – Neu in v2.4](#65-der-interview-modus-one-shot-extraction--neu-in-v24) + - [6.5 Der Interview-Modus (One-Shot Extraction)](#65-der-interview-modus-one-shot-extraction) + - [6.6 Active Intelligence (Link Suggestions) – Neu in v2.4](#66-active-intelligence-link-suggestions--neu-in-v24) - [7) Future Concepts: The Empathic Digital Twin (Ausblick)](#7-future-concepts-the-empathic-digital-twin-ausblick) - [7.1 Antizipation durch Erfahrung](#71-antizipation-durch-erfahrung) - [7.2 Empathie \& "Ich"-Modus](#72-empathie--ich-modus) @@ -45,7 +47,7 @@ - [11) Semantik ausgewählter `kind`-Werte](#11-semantik-ausgewählter-kind-werte) - [12) Frontmatter-Eigenschaften – Rolle \& Empfehlung](#12-frontmatter-eigenschaften--rolle--empfehlung) - [13) Lösch-/Update-Garantien (Idempotenz)](#13-lösch-update-garantien-idempotenz) - - [14) Beispiel – Von Markdown zu Kanten (v2.2)](#14-beispiel--von-markdown-zu-kanten-v22) + - [14) Beispiel – Von Markdown zu Kanten](#14-beispiel--von-markdown-zu-kanten) - [15) Referenzen (Projektdateien \& Leitlinien)](#15-referenzen-projektdateien--leitlinien) - [16) Workpackage Status (v2.4.0)](#16-workpackage-status-v240) @@ -61,7 +63,7 @@ Die drei zentralen Artefakt-Sammlungen lauten: - `mindnet_chunks` – semantische Teilstücke einer Note (Fenster/„Chunks“) - `mindnet_edges` – gerichtete Beziehungen zwischen Knoten (Chunks/Notes) -Die Import-Pipeline erzeugt diese Artefakte **deterministisch** und **idempotent** (erneute Läufe überschreiben konsistent statt zu duplizieren). Die Import-Schritte sind: *parse → chunk → edge → upsert*. +Die Import-Pipeline (seit v2.3.10 asynchron) erzeugt diese Artefakte **deterministisch** und **idempotent** (erneute Läufe überschreiben konsistent statt zu duplizieren). Die Import-Schritte sind: *parse → chunk → embed → edge → upsert*. --- @@ -77,6 +79,7 @@ Die Import-Pipeline erzeugt diese Artefakte **deterministisch** und **idempotent - Ausschnitt/Textfenster aus der Note, als eigenständiger Such-Anker. - Jeder Chunk gehört **genau einer** Note. - Chunks bilden eine Sequenz (1…N) – das ermöglicht *next/prev*. +- **Update v2.4:** Chunks werden jetzt durch das Modell `nomic-embed-text` in **768-dimensionale Vektoren** umgewandelt. Dies erlaubt eine deutlich höhere semantische Auflösung als frühere Modelle (384 Dim). - **Neu in v2.2:** Alle Kanten entstehen ausschließlich zwischen Chunks (Scope="chunk"), nie zwischen Notes direkt. Notes dienen nur noch als Metadatencontainer. > **Wichtig:** Chunking-Profile (short/medium/long) kommen aus `types.yaml` (per Note-Typ), können aber lokal überschrieben werden. Die effektiven Werte werden bei der Payload-Erzeugung bestimmt. @@ -128,13 +131,23 @@ Regel: **Für jede gefundene explizite Referenz** (s. o.) werden **zusätzliche* Beispiel: Ein *project* mit `edge_defaults=["depends_on"]` erzeugt zu *jedem* explizit referenzierten Ziel **zusätzlich** eine `depends_on`-Kante. Diese Kanten tragen *provenance=rule* und eine **rule_id** der Form `edge_defaults:{note_type}:{relation}` sowie eine geringere Confidence (~0.7). +### 2.4 Matrix-Logik (Kontextsensitive Kanten) – Neu in v2.4 +Mit WP-11 wurde eine Intelligenz eingeführt, die Kanten-Typen nicht nur anhand des Quell-Typs, sondern auch anhand des Ziel-Typs bestimmt ("Matrix"). + +**Beispiel für `Source Type: experience`:** +* Wenn Ziel ist `value` -> Kante: `based_on` +* Wenn Ziel ist `principle` -> Kante: `derived_from` +* Wenn Ziel ist `project` -> Kante: `related_to` + +Dies ermöglicht im Graphen präzise Abfragen wie "Zeige alle Erfahrungen, die auf Wert X basieren" (via `based_on`), was mit generischen `related_to` Kanten nicht möglich wäre. + --- ## 3) Edge-Payload – Felder & Semantik Jede Kante hat mindestens: -- `kind` – Beziehungsart *(belongs_to, next, prev, references, related_to, depends_on, similar_to, …)* +- `kind` – Beziehungsart *(belongs_to, next, prev, references, related_to, depends_on, similar_to, based_on, uses, …)* - `scope` – `"chunk"` (Standard in v2.2) - `source_id`, `target_id` – Quell-/Ziel-Knoten (Chunk-IDs oder Note-Titel bei unresolved Targets) - `note_id` – **Owner-Note** (die Note, aus der die Kante stammt) @@ -209,9 +222,9 @@ Die API gibt diese Analysen als menschenlesbare Sätze (`reasons`) und als Daten --- -## 6) Context Intelligence & Intent Router (WP06/WP07) +## 6) Context Intelligence & Intent Router (WP06–WP11) -Seit WP06/WP07 agiert Mindnet nicht mehr statisch, sondern passt seine Suchstrategie dem **Intent** (der Absicht) des Nutzers an. Dies ist die Transformation vom reinen Wissens-Abrufer zum strategischen Partner. +Seit WP06 agiert Mindnet nicht mehr statisch, sondern passt seine Suchstrategie dem **Intent** (der Absicht) des Nutzers an. Dies ist die Transformation vom reinen Wissens-Abrufer zum strategischen Partner. ### 6.1 Das Problem: Statische vs. Dynamische Antworten * **Früher (Pre-WP06):** Jede Frage ("Was ist X?" oder "Soll ich X?") wurde gleich behandelt -> Fakten-Retrieval. @@ -223,7 +236,7 @@ Der Router prüft vor jeder Antwort die Absicht über konfigurierbare Strategien 1. **FACT:** Reine Wissensfrage ("Was ist Qdrant?"). → Standard RAG. 2. **DECISION:** Frage nach Rat oder Strategie ("Soll ich Qdrant nutzen?"). → Aktiviert die Decision Engine. 3. **EMPATHY:** Emotionale Zustände ("Ich bin gestresst"). → Aktiviert den empathischen Modus. -4. **INTERVIEW (Neu in WP07):** Wunsch, Wissen zu erfassen ("Neues Projekt anlegen"). → Aktiviert den Draft-Generator. +4. **INTERVIEW (WP07):** Wunsch, Wissen zu erfassen ("Neues Projekt anlegen"). → Aktiviert den Draft-Generator. 5. **CODING:** Technische Anfragen. ### 6.3 Strategic Retrieval (Injektion von Werten) @@ -236,7 +249,7 @@ Im Modus `DECISION` führt das System eine **zweite Suchstufe** aus. Es sucht ni Das LLM erhält im Prompt die explizite Anweisung: *"Wäge die Fakten (aus der Suche) gegen die injizierten Werte ab."* Dadurch entstehen Antworten, die nicht nur technisch korrekt sind, sondern subjektiv passend ("Tool X passt nicht zu deinem Ziel Z"). -### 6.5 Der Interview-Modus (One-Shot Extraction) – Neu in v2.4 +### 6.5 Der Interview-Modus (One-Shot Extraction) Wenn der User Wissen erfassen will ("Ich möchte ein neues Projekt anlegen"), wechselt Mindnet in den **Interview-Modus**. * **Late Binding Schema:** Das System lädt ein konfiguriertes Schema für den Ziel-Typ (z.B. `project`: Pflichtfelder sind Titel, Ziel, Status). @@ -244,6 +257,14 @@ Wenn der User Wissen erfassen will ("Ich möchte ein neues Projekt anlegen"), we * **Draft-Status:** Fehlende Pflichtfelder werden mit `[TODO]` markiert. * **UI-Integration:** Das Frontend rendert statt einer Chat-Antwort einen **interaktiven Editor** (WP10), in dem der Entwurf finalisiert werden kann. +### 6.6 Active Intelligence (Link Suggestions) – Neu in v2.4 +Im **Draft Editor** (Frontend) unterstützt das System den Autor aktiv. +* **Analyse:** Ein "Sliding Window" scannt den Text im Hintergrund (auch lange Entwürfe). +* **Erkennung:** Es findet Begriffe ("Mindnet") und semantische Konzepte ("Autofahrt in Italien"). +* **Matching:** Es prüft gegen den Index (Aliases und Vektoren). +* **Vorschlag:** Es bietet fertige Markdown-Links an (z.B. `[[rel:related_to ...]]`), die per Klick eingefügt werden. +* **Logik:** Dabei kommt die in 2.4 beschriebene **Matrix-Logik** zum Einsatz, um den korrekten Kanten-Typ vorzuschlagen. + --- ## 7) Future Concepts: The Empathic Digital Twin (Ausblick) @@ -353,6 +374,10 @@ Eine typische Gewichtung (konfigurierbar in `retriever.yaml`) ist: - `related_to` – Ähnlichkeit/Verwandtschaft (symmetrisch interpretierbar). - `similar_to` – noch engere Ähnlichkeit; oft aus Inline-Rel (bewusst gesetzt). - `depends_on` – fachliche Abhängigkeit (z. B. „Projekt X hängt von Y ab“). +- **Neu in v2.4 (Matrix):** + - `based_on` – Erfahrung basiert auf Wert. + - `derived_from` – Erkenntnis stammt aus Prinzip. + - `uses` – Projekt nutzt Konzept. - `belongs_to`, `next`, `prev` – Struktur. > Symmetrische Relationen (z. B. `related_to`, `similar_to`) können **explizit** nur einseitig notiert sein, aber im Retriever beidseitig interpretiert werden. @@ -377,7 +402,7 @@ Frontmatter-Eigenschaften (Properties) bleiben **minimiert**: --- -## 14) Beispiel – Von Markdown zu Kanten (v2.2) +## 14) Beispiel – Von Markdown zu Kanten **Markdown (Auszug)** # Relations Showcase @@ -406,6 +431,7 @@ Frontmatter-Eigenschaften (Properties) bleiben **minimiert**: - Decision Engine: `config/decision_engine.yaml`. - Logging Service: `app/services/feedback_service.py`. - Frontend UI: `app/frontend/ui.py`. +- Intelligence Logic: `app/services/discovery.py`. --- @@ -425,5 +451,6 @@ Aktueller Implementierungsstand der Module. | **WP06** | Decision Engine | 🟢 Live | Intent-Router & Strategic Retrieval. | | **WP07** | Interview Assistent | 🟢 Live | **One-Shot Extractor & Schemas aktiv.** | | **WP08** | Self-Tuning | 🔴 Geplant | Auto-Adjustment der Gewichte. | -| **WP10** | Chat Interface | 🟢 Live | Web-UI mit Feedback & Intents. | -| **WP10a**| Draft Editor | 🟢 Live | **Interaktiver Editor für WP07 Drafts.** | \ No newline at end of file +| **WP10** | Chat Interface | 🟢 Live | Web-Interface (Streamlit). | +| **WP10a**| Draft Editor | 🟢 Live | **Interaktives UI für WP07 Drafts.** | +| **WP11** | Backend Intelligence | 🟢 Live | **Async Ingestion, Nomic Embeddings, Matrix Logic.** | \ No newline at end of file diff --git a/docs/mindnet_technical_architecture.md b/docs/mindnet_technical_architecture.md index 7636dd6..875bc98 100644 --- a/docs/mindnet_technical_architecture.md +++ b/docs/mindnet_technical_architecture.md @@ -1,7 +1,7 @@ # Mindnet v2.4 – Technische Architektur **Datei:** `docs/mindnet_technical_architecture_v2.4.md` -**Stand:** 2025-12-10 -**Status:** **FINAL** (Integrierter Stand WP01–WP10 + WP07) +**Stand:** 2025-12-11 +**Status:** **FINAL** (Integrierter Stand WP01–WP11: Async Intelligence) **Quellen:** `Programmplan_V2.2.md`, `Handbuch.md`, `chunking_strategy.md`, `wp04_retriever_scoring.md`. > **Ziel dieses Dokuments:** @@ -27,7 +27,7 @@ - [3.4 Prompts (`config/prompts.yaml`)](#34-prompts-configpromptsyaml) - [3.5 Environment (`.env`)](#35-environment-env) - [4. Import-Pipeline (Markdown → Qdrant)](#4-import-pipeline-markdown--qdrant) - - [4.1 Verarbeitungsschritte](#41-verarbeitungsschritte) + - [4.1 Verarbeitungsschritte (Async)](#41-verarbeitungsschritte-async) - [5. Retriever-Architektur \& Scoring](#5-retriever-architektur--scoring) - [5.1 Betriebsmodi](#51-betriebsmodi) - [5.2 Scoring-Formel (WP04a)](#52-scoring-formel-wp04a) @@ -43,7 +43,8 @@ - [7.1 Kommunikation](#71-kommunikation) - [7.2 Features \& UI-Logik](#72-features--ui-logik) - [7.3 Draft-Editor \& Sanitizer (Neu in WP10a)](#73-draft-editor--sanitizer-neu-in-wp10a) - - [7.4 Deployment Ports](#74-deployment-ports) + - [7.4 State Management (Resurrection Pattern)](#74-state-management-resurrection-pattern) + - [7.5 Deployment Ports](#75-deployment-ports) - [8. Feedback \& Logging Architektur (WP04c)](#8-feedback--logging-architektur-wp04c) - [8.1 Komponenten](#81-komponenten) - [8.2 Log-Dateien](#82-log-dateien) @@ -65,8 +66,9 @@ Mindnet ist ein **lokales RAG-System (Retrieval Augmented Generation)** mit Web- * **Qdrant:** Vektor-Datenbank für Graph und Semantik (Collections: notes, chunks, edges). * **Local Files (JSONL):** Append-Only Logs für Feedback und Search-History (Data Flywheel). 4. **Backend:** Eine FastAPI-Anwendung stellt Endpunkte für **Semantische** und **Hybride Suche** sowie **Feedback** bereit. -5. **Frontend:** Streamlit-App (`ui.py`) für Interaktion und Visualisierung inkl. **Draft Editor**. -6. **Inference:** Lokales LLM (Ollama: Phi-3 Mini) für RAG-Chat und Antwortgenerierung. + * **Update v2.3.10:** Der Core arbeitet nun vollständig **asynchron (AsyncIO)**, um Blockaden bei Embedding-Requests zu vermeiden. +5. **Frontend:** Streamlit-App (`ui.py`) für Interaktion und Visualisierung inkl. **Draft Editor** und **Intelligence-Features**. +6. **Inference:** Lokales LLM (Ollama: Phi-3 Mini) für RAG-Chat und Antwortgenerierung. Embedding via `nomic-embed-text`. Das System arbeitet **deterministisch** (stabile IDs) und ist **konfigurationsgetrieben** (`types.yaml`, `retriever.yaml`, `decision_engine.yaml`, `prompts.yaml`). @@ -76,6 +78,7 @@ Das System arbeitet **deterministisch** (stabile IDs) und ist **konfigurationsge ├── app/ │ ├── main.py # FastAPI Einstiegspunkt │ ├── core/ + │ │ ├── ingestion.py # NEU: Async Ingestion Service (WP11) │ │ ├── qdrant.py # Client-Factory & Connection │ │ ├── qdrant_points.py # Low-Level Point Operations (Upsert/Delete) │ │ ├── note_payload.py # Bau der Note-Objekte @@ -88,13 +91,14 @@ Das System arbeitet **deterministisch** (stabile IDs) und ist **konfigurationsge │ ├── models/ # Pydantic DTOs │ ├── routers/ │ │ ├── query.py # Such-Endpunkt + │ │ ├── ingest.py # NEU: API für Save & Analyze (WP11) │ │ ├── chat.py # Hybrid Router & Interview Logic (WP06/07) │ │ ├── feedback.py # Feedback-Endpunkt (WP04c) │ │ └── ... │ ├── services/ - │ │ ├── llm_service.py # Ollama Client mit Timeout & Raw-Mode - │ │ ├── feedback_service.py # JSONL Logging (WP04c) - │ │ └── embeddings_client.py + │ │ ├── llm_service.py # Ollama Chat Client + │ │ ├── embeddings_client.py# NEU: Async Embedding Client (HTTPX) + │ │ └── feedback_service.py # JSONL Logging (WP04c) │ ├── frontend/ # NEU (WP10) │ └── ui.py # Streamlit Application inkl. Sanitizer ├── config/ @@ -105,7 +109,7 @@ Das System arbeitet **deterministisch** (stabile IDs) und ist **konfigurationsge ├── data/ │ └── logs/ # Lokale JSONL-Logs (WP04c) ├── scripts/ - │ ├── import_markdown.py # Haupt-Importer CLI + │ ├── import_markdown.py # Haupt-Importer CLI (Async) │ ├── payload_dryrun.py # Diagnose: JSON-Generierung ohne DB │ └── edges_full_check.py # Diagnose: Graph-Integrität └── tests/ # Pytest Suite @@ -136,6 +140,7 @@ Repräsentiert die Metadaten einer Datei. ### 2.2 Chunks Collection (`_chunks`) Die atomaren Sucheinheiten. * **Zweck:** Vektorsuche (Embeddings), Granulares Ergebnis. +* **Update v2.3.10:** Vektor-Dimension ist jetzt **768** (für `nomic-embed-text`). * **Schema (Payload):** | Feld | Datentyp | Beschreibung | @@ -204,37 +209,40 @@ Steuert die LLM-Persönlichkeit und Templates. * Enthält Templates für alle Strategien inkl. `interview_template` mit One-Shot Logik. ### 3.5 Environment (`.env`) -Erweiterung für LLM-Steuerung: +Erweiterung für LLM-Steuerung und Embedding-Modell: MINDNET_LLM_MODEL=phi3:mini + MINDNET_EMBEDDING_MODEL=nomic-embed-text # NEU in v2.3.10 MINDNET_OLLAMA_URL=http://127.0.0.1:11434 MINDNET_LLM_TIMEOUT=300.0 # Neu: Erhöht für CPU-Inference Cold-Starts + MINDNET_API_TIMEOUT=60.0 # Neu: Timeout für Frontend-API Calls MINDNET_DECISION_CONFIG="config/decision_engine.yaml" + MINDNET_VAULT_ROOT="./vault" # Neu: Pfad für Write-Back --- ## 4. Import-Pipeline (Markdown → Qdrant) Das Skript `scripts/import_markdown.py` orchestriert den Prozess. +**Neu in v2.3.10:** Der Import nutzt `asyncio` und eine **Semaphore**, um Ollama nicht zu überlasten. -### 4.1 Verarbeitungsschritte +### 4.1 Verarbeitungsschritte (Async) 1. **Discovery & Parsing:** * Einlesen der `.md` Dateien. Hash-Vergleich (Body/Frontmatter) zur Erkennung von Änderungen. 2. **Typauflösung:** - * Laden der `types.yaml`. Bestimmen des effektiven Typs und der `edge_defaults`. + * Bestimmung des `type` via `types.yaml`. 3. **Chunking:** - * Zerlegung via `chunker.py` basierend auf `chunk_profile` (z.B. `by_heading`, `short`, `long`). - * Trennung von `text` (Kern) und `window` (Embedding-Kontext). -4. **Kantenableitung (Edge Derivation):** - Die `derive_edges.py` erzeugt Kanten in strikter Reihenfolge: - 1. **Inline-Edges:** `[[rel:depends_on X]]` → `kind=depends_on`, `rule_id=inline:rel`, `conf=0.95`. - 2. **Callout-Edges:** `> [!edge] related_to: [[X]]` → `kind=related_to`, `rule_id=callout:edge`, `conf=0.90`. - 3. **Explizite Referenzen:** `[[X]]` → `kind=references`, `rule_id=explicit:wikilink`, `conf=1.0`. - 4. **Typ-Defaults:** Für jede Referenz werden Zusatzkanten gemäß `edge_defaults` erzeugt (z.B. `project` -> `depends_on`). `rule_id=edge_defaults:...`, `conf=0.7`. - 5. **Struktur:** `belongs_to`, `next`, `prev` (automatisch). -5. **Upsert:** - * Schreiben in Qdrant. Nutzung von `--purge-before-upsert` für saubere Updates. + * Zerlegung via `chunker.py` basierend auf `chunk_profile`. +4. **Embedding (Async):** + * Der `EmbeddingsClient` (`app/services/embeddings_client.py`) sendet Text-Chunks asynchron an Ollama. + * Modell: `nomic-embed-text` (768d). + * Semaphore: Max. 5 gleichzeitige Files, um OOM (Out-of-Memory) zu verhindern. +5. **Kantenableitung (Edge Derivation):** + * `derive_edges.py` erzeugt Inline-, Callout- und Default-Edges. +6. **Upsert:** + * Schreiben in Qdrant. Nutzung von `--purge-before-upsert`. + * **Strict Mode:** Der Prozess bricht ab, wenn Embeddings leer sind oder Dimension `0` haben. --- @@ -243,7 +251,7 @@ Das Skript `scripts/import_markdown.py` orchestriert den Prozess. Der Retriever (`app/core/retriever.py`) unterstützt zwei Modi. Für den Chat wird **zwingend** der Hybrid-Modus genutzt. ### 5.1 Betriebsmodi -* **Semantic:** Reine Vektorsuche. Schnell. +* **Semantic:** Reine Vektorsuche (768d). * **Hybrid:** Vektorsuche + Graph-Expansion (Tiefe N) + Re-Ranking. ### 5.2 Scoring-Formel (WP04a) @@ -274,7 +282,7 @@ Der Hybrid-Modus lädt dynamisch die Nachbarschaft der Top-K Vektor-Treffer ("Se --- -## 6. RAG & Chat Architektur (WP06 Hybrid Router + WP07 Interview) +## 6. RAG \& Chat Architektur (WP06 Hybrid Router + WP07 Interview) Der Flow für eine Chat-Anfrage (`/chat`) wurde in WP06 auf eine **Configuration-Driven Architecture** umgestellt. Der `ChatRouter` (`app/routers/chat.py`) fungiert als zentraler Dispatcher. @@ -329,7 +337,7 @@ Das Frontend ist eine **Streamlit-Anwendung** (`app/frontend/ui.py`), die als se ### 7.1 Kommunikation * **Backend-URL:** Konfiguriert via `MINDNET_API_URL` (Default: `http://localhost:8002`). -* **Endpoints:** Nutzt `/chat` für Interaktion und `/feedback` für Bewertungen. +* **Endpoints:** Nutzt `/chat` für Interaktion, `/feedback` für Bewertungen und `/ingest/analyze` für Intelligence. * **Resilienz:** Das Frontend implementiert eigene Timeouts (`MINDNET_API_TIMEOUT`, Default 300s). ### 7.2 Features & UI-Logik @@ -350,7 +358,14 @@ Wenn der Intent `INTERVIEW` ist, rendert die UI statt einer Textblase den **Draf 3. **Editor Widget:** `st.text_area` erlaubt das Bearbeiten des Inhalts vor dem Speichern. 4. **Action:** Buttons zum Download oder Kopieren des fertigen Markdowns. -### 7.4 Deployment Ports +### 7.4 State Management (Resurrection Pattern) +Um Datenverlust bei Tab-Wechseln (Chat <-> Editor) zu verhindern, nutzt `ui.py` ein Persistenz-Muster: +* Daten liegen in `st.session_state[data_key]`. +* Widgets liegen in `st.session_state[widget_key]`. +* Callbacks (`on_change`) synchronisieren Widget -> Data. +* Beim Neu-Rendern wird Widget-State aus Data-State wiederhergestellt. + +### 7.5 Deployment Ports Zur sauberen Trennung von Prod und Dev laufen Frontend und Backend auf dedizierten Ports: | Umgebung | Backend (FastAPI) | Frontend (Streamlit) | diff --git a/docs/pipeline_playbook.md b/docs/pipeline_playbook.md index 2834ee3..ac1170a 100644 --- a/docs/pipeline_playbook.md +++ b/docs/pipeline_playbook.md @@ -1,7 +1,7 @@ # mindnet v2.4 – Pipeline Playbook **Datei:** `docs/mindnet_pipeline_playbook_v2.4.md` -**Stand:** 2025-12-10 -**Status:** **FINAL** (Inkl. WP07 Interview & WP10a Editor) +**Stand:** 2025-12-11 +**Status:** **FINAL** (Inkl. Async Ingestion & Active Intelligence) **Quellen:** `mindnet_v2_implementation_playbook.md`, `Handbuch.md`, `chunking_strategy.md`, `docs_mindnet_retriever.md`, `mindnet_admin_guide_v2.4.md`. --- @@ -12,7 +12,7 @@ - [](#) - [1. Zweck \& Einordnung](#1-zweck--einordnung) - [2. Die Import-Pipeline (Runbook)](#2-die-import-pipeline-runbook) - - [2.1 Der 12-Schritte-Prozess](#21-der-12-schritte-prozess) + - [2.1 Der 12-Schritte-Prozess (Async)](#21-der-12-schritte-prozess-async) - [2.2 Standard-Betrieb (Inkrementell)](#22-standard-betrieb-inkrementell) - [2.3 Deployment \& Restart (Systemd)](#23-deployment--restart-systemd) - [2.4 Full Rebuild (Clean Slate)](#24-full-rebuild-clean-slate) @@ -27,6 +27,7 @@ - [5.2 Intent Router (WP06/07)](#52-intent-router-wp0607) - [5.3 Context Enrichment](#53-context-enrichment) - [5.4 Generation (LLM)](#54-generation-llm) + - [5.5 Active Intelligence Pipeline (Neu in v2.4)](#55-active-intelligence-pipeline-neu-in-v24) - [6. Feedback \& Lernen (WP04c)](#6-feedback--lernen-wp04c) - [7. Quality Gates \& Tests](#7-quality-gates--tests) - [7.1 Pflicht-Tests vor Commit](#71-pflicht-tests-vor-commit) @@ -44,7 +45,7 @@ Dieses Playbook ist das zentrale operative Handbuch für die **mindnet-Pipeline* **Zielgruppe:** Dev/Ops, Tech-Leads. **Scope:** -* **Ist-Stand (WP01–WP10a):** Import, Chunking, Edge-Erzeugung, Hybrider Retriever, RAG-Chat (Hybrid Router), Feedback Loop, Frontend, Draft Editor. +* **Ist-Stand (WP01–WP11):** Async Import, Chunking, Edge-Erzeugung, Hybrider Retriever, RAG-Chat (Hybrid Router), Feedback Loop, Frontend, Draft Editor, Active Intelligence. * **Roadmap (Ausblick):** Technische Skizze für Self-Tuning (WP08). --- @@ -53,8 +54,8 @@ Dieses Playbook ist das zentrale operative Handbuch für die **mindnet-Pipeline* Der Import ist der kritischste Prozess ("Data Ingestion"). Er muss **deterministisch** und **idempotent** sein. Wir nutzen `scripts/import_markdown.py` als zentralen Entrypoint. -### 2.1 Der 12-Schritte-Prozess -Gemäß WP03-Spezifikation läuft der Import intern wie folgt ab: +### 2.1 Der 12-Schritte-Prozess (Async) +Seit v2.3.10 läuft der Import **asynchron**, um Netzwerk-Blockaden bei der Embedding-Generierung zu vermeiden. 1. **Markdown lesen:** Rekursives Scannen des Vaults. 2. **Frontmatter extrahieren:** Validierung von Pflichtfeldern (`id`, `type`, `title`). @@ -65,8 +66,10 @@ Gemäß WP03-Spezifikation läuft der Import intern wie folgt ab: 7. **Callout-Kanten finden:** Parsing von `> [!edge]` Blöcken. 8. **Default-Edges erzeugen:** Anwendung der `edge_defaults` aus der Typ-Registry. 9. **Strukturkanten erzeugen:** `belongs_to` (Chunk->Note), `next`/`prev` (Sequenz). -10. **Chunks upserten:** Schreiben in Qdrant (`mindnet_chunks`). -11. **Edges upserten:** Schreiben in Qdrant (`mindnet_edges`). +10. **Embedding & Upsert (Async):** + * Das System nutzt eine **Semaphore** (Limit: 5 Files concurrent), um Ollama nicht zu überlasten. + * Generierung der Vektoren via `nomic-embed-text` (768 Dim). +11. **Strict Mode:** Der Prozess bricht sofort ab, wenn ein Embedding leer ist oder die Dimension `0` hat. 12. **Diagnose:** Automatischer Check der Integrität nach dem Lauf. ### 2.2 Standard-Betrieb (Inkrementell) @@ -99,13 +102,18 @@ Nach einem Import oder Code-Update müssen die API-Prozesse neu gestartet werden sudo systemctl status mindnet-prod ### 2.4 Full Rebuild (Clean Slate) -Notwendig bei Änderungen an `types.yaml` (z.B. neue Chunk-Größen) oder Embedding-Modellen. +Notwendig bei Änderungen an `types.yaml` (z.B. neue Chunk-Größen) oder beim Wechsel des Embedding-Modells (z.B. Update auf `nomic-embed-text`). - # 1. Qdrant Collections löschen und neu anlegen (Wipe inkl. Schema) +**WICHTIG:** Vorher das Modell pullen, sonst schlägt der Import fehl! + + # 0. Modell sicherstellen (WICHTIG für v2.4+) + ollama pull nomic-embed-text + + # 1. Qdrant Collections löschen und neu anlegen (Wipe inkl. Schema 768d) python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet" --yes # 2. Vollständiger Import aller Dateien - python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply + python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force --- @@ -177,6 +185,17 @@ Der Router (`chat.py`) reichert die gefundenen Chunks mit Metadaten an: * **Prompting:** Template wird basierend auf Intent gewählt (`decision_template`, `interview_template` etc.). * **One-Shot (WP07):** Im Interview-Modus generiert das LLM direkt einen Markdown-Block ohne Rückfragen. +### 5.5 Active Intelligence Pipeline (Neu in v2.4) +Ein paralleler Datenfluss im Frontend ("Draft Editor") zur Unterstützung des Autors. +1. **Trigger:** User klickt "Analyse starten" oder tippt. +2. **Service:** `ingest/analyze` (Backend). +3. **Discovery:** + * **Sliding Window:** Zerlegt Text in Abschnitte. + * **Embedding:** Vektorisiert Abschnitte via Nomic (Async). + * **Exact Match:** Sucht nach Aliases ("KI-Gedächtnis"). + * **Matrix Logic:** Bestimmt Kanten-Typ (`experience` -> `based_on` -> `value`). +4. **Feedback:** UI zeigt Vorschläge (`[[rel:...]]`) zum Einfügen an. + --- ## 6. Feedback & Lernen (WP04c) @@ -209,12 +228,12 @@ Prüft am laufenden System (Prod oder Dev), ob Semantik, Graph und Feedback funk # Retriever Test python scripts/test_retriever_smoke.py --mode hybrid --top-k 5 + # Intelligence Test (WP11) + curl -X POST "http://localhost:8002/ingest/analyze" -d '{"text": "mindnet", "type": "journal"}' + # Decision Engine Test (WP06) python tests/test_wp06_decision.py -p 8002 -e EMPATHY -q "Alles ist grau" - # Interview Test (WP07) - python tests/test_wp06_decision.py -p 8002 -e INTERVIEW -q "Neues Projekt starten" - # Feedback Test python tests/test_feedback_smoke.py --url http://localhost:8001/query @@ -250,4 +269,5 @@ Aktueller Implementierungsstand der Module. | **WP07** | Interview Assistent | 🟢 Live | **One-Shot Extractor & Schemas aktiv.** | | **WP08** | Self-Tuning | 🔴 Geplant | Auto-Adjustment der Gewichte. | | **WP10** | Chat Interface | 🟢 Live | Web-Interface (Streamlit). | -| **WP10a**| Draft Editor | 🟢 Live | **Interaktives UI für WP07 Drafts.** | \ No newline at end of file +| **WP10a**| Draft Editor | 🟢 Live | **Interaktives UI für WP07 Drafts.** | +| **WP11** | Backend Intelligence | 🟢 Live | **Async Ingestion, Nomic Embeddings, Matrix Logic.** | \ No newline at end of file diff --git a/docs/user_guide.md b/docs/user_guide.md index 09756d2..8f77940 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -1,7 +1,7 @@ # Mindnet v2.4 – User Guide **Datei:** `docs/mindnet_user_guide_v2.4.md` -**Stand:** 2025-12-10 -**Status:** **FINAL** (Inkl. RAG, Web-Interface & Interview-Assistent) +**Stand:** 2025-12-11 +**Status:** **FINAL** (Inkl. RAG, Web-Interface & Interview-Assistent & Intelligence) **Quellen:** `knowledge_design.md`, `wp04_retriever_scoring.md`, `Programmplan_V2.2.md`, `Handbuch.md`. > **Willkommen bei Mindnet.** @@ -42,6 +42,7 @@ Seit Version 2.3.1 bedienst du Mindnet über eine grafische Oberfläche im Brows ### 2.2 Die Sidebar (Einstellungen & Verlauf) * **Modus-Wahl:** Umschalten zwischen "💬 Chat" und "📝 Manueller Editor". + * *Neu in v2.4:* Der manuelle Editor speichert deine Eingaben auch beim Wechseln der Tabs ("State Resurrection"). * **Verlauf:** Die letzten Suchanfragen sind hier gelistet. Ein Klick führt die Suche erneut aus. * **Settings:** * **Top-K:** Wie viele Quellen sollen gelesen werden? (Standard: 5). @@ -68,7 +69,7 @@ Wenn du frustriert bist oder reflektieren willst, wechselt Mindnet in den "Ich"- * **Auslöser (Keywords & Semantik):** "Ich fühle mich...", "Traurig", "Gestresst", "Alles ist sinnlos", "Ich bin überfordert". * **Was passiert:** Mindnet lädt deine **Erfahrungen** (`type: experience`) und **Glaubenssätze** (`type: belief`). Es antwortet verständnisvoll und zitiert deine eigenen Lektionen. -### 3.3 Modus: Interview ("Der Analyst") – Neu! +### 3.3 Modus: Interview ("Der Analyst") Wenn du Wissen festhalten willst, statt zu suchen. * **Auslöser:** "Neues Projekt", "Notiz erstellen", "Ich will etwas festhalten", "Neue Entscheidung dokumentieren". @@ -128,4 +129,15 @@ Mindnet kann dir helfen, Markdown-Notizen zu schreiben. * Du siehst das generierte Frontmatter (`type: project`, `status: draft`). * Du siehst den Body-Text mit Platzhaltern (`[TODO]`), wo Infos fehlten (z.B. Stakeholder). 4. **Finalisierung:** Ergänze die fehlenden Infos direkt im Editor und klicke auf **Download** oder **Kopieren**. -5. **Speichern:** Speichere die Datei in deinen Obsidian Vault. Beim nächsten Import ist sie im System. \ No newline at end of file +5. **Speichern:** Speichere die Datei in deinen Obsidian Vault. Beim nächsten Import ist sie im System. + +### 6.4 Der Intelligence-Workflow (Neu in v2.4) +Wenn du Texte im **manuellen Editor** schreibst, unterstützt dich Mindnet aktiv bei der Vernetzung: + +1. **Schreiben:** Tippe deinen Text im Tab **"✏️ Inhalt"**. +2. **Analysieren:** Wechsle zum Tab **"🧠 Intelligence"** und klicke auf **"🔍 Analyse starten"**. Das System scannt deinen Text (Vektor-Suche & Exact Match). +3. **Vorschläge nutzen:** + * **Exakte Treffer:** Das System erkennt Begriffe wie "KI-Gedächtnis" automatisch als Alias für "Mindnet (System)". + * **Semantische Treffer:** Das System findet inhaltlich verwandte Notizen. + * **Klick auf "➕ Einfügen":** Fügt den Link (z.B. `[[rel:related_to Mindnet]]`) an der Cursor-Position oder am Ende ein. +4. **Speichern:** Klicke auf "💾 Speichern & Indizieren". Der Text wird sofort in den Vault geschrieben und in Qdrant indiziert. \ No newline at end of file