From 83bb18b6a76a9e1d8e80426e2213350358dbe6bb Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Dec 2025 15:40:39 +0100 Subject: [PATCH 01/24] code header --- app/__init__.py | 19 +- app/config.py | 8 +- app/core/chunk_payload.py | 14 +- app/core/chunker.py | 10 + app/core/derive_edges.py | 29 +- app/core/graph_adapter.py | 19 +- app/core/ingestion.py | 11 +- app/core/note_payload.py | 20 +- app/core/parser.py | 45 +--- app/core/qdrant.py | 30 +-- app/core/qdrant_points.py | 20 +- app/core/retriever.py | 10 +- app/core/type_registry.py | 33 +-- app/embeddings.py | 23 -- app/frontend/ui.py | 9 + app/frontend/ui_api.py | 9 + app/frontend/ui_callbacks.py | 9 + app/frontend/ui_chat.py | 9 + app/frontend/ui_config.py | 9 + app/frontend/ui_editor.py | 8 + app/frontend/ui_graph.py | 9 + app/frontend/ui_graph_cytoscape.py | 9 + app/frontend/ui_graph_service.py | 9 + app/frontend/ui_sidebar.py | 9 + app/frontend/ui_utils.py | 9 + app/graph/service.py | 172 ------------ app/main.py | 8 +- app/models/dto.py | 16 +- app/routers/admin.py | 22 +- app/routers/chat.py | 9 +- app/routers/embed_router.py | 7 +- app/routers/feedback.py | 8 +- app/routers/graph.py | 23 +- app/routers/ingest.py | 10 +- app/routers/query.py | 25 +- app/routers/tools.py | 23 +- app/services/discovery.py | 15 +- app/services/embeddings_client.py | 14 +- app/services/feedback_service.py | 11 +- app/services/llm_service.py | 9 +- app/services/semantic_analyzer.py | 8 +- docs/05_Development/05_developer_guide.md | 315 +++++++++++++--------- 42 files changed, 469 insertions(+), 615 deletions(-) delete mode 100644 app/embeddings.py delete mode 100644 app/graph/service.py diff --git a/app/__init__.py b/app/__init__.py index a382c5b..c0f2d40 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,17 +1,10 @@ """ -app — mindnet API package - -Zweck: - Markiert 'app/' als Python-Paket, damit 'from app.main import create_app' - in Tests und Skripten funktioniert. -Kompatibilität: - Python 3.12+ -Version: - 0.1.0 (Erstanlage) -Stand: - 2025-10-07 -Hinweise: - Keine Logik – nur Paketinitialisierung. +FILE: app/__init__.py +DESCRIPTION: Paket-Initialisierung. +VERSION: 0.1.0 +STATUS: Active +DEPENDENCIES: None +LAST_ANALYSIS: 2025-12-15 """ __version__ = "0.1.0" diff --git a/app/config.py b/app/config.py index 862e4f9..a860f2d 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,10 @@ """ -app/config.py — zentrale Konfiguration -Version: 0.4.0 (WP-06 Complete) +FILE: app/config.py +DESCRIPTION: Zentrale Pydantic-Konfiguration (Env-Vars für Qdrant, LLM, Retriever). +VERSION: 0.4.0 +STATUS: Active +DEPENDENCIES: os, functools, pathlib +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations import os diff --git a/app/core/chunk_payload.py b/app/core/chunk_payload.py index 1e56eda..5cc3000 100644 --- a/app/core/chunk_payload.py +++ b/app/core/chunk_payload.py @@ -1,11 +1,11 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ -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. +FILE: app/core/chunk_payload.py +DESCRIPTION: Baut das JSON-Objekt für 'mindnet_chunks'. Inkludiert Nachbarschafts-IDs (prev/next) und Titel. +VERSION: 2.0.0 +STATUS: Active +DEPENDENCIES: yaml, os +EXTERNAL_CONFIG: config/types.yaml +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations from typing import Any, Dict, List, Optional diff --git a/app/core/chunker.py b/app/core/chunker.py index 9e0c5fa..8348715 100644 --- a/app/core/chunker.py +++ b/app/core/chunker.py @@ -1,3 +1,13 @@ +""" +FILE: app/core/chunker.py +DESCRIPTION: Zerlegt Texte in Chunks (Sliding Window oder nach Headings). Orchestriert die Smart-Edge-Allocation via SemanticAnalyzer. +VERSION: 2.5.0 +STATUS: Active +DEPENDENCIES: app.services.semantic_analyzer, app.core.derive_edges, markdown_it, yaml, asyncio +EXTERNAL_CONFIG: config/types.yaml +LAST_ANALYSIS: 2025-12-15 +""" + from __future__ import annotations from dataclasses import dataclass from typing import List, Dict, Optional, Tuple, Any, Set diff --git a/app/core/derive_edges.py b/app/core/derive_edges.py index 6a89950..96e0ad0 100644 --- a/app/core/derive_edges.py +++ b/app/core/derive_edges.py @@ -1,26 +1,11 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ -Modul: app/core/derive_edges.py -Zweck: -- Bewahrt bestehende Edgelogik (belongs_to, prev/next, references, backlink) -- Ergänzt typenbasierte Default-Kanten (edge_defaults aus config/types.yaml) -- Unterstützt "typed inline relations": - * [[rel:KIND | Target]] - * [[rel:KIND Target]] - * rel: KIND [[Target]] -- Unterstützt Obsidian-Callouts: - * > [!edge] KIND: [[Target]] [[Target2]] ... -Kompatibilität: -- build_edges_for_note(...) Signatur unverändert -- rule_id Werte: - * structure:belongs_to - * structure:order - * explicit:wikilink - * inline:rel - * callout:edge - * edge_defaults:: - * derived:backlink +FILE: app/core/derive_edges.py +DESCRIPTION: Extrahiert Graph-Kanten aus Text. Unterstützt Wikilinks, Inline-Relations ([[rel:type|target]]) und Obsidian Callouts. +VERSION: 2.0.0 +STATUS: Active +DEPENDENCIES: re, os, yaml, typing +EXTERNAL_CONFIG: config/types.yaml +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/core/graph_adapter.py b/app/core/graph_adapter.py index 52452fc..e4b2cb7 100644 --- a/app/core/graph_adapter.py +++ b/app/core/graph_adapter.py @@ -1,17 +1,10 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ -app/core/graph_adapter.py — Adjazenzaufbau & Subgraph-Expansion - -Zweck: - Baut aus Qdrant-Edges (Collection: *_edges) einen leichten In-Memory-Graph. - -Kompatibilität: - - WP-04a: Liefert Scores (edge_bonus, centrality). - - WP-04b: Liefert jetzt auch Struktur-Daten für Erklärungen (Reverse-Lookup). - -Version: - 0.4.0 (Update für WP-04b: Reverse Adjacency für Explainability) +FILE: app/core/graph_adapter.py +DESCRIPTION: Lädt Kanten aus Qdrant und baut einen In-Memory Subgraphen für Scoring (Centrality) und Explanation. +VERSION: 0.4.0 +STATUS: Active +DEPENDENCIES: qdrant_client, app.core.qdrant +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/core/ingestion.py b/app/core/ingestion.py index 8035c5c..be690ba 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -1,8 +1,11 @@ """ -app/core/ingestion.py - -Zentraler Service für die Transformation von Markdown-Dateien in Qdrant-Objekte. -Version: 2.5.2 (Full Feature: Change Detection + Robust IO + Clean Config) +FILE: app/core/ingestion.py +DESCRIPTION: Haupt-Ingestion-Logik. Liest Markdown, prüft Hashes (Change Detection), zerlegt in Chunks und schreibt in Qdrant. +VERSION: 2.5.2 +STATUS: Active +DEPENDENCIES: app.core.parser, app.core.note_payload, app.core.chunker, app.core.derive_edges, app.core.qdrant*, app.services.embeddings_client +EXTERNAL_CONFIG: config/types.yaml +LAST_ANALYSIS: 2025-12-15 """ import os import logging diff --git a/app/core/note_payload.py b/app/core/note_payload.py index 285012f..36108a9 100644 --- a/app/core/note_payload.py +++ b/app/core/note_payload.py @@ -1,17 +1,11 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ -Modul: app/core/note_payload.py -Version: 2.1.0 (WP-11 Update: Aliases support) - -Zweck ------ -Erzeugt ein robustes Note-Payload. Werte wie `retriever_weight`, `chunk_profile` -und `edge_defaults` werden in folgender Priorität bestimmt: -1) Frontmatter (Note) -2) Typ-Registry (config/types.yaml: types..*) -3) Registry-Defaults (config/types.yaml: defaults.*) -4) ENV-Defaults (MINDNET_DEFAULT_RETRIEVER_WEIGHT / MINDNET_DEFAULT_CHUNK_PROFILE) +FILE: app/core/note_payload.py +DESCRIPTION: Baut das JSON-Objekt für 'mindnet_notes'. Wendet Vererbung für Configs an (Frontmatter > Type > Default). +VERSION: 2.1.0 +STATUS: Active +DEPENDENCIES: yaml, os, json, pathlib +EXTERNAL_CONFIG: config/types.yaml +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/core/parser.py b/app/core/parser.py index a2ad35f..9d106e8 100644 --- a/app/core/parser.py +++ b/app/core/parser.py @@ -1,43 +1,10 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ -Modul: app/core/parser.py -Version: 1.7.1 (fault-tolerant, API-kompatibel) -Datum: 2025-10-01 - -Zweck ------ -Fehlertolerantes Einlesen von Markdown-Dateien mit YAML-Frontmatter. -Kompatibel zur bisherigen Parser-API, aber robust gegenüber Nicht-UTF-8-Dateien: -- Versucht nacheinander: utf-8 → utf-8-sig → cp1252 → latin-1. -- Bei Fallback wird ein JSON-Warnhinweis auf stdout ausgegeben; der Import bricht NICHT ab. -- YAML-Frontmatter wird mit '---' am Anfang und '---' als Abschluss erkannt. -- extract_wikilinks() normalisiert [[id#anchor|label]] → 'id'. - -Öffentliche API (kompatibel): -- class ParsedNote(frontmatter: dict, body: str, path: str) -- read_markdown(path) -> ParsedNote | None -- normalize_frontmatter(fm) -> dict -- validate_required_frontmatter(fm, required: tuple[str,...]=("id","title")) -> None -- extract_wikilinks(text) -> list[str] -- FRONTMATTER_RE (Kompatibilitäts-Konstante; Regex für '---'-Zeilen) - -Beispiele ---------- - from app.core.parser import read_markdown, normalize_frontmatter, validate_required_frontmatter - parsed = read_markdown("./vault/30_projects/project-demo.md") - fm = normalize_frontmatter(parsed.frontmatter) - validate_required_frontmatter(fm) - body = parsed.body - - from app.core.parser import extract_wikilinks - links = extract_wikilinks(body) - -Abhängigkeiten --------------- -- PyYAML (yaml) - -Lizenz: MIT (projektintern) +FILE: app/core/parser.py +DESCRIPTION: Liest Markdown-Dateien fehlertolerant (Encoding-Fallback). Trennt Frontmatter (YAML) vom Body. +VERSION: 1.7.1 +STATUS: Active +DEPENDENCIES: yaml, re, dataclasses, json, io, os +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/core/qdrant.py b/app/core/qdrant.py index cc452b3..950a75d 100644 --- a/app/core/qdrant.py +++ b/app/core/qdrant.py @@ -1,28 +1,10 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ -app/core/qdrant.py -Version: 2.2.0 (2025-11-11) - -Aufgabe -------- -- Zentraler Qdrant-Zugriff (Client, Config) -- Collection-Anlage (notes/chunks/edges) -- **Payload-Indizes sicherstellen** (idempotent) - -Hinweis -------- -Diese Datei ist als Drop-in-Ersatz gedacht, falls in deinem Projekt noch keine -robuste ensure_payload_indexes()-Implementierung vorliegt. Die Signaturen -bleiben kompatibel zu scripts.import_markdown und scripts.reset_qdrant. - -API-Notizen ------------ -- Payload-Indizes werden mit `create_payload_index` angelegt. -- Typen stammen aus `qdrant_client.http.models.PayloadSchemaType`: - KEYWORD | TEXT | INTEGER | FLOAT | BOOL | GEO | DATETIME -- Für häufige Filterfelder (note_id, kind, scope, type, tags, ...) legen wir - Indizes an. Das ist laut Qdrant-Doku Best Practice für performante Filter. +FILE: app/core/qdrant.py +DESCRIPTION: Qdrant-Client Factory und Schema-Management. Erstellt Collections und Payload-Indizes. +VERSION: 2.2.0 +STATUS: Active +DEPENDENCIES: qdrant_client, dataclasses, os +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/core/qdrant_points.py b/app/core/qdrant_points.py index d2254e5..9c4b878 100644 --- a/app/core/qdrant_points.py +++ b/app/core/qdrant_points.py @@ -1,18 +1,10 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ -app/core/qdrant_points.py - robust points helpers for Qdrant - -- Single source of truth for building PointStruct for notes/chunks/edges -- Backward-compatible payloads for edges -- Handles both Single-Vector and Named-Vector collections -- Deterministic overrides via ENV to avoid auto-detection traps: - * NOTES_VECTOR_NAME, CHUNKS_VECTOR_NAME, EDGES_VECTOR_NAME - * MINDNET_VECTOR_NAME (fallback) - > Set to a concrete name (e.g. "text") to force Named-Vector with that name - > Set to "__single__" (or "single") to force Single-Vector - -Version: 1.5.0 (2025-11-08) +FILE: app/core/qdrant_points.py +DESCRIPTION: Object-Mapper für Qdrant. Konvertiert JSON-Payloads (Notes, Chunks, Edges) in PointStructs und generiert deterministische UUIDs. +VERSION: 1.5.0 +STATUS: Active +DEPENDENCIES: qdrant_client, uuid, os +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations import os diff --git a/app/core/retriever.py b/app/core/retriever.py index 234d32c..8714eac 100644 --- a/app/core/retriever.py +++ b/app/core/retriever.py @@ -1,8 +1,10 @@ """ -app/core/retriever.py — Hybrider Such-Algorithmus - -Version: - 0.5.3 (WP-06 Fix: Populate 'payload' in QueryHit for meta-data access) +FILE: app/core/retriever.py +DESCRIPTION: Implementiert die Hybrid-Suche (Vektor + Graph-Expansion) und das Scoring-Modell (Explainability). +VERSION: 0.5.3 +STATUS: Active +DEPENDENCIES: app.config, app.models.dto, app.core.qdrant*, app.services.embeddings_client, app.core.graph_adapter +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/core/type_registry.py b/app/core/type_registry.py index 83c4703..36763a5 100644 --- a/app/core/type_registry.py +++ b/app/core/type_registry.py @@ -1,30 +1,11 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ -Modul: app/core/type_registry.py -Version: 1.0.0 -Datum: 2025-11-08 - -Zweck ------ -Lädt eine optionale Typ-Registry (config/types.yaml) und stellt -komfortable Zugriffsfunktionen bereit. Die Registry ist *optional*: -- Fehlt die Datei oder ist das YAML defekt, wird ein konservativer - Default (Typ "concept") verwendet und es wird eine Warnung ausgegeben. -- Änderungen an der Datei greifen nach einem Neustart des Prozesses. - -Öffentliche API ---------------- -- load_type_registry(path: str = "config/types.yaml") -> dict -- get_type_config(note_type: str, reg: dict) -> dict -- resolve_note_type(fm_type: str | None, reg: dict) -> str -- effective_chunk_profile(note_type: str, reg: dict) -> str | None -- profile_overlap(profile: str | None) -> tuple[int,int] # nur Overlap-Empfehlung - -Hinweis -------- -Die Registry steuert KEINE Breaking Changes. Ohne Datei/Typ bleibt das -Verhalten exakt wie im Release-Stand 20251105. +FILE: app/core/type_registry.py +DESCRIPTION: Loader für types.yaml. Achtung: Wird in der aktuellen Pipeline meist durch lokale Loader in 'ingestion.py' oder 'note_payload.py' umgangen. +VERSION: 1.0.0 +STATUS: Deprecated (Redundant) +DEPENDENCIES: yaml, os, functools +EXTERNAL_CONFIG: config/types.yaml +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/embeddings.py b/app/embeddings.py deleted file mode 100644 index 92330cb..0000000 --- a/app/embeddings.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Version 0.1 - -""" - -from __future__ import annotations -from typing import List -from functools import lru_cache - -from .config import get_settings - -@lru_cache -def _load_model(): - from sentence_transformers import SentenceTransformer - settings = get_settings() - model = SentenceTransformer(settings.MODEL_NAME, device="cpu") - return model - -def embed_texts(texts: List[str]) -> list[list[float]]: - model = _load_model() - texts = [t if isinstance(t, str) else str(t) for t in texts] - vecs = model.encode(texts, normalize_embeddings=True, convert_to_numpy=False) - return [list(map(float, v)) for v in vecs] diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 0c6060d..eb5f68d 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -1,3 +1,12 @@ +""" +FILE: app/frontend/ui.py +DESCRIPTION: Main Entrypoint für Streamlit. Router, der basierend auf Sidebar-Auswahl die Module (Chat, Editor, Graph) lädt. +VERSION: 2.6.0 +STATUS: Active +DEPENDENCIES: streamlit, ui_config, ui_sidebar, ui_chat, ui_editor, ui_graph_service, ui_graph*, ui_graph_cytoscape +LAST_ANALYSIS: 2025-12-15 +""" + import streamlit as st import uuid diff --git a/app/frontend/ui_api.py b/app/frontend/ui_api.py index 8057a26..963689a 100644 --- a/app/frontend/ui_api.py +++ b/app/frontend/ui_api.py @@ -1,3 +1,12 @@ +""" +FILE: app/frontend/ui_api.py +DESCRIPTION: Wrapper für Backend-Calls (Chat, Ingest, Feedback). Kapselt requests und Error-Handling. +VERSION: 2.6.0 +STATUS: Active +DEPENDENCIES: requests, streamlit, ui_config +LAST_ANALYSIS: 2025-12-15 +""" + import requests import streamlit as st from ui_config import CHAT_ENDPOINT, INGEST_ANALYZE_ENDPOINT, INGEST_SAVE_ENDPOINT, FEEDBACK_ENDPOINT, API_TIMEOUT diff --git a/app/frontend/ui_callbacks.py b/app/frontend/ui_callbacks.py index b9bf955..cdd4e1c 100644 --- a/app/frontend/ui_callbacks.py +++ b/app/frontend/ui_callbacks.py @@ -1,3 +1,12 @@ +""" +FILE: app/frontend/ui_callbacks.py +DESCRIPTION: Event-Handler für UI-Interaktionen. Implementiert den Übergang vom Graphen zum Editor (State Transfer). +VERSION: 2.6.0 +STATUS: Active +DEPENDENCIES: streamlit, os, ui_utils +LAST_ANALYSIS: 2025-12-15 +""" + import streamlit as st import os from ui_utils import build_markdown_doc diff --git a/app/frontend/ui_chat.py b/app/frontend/ui_chat.py index 31b552a..eaa1f8b 100644 --- a/app/frontend/ui_chat.py +++ b/app/frontend/ui_chat.py @@ -1,3 +1,12 @@ +""" +FILE: app/frontend/ui_chat.py +DESCRIPTION: Chat-UI. Rendert Nachrichtenverlauf, Quellen-Expanders mit Feedback-Buttons und delegiert bei Bedarf an den Editor. +VERSION: 2.6.0 +STATUS: Active +DEPENDENCIES: streamlit, ui_api, ui_editor +LAST_ANALYSIS: 2025-12-15 +""" + import streamlit as st from ui_api import send_chat_message, submit_feedback from ui_editor import render_draft_editor diff --git a/app/frontend/ui_config.py b/app/frontend/ui_config.py index a63c6b7..334c8bf 100644 --- a/app/frontend/ui_config.py +++ b/app/frontend/ui_config.py @@ -1,3 +1,12 @@ +""" +FILE: app/frontend/ui_config.py +DESCRIPTION: Zentrale Konfiguration für das Frontend. Definiert API-Endpoints, Timeouts und Graph-Styles (Farben). +VERSION: 2.6.0 +STATUS: Active +DEPENDENCIES: os, hashlib, dotenv, pathlib +LAST_ANALYSIS: 2025-12-15 +""" + import os import hashlib from dotenv import load_dotenv diff --git a/app/frontend/ui_editor.py b/app/frontend/ui_editor.py index 0dd2f80..ec9d34d 100644 --- a/app/frontend/ui_editor.py +++ b/app/frontend/ui_editor.py @@ -1,3 +1,11 @@ +""" +FILE: app/frontend/ui_editor.py +DESCRIPTION: Markdown-Editor mit Live-Vorschau und Metadaten-Feldern. Unterstützt Intelligence-Features (Link-Vorschläge) und unterscheidet Create/Update-Modus. +VERSION: 2.6.0 +STATUS: Active +DEPENDENCIES: streamlit, uuid, re, datetime, ui_utils, ui_api +LAST_ANALYSIS: 2025-12-15 +""" import streamlit as st import uuid import re diff --git a/app/frontend/ui_graph.py b/app/frontend/ui_graph.py index e7920cc..8e364eb 100644 --- a/app/frontend/ui_graph.py +++ b/app/frontend/ui_graph.py @@ -1,3 +1,12 @@ +""" +FILE: app/frontend/ui_graph.py +DESCRIPTION: Legacy Graph-Explorer (Streamlit-Agraph). Implementiert Physik-Simulation (BarnesHut) und direkten Editor-Sprung. +VERSION: 2.6.0 +STATUS: Maintenance (Active Fallback) +DEPENDENCIES: streamlit, streamlit_agraph, qdrant_client, ui_config, ui_callbacks +LAST_ANALYSIS: 2025-12-15 +""" + import streamlit as st from streamlit_agraph import agraph, Config from qdrant_client import models diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index 62b267e..e2e4cff 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -1,3 +1,12 @@ +""" +FILE: app/frontend/ui_graph_cytoscape.py +DESCRIPTION: Moderner Graph-Explorer (Cytoscape.js). Features: COSE-Layout, Deep-Linking (URL Params), Active Inspector Pattern (CSS-Styling ohne Re-Render). +VERSION: 2.6.0 +STATUS: Active +DEPENDENCIES: streamlit, st_cytoscape, qdrant_client, ui_config, ui_callbacks +LAST_ANALYSIS: 2025-12-15 +""" + import streamlit as st from st_cytoscape import cytoscape from qdrant_client import models diff --git a/app/frontend/ui_graph_service.py b/app/frontend/ui_graph_service.py index 01b8191..bcaa0a3 100644 --- a/app/frontend/ui_graph_service.py +++ b/app/frontend/ui_graph_service.py @@ -1,3 +1,12 @@ +""" +FILE: app/frontend/ui_graph_service.py +DESCRIPTION: Data Layer für den Graphen. Greift direkt auf Qdrant zu (Performance), um Knoten/Kanten zu laden und Texte zu rekonstruieren ("Stitching"). +VERSION: 2.6.0 +STATUS: Active +DEPENDENCIES: qdrant_client, streamlit_agraph, ui_config, re +LAST_ANALYSIS: 2025-12-15 +""" + import re from qdrant_client import QdrantClient, models from streamlit_agraph import Node, Edge diff --git a/app/frontend/ui_sidebar.py b/app/frontend/ui_sidebar.py index c771358..c9ac53e 100644 --- a/app/frontend/ui_sidebar.py +++ b/app/frontend/ui_sidebar.py @@ -1,3 +1,12 @@ +""" +FILE: app/frontend/ui_sidebar.py +DESCRIPTION: Rendert die Sidebar. Steuert den Modus-Wechsel (Chat/Editor/Graph) und globale Settings (Top-K). +VERSION: 2.6.0 +STATUS: Active +DEPENDENCIES: streamlit, ui_utils, ui_config +LAST_ANALYSIS: 2025-12-15 +""" + import streamlit as st from ui_utils import load_history_from_logs from ui_config import HISTORY_FILE diff --git a/app/frontend/ui_utils.py b/app/frontend/ui_utils.py index 3afc6d9..e4d6625 100644 --- a/app/frontend/ui_utils.py +++ b/app/frontend/ui_utils.py @@ -1,3 +1,12 @@ +""" +FILE: app/frontend/ui_utils.py +DESCRIPTION: String-Utilities. Parser für Markdown/YAML (LLM-Healing) und Helper für History-Loading. +VERSION: 2.6.0 +STATUS: Active +DEPENDENCIES: re, yaml, unicodedata, json, datetime +LAST_ANALYSIS: 2025-12-15 +""" + import re import yaml import unicodedata diff --git a/app/graph/service.py b/app/graph/service.py deleted file mode 100644 index 40b430e..0000000 --- a/app/graph/service.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Modul: app/graph/service.py -Version: 0.1.0 -Datum: 2025-09-10 - -Zweck ------ -Leichtgewichtiger Graph-Layer über Qdrant: - - get_note(note_id) - - get_chunks(note_id) - - neighbors(source_id, kinds=[...], scope=['note','chunk'], depth=1) - - walk_bfs(source_id, kinds, max_depth) - - context_for_note(note_id, max_neighbors): heuristische Kontextsammlung - -Hinweise --------- -- Nutzt die bestehenden Collections _notes/_chunks/_edges. -- Edges werden über Payload-Felder (`kind`, `source_id`, `target_id`) abgefragt. -""" -from __future__ import annotations -from typing import List, Dict, Any, Optional, Iterable, Set, Tuple -from qdrant_client.http import models as rest -from app.core.qdrant import QdrantConfig, get_client - -def _cols(prefix: str): - return f"{prefix}_notes", f"{prefix}_chunks", f"{prefix}_edges" - -class GraphService: - def __init__(self, cfg: Optional[QdrantConfig] = None, prefix: Optional[str] = None): - self.cfg = cfg or QdrantConfig.from_env() - if prefix: - self.cfg.prefix = prefix - self.client = get_client(self.cfg) - self.notes_col, self.chunks_col, self.edges_col = _cols(self.cfg.prefix) - - # ------------------------ fetch helpers ------------------------ - def _scroll(self, col: str, flt: Optional[rest.Filter] = None, limit: int = 256): - out = [] - nextp = None - while True: - pts, nextp = self.client.scroll( - collection_name=col, - with_payload=True, - with_vectors=False, - limit=limit, - offset=nextp, - scroll_filter=flt, - ) - if not pts: - break - out.extend(pts) - if nextp is None: - break - return out - - # ------------------------ public API --------------------------- - def get_note(self, note_id: str) -> Optional[Dict[str, Any]]: - f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - pts, _ = self.client.scroll(self.notes_col, with_payload=True, with_vectors=False, limit=1, scroll_filter=f) - return (pts[0].payload or None) if pts else None - - def get_chunks(self, note_id: str) -> List[Dict[str, Any]]: - f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - pts = self._scroll(self.chunks_col, f) - # Sortierung analog Export - def key(pl): - p = pl.payload or {} - s = p.get("seq") or 0 - ci = p.get("chunk_index") or 0 - n = 0 - cid = p.get("chunk_id") or "" - if isinstance(cid, str) and "#" in cid: - try: - n = int(cid.rsplit("#", 1)[-1]) - except Exception: - n = 0 - return (int(s), int(ci), n) - pts_sorted = sorted(pts, key=key) - return [p.payload or {} for p in pts_sorted] - - def neighbors(self, source_id: str, kinds: Optional[Iterable[str]] = None, - scope: Optional[Iterable[str]] = None, depth: int = 1) -> Dict[str, List[Dict[str, Any]]]: - """ - Liefert eingehende & ausgehende Nachbarn (nur nach kind gefiltert). - depth==1: direkte Kanten. - """ - kinds = list(kinds) if kinds else None - must = [rest.FieldCondition(key="source_id", match=rest.MatchValue(value=source_id))] - if kinds: - must.append(rest.FieldCondition(key="kind", match=rest.MatchAny(any=kinds))) - f = rest.Filter(must=must) - edges = self._scroll(self.edges_col, f) - out = {"out": [], "in": []} - for e in edges: - out["out"].append(e.payload or {}) - # Inverse Richtung (eingehend) - must_in = [rest.FieldCondition(key="target_id", match=rest.MatchValue(value=source_id))] - if kinds: - must_in.append(rest.FieldCondition(key="kind", match=rest.MatchAny(any=kinds))) - f_in = rest.Filter(must=must_in) - edges_in = self._scroll(self.edges_col, f_in) - for e in edges_in: - out["in"].append(e.payload or {}) - return out - - def walk_bfs(self, source_id: str, kinds: Iterable[str], max_depth: int = 2) -> Set[str]: - visited: Set[str] = {source_id} - frontier: Set[str] = {source_id} - kinds = list(kinds) - for _ in range(max_depth): - nxt: Set[str] = set() - for s in frontier: - neigh = self.neighbors(s, kinds=kinds) - for e in neigh["out"]: - t = e.get("target_id") - if isinstance(t, str) and t not in visited: - visited.add(t) - nxt.add(t) - frontier = nxt - if not frontier: - break - return visited - - def context_for_note(self, note_id: str, kinds: Iterable[str] = ("references","backlink"), max_neighbors: int = 12) -> Dict[str, Any]: - """ - Heuristischer Kontext: eigene Chunks + Nachbarn nach Kantenarten, dedupliziert. - """ - note = self.get_note(note_id) or {} - chunks = self.get_chunks(note_id) - neigh = self.neighbors(note_id, kinds=list(kinds)) - targets = [] - for e in neigh["out"]: - t = e.get("target_id") - if isinstance(t, str): - targets.append(t) - for e in neigh["in"]: - s = e.get("source_id") - if isinstance(s, str): - targets.append(s) - # de-dupe - seen = set() - uniq = [] - for t in targets: - if t not in seen: - seen.add(t) - uniq.append(t) - uniq = uniq[:max_neighbors] - neighbor_notes = [self.get_note(t) for t in uniq] - return { - "note": note, - "chunks": chunks, - "neighbors": [n for n in neighbor_notes if n], - "edges_out": neigh["out"], - "edges_in": neigh["in"], - } - -# Optional: Mini-CLI -if __name__ == "__main__": # pragma: no cover - import argparse, json - ap = argparse.ArgumentParser() - ap.add_argument("--prefix", help="Collection-Prefix (überschreibt ENV)") - ap.add_argument("--note-id", required=True) - ap.add_argument("--neighbors", action="store_true", help="Nur Nachbarn anzeigen") - args = ap.parse_args() - svc = GraphService(prefix=args.prefix) - if args.neighbors: - out = svc.neighbors(args.note_id, kinds=["references","backlink","prev","next","belongs_to"]) - else: - out = svc.context_for_note(args.note_id) - print(json.dumps(out, ensure_ascii=False, indent=2)) diff --git a/app/main.py b/app/main.py index fa23b73..521bc9a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,12 @@ """ -app/main.py — mindnet API bootstrap +FILE: app/main.py +DESCRIPTION: Bootstrap der FastAPI Anwendung. Inkludiert Router und Middleware. +VERSION: 0.6.0 +STATUS: Active +DEPENDENCIES: app.config, app.routers.* (embed, qdrant, query, graph, tools, feedback, chat, ingest, admin) +LAST_ANALYSIS: 2025-12-15 """ + from __future__ import annotations from fastapi import FastAPI from .config import get_settings diff --git a/app/models/dto.py b/app/models/dto.py index 22e4cff..860670f 100644 --- a/app/models/dto.py +++ b/app/models/dto.py @@ -1,14 +1,10 @@ """ -app/models/dto.py — Pydantic-Modelle (DTOs) für WP-04/WP-05/WP-06 - -Zweck: - Laufzeit-Modelle für FastAPI (Requests/Responses). - WP-06 Update: Intent & Intent-Source in ChatResponse. - -Version: - 0.6.2 (WP-06: Decision Engine Transparency, Erweiterung des Feeback Request) -Stand: - 2025-12-09 +FILE: app/models/dto.py +DESCRIPTION: Pydantic-Modelle (DTOs) für Request/Response Bodies. Definiert das API-Schema. +VERSION: 0.6.2 +STATUS: Active +DEPENDENCIES: pydantic, typing, uuid +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/routers/admin.py b/app/routers/admin.py index d3bd4bb..f55b1fc 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -1,20 +1,10 @@ """ -app/routers/admin.py — Admin-/Monitoring-Endpunkte (optional) - -Zweck: - Liefert einfache Kennzahlen zu Collections (Counts) und Config. -Kompatibilität: - Python 3.12+, FastAPI 0.110+, qdrant-client 1.x -Version: - 0.1.0 (Erstanlage) -Stand: - 2025-10-07 -Bezug: - - Qdrant Collections: *_notes, *_chunks, *_edges -Nutzung: - app.include_router(admin.router, prefix="/admin", tags=["admin"]) -Änderungsverlauf: - 0.1.0 (2025-10-07) – Erstanlage. +FILE: app/routers/admin.py +DESCRIPTION: Monitoring-Endpunkt. Zeigt Qdrant-Collection-Counts und geladene Config. +VERSION: 0.1.0 +STATUS: Active (Optional) +DEPENDENCIES: qdrant_client, app.config +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/routers/chat.py b/app/routers/chat.py index 598bd79..03116eb 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -1,6 +1,11 @@ """ -app/routers/chat.py — RAG Endpunkt -Version: 2.5.0 (Fix: Question Detection protects against False-Positive Interviews) +FILE: app/routers/chat.py +DESCRIPTION: Haupt-Chat-Interface (RAG & Interview). Enthält Intent-Router (Keywords/LLM) und Prompt-Construction. +VERSION: 2.5.0 +STATUS: Active +DEPENDENCIES: app.config, app.models.dto, app.services.llm_service, app.core.retriever, app.services.feedback_service +EXTERNAL_CONFIG: config/decision_engine.yaml, config/types.yaml +LAST_ANALYSIS: 2025-12-15 """ from fastapi import APIRouter, HTTPException, Depends diff --git a/app/routers/embed_router.py b/app/routers/embed_router.py index 7391b40..6b873f1 100644 --- a/app/routers/embed_router.py +++ b/app/routers/embed_router.py @@ -1,5 +1,10 @@ """ -Version 0.1 +FILE: app/routers/embed_router.py +DESCRIPTION: Exponiert die lokale Embedding-Funktion als API-Endpunkt. +VERSION: 0.1.0 +STATUS: Active +DEPENDENCIES: app.embeddings, pydantic +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/routers/feedback.py b/app/routers/feedback.py index 2ebaf4a..f536606 100644 --- a/app/routers/feedback.py +++ b/app/routers/feedback.py @@ -1,6 +1,10 @@ """ -app/routers/feedback.py -Endpunkt für User-Feedback (WP-04c). +FILE: app/routers/feedback.py +DESCRIPTION: Endpunkt für explizites User-Feedback (WP-04c). +VERSION: 0.1.0 +STATUS: Active +DEPENDENCIES: app.models.dto, app.services.feedback_service +LAST_ANALYSIS: 2025-12-15 """ from fastapi import APIRouter, HTTPException from app.models.dto import FeedbackRequest diff --git a/app/routers/graph.py b/app/routers/graph.py index 8ef613a..bbbfb48 100644 --- a/app/routers/graph.py +++ b/app/routers/graph.py @@ -1,21 +1,10 @@ """ -app/routers/graph.py — Graph-Endpunkte (WP-04) - -Zweck: - Liefert die Nachbarschaft einer Note/ID als JSON-Graph (Nodes/Edges/Stats). -Kompatibilität: - Python 3.12+, FastAPI 0.110+, qdrant-client 1.x -Version: - 0.1.0 (Erstanlage) -Stand: - 2025-10-07 -Bezug: - - app/core/graph_adapter.py - - app/models/dto.py -Nutzung: - app.include_router(graph.router, prefix="/graph", tags=["graph"]) -Änderungsverlauf: - 0.1.0 (2025-10-07) – Erstanlage. +FILE: app/routers/graph.py +DESCRIPTION: Liefert Graph-Daten (Knoten/Kanten) für UI-Visualisierungen basierend auf einer Seed-ID. (WP4) +VERSION: 0.1.0 +STATUS: Active +DEPENDENCIES: qdrant_client, app.models.dto, app.core.graph_adapter, app.config +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/routers/ingest.py b/app/routers/ingest.py index d40b529..9603171 100644 --- a/app/routers/ingest.py +++ b/app/routers/ingest.py @@ -1,8 +1,12 @@ """ -app/routers/ingest.py -API-Endpunkte für WP-11 (Discovery & Persistence). -Delegiert an Services. +FILE: app/routers/ingest.py +DESCRIPTION: Endpunkte für WP-11. Nimmt Markdown entgegen, steuert Ingestion und Discovery (Link-Vorschläge). +VERSION: 0.6.0 +STATUS: Active +DEPENDENCIES: app.core.ingestion, app.services.discovery, fastapi, pydantic +LAST_ANALYSIS: 2025-12-15 """ + import os import time import logging diff --git a/app/routers/query.py b/app/routers/query.py index 4edb689..ec9db7b 100644 --- a/app/routers/query.py +++ b/app/routers/query.py @@ -1,23 +1,12 @@ """ -app/routers/query.py — Query-Endpunkte (WP-04) +FILE: app/routers/query.py +DESCRIPTION: Klassische Such-Endpunkte (Semantic & Hybrid). Initiiert asynchrones Feedback-Logging und ruft den richtigen Retriever Modus auf +VERSION: 0.2.0 +STATUS: Active +DEPENDENCIES: app.models.dto, app.core.retriever, app.services.feedback_service +LAST_ANALYSIS: 2025-12-15 +""" -Zweck: - Stellt POST /query bereit und ruft den passenden Retriever-Modus auf. -Kompatibilität: - Python 3.12+, FastAPI 0.110+ -Version: - 0.1.0 (Erstanlage) -Stand: - 2025-10-07 -Bezug: - - app/core/retriever.py - - app/models/dto.py -Nutzung: - app.include_router(query.router, prefix="/query", tags=["query"]) -Änderungsverlauf: - 0.2.0 (2025-12-07) - Update für WP04c Feedback - 0.1.0 (2025-10-07) – Erstanlage. -""" from __future__ import annotations from fastapi import APIRouter, HTTPException, BackgroundTasks from app.models.dto import QueryRequest, QueryResponse diff --git a/app/routers/tools.py b/app/routers/tools.py index 5b144c3..1044725 100644 --- a/app/routers/tools.py +++ b/app/routers/tools.py @@ -1,21 +1,10 @@ """ -app/routers/tools.py — Tool-Definitionen für Ollama/n8n/MCP (read-only) - -Zweck: - Liefert Funktions-Schemas (OpenAI-/Ollama-kompatibles Tool-JSON) für: - - mindnet_query -> POST /query - - mindnet_subgraph -> GET /graph/{note_id} -Kompatibilität: - Python 3.12+, FastAPI 0.110+ -Version: - 0.1.1 (query ODER query_vector möglich) -Stand: - 2025-10-07 -Nutzung: - app.include_router(tools.router, prefix="/tools", tags=["tools"]) -Änderungsverlauf: - 0.1.1 (2025-10-07) – mindnet_query: oneOf(query, query_vector). - 0.1.0 (2025-10-07) – Erstanlage. +FILE: app/routers/tools.py +DESCRIPTION: Liefert JSON-Schemas für die Integration als 'Tools' in Agents (Ollama/OpenAI). Read-Only. +VERSION: 0.1.1 +STATUS: Active +DEPENDENCIES: fastapi +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations diff --git a/app/services/discovery.py b/app/services/discovery.py index 995abde..95b502c 100644 --- a/app/services/discovery.py +++ b/app/services/discovery.py @@ -1,12 +1,11 @@ """ -app/services/discovery.py -Service für Link-Vorschläge und Knowledge-Discovery (WP-11). - -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. +FILE: app/services/discovery.py +DESCRIPTION: Service für WP-11. Analysiert Texte, findet Entitäten und schlägt typisierte Verbindungen vor ("Matrix-Logic"). +VERSION: 0.6.0 +STATUS: Active +DEPENDENCIES: app.core.qdrant, app.models.dto, app.core.retriever +EXTERNAL_CONFIG: config/types.yaml +LAST_ANALYSIS: 2025-12-15 """ import logging import asyncio diff --git a/app/services/embeddings_client.py b/app/services/embeddings_client.py index afad847..2ccda42 100644 --- a/app/services/embeddings_client.py +++ b/app/services/embeddings_client.py @@ -1,12 +1,10 @@ """ -app/services/embeddings_client.py — Text→Embedding Service - -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) +FILE: app/services/embeddings_client.py +DESCRIPTION: Unified Embedding Client. Nutzt Ollama API (HTTP). Ersetzt lokale sentence-transformers. +VERSION: 2.5.0 +STATUS: Active +DEPENDENCIES: httpx, requests, app.config +LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations import os diff --git a/app/services/feedback_service.py b/app/services/feedback_service.py index 99bbca2..594033c 100644 --- a/app/services/feedback_service.py +++ b/app/services/feedback_service.py @@ -1,9 +1,10 @@ """ -app/services/feedback_service.py -Service zum Loggen von Suchanfragen und Feedback (WP-04c). -Speichert Daten als JSONL für späteres Self-Tuning (WP-08). - -Version: 1.1 (Chat-Support) +FILE: app/services/feedback_service.py +DESCRIPTION: Schreibt Search- und Feedback-Logs in JSONL-Dateien. +VERSION: 1.1 +STATUS: Active +DEPENDENCIES: app.models.dto +LAST_ANALYSIS: 2025-12-15 """ import json import os diff --git a/app/services/llm_service.py b/app/services/llm_service.py index bfbe343..cff8880 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -1,6 +1,11 @@ """ -app/services/llm_service.py — LLM Client -Version: 2.8.0 (Configurable Concurrency Limit) +FILE: app/services/llm_service.py +DESCRIPTION: Asynchroner Client für Ollama. Verwaltet Prompts und Background-Last (Semaphore). +VERSION: 2.8.0 +STATUS: Active +DEPENDENCIES: httpx, yaml, asyncio, app.config +EXTERNAL_CONFIG: config/prompts.yaml +LAST_ANALYSIS: 2025-12-15 """ import httpx diff --git a/app/services/semantic_analyzer.py b/app/services/semantic_analyzer.py index 3a971d6..aa9eafd 100644 --- a/app/services/semantic_analyzer.py +++ b/app/services/semantic_analyzer.py @@ -1,6 +1,10 @@ """ -app/services/semantic_analyzer.py — Edge Validation & Filtering -Version: 2.0 (Update: Background Priority for Batch Jobs) +FILE: app/services/semantic_analyzer.py +DESCRIPTION: KI-gestützte Kanten-Validierung. Nutzt LLM (Background-Priority), um Kanten präzise einem Chunk zuzuordnen. +VERSION: 2.0.0 +STATUS: Active +DEPENDENCIES: app.services.llm_service, json, logging +LAST_ANALYSIS: 2025-12-15 """ import json diff --git a/docs/05_Development/05_developer_guide.md b/docs/05_Development/05_developer_guide.md index 972dc84..f83a643 100644 --- a/docs/05_Development/05_developer_guide.md +++ b/docs/05_Development/05_developer_guide.md @@ -9,145 +9,200 @@ context: "Umfassender Guide für Entwickler: Architektur, Modul-Interna (Deep Di # Mindnet Developer Guide & Workflow -**Quellen:** `developer_guide.md`, `dev_workflow.md` +**Quellen:** `developer_guide.md`, `dev_workflow.md`, `Architecture_Audit_v2.6` -Dieser Guide vereint das technische Verständnis der Module mit dem operativen Workflow zwischen Windows (Dev) und Linux (Runtime). +Dieser Guide ist die zentrale technische Referenz für Mindnet v2.6. Er vereint das technische Verständnis der Module mit dem operativen Workflow zwischen Windows (Dev) und Linux (Runtime). --- -## 1. Die physische Architektur +## 1. Einführung & Systemüberblick + +### Was ist Mindnet? +Mindnet ist ein **Hybrides Knowledge Management System**, das klassische Notizen (Markdown) mit KI-gestützter Analyse verbindet. Es kombiniert **RAG** (Retrieval Augmented Generation) mit einer **Graphen-Datenbank** (Qdrant), um Wissen nicht nur semantisch zu finden, sondern auch strukturell zu vernetzen. + +### Kern-Philosophie +1. **Filesystem First:** Die Wahrheit liegt immer auf der Festplatte (Markdown-Dateien). Die Datenbank ist ein abgeleiteter Index. +2. **Hybrid Retrieval:** Relevanz entsteht aus Textähnlichkeit (Semantik) + Graphen-Verbindungen (Edges) + Wichtigkeit (Centrality). +3. **Active Intelligence:** Das System wartet nicht nur auf Anfragen, sondern schlägt beim Schreiben proaktiv Verbindungen vor ("Matrix Logic"). +4. **Local Privacy:** Alle KI-Berechnungen (Ollama) laufen lokal. Keine Cloud-Abhängigkeit für Inference. + +--- + +## 2. Architektur + +### 2.1 High-Level Übersicht +Das System folgt einer strikten Trennung zwischen Frontend (Streamlit) und Backend (FastAPI), wobei bestimmte Performance-Pfade (Graph-Visualisierung) optimiert wurden. + +```mermaid +graph TD + User((User)) + + subgraph "Frontend Layer (Streamlit)" + UI[ui.py Router] + ViewChat[Chat View] + ViewGraph[Graph View] + ViewEditor[Editor View] + Logic[Callbacks & State] + end + + subgraph "Backend Layer (FastAPI)" + API[main.py] + RouterChat[Chat / RAG] + RouterIngest[Ingest / Write] + CoreRet[Retriever Engine] + CoreIngest[Ingestion Pipeline] + end + + subgraph "Infrastructure & Services" + LLM[Ollama (Phi3/Nomic)] + DB[(Qdrant Vector DB)] + FS[File System (.md)] + end + + User <--> UI + UI --> API : REST (Chat, Save, Feedback) + UI -.-> DB : Direct Read (Graph Viz Performance) + API --> LLM : Embeddings & Completion + API --> DB : Read/Write + API --> FS : Read/Write (Source of Truth) +``` + +### 2.2 Datenfluss-Muster + +1. **Ingestion (Write):** + `Markdown` -> `Parser` -> `Chunker` -> `SemanticAnalyzer (LLM)` -> `Embedder` -> `Qdrant (Points)` +2. **Retrieval (Read):** + `Query` -> `Embedding` -> `Hybrid Search (Vector + Graph)` -> `Re-Ranking` -> `LLM Context` +3. **Visualisierung (Graph):** + `UI` -> `GraphService` -> `Qdrant (Edges Collection)` -> `Cytoscape` + +--- + +## 3. Physische Architektur Mindnet läuft in einer verteilten Umgebung (Post-WP15 Setup). -* **Windows 11 (VS Code):** Hier schreibst du Code. **Nie** direkt auf `main` arbeiten! -* **Beelink (Runtime):** Der Server. Hier läuft die Software. Wir nutzen **Systemd-Services**: - * **PROD:** API (8001) + UI (8501). Ordner: `~/mindnet`. - * **DEV:** API (8002) + UI (8502). Ordner: `~/mindnet_dev`. -* **Gitea:** Der "Safe" (Raspberry Pi). Speichert den Code und verwaltet Versionen. +* **Windows 11 (VS Code):** Entwicklungsumgebung. **Nie** direkt auf `main` arbeiten! +* **Beelink (Runtime):** Der Server hostet zwei Instanzen via Systemd: + * **PROD:** API (Port 8001) + UI (Port 8501). Home: `~/mindnet`. + * **DEV:** API (Port 8002) + UI (Port 8502). Home: `~/mindnet_dev`. +* **Gitea (Raspberry Pi):** Versionskontrolle ("Safe"). Speichert den Code. --- -## 2. Projektstruktur & Referenz +## 4. Projektstruktur & Modul-Referenz (Deep Dive) -### 2.1 Verzeichnisbaum +Das System ist modular aufgebaut. Hier ist die detaillierte Analyse aller Komponenten. + +### 4.1 Verzeichnisbaum ```text mindnet/ ├── app/ -│ ├── core/ # Ingestion, Chunker, Qdrant Wrapper -│ ├── routers/ # FastAPI Endpoints -│ ├── services/ # Ollama Client, Traffic Control +│ ├── core/ # Business Logic & Algorithms +│ ├── routers/ # API Interface (FastAPI) +│ ├── services/ # External Integrations (LLM, DB) │ ├── models/ # Pydantic DTOs -│ └── frontend/ # Streamlit UI Module -├── config/ # YAML Configs (Single Source of Truth) -├── scripts/ # CLI Tools (Import, Diagnose, Reset) -├── tests/ # Pytest Suite & Smoke Scripts -└── vault/ # Lokaler Test-Content +│ └── frontend/ # UI Logic (Streamlit) +├── config/ # Configuration Files (YAML) +├── scripts/ # CLI Tools (Ops & Maintenance) +└── vault/ # Local Content Storage ``` -### 2.2 Vollständige Datei-Referenz (Auto-Scan) +### 4.2 Frontend Architecture (`app/frontend/`) -Eine Übersicht aller Skripte und Module im System. +Das Frontend ist eine Streamlit-App, die sich wie eine Single-Page-Application (SPA) verhält. -| Datei/Pfad | Typ | Beschreibung | +| Modul | Status | Verantwortung | | :--- | :--- | :--- | -| **Backend Core** | | | -| `app/main.py` | Skript | Bootstrap der FastAPI API. | -| `app/config.py` | Config | Zentrale Konfiguration (Pydantic Settings). | -| `app/core/ingestion.py` | Core Modul | Async Ingestion Service & Change Detection. | -| `app/core/chunker.py` | Core Modul | Smart Chunker Orchestrator. | -| `app/core/retriever.py` | Core Modul | Hybrider Such-Algorithmus (Semantik + Graph). | -| `app/core/ranking.py` | Core Modul | Kombiniertes Scoring (WP-04). | -| `app/core/graph_adapter.py` | Core Modul | Adjazenzaufbau & Subgraph-Expansion. | -| `app/core/qdrant.py` | Core Modul | Qdrant Client Wrapper. | -| `app/core/qdrant_points.py` | Core Modul | Robuste Point-Helper für Qdrant (Retry-Logik). | -| `app/core/derive_edges.py` | Core Modul | Edge-Erzeugung aus Markdown. | -| `app/core/edges.py` | Core Modul | Datenstrukturen für Kanten. | -| `app/core/edges_writer.py` | Core Modul | Schreibt Kanten in die DB. | -| `app/core/note_payload.py` | Core Modul | Builder für Note-Metadaten. | -| `app/core/chunk_payload.py` | Core Modul | Builder für Chunk-Payloads. | -| `app/core/type_registry.py` | Core Modul | Logik zum Laden der `types.yaml`. | -| `app/core/schema_loader.py` | Core Modul | Lädt JSON-Schemas für Validierung. | -| `app/core/env_vars.py` | Core Modul | Environment-Variablen Konstanten. | -| **API Router** | | | -| `app/routers/chat.py` | API Router | RAG Endpunkt & Hybrid Router. | -| `app/routers/query.py` | API Router | Query-Endpunkte (WP-04). | -| `app/routers/graph.py` | API Router | Graph-Endpunkte (WP-04). | -| `app/routers/ingest.py` | API Router | Ingestion-Trigger & Analyse. | -| `app/routers/feedback.py` | API Router | Feedback-Endpunkt. | -| `app/routers/tools.py` | API Router | Tool-Definitionen für Ollama/n8n/MCP. | -| `app/routers/admin.py` | API Router | Admin-/Monitoring-Endpunkte. | +| **`ui.py`** | 🟢 Core | **Main Router.** Initialisiert Session-State und entscheidet anhand der Sidebar-Auswahl, welche View gerendert wird. | +| **`ui_config.py`** | 🟢 Config | **Constants.** Zentraler Ort für Farben (`GRAPH_COLORS`), API-URLs und Timeouts. Änderungen am Look & Feel passieren hier. | +| **`ui_chat.py`** | 🟢 View | **Chat UI.** Rendert Nachrichtenverlauf, Intent-Badges, Quellen-Expanders und Feedback-Buttons. | +| **`ui_editor.py`** | 🟢 View | **Editor UI.** Markdown-Editor mit Live-Vorschau. Integriert "Intelligence" (KI-Link-Vorschläge). | +| **`ui_graph_cytoscape.py`**| 🟢 View | **Modern Graph.** Interaktiver Graph basierend auf Cytoscape.js (COSE Layout). | +| **`ui_graph.py`** | 🟡 Legacy | **Graph UI (Fallback).** Alte Implementierung mittels `streamlit-agraph`. | +| **`ui_callbacks.py`** | 🟢 Logic | **State Controller.** Handhabt komplexe State-Übergänge (z.B. Graph -> Editor). | +| **`ui_utils.py`** | 🟢 Logic | **Helper.** Enthält den **Healing Parser** (`parse_markdown_draft`), der defektes JSON/YAML von LLMs repariert. | +| **`ui_api.py`** | 🟢 Data | **API Client.** Wrapper für Backend REST-Calls. | +| **`ui_graph_service.py`** | 🟢 Data | **Performance Hack.** Greift direkt auf Qdrant zu (bypass API), um Graphen schnell zu laden. | + +#### Frontend Design Patterns (Wichtig!) + +1. **Active Inspector Pattern (`ui_graph_cytoscape.py`)** + Um Re-Renders im Graphen zu vermeiden, nutzen wir CSS-Klassen. Wird ein Knoten angeklickt, ändert sich nur die CSS-Klasse (`.inspected`), aber die Physik-Simulation startet nicht neu. Das sorgt für ein stabiles UI-Gefühl. + +2. **Resurrection Pattern (`ui_editor.py`)** + Streamlit neigt dazu, Eingaben bei Re-Runs zu "vergessen". Der Editor synchronisiert seinen Inhalt aggressiv in den `session_state`. + * Logik: `if widget_key not in session_state: restore_from_data_key()`. + * Ergebnis: Texteingaben überleben Tab-Wechsel. + +3. **Filesystem First (`ui_callbacks.py`)** + Wenn man im Graphen auf "Bearbeiten" klickt: + 1. Versucht das System, die **echte Datei** von der Festplatte zu lesen. + 2. Nur wenn das fehlschlägt, wird der Text aus den Datenbank-Chunks rekonstruiert ("Stitching"). + Dies verhindert, dass veraltete Datenbank-Stände die echten Dateien überschreiben. + +--- + +### 4.3 Backend Architecture (`app/`) + +Das Backend stellt die Logik via REST-API bereit. + +| Modul | Typ | Verantwortung | +| :--- | :--- | :--- | +| **Core Engine** | | | +| `core/ingestion.py` | Engine | **Pipeline Controller.** Koordiniert den 13-Schritte-Import, Parsing, Hash-Check und DB-Upserts. | +| `core/retriever.py` | Engine | **Search Engine.** Berechnet Hybrid-Score: `(Semantic * W) + (Edge Bonus * 0.25) + (Centrality * 0.05)`. | +| `core/chunker.py` | Engine | **Segmentation.** Zerlegt Text intelligent. Orchestriert `SemanticAnalyzer` für Smart Edges. | +| `core/derive_edges.py`| Engine | **Link Extractor.** Findet Wikilinks, Callouts und Typed Relations im Text. | +| `core/qdrant_points.py`| Mapper | **Object Mapper.** Wandelt Payloads in Qdrant `PointStruct`s um. | +| `core/graph_adapter.py` | Algo | **Graph Logic.** Baut In-Memory Graphen für Re-Ranking und Pfad-Analysen. | +| **Router (API)** | | | +| `routers/chat.py` | Router | **Hybrid Router.** Entscheidet: RAG-Antwort vs. Interview-Modus. | +| `routers/ingest.py` | Router | **Write API.** Nimmt Markdown entgegen, steuert Ingestion und Discovery-Analyse. | +| `routers/query.py` | Router | **Search API.** Klassischer Hybrid-Retriever Endpunkt. | +| `routers/graph.py` | Router | **Viz API.** Liefert Knoten/Kanten für Frontend. | | **Services** | | | -| `app/services/llm_service.py` | Service | LLM Client mit Traffic Control. | -| `app/services/llm_ollama.py` | Service | Legacy: Ollama-Integration & Prompt-Bau. | -| `app/services/embeddings_client.py` | Service | Async Text→Embedding Service. | -| `app/services/semantic_analyzer.py` | Service | Smart Edge Validation & Filtering. | -| `app/services/discovery.py` | Service | Backend Intelligence (Matrix-Logik). | -| `app/services/feedback_service.py` | Service | Schreibt JSONL-Logs. | -| **Frontend** | | | -| `app/frontend/ui.py` | Frontend | Entrypoint (Streamlit). | -| `app/frontend/ui_editor.py` | Frontend | Editor-View & Logic. | -| `app/frontend/ui_chat.py` | Frontend | Chat-View. | -| `app/frontend/ui_graph_cytoscape.py` | Frontend | Graph-Visualisierung (Modern). | -| `app/frontend/ui_graph.py` | Frontend | Graph-Visualisierung (Legacy). | -| `app/frontend/ui_graph_service.py` | Frontend | Datenaufbereitung für Graphen. | -| `app/frontend/ui_callbacks.py` | Frontend | Event-Handler. | -| `app/frontend/ui_api.py` | Frontend | Backend-Bridge. | -| `app/frontend/ui_utils.py` | Frontend | Helper (Healing Parser). | -| `app/frontend/ui_config.py` | Frontend | Konstanten (Farben, URLs). | -| **CLI & Scripts** | | | -| `scripts/import_markdown.py` | Skript | Haupt-Importer CLI. | -| `scripts/reset_qdrant.py` | Skript | Löscht Collections (`--mode wipe`). | -| `scripts/payload_dryrun.py` | Skript | Zeigt Payloads VOR dem Upsert. | -| `scripts/edges_dryrun.py` | Skript | Erzeugt Edges ohne DB-Write. | -| `scripts/edges_full_check.py` | Skript | Prüft Graph-Integrität. | -| `scripts/resolve_unresolved_references.py`| Skript | Löst Wikilinks nachträglich auf. | -| `scripts/audit_vault_vs_qdrant.py` | Skript | Konsistenz-Check File vs. DB. | -| `scripts/audit_edges_vs_expectations.py`| Skript | Prüft Kanten gegen Erwartungswert. | -| `scripts/setup_mindnet_collections.py` | Skript | Richtet Collections initial ein. | -| `scripts/export_markdown.py` | Skript | Exportiert Qdrant zurück zu Markdown. | -| `scripts/wp04_smoketest.py` | Skript | E2E-Schnelltest der WP-04 Endpunkte. | -| `scripts/health_check_mindnet.py` | Skript | System Health Check. | -| `scripts/report_hashes.py` | Skript | Übersicht bei Mehrfach-Hashes. | -| `scripts/make_test_vault.py` | Skript | Erzeugt minimalen Test-Vault. | -| `scripts/ollama_tool_runner.py` | Skript | Minimaler Tool-Caller für Ollama. | +| `services/llm_service.py`| Service | **Traffic Control.** Async Client für Ollama. Nutzt **Semaphore**, um Hintergrund-Jobs (Import) zu drosseln. | +| `services/discovery.py`| Service | **Intelligence.** "Matrix Logic" für Link-Vorschläge (WP-11). | +| `services/semantic_analyzer.py`| Service | **Filter.** KI-Validierung von Kanten im Hintergrund. | +| `services/feedback_service.py`| Service | **Logging.** Schreibt Interaktions-Logs (JSONL). | --- -## 3. Core-Module im Detail (Architektur) +### 4.4 Scripts & Tooling (Die Admin-Toolbox) -Hier wird erklärt, *wie* die wichtigsten Komponenten unter der Haube arbeiten. +Der Ordner `scripts/` enthält verifizierte Werkzeuge für den Betrieb. -### 3.1 Der Importer (`scripts.import_markdown`) -Dies ist das komplexeste Modul. -* **Orchestrierung:** Es ruft `app.core.chunker` für die Textzerlegung und `app.services.semantic_analyzer` für Smart Edges auf. -* **Idempotenz:** Der Importer kann beliebig oft laufen. Er nutzt deterministische IDs (UUIDv5) und überschreibt vorhandene Einträge konsistent. -* **Robustheit:** In `ingestion.py` sind Mechanismen wie Change Detection (Hash-Vergleich) und Robust File I/O implementiert. - -### 3.2 Der Hybrid Router (`app.routers.chat`) -Hier liegt die Logik für Intent Detection (WP06) und Interview-Modus (WP07). -* **Question Detection:** Prüft zuerst regelbasiert, ob der Input eine Frage ist (`?`, W-Wörter). Falls ja -> RAG. -* **Keyword Match:** Prüft Keywords aus `decision_engine.yaml` und `types.yaml`. -* **Priority:** Ruft `llm_service` mit `priority="realtime"` auf, um die Import-Warteschlange zu umgehen. - -### 3.3 Der Retriever (`app.core.retriever`) -Hier passiert das Scoring (WP04a). -* **Hybrid Search:** Der Chat-Endpoint erzwingt `mode="hybrid"`. -* **Strategic Retrieval:** In `chat.py` wird der Retriever ggf. *zweimal* aufgerufen, wenn ein Intent (z.B. `DECISION`) eine Injection (`value`) erfordert. - -### 3.4 Das Frontend (`app.frontend.ui`) -Eine Streamlit-App (WP10/19). -* **Resurrection Pattern:** Das UI nutzt ein spezielles State-Management (`st.session_state`), um Eingaben bei Tab-Wechseln (Chat <-> Editor) zu erhalten. Widgets synchronisieren sich via Callbacks. -* **Healing Parser:** Die Funktion `parse_markdown_draft` repariert defekte YAML-Frontmatter (z.B. fehlendes `---`) vom LLM automatisch. - -### 3.5 Traffic Control (`app.services.llm_service`) -Neu in v2.6. Stellt sicher, dass Batch-Prozesse (Import) den Live-Chat nicht ausbremsen. -* **Methode:** `generate_raw_response(..., priority="background")` aktiviert eine Semaphore. -* **Limit:** Konfigurierbar über `MINDNET_LLM_BACKGROUND_LIMIT` (Default: 2). +| Skript | Status | Zweck | Wichtiges Argument | +| :--- | :--- | :--- | :--- | +| **`import_markdown.py`** | 🟢 Prod | **Master-Sync.** Der zentrale Importer. | `--apply`, `--purge-before-upsert` | +| **`reset_qdrant.py`** | ⚠️ Ops | **Wipe.** Löscht Collections für Rebuilds. | `--mode wipe`, `--yes` | +| **`export_markdown.py`** | 🟢 Backup| **Backup.** Exportiert DB-Inhalt zurück zu MD. | -- | +| **`health_check_mindnet.py`**| 🟢 Ops | **Monitoring.** Prüft ob API/DB laufen. | (Exit Code 0/1) | +| **`payload_dryrun.py`** | 🟢 Test | **Audit.** Simuliert Import (Schema Check). | -- | +| **`edges_full_check.py`** | 🟢 Test | **Integrity.** Prüft Graph-Logik. | -- | +| **`resolve_unresolved.py`**| 🟡 Maint | **Repair.** Versucht, kaputte Links zu heilen. | -- | --- -## 4. Lokales Setup (Development) +## 5. Maintenance & "Kill List" + +Folgende Dateien wurden im Audit v2.6 als veraltet, redundant oder "Zombie-Code" identifiziert und sollten entfernt werden. + +| Datei | Diagnose | Empfohlene Aktion | +| :--- | :--- | :--- | +| `app/embed_server.py` | **Zombie.** Alter Standalone-Server. | 🗑️ Löschen | +| `app/embeddings.py` | **Zombie.** Veraltete lokale Lib. | 🗑️ Löschen | +| `app/core/edges.py` | **Redundant.** Ersetzt durch `derive_edges.py`. | 🗑️ Löschen | +| `app/core/ranking.py` | **Redundant.** Logik in `retriever.py` integriert. | 🗑️ Löschen | +| `app/core/type_registry.py` | **Redundant.** Logik in `ingestion.py` integriert. | 🗑️ Löschen | +| `app/core/env_vars.py` | **Veraltet.** Ersetzt durch `config.py`. | 🗑️ Löschen | +| `app/routers/qdrant_router.py`| **Deprecated.** Keine Logik, nur CRUD. | 📂 Verschieben nach `scripts/archive/` | + +--- + +## 6. Lokales Setup (Development) **Voraussetzungen:** Python 3.10+, Docker, Ollama. @@ -170,16 +225,18 @@ ollama pull nomic-embed-text **Konfiguration (`.env`):** ```ini QDRANT_URL="http://localhost:6333" +MINDNET_OLLAMA_URL="http://localhost:11434" +MINDNET_LLM_MODEL="phi3:mini" +MINDNET_EMBEDDING_MODEL="nomic-embed-text" COLLECTION_PREFIX="mindnet_dev" VECTOR_DIM=768 MINDNET_LLM_BACKGROUND_LIMIT=2 MINDNET_API_URL="http://localhost:8002" -MINDNET_LLM_TIMEOUT=300.0 ``` --- -## 5. Der Entwicklungs-Zyklus (Workflow) +## 7. Der Entwicklungs-Zyklus (Workflow) ### Phase 1: Windows (Code) 1. **Basis aktualisieren:** `git checkout main && git pull`. @@ -219,7 +276,7 @@ Wenn alles getestet ist: --- -## 6. Erweiterungs-Guide: "Teach-the-AI" +## 8. Erweiterungs-Guide: "Teach-the-AI" Mindnet lernt nicht durch Training (Fine-Tuning), sondern durch **Konfiguration** und **Vernetzung**. @@ -227,6 +284,7 @@ Mindnet lernt nicht durch Training (Fine-Tuning), sondern durch **Konfiguration* 1. **Physik (`config/types.yaml`):** ```yaml risk: + chunk_profile: sliding_short retriever_weight: 0.90 # Sehr wichtig edge_defaults: ["blocks"] # Automatische Kante detection_keywords: ["gefahr", "risiko"] @@ -238,21 +296,20 @@ Mindnet lernt nicht durch Training (Fine-Tuning), sondern durch **Konfiguration* ``` *Ergebnis:* Wenn der Intent `DECISION` erkannt wird, sucht das System nun auch aktiv nach Risiken. -### Workflow B: Interview-Schema anpassen (WP07) -Wenn Mindnet neue Fragen stellen soll (z.B. "Budget" bei Projekten): -1. **Schema (`config/types.yaml`):** - ```yaml - project: - schema: - - "Titel" - - "Ziel" - - "Budget (Neu)" - ``` -2. **Kein Code nötig:** Der `One-Shot Extractor` (Prompt Template) liest diese Liste dynamisch. +### Workflow B: Graph-Farben ändern +1. Öffne `app/frontend/ui_config.py`. +2. Bearbeite das Dictionary `GRAPH_COLORS`. + +```python +GRAPH_COLORS = { + "project": "#FF4B4B", + "risk": "#8B0000" # Neu +} +``` --- -## 7. Tests & Debugging +## 9. Tests & Debugging **Unit Tests (Pytest):** ```bash @@ -280,7 +337,7 @@ python tests/test_feedback_smoke.py --url http://localhost:8002/query --- -## 8. Troubleshooting & One-Liners +## 10. Troubleshooting & One-Liners **DB komplett zurücksetzen (Vorsicht!):** ```bash @@ -300,5 +357,9 @@ journalctl -u mindnet-ui-dev -f ``` **"UnicodeDecodeError in .env":** -* Ursache: Umlaute oder Sonderzeichen in der `.env`. -* Lösung: Datei bereinigen (nur ASCII) und sicherstellen, dass UTF-8 ohne BOM genutzt wird. \ No newline at end of file +* **Ursache:** Umlaute oder Sonderzeichen in der `.env`. +* **Lösung:** Datei bereinigen (nur ASCII) und sicherstellen, dass UTF-8 ohne BOM genutzt wird. + +**"Read timed out" im Frontend:** +* **Ursache:** Smart Edges brauchen länger als 60s. +* **Lösung:** `MINDNET_API_TIMEOUT=300.0` in `.env`. \ No newline at end of file -- 2.43.0 From 9025af62f087879531079593ad7b814cad2d9d75 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Dec 2025 15:45:36 +0100 Subject: [PATCH 02/24] wiederhergstellt --- app/embeddings.py | 27 +++++++ app/graph/service.py | 172 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 app/embeddings.py create mode 100644 app/graph/service.py diff --git a/app/embeddings.py b/app/embeddings.py new file mode 100644 index 0000000..d186d9b --- /dev/null +++ b/app/embeddings.py @@ -0,0 +1,27 @@ +""" +FILE: app/embeddings.py +DESCRIPTION: Lokaler Wrapper für SentenceTransformer Embeddings. +VERSION: 0.1.0 +STATUS: Active (Bestätigung durch Aufrufer erforderlich) +DEPENDENCIES: app.config, sentence_transformers +LAST_ANALYSIS: 2025-12-15 +""" + +from __future__ import annotations +from typing import List +from functools import lru_cache + +from .config import get_settings + +@lru_cache +def _load_model(): + from sentence_transformers import SentenceTransformer + settings = get_settings() + model = SentenceTransformer(settings.MODEL_NAME, device="cpu") + return model + +def embed_texts(texts: List[str]) -> list[list[float]]: + model = _load_model() + texts = [t if isinstance(t, str) else str(t) for t in texts] + vecs = model.encode(texts, normalize_embeddings=True, convert_to_numpy=False) + return [list(map(float, v)) for v in vecs] diff --git a/app/graph/service.py b/app/graph/service.py new file mode 100644 index 0000000..40b430e --- /dev/null +++ b/app/graph/service.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Modul: app/graph/service.py +Version: 0.1.0 +Datum: 2025-09-10 + +Zweck +----- +Leichtgewichtiger Graph-Layer über Qdrant: + - get_note(note_id) + - get_chunks(note_id) + - neighbors(source_id, kinds=[...], scope=['note','chunk'], depth=1) + - walk_bfs(source_id, kinds, max_depth) + - context_for_note(note_id, max_neighbors): heuristische Kontextsammlung + +Hinweise +-------- +- Nutzt die bestehenden Collections _notes/_chunks/_edges. +- Edges werden über Payload-Felder (`kind`, `source_id`, `target_id`) abgefragt. +""" +from __future__ import annotations +from typing import List, Dict, Any, Optional, Iterable, Set, Tuple +from qdrant_client.http import models as rest +from app.core.qdrant import QdrantConfig, get_client + +def _cols(prefix: str): + return f"{prefix}_notes", f"{prefix}_chunks", f"{prefix}_edges" + +class GraphService: + def __init__(self, cfg: Optional[QdrantConfig] = None, prefix: Optional[str] = None): + self.cfg = cfg or QdrantConfig.from_env() + if prefix: + self.cfg.prefix = prefix + self.client = get_client(self.cfg) + self.notes_col, self.chunks_col, self.edges_col = _cols(self.cfg.prefix) + + # ------------------------ fetch helpers ------------------------ + def _scroll(self, col: str, flt: Optional[rest.Filter] = None, limit: int = 256): + out = [] + nextp = None + while True: + pts, nextp = self.client.scroll( + collection_name=col, + with_payload=True, + with_vectors=False, + limit=limit, + offset=nextp, + scroll_filter=flt, + ) + if not pts: + break + out.extend(pts) + if nextp is None: + break + return out + + # ------------------------ public API --------------------------- + def get_note(self, note_id: str) -> Optional[Dict[str, Any]]: + f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) + pts, _ = self.client.scroll(self.notes_col, with_payload=True, with_vectors=False, limit=1, scroll_filter=f) + return (pts[0].payload or None) if pts else None + + def get_chunks(self, note_id: str) -> List[Dict[str, Any]]: + f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) + pts = self._scroll(self.chunks_col, f) + # Sortierung analog Export + def key(pl): + p = pl.payload or {} + s = p.get("seq") or 0 + ci = p.get("chunk_index") or 0 + n = 0 + cid = p.get("chunk_id") or "" + if isinstance(cid, str) and "#" in cid: + try: + n = int(cid.rsplit("#", 1)[-1]) + except Exception: + n = 0 + return (int(s), int(ci), n) + pts_sorted = sorted(pts, key=key) + return [p.payload or {} for p in pts_sorted] + + def neighbors(self, source_id: str, kinds: Optional[Iterable[str]] = None, + scope: Optional[Iterable[str]] = None, depth: int = 1) -> Dict[str, List[Dict[str, Any]]]: + """ + Liefert eingehende & ausgehende Nachbarn (nur nach kind gefiltert). + depth==1: direkte Kanten. + """ + kinds = list(kinds) if kinds else None + must = [rest.FieldCondition(key="source_id", match=rest.MatchValue(value=source_id))] + if kinds: + must.append(rest.FieldCondition(key="kind", match=rest.MatchAny(any=kinds))) + f = rest.Filter(must=must) + edges = self._scroll(self.edges_col, f) + out = {"out": [], "in": []} + for e in edges: + out["out"].append(e.payload or {}) + # Inverse Richtung (eingehend) + must_in = [rest.FieldCondition(key="target_id", match=rest.MatchValue(value=source_id))] + if kinds: + must_in.append(rest.FieldCondition(key="kind", match=rest.MatchAny(any=kinds))) + f_in = rest.Filter(must=must_in) + edges_in = self._scroll(self.edges_col, f_in) + for e in edges_in: + out["in"].append(e.payload or {}) + return out + + def walk_bfs(self, source_id: str, kinds: Iterable[str], max_depth: int = 2) -> Set[str]: + visited: Set[str] = {source_id} + frontier: Set[str] = {source_id} + kinds = list(kinds) + for _ in range(max_depth): + nxt: Set[str] = set() + for s in frontier: + neigh = self.neighbors(s, kinds=kinds) + for e in neigh["out"]: + t = e.get("target_id") + if isinstance(t, str) and t not in visited: + visited.add(t) + nxt.add(t) + frontier = nxt + if not frontier: + break + return visited + + def context_for_note(self, note_id: str, kinds: Iterable[str] = ("references","backlink"), max_neighbors: int = 12) -> Dict[str, Any]: + """ + Heuristischer Kontext: eigene Chunks + Nachbarn nach Kantenarten, dedupliziert. + """ + note = self.get_note(note_id) or {} + chunks = self.get_chunks(note_id) + neigh = self.neighbors(note_id, kinds=list(kinds)) + targets = [] + for e in neigh["out"]: + t = e.get("target_id") + if isinstance(t, str): + targets.append(t) + for e in neigh["in"]: + s = e.get("source_id") + if isinstance(s, str): + targets.append(s) + # de-dupe + seen = set() + uniq = [] + for t in targets: + if t not in seen: + seen.add(t) + uniq.append(t) + uniq = uniq[:max_neighbors] + neighbor_notes = [self.get_note(t) for t in uniq] + return { + "note": note, + "chunks": chunks, + "neighbors": [n for n in neighbor_notes if n], + "edges_out": neigh["out"], + "edges_in": neigh["in"], + } + +# Optional: Mini-CLI +if __name__ == "__main__": # pragma: no cover + import argparse, json + ap = argparse.ArgumentParser() + ap.add_argument("--prefix", help="Collection-Prefix (überschreibt ENV)") + ap.add_argument("--note-id", required=True) + ap.add_argument("--neighbors", action="store_true", help="Nur Nachbarn anzeigen") + args = ap.parse_args() + svc = GraphService(prefix=args.prefix) + if args.neighbors: + out = svc.neighbors(args.note_id, kinds=["references","backlink","prev","next","belongs_to"]) + else: + out = svc.context_for_note(args.note_id) + print(json.dumps(out, ensure_ascii=False, indent=2)) -- 2.43.0 From 60092b378b749e103fd39a1d7e5443f72d1d7b0f Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Dec 2025 16:07:58 +0100 Subject: [PATCH 03/24] =?UTF-8?q?l=C3=B6schen=20von=20Zombies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/edges.py | 296 ----------------------------------- app/core/edges_writer.py | 94 ----------- app/core/env_vars.py | 103 ------------ app/core/ranking.py | 56 ------- app/core/retriever_config.py | 116 -------------- app/core/schema_loader.py | 22 --- app/core/validate_note.py | 16 -- app/embed_server.py | 40 ----- app/graph/service.py | 172 -------------------- app/main.py | 8 +- 10 files changed, 4 insertions(+), 919 deletions(-) delete mode 100644 app/core/edges.py delete mode 100644 app/core/edges_writer.py delete mode 100644 app/core/env_vars.py delete mode 100644 app/core/ranking.py delete mode 100644 app/core/retriever_config.py delete mode 100644 app/core/schema_loader.py delete mode 100644 app/core/validate_note.py delete mode 100644 app/embed_server.py delete mode 100644 app/graph/service.py diff --git a/app/core/edges.py b/app/core/edges.py deleted file mode 100644 index c8b7fd7..0000000 --- a/app/core/edges.py +++ /dev/null @@ -1,296 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Modul: app/core/edges.py -Version: 2.0.0 (V2‑superset, rückwärtskompatibel zu v1 vom 2025‑09‑09) - -Zweck ------ -Bewahrt die bestehende Edgelogik (belongs_to, prev/next, references, backlink) -und ergänzt V2‑Felder + Typ‑Default‑Kanten gemäß config/types.yaml (edge_defaults). -Die Funktion ist **idempotent** und **rückwärtskompatibel** zur bisherigen Signatur. - -Kompatibilitätsgarantien (gegenüber v1): -- **Input**: akzeptiert identische Chunk‑Payloads wie v1: - * `id` (Chunk‑ID), `note_id` (Owner), `neighbors.prev|next` (optional), - `references: [{target_id: ...}]` (optional), - alternativ: `chunk_id`, `chunk_index|ord`, `window|text` -- **Output (v1‑Felder)**: `kind`, `source_id`, `target_id`, `scope`, `note_id`, `edge_id` -- **Neu (v2‑Felder)**: `relation`, `src_note_id`, `src_chunk_id?`, `dst_note_id`, `dst_chunk_id?`, - `provenance` (`explicit|rule`), `rule_id?`, `confidence?` - -Regeln ------- -- Deduplizierungsschlüssel: (source_id, target_id, relation, rule_id) -- Strukturkanten: - * belongs_to: 1× pro Chunk - * next/prev: Sequenz der Chunks; nutzt bevorzugt neighbors; sonst ord/chunk_index -- Explizite Referenzen: - * aus Chunk: `references[].target_id` (falls vorhanden) - * Fallback: Wikilinks in `window|text`: [[Some Title|some-id]] oder [[some-id]] -- Note‑Scope: - * backlink immer; references nur, wenn include_note_scope_refs=True -- Typ‑Defaults (edge_defaults aus config/types.yaml des **Quell‑Notiztyps**): - * Für jede explizite Referenz wird je default‑Relation eine Regel‑Kante erzeugt - * rule_id: "type_default:{note_type}:{relation}:v1", provenance="rule" - -Konfiguration -------------- -- ENV MINDNET_TYPES_FILE (Default: ./config/types.yaml) - -Lizenz/Autor ------------- -- Erstimplementierung v1 (2025‑09‑09) — Projekt Mindnet -- Erweiterung v2 (2025‑11‑11) — kompatible Superset‑Implementierung -""" -from __future__ import annotations - -import os -import re -from typing import Dict, Iterable, List, Optional, Tuple, Set - -try: - import yaml # optional, nur für types.yaml -except Exception: # pragma: no cover - yaml = None - -# ------------------------------------------------------------ -# Hilfen: types.yaml laden (edge_defaults) -# ------------------------------------------------------------ - -def _types_path() -> str: - return os.getenv("MINDNET_TYPES_FILE") or "./config/types.yaml" - -def _load_types() -> Dict[str, dict]: - p = _types_path() - if not os.path.isfile(p) or yaml is None: - return {} - try: - with open(p, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) or {} - if isinstance(data, dict) and "types" in data and isinstance(data["types"], dict): - return data["types"] - return data if isinstance(data, dict) else {} - except Exception: - return {} - -def _edge_defaults_for(note_type: Optional[str]) -> List[str]: - types = _load_types() - t = (note_type or "").strip().lower() - cfg = types.get(t) or {} - defaults = cfg.get("edge_defaults") or [] - if isinstance(defaults, str): - defaults = [defaults] - return [str(x) for x in defaults if isinstance(x, (str, int, float))] - -# ------------------------------------------------------------ -# Wikilink‑Parser (Fallback, wenn ch["references"] fehlt) -# ------------------------------------------------------------ - -_WIKILINK_RE = re.compile(r"\[\[(?:[^\|\]]+\|)?([a-zA-Z0-9_\-#:. ]+)\]\]") - -def _extract_wikilinks(text: str) -> List[str]: - ids: List[str] = [] - for m in _WIKILINK_RE.finditer(text or ""): - ids.append(m.group(1).strip()) - return ids - -# ------------------------------------------------------------ -# Utility -# ------------------------------------------------------------ - -def _mk_edge_id(kind: str, s: str, t: str, scope: str, rule_id: Optional[str] = None) -> str: - base = f"{kind}:{s}->{t}#{scope}" - if rule_id: - base += f"|{rule_id}" - try: - import hashlib - return hashlib.blake2s(base.encode("utf-8"), digest_size=12).hexdigest() - except Exception: # pragma: no cover - return base - -def _dedupe(edges: List[Dict]) -> List[Dict]: - seen: Set[Tuple[str,str,str,str]] = set() - out: List[Dict] = [] - for e in edges: - s = str(e.get("source_id") or "") - t = str(e.get("target_id") or "") - rel = str(e.get("relation") or e.get("kind") or "edge") - rule = str(e.get("rule_id") or "") - key = (s, t, rel, rule) - if key in seen: - continue - seen.add(key) - out.append(e) - return out - -def _first(v: dict, *keys, default=None): - for k in keys: - if k in v and v[k] is not None: - return v[k] - return default - -# ------------------------------------------------------------ -# Hauptfunktion -# ------------------------------------------------------------ - -def build_edges_for_note( - note_id: str, - chunk_payloads: List[Dict], - note_level_refs: Optional[List[str]] = None, - *, - include_note_scope_refs: bool = False, -) -> List[Dict]: - edges: List[Dict] = [] - chunks = list(chunk_payloads or []) - # Notiztyp aus erstem Chunk ableiten (kompatibel zu existierenden Payloads) - note_type = (chunks[0].get("type") if chunks else None) or (chunks[0].get("note_type") if chunks else None) - - # --- Strukturkanten ------------------------------------------------------ - # belongs_to - for ch in chunks: - cid = _first(ch, "id", "chunk_id") - if not cid: - continue - owner = ch.get("note_id") or note_id - e = { - "edge_id": _mk_edge_id("belongs_to", cid, note_id, "chunk", "structure:belongs_to:v1"), - "kind": "belongs_to", - "relation": "belongs_to", - "scope": "chunk", - "source_id": cid, - "target_id": note_id, - "note_id": owner, # v1-Kompat - # v2 - "src_note_id": owner, - "src_chunk_id": cid, - "dst_note_id": note_id, - "provenance": "rule", - "rule_id": "structure:belongs_to:v1", - "confidence": 1.0, - } - edges.append(e) - - # next/prev — bevorzugt neighbors.prev/next; sonst via ord/chunk_index - # Map der Chunks nach Index - ordered = list(chunks) - def _idx(c): - return _first(c, "chunk_index", "ord", default=0) - ordered.sort(key=_idx) - - for i, ch in enumerate(ordered): - cid = _first(ch, "id", "chunk_id") - if not cid: - continue - owner = ch.get("note_id") or note_id - nb = ch.get("neighbors") or {} - prev_id = nb.get("prev") - next_id = nb.get("next") - # Fallback-Reihenfolge - if prev_id is None and i > 0: - prev_id = _first(ordered[i-1], "id", "chunk_id") - if next_id is None and i+1 < len(ordered): - next_id = _first(ordered[i+1], "id", "chunk_id") - - if prev_id: - edges.append({ - "edge_id": _mk_edge_id("prev", cid, prev_id, "chunk", "structure:order:v1"), - "kind": "prev", "relation": "prev", "scope": "chunk", - "source_id": cid, "target_id": prev_id, "note_id": owner, - "src_note_id": owner, "src_chunk_id": cid, - "dst_note_id": owner, "dst_chunk_id": prev_id, - "provenance": "rule", "rule_id": "structure:order:v1", "confidence": 0.95, - }) - edges.append({ - "edge_id": _mk_edge_id("next", prev_id, cid, "chunk", "structure:order:v1"), - "kind": "next", "relation": "next", "scope": "chunk", - "source_id": prev_id, "target_id": cid, "note_id": owner, - "src_note_id": owner, "src_chunk_id": prev_id, - "dst_note_id": owner, "dst_chunk_id": cid, - "provenance": "rule", "rule_id": "structure:order:v1", "confidence": 0.95, - }) - - # --- Explizite Referenzen (Chunk‑Scope) --------------------------------- - explicit_refs: List[Dict] = [] - for ch in chunks: - cid = _first(ch, "id", "chunk_id") - if not cid: - continue - owner = ch.get("note_id") or note_id - # 1) bevorzugt vorhandene ch["references"] - refs = ch.get("references") or [] - targets = [r.get("target_id") for r in refs if isinstance(r, dict) and r.get("target_id")] - # 2) Fallback: Wikilinks aus Text - if not targets: - text = _first(ch, "window", "text", default="") or "" - targets = _extract_wikilinks(text) - for tid in targets: - if not isinstance(tid, str) or not tid.strip(): - continue - e = { - "edge_id": _mk_edge_id("references", cid, tid, "chunk"), - "kind": "references", - "relation": "references", - "scope": "chunk", - "source_id": cid, - "target_id": tid, - "note_id": owner, - # v2 - "src_note_id": owner, - "src_chunk_id": cid, - "dst_note_id": tid, - "provenance": "explicit", - "rule_id": "", - "confidence": 1.0, - } - edges.append(e) - explicit_refs.append(e) - - # --- Note‑Scope: references (optional) + backlink (immer) ---------------- - unique_refs = [] - if note_level_refs: - seen = set() - for tid in note_level_refs: - if isinstance(tid, str) and tid.strip() and tid not in seen: - unique_refs.append(tid); seen.add(tid) - - for tid in unique_refs: - if include_note_scope_refs: - edges.append({ - "edge_id": _mk_edge_id("references", note_id, tid, "note"), - "kind": "references", "relation": "references", "scope": "note", - "source_id": note_id, "target_id": tid, "note_id": note_id, - "src_note_id": note_id, "dst_note_id": tid, - "provenance": "explicit", "rule_id": "", "confidence": 1.0, - }) - edges.append({ - "edge_id": _mk_edge_id("backlink", tid, note_id, "note", "derived:backlink:v1"), - "kind": "backlink", "relation": "backlink", "scope": "note", - "source_id": tid, "target_id": note_id, "note_id": note_id, - "src_note_id": tid, "dst_note_id": note_id, - "provenance": "rule", "rule_id": "derived:backlink:v1", "confidence": 0.9, - }) - - # --- Type‑Defaults je expliziter Referenz -------------------------------- - defaults = [d for d in _edge_defaults_for(note_type) if d and d != "references"] - if defaults: - for e in explicit_refs + ([ ] if not include_note_scope_refs else []): - # wir nutzen die bereits erzeugten explicit‑Edges als Vorlage - src = e["source_id"]; tgt = e["target_id"] - scope = e.get("scope", "chunk") - s_note = e.get("src_note_id") or note_id - s_chunk = e.get("src_chunk_id") - t_note = e.get("dst_note_id") or tgt - for rel in defaults: - rule_id = f"type_default:{(note_type or 'unknown')}:{rel}:v1" - edges.append({ - "edge_id": _mk_edge_id(rel, src, tgt, scope, rule_id), - "kind": rel, "relation": rel, "scope": scope, - "source_id": src, "target_id": tgt, "note_id": s_note, - "src_note_id": s_note, "src_chunk_id": s_chunk, - "dst_note_id": t_note, - "provenance": "rule", "rule_id": rule_id, "confidence": 0.7, - }) - - # --- Dedupe & Return ----------------------------------------------------- - return _dedupe(edges) diff --git a/app/core/edges_writer.py b/app/core/edges_writer.py deleted file mode 100644 index 066e44a..0000000 --- a/app/core/edges_writer.py +++ /dev/null @@ -1,94 +0,0 @@ -# app/core/edges_writer.py -from __future__ import annotations -import hashlib -from typing import Dict, List, Iterable, Tuple - -try: - # Dein Modul mit der Schemadefinition und der Builder-Funktion - from app.core.edges import build_edges_for_note # noqa: F401 -except Exception as e: - raise RuntimeError("Konnte app.core.edges nicht importieren. " - "Bitte sicherstellen, dass app/core/edges.py vorhanden ist.") from e - -def _edge_uid(kind: str, source_id: str, target_id: str, scope: str) -> str: - """ - Deterministische, kurze ID für eine Edge. - Kollisionen sind praktisch ausgeschlossen (BLAKE2s über den Kanonischen Schlüssel). - """ - key = f"{kind}|{source_id}|{target_id}|{scope}" - return hashlib.blake2s(key.encode("utf-8"), digest_size=12).hexdigest() - -def ensure_edges_collection(qdrant_client, collection: str) -> None: - """ - Legt die Edge-Collection an, falls sie nicht existiert. - Minimal: 1D-Vector (Dummy), Cosine. Payload-only-Collections sind je nach Qdrant-Version heikel. - """ - from qdrant_client.http import models as qm - - existing = [c.name for c in qdrant_client.get_collections().collections] - if collection in existing: - return - - qdrant_client.recreate_collection( - collection_name=collection, - vectors_config=qm.VectorParams(size=1, distance=qm.Distance.COSINE), - on_disk_payload=True, - ) - -def edges_from_note( - note_id: str, - chunk_payloads: List[Dict], - note_level_refs: Iterable[str] | None, - *, - include_note_scope_refs: bool = False, -) -> List[Dict]: - """ - Ruft deinen Edge-Builder auf und gibt die (deduplizierten) Edge-Payloads zurück. - Keine Schemaänderung – exakt das aus app/core/edges.py. - """ - return build_edges_for_note( - note_id=note_id, - chunk_payloads=chunk_payloads, - note_level_refs=list(note_level_refs or []), - include_note_scope_refs=include_note_scope_refs, - ) - -def upsert_edges( - qdrant_client, - collection: str, - edge_payloads: List[Dict], -) -> Tuple[int, int]: - """ - Schreibt Edges als Points in Qdrant. - - id: deterministisch aus (kind, source_id, target_id, scope) - - vector: [0.0] Dummy - - payload: Edge-Dict (unverändert, siehe Schema in app/core/edges.py) - Gibt (anzahl_points, anzahl_unique_keys) zurück. - """ - from qdrant_client.models import PointStruct - - if not edge_payloads: - return 0, 0 - - points = [] - seen = set() - for e in edge_payloads: - key = (e.get("kind"), e.get("source_id"), e.get("target_id"), e.get("scope")) - if key in seen: - continue - seen.add(key) - eid = _edge_uid(*key) - points.append( - PointStruct( - id=eid, - vector=[0.0], - payload=e, - ) - ) - - if not points: - return 0, 0 - - ensure_edges_collection(qdrant_client, collection) - qdrant_client.upsert(collection_name=collection, points=points) - return len(points), len(seen) diff --git a/app/core/env_vars.py b/app/core/env_vars.py deleted file mode 100644 index b1f2d4b..0000000 --- a/app/core/env_vars.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Datei: app/core/env_vars.py -Version: 1.1.0 (2025-11-08) - -Zweck - Einheitliche Auflösung von ENV-Variablen (Prefix, Qdrant, Embeddings, Hashing) - mit Abwärtskompatibilität. - -Grundsatz - - Für Qdrant-Funktionen ist 'COLLECTION_PREFIX' der Primärschlüssel. - - 'MINDNET_PREFIX' bleibt für App-/UI-/Exporter-Kontexte nutzbar. - - Fallbacks sorgen dafür, dass ältere Umgebungen weiter funktionieren. - -Wichtig - - Lädt optional eine .env (wenn python-dotenv verfügbar ist). - - Überschreibt keine bereits gesetzten OS-Variablen (override=False). -""" -from __future__ import annotations - -import os -from typing import Optional, Dict - -# Optional: .env automatisch laden (ohne Hard-Fail, falls nicht vorhanden) -try: - from dotenv import load_dotenv, find_dotenv # type: ignore - _p = find_dotenv() - if _p: - load_dotenv(_p, override=False) -except Exception: - pass - -# -------- Prefix-Auflösung -------- - -def get_collection_prefix(cli_override: Optional[str] = None) -> str: - """ - Für Qdrant-relevante Funktionen: - 1) CLI-Override (--prefix) - 2) ENV COLLECTION_PREFIX - 3) ENV MINDNET_PREFIX (Fallback) - 4) 'mindnet' (Default) - """ - if cli_override and str(cli_override).strip(): - return str(cli_override).strip() - return ( - os.getenv("COLLECTION_PREFIX") - or os.getenv("MINDNET_PREFIX") - or "mindnet" - ) - -def get_mindnet_prefix(cli_override: Optional[str] = None) -> str: - """ - Für App-/UI-/Exporter-Kontexte: - 1) CLI-Override (--prefix) - 2) ENV MINDNET_PREFIX - 3) ENV COLLECTION_PREFIX (Fallback) - 4) 'mindnet' - """ - if cli_override and str(cli_override).strip(): - return str(cli_override).strip() - return ( - os.getenv("MINDNET_PREFIX") - or os.getenv("COLLECTION_PREFIX") - or "mindnet" - ) - -def get_prefix(cli_override: Optional[str] = None, target: str = "qdrant") -> str: - """ - Universelle Hülle (abwärtskompatibel): - target='qdrant' -> get_collection_prefix - target='app' -> get_mindnet_prefix - """ - if target.lower() == "app": - return get_mindnet_prefix(cli_override) - return get_collection_prefix(cli_override) - -# -------- Qdrant / Embeddings / Hashing -------- - -def get_qdrant_url(default: str = "http://127.0.0.1:6333") -> str: - return os.getenv("QDRANT_URL", default) - -def get_qdrant_api_key(default: str = "") -> str: - return os.getenv("QDRANT_API_KEY", default) - -def get_vector_dim(default: int = 384) -> int: - try: - return int(os.getenv("VECTOR_DIM", str(default))) - except Exception: - return default - -def get_embed_url(default: Optional[str] = None) -> Optional[str]: - return os.getenv("EMBED_URL", default) - -def get_hash_env() -> Dict[str, str]: - """ - Liefert die Hash-Konfiguration (nur Aggregation; die Auswertung bleibt in den Skripten). - """ - return { - "MINDNET_HASH_COMPARE": os.getenv("MINDNET_HASH_COMPARE", ""), - "MINDNET_HASH_SOURCE": os.getenv("MINDNET_HASH_SOURCE", ""), - "MINDNET_HASH_NORMALIZE": os.getenv("MINDNET_HASH_NORMALIZE", ""), - } diff --git a/app/core/ranking.py b/app/core/ranking.py deleted file mode 100644 index e9105f3..0000000 --- a/app/core/ranking.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -app/core/ranking.py — Kombiniertes Scoring (WP-04) - -Zweck: - Zusammenführen von semantischem Score (normalisiert), Edge-Bonus und - Centrality-Bonus in einen Gesamtscore für die Ergebnisreihung. -Kompatibilität: - Python 3.12+ -Version: - 0.1.0 (Erstanlage) -Stand: - 2025-10-07 -Bezug: - WP-04 Ranking-Formel (w_sem, w_edge, w_cent) -Nutzung: - from app.core.ranking import combine_scores -Änderungsverlauf: - 0.1.0 (2025-10-07) – Erstanlage. -""" - -from __future__ import annotations -from typing import List, Tuple, Dict - - -def normalize_scores(values: List[float]) -> List[float]: - """Min-Max-Normalisierung über die Kandidatenmenge (Fallback 0.5 bei Konstanz).""" - if not values: - return values - lo, hi = min(values), max(values) - if hi - lo < 1e-9: - return [0.5] * len(values) - return [(v - lo) / (hi - lo) for v in values] - - -def combine_scores( - hits: List[Tuple[str, float, dict]], # (id, semantic_score, payload) - edge_bonus_map: Dict[str, float], - centrality_map: Dict[str, float], - w_sem: float = 0.70, - w_edge: float = 0.25, - w_cent: float = 0.05, -) -> List[Tuple[str, float, float, float, float]]: - """ - Liefert Liste von (point_id, total_score, edge_bonus, centrality_bonus, raw_semantic_score), - absteigend nach total_score sortiert. - """ - sem = [h[1] for h in hits] - sem_n = normalize_scores(sem) - out = [] - for (pid, s, payload), s_norm in zip(hits, sem_n): - e = edge_bonus_map.get(pid, 0.0) - c = centrality_map.get(pid, 0.0) - total = w_sem * s_norm + w_edge * e + w_cent * c - out.append((pid, total, e, c, s)) - out.sort(key=lambda t: t[1], reverse=True) - return out diff --git a/app/core/retriever_config.py b/app/core/retriever_config.py deleted file mode 100644 index fcf7bc7..0000000 --- a/app/core/retriever_config.py +++ /dev/null @@ -1,116 +0,0 @@ -"""app/core/retriever_config.py ---------------------------------- -Zentrale Konfiguration für den mindnet-Retriever (WP-04). - -Zweck: - - Lädt config/retriever.yaml (falls vorhanden) oder nutzt sinnvolle Defaults. - - Bietet einen gecachten Zugriff auf die Retriever-Config für - andere Module (z. B. graph_adapter, retriever). - -Hinweis zur Weiterentwicklung (Selbstjustierung): - - Die hier definierten Parameter sind so gewählt, dass sie später - durch ein Feedback-/Learning-to-Rank-Modell überschrieben werden - können, ohne die restliche Architektur anzupassen. -""" - -from __future__ import annotations - -import os -from dataclasses import dataclass -from functools import lru_cache -from pathlib import Path -from typing import Dict - -try: - import yaml # type: ignore -except Exception: # pragma: no cover - Fallback, falls PyYAML nicht installiert ist. - yaml = None # type: ignore - -@dataclass(frozen=True) -class RetrieverConfig: - semantic_scale: float - edge_scale: float - centrality_scale: float - edge_weights: Dict[str, float] - -@lru_cache -def get_retriever_config() -> RetrieverConfig: - """Lädt die Retriever-Konfiguration (YAML + Defaults). - - Reihenfolge: - 1. Defaults (sinnvoll gewählte Startwerte). - 2. Optional: config/retriever.yaml bzw. Pfad aus ENV - MINDNET_RETRIEVER_CONFIG überschreibt die Defaults. - - Die Funktion ist bewusst gecached, da sich die Konfiguration zur - Laufzeit üblicherweise nicht ändert. Für dynamisches Nachladen - könnte der Cache explizit geleert werden. - """ - - # 1) Defaults – bewusst konservativ gewählt. - semantic_scale = 1.0 - edge_scale = 1.0 - centrality_scale = 1.0 - - edge_weights: Dict[str, float] = { - # Wissens-Kanten - "depends_on": 1.0, - "related_to": 0.7, - "similar_to": 0.7, - "references": 0.5, - # Struktur-Kanten - "belongs_to": 0.2, - "next": 0.1, - "prev": 0.1, - # Sonstige / technische Kanten - "backlink": 0.2, - "references_at": 0.2, - } - - # 2) Optional: YAML-Konfiguration laden - cfg_path_env = os.getenv("MINDNET_RETRIEVER_CONFIG") - if cfg_path_env: - cfg_path = Path(cfg_path_env) - else: - # Project-Root = zwei Ebenen über app/core/ - cfg_path = Path(__file__).resolve().parents[2] / "config" / "retriever.yaml" - - if yaml is not None and cfg_path.exists(): - try: - data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} - except Exception: - data = {} - - retr = data.get("retriever") or {} - - # Skalenwerte überschreiben, falls angegeben - try: - semantic_scale = float(retr.get("semantic_scale", semantic_scale)) - except (TypeError, ValueError): - pass - - try: - edge_scale = float(retr.get("edge_scale", edge_scale)) - except (TypeError, ValueError): - pass - - try: - centrality_scale = float(retr.get("centrality_scale", centrality_scale)) - except (TypeError, ValueError): - pass - - # Edge-Gewichte je Kanten-Typ - ew_cfg = retr.get("edge_weights") or {} - if isinstance(ew_cfg, dict): - for k, v in ew_cfg.items(): - try: - edge_weights[str(k)] = float(v) - except (TypeError, ValueError): - continue - - return RetrieverConfig( - semantic_scale=semantic_scale, - edge_scale=edge_scale, - centrality_scale=centrality_scale, - edge_weights=edge_weights, - ) diff --git a/app/core/schema_loader.py b/app/core/schema_loader.py deleted file mode 100644 index 019755f..0000000 --- a/app/core/schema_loader.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations -import json -import os -from functools import lru_cache -from jsonschema import Draft202012Validator, RefResolver - -SCHEMAS_DIR = os.getenv("SCHEMAS_DIR", os.path.join(os.path.dirname(os.path.dirname(__file__)), "..", "schemas")) - -@lru_cache(maxsize=16) -def load_schema(name: str) -> dict: - # name: "note.schema.json" | "chunk.schema.json" | "edge.schema.json" - path = os.path.join(SCHEMAS_DIR, name) - if not os.path.isfile(path): - raise FileNotFoundError(f"Schema not found: {path}") - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - -@lru_cache(maxsize=16) -def get_validator(name: str) -> Draft202012Validator: - schema = load_schema(name) - resolver = RefResolver.from_schema(schema) - return Draft202012Validator(schema, resolver=resolver) diff --git a/app/core/validate_note.py b/app/core/validate_note.py deleted file mode 100644 index 1fa77a6..0000000 --- a/app/core/validate_note.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations -from typing import Dict -from jsonschema import ValidationError -from .schema_loader import get_validator - -NOTE_SCHEMA_NAME = "note.schema.json" - -def validate_note_payload(payload: Dict) -> None: - validator = get_validator(NOTE_SCHEMA_NAME) - errors = sorted(validator.iter_errors(payload), key=lambda e: e.path) - if errors: - msgs = [] - for e in errors: - loc = ".".join([str(x) for x in e.path]) or "" - msgs.append(f"{loc}: {e.message}") - raise ValidationError(" | ".join(msgs)) diff --git a/app/embed_server.py b/app/embed_server.py deleted file mode 100644 index fdc3213..0000000 --- a/app/embed_server.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Version 1 -""" -from __future__ import annotations -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -from typing import List, Optional -from sentence_transformers import SentenceTransformer - -app = FastAPI(title="mindnet-embed", version="1.0") - -MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" # 384-dim -_model: SentenceTransformer | None = None - -class EmbedIn(BaseModel): - model: Optional[str] = None - inputs: List[str] - -class EmbedOut(BaseModel): - embeddings: List[List[float]] - -@app.on_event("startup") -def _load_model(): - global _model - _model = SentenceTransformer(MODEL_NAME) - -@app.get("/health") -def health(): - return {"ok": True, "model": MODEL_NAME, "dim": 384} - -@app.post("/embed", response_model=EmbedOut) -def embed(payload: EmbedIn) -> EmbedOut: - if _model is None: - raise HTTPException(status_code=503, detail="Model not loaded") - if not payload.inputs: - return EmbedOut(embeddings=[]) - vecs = _model.encode(payload.inputs, normalize_embeddings=False).tolist() - if any(len(v) != 384 for v in vecs): - raise HTTPException(status_code=500, detail="Embedding size mismatch (expected 384)") - return EmbedOut(embeddings=vecs) diff --git a/app/graph/service.py b/app/graph/service.py deleted file mode 100644 index 40b430e..0000000 --- a/app/graph/service.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Modul: app/graph/service.py -Version: 0.1.0 -Datum: 2025-09-10 - -Zweck ------ -Leichtgewichtiger Graph-Layer über Qdrant: - - get_note(note_id) - - get_chunks(note_id) - - neighbors(source_id, kinds=[...], scope=['note','chunk'], depth=1) - - walk_bfs(source_id, kinds, max_depth) - - context_for_note(note_id, max_neighbors): heuristische Kontextsammlung - -Hinweise --------- -- Nutzt die bestehenden Collections _notes/_chunks/_edges. -- Edges werden über Payload-Felder (`kind`, `source_id`, `target_id`) abgefragt. -""" -from __future__ import annotations -from typing import List, Dict, Any, Optional, Iterable, Set, Tuple -from qdrant_client.http import models as rest -from app.core.qdrant import QdrantConfig, get_client - -def _cols(prefix: str): - return f"{prefix}_notes", f"{prefix}_chunks", f"{prefix}_edges" - -class GraphService: - def __init__(self, cfg: Optional[QdrantConfig] = None, prefix: Optional[str] = None): - self.cfg = cfg or QdrantConfig.from_env() - if prefix: - self.cfg.prefix = prefix - self.client = get_client(self.cfg) - self.notes_col, self.chunks_col, self.edges_col = _cols(self.cfg.prefix) - - # ------------------------ fetch helpers ------------------------ - def _scroll(self, col: str, flt: Optional[rest.Filter] = None, limit: int = 256): - out = [] - nextp = None - while True: - pts, nextp = self.client.scroll( - collection_name=col, - with_payload=True, - with_vectors=False, - limit=limit, - offset=nextp, - scroll_filter=flt, - ) - if not pts: - break - out.extend(pts) - if nextp is None: - break - return out - - # ------------------------ public API --------------------------- - def get_note(self, note_id: str) -> Optional[Dict[str, Any]]: - f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - pts, _ = self.client.scroll(self.notes_col, with_payload=True, with_vectors=False, limit=1, scroll_filter=f) - return (pts[0].payload or None) if pts else None - - def get_chunks(self, note_id: str) -> List[Dict[str, Any]]: - f = rest.Filter(must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))]) - pts = self._scroll(self.chunks_col, f) - # Sortierung analog Export - def key(pl): - p = pl.payload or {} - s = p.get("seq") or 0 - ci = p.get("chunk_index") or 0 - n = 0 - cid = p.get("chunk_id") or "" - if isinstance(cid, str) and "#" in cid: - try: - n = int(cid.rsplit("#", 1)[-1]) - except Exception: - n = 0 - return (int(s), int(ci), n) - pts_sorted = sorted(pts, key=key) - return [p.payload or {} for p in pts_sorted] - - def neighbors(self, source_id: str, kinds: Optional[Iterable[str]] = None, - scope: Optional[Iterable[str]] = None, depth: int = 1) -> Dict[str, List[Dict[str, Any]]]: - """ - Liefert eingehende & ausgehende Nachbarn (nur nach kind gefiltert). - depth==1: direkte Kanten. - """ - kinds = list(kinds) if kinds else None - must = [rest.FieldCondition(key="source_id", match=rest.MatchValue(value=source_id))] - if kinds: - must.append(rest.FieldCondition(key="kind", match=rest.MatchAny(any=kinds))) - f = rest.Filter(must=must) - edges = self._scroll(self.edges_col, f) - out = {"out": [], "in": []} - for e in edges: - out["out"].append(e.payload or {}) - # Inverse Richtung (eingehend) - must_in = [rest.FieldCondition(key="target_id", match=rest.MatchValue(value=source_id))] - if kinds: - must_in.append(rest.FieldCondition(key="kind", match=rest.MatchAny(any=kinds))) - f_in = rest.Filter(must=must_in) - edges_in = self._scroll(self.edges_col, f_in) - for e in edges_in: - out["in"].append(e.payload or {}) - return out - - def walk_bfs(self, source_id: str, kinds: Iterable[str], max_depth: int = 2) -> Set[str]: - visited: Set[str] = {source_id} - frontier: Set[str] = {source_id} - kinds = list(kinds) - for _ in range(max_depth): - nxt: Set[str] = set() - for s in frontier: - neigh = self.neighbors(s, kinds=kinds) - for e in neigh["out"]: - t = e.get("target_id") - if isinstance(t, str) and t not in visited: - visited.add(t) - nxt.add(t) - frontier = nxt - if not frontier: - break - return visited - - def context_for_note(self, note_id: str, kinds: Iterable[str] = ("references","backlink"), max_neighbors: int = 12) -> Dict[str, Any]: - """ - Heuristischer Kontext: eigene Chunks + Nachbarn nach Kantenarten, dedupliziert. - """ - note = self.get_note(note_id) or {} - chunks = self.get_chunks(note_id) - neigh = self.neighbors(note_id, kinds=list(kinds)) - targets = [] - for e in neigh["out"]: - t = e.get("target_id") - if isinstance(t, str): - targets.append(t) - for e in neigh["in"]: - s = e.get("source_id") - if isinstance(s, str): - targets.append(s) - # de-dupe - seen = set() - uniq = [] - for t in targets: - if t not in seen: - seen.add(t) - uniq.append(t) - uniq = uniq[:max_neighbors] - neighbor_notes = [self.get_note(t) for t in uniq] - return { - "note": note, - "chunks": chunks, - "neighbors": [n for n in neighbor_notes if n], - "edges_out": neigh["out"], - "edges_in": neigh["in"], - } - -# Optional: Mini-CLI -if __name__ == "__main__": # pragma: no cover - import argparse, json - ap = argparse.ArgumentParser() - ap.add_argument("--prefix", help="Collection-Prefix (überschreibt ENV)") - ap.add_argument("--note-id", required=True) - ap.add_argument("--neighbors", action="store_true", help="Nur Nachbarn anzeigen") - args = ap.parse_args() - svc = GraphService(prefix=args.prefix) - if args.neighbors: - out = svc.neighbors(args.note_id, kinds=["references","backlink","prev","next","belongs_to"]) - else: - out = svc.context_for_note(args.note_id) - print(json.dumps(out, ensure_ascii=False, indent=2)) diff --git a/app/main.py b/app/main.py index 521bc9a..1e2969c 100644 --- a/app/main.py +++ b/app/main.py @@ -10,8 +10,8 @@ LAST_ANALYSIS: 2025-12-15 from __future__ import annotations from fastapi import FastAPI from .config import get_settings -from .routers.embed_router import router as embed_router -from .routers.qdrant_router import router as qdrant_router +#from .routers.embed_router import router as embed_router +#from .routers.qdrant_router import router as qdrant_router from .routers.query import router as query_router from .routers.graph import router as graph_router @@ -35,8 +35,8 @@ def create_app() -> FastAPI: def healthz(): return {"status": "ok", "qdrant": s.QDRANT_URL, "prefix": s.COLLECTION_PREFIX} - app.include_router(embed_router) - app.include_router(qdrant_router) +# app.include_router(embed_router) +# app.include_router(qdrant_router) app.include_router(query_router, prefix="/query", tags=["query"]) app.include_router(graph_router, prefix="/graph", tags=["graph"]) -- 2.43.0 From fd2fac71129929f35a0859c9250de223871562a7 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Dec 2025 16:38:51 +0100 Subject: [PATCH 04/24] dev guide --- docs/05_Development/05_developer_guide.md | 61 +++++++++++++++-------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/docs/05_Development/05_developer_guide.md b/docs/05_Development/05_developer_guide.md index f83a643..408714f 100644 --- a/docs/05_Development/05_developer_guide.md +++ b/docs/05_Development/05_developer_guide.md @@ -38,25 +38,25 @@ graph TD User((User)) subgraph "Frontend Layer (Streamlit)" - UI[ui.py Router] - ViewChat[Chat View] - ViewGraph[Graph View] - ViewEditor[Editor View] - Logic[Callbacks & State] + UI["ui.py Router"] + ViewChat["Chat View"] + ViewGraph["Graph View"] + ViewEditor["Editor View"] + Logic["Callbacks & State"] end subgraph "Backend Layer (FastAPI)" - API[main.py] - RouterChat[Chat / RAG] - RouterIngest[Ingest / Write] - CoreRet[Retriever Engine] - CoreIngest[Ingestion Pipeline] + API["main.py"] + RouterChat["Chat / RAG"] + RouterIngest["Ingest / Write"] + CoreRet["Retriever Engine"] + CoreIngest["Ingestion Pipeline"] end subgraph "Infrastructure & Services" - LLM[Ollama (Phi3/Nomic)] - DB[(Qdrant Vector DB)] - FS[File System (.md)] + LLM["Ollama (Phi3/Nomic)"] + DB[("Qdrant Vector DB")] + FS["File System (.md)"] end User <--> UI @@ -173,19 +173,36 @@ Das Backend stellt die Logik via REST-API bereit. ### 4.4 Scripts & Tooling (Die Admin-Toolbox) Der Ordner `scripts/` enthält verifizierte Werkzeuge für den Betrieb. +#### 1. Script-Übersicht -| Skript | Status | Zweck | Wichtiges Argument | -| :--- | :--- | :--- | :--- | -| **`import_markdown.py`** | 🟢 Prod | **Master-Sync.** Der zentrale Importer. | `--apply`, `--purge-before-upsert` | -| **`reset_qdrant.py`** | ⚠️ Ops | **Wipe.** Löscht Collections für Rebuilds. | `--mode wipe`, `--yes` | -| **`export_markdown.py`** | 🟢 Backup| **Backup.** Exportiert DB-Inhalt zurück zu MD. | -- | -| **`health_check_mindnet.py`**| 🟢 Ops | **Monitoring.** Prüft ob API/DB laufen. | (Exit Code 0/1) | -| **`payload_dryrun.py`** | 🟢 Test | **Audit.** Simuliert Import (Schema Check). | -- | -| **`edges_full_check.py`** | 🟢 Test | **Integrity.** Prüft Graph-Logik. | -- | -| **`resolve_unresolved.py`**| 🟡 Maint | **Repair.** Versucht, kaputte Links zu heilen. | -- | +| Skript | Status | Zweck | Argumente & Parameter | Beispielaufruf | +| :--- | :--- | :--- | :--- | :--- | +| **`import_markdown.py`** | 🟢 **Core** | **Ingestion.** Scannt den Vault und importiert Dateien asynchron in Qdrant. Beachtet Hashes. | `--vault PATH` (Quellordner)
`--prefix TEXT` (Collection Prefix)
`--force` (Erzwingt Re-Index)
`--apply` (Schreibt in DB; sonst Dry-Run) | `python3 scripts/import_markdown.py --vault ./vault --apply` | +| **`reset_qdrant.py`** | 🟢 **Core** | **DB-Reset.** Löscht Collections (`wipe`) oder leert sie (`truncate`). Legt Indizes neu an. | `--mode {wipe,truncate}`
`--prefix TEXT`
`--yes` (Keine Rückfrage)
`--no-indexes` (Skip Index-Erstellung) | `python3 -m scripts.reset_qdrant --mode wipe --yes` | +| **`health_check_mindnet.py`** | 🟢 **Ops** | **Monitoring.** Prüft API-Verfügbarkeit (`/query`) und Antwortqualität. Standalone (keine Imports). | `--url URL`
`--query TEXT`
`--top-k INT`
`--strict` (Exit-Code 1 bei Warnings) | `python3 scripts/health_check_mindnet.py --strict` | +| **`payload_dryrun.py`** | 🟡 **Dev** | **Debugger.** Simuliert die JSON-Erstellung (Payloads) ohne DB-Schreibzugriff. Prüft `types.yaml`. | `--vault PATH`
`--note-id ID` (Filter)
`--with-edges` (Zeigt Kanten) | `python3 -m scripts.payload_dryrun --vault ./vault --note-id "projekt-alpha"` | +| **`edges_full_check.py`** | 🟡 **Audit** | **Integritäts-Check.** Zählt Kanten in der DB und prüft logische Konsistenz (z.B. Chunk-Reihenfolge). | Keine (Liest Konfiguration nur aus Umgebungsvariablen `.env`) | `python3 -m scripts.edges_full_check` | +| **`resolve_unresolved_references.py`** | 🔵 **Maint** | **Link-Healer.** Repariert "tote" Links in der DB nachträglich und erzeugt Backlinks. | `--prefix TEXT`
`--limit INT`
`--apply` (Schreibt Änderungen) | `python3 -m scripts.resolve_unresolved_references --apply` | +| **`export_markdown.py`** | ⚪ **Utility** | **Reverse ETL.** Exportiert den Datenbank-Inhalt zurück in Markdown-Dateien (Backup/Recovery). | `--out PATH` (Ziel)
`--note-id ID`
`--include-edges {yaml,footer}`
`--flatten-paths` | `python3 -m scripts.export_markdown --out ./backup` | --- +#### 2. Einsatzszenarien & Bewertung + +### 🟢 Essentiell für den Betrieb (Must-Have) +* **`import_markdown.py`**: Das Arbeitspferd. Ohne dieses Skript kommen keine Daten ins System (außer man nutzt die API einzeln). Es ist für Cronjobs optimiert. +* **`reset_qdrant.py`**: Zwingend notwendig für CI/CD-Pipelines oder saubere Neustarts bei Schema-Änderungen. +* **`health_check_mindnet.py`**: Ideal für Docker-Healthchecks oder Monitoring-Tools (Nagios, Uptime Kuma), da es keine Python-Abhängigkeiten zur App hat. + +### 🟡 Hilfreich für Entwicklung & Debugging (Should-Have) +* **`payload_dryrun.py`**: Sehr wertvoll, wenn man an der `config/types.yaml` oder dem `chunker.py` arbeitet. Man sieht sofort, wie das JSON aussieht, ohne die Datenbank "zuzumüllen". +* **`edges_full_check.py`**: Wichtiges Diagnose-Tool, wenn der Graph im Frontend "komisch" aussieht oder Kanten fehlen. + +### 🔵 Wartung & Spezialfälle (Nice-to-Have) +* **`resolve_unresolved_references.py`**: Sinnvoll in einem "Knowledge Garden", wo oft Links auf noch nicht existierende Notizen gesetzt werden ("Red Links"). Dieses Skript "heilt" den Graphen nachträglich. +* **`export_markdown.py`**: Ein Notfall-Tool. Da Mindnet nach dem Prinzip "Filesystem First" arbeitet, ist ein Export aus der DB selten nötig, kann aber bei versehentlichem Löschen von Dateien lebensrettend sein. +--- + ## 5. Maintenance & "Kill List" Folgende Dateien wurden im Audit v2.6 als veraltet, redundant oder "Zombie-Code" identifiziert und sollten entfernt werden. -- 2.43.0 From 12e374bc0552724491c246419d616864ad18cc2f Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Dec 2025 16:42:53 +0100 Subject: [PATCH 05/24] mermaid fix --- docs/05_Development/05_developer_guide.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/05_Development/05_developer_guide.md b/docs/05_Development/05_developer_guide.md index 408714f..ba1a227 100644 --- a/docs/05_Development/05_developer_guide.md +++ b/docs/05_Development/05_developer_guide.md @@ -60,11 +60,11 @@ graph TD end User <--> UI - UI --> API : REST (Chat, Save, Feedback) - UI -.-> DB : Direct Read (Graph Viz Performance) - API --> LLM : Embeddings & Completion - API --> DB : Read/Write - API --> FS : Read/Write (Source of Truth) + UI -- "REST (Chat, Save, Feedback)" --> API + UI -. "Direct Read (Graph Viz Performance)" .-> DB + API -- "Embeddings & Completion" --> LLM + API -- "Read/Write" --> DB + API -- "Read/Write (Source of Truth)" --> FS ``` ### 2.2 Datenfluss-Muster -- 2.43.0 From cd3946bd119e203c2874d1fd3bb0a5c7b8e080ba Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Dec 2025 16:50:11 +0100 Subject: [PATCH 06/24] mermaid --- docs/05_Development/05_developer_guide.md | 46 ++++++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/05_Development/05_developer_guide.md b/docs/05_Development/05_developer_guide.md index ba1a227..38c485c 100644 --- a/docs/05_Development/05_developer_guide.md +++ b/docs/05_Development/05_developer_guide.md @@ -69,13 +69,47 @@ graph TD ### 2.2 Datenfluss-Muster -1. **Ingestion (Write):** - `Markdown` -> `Parser` -> `Chunker` -> `SemanticAnalyzer (LLM)` -> `Embedder` -> `Qdrant (Points)` -2. **Retrieval (Read):** - `Query` -> `Embedding` -> `Hybrid Search (Vector + Graph)` -> `Re-Ranking` -> `LLM Context` -3. **Visualisierung (Graph):** - `UI` -> `GraphService` -> `Qdrant (Edges Collection)` -> `Cytoscape` +#### A. Ingestion (Write) +Vom Markdown zur Vektor-Datenbank. +```mermaid +graph LR + MD[Markdown File] --> Parser(Parser) + Parser --> Chunker(Chunker) + Chunker -- "Text Chunks" --> SemAn{SemanticAnalyzer
(LLM)} + SemAn -- "Smart Edges" --> Embedder(Embedder) + Embedder --> DB[(Qdrant
Points)] + + style DB fill:#f9f,stroke:#333,stroke-width:2px + style SemAn fill:#ff9,stroke:#333,stroke-width:2px +``` +#### B. Retrieval (Read) +Die hybride Suche für Chat & RAG. +```mermaid +graph LR + Query([Query]) --> Embed(Embedding) + Embed --> Hybrid{Hybrid Search} + + subgraph Search Components + Vec[Vector Score] + Graph[Graph/Edge Bonus] + end + + Vec --> Hybrid + Graph --> Hybrid + + Hybrid --> Rank(Re-Ranking) + Rank --> Ctx[LLM Context] +``` + +#### C. Visualisierung (Graph) +Der optimierte Pfad für das Frontend. +```mermaid +graph LR + UI[Frontend UI] --> Service(GraphService) + Service -- "Direct Read" --> DB[(Qdrant
Edges Collection)] + DB --> Cyto[Cytoscape
Rendering] +``` --- ## 3. Physische Architektur -- 2.43.0 From 179949b28925b481637de02f52b0ef0bb6febaf1 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Dec 2025 16:52:20 +0100 Subject: [PATCH 07/24] bug fix --- docs/05_Development/05_developer_guide.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/05_Development/05_developer_guide.md b/docs/05_Development/05_developer_guide.md index 38c485c..609ad61 100644 --- a/docs/05_Development/05_developer_guide.md +++ b/docs/05_Development/05_developer_guide.md @@ -73,11 +73,11 @@ graph TD Vom Markdown zur Vektor-Datenbank. ```mermaid graph LR - MD[Markdown File] --> Parser(Parser) - Parser --> Chunker(Chunker) - Chunker -- "Text Chunks" --> SemAn{SemanticAnalyzer
(LLM)} - SemAn -- "Smart Edges" --> Embedder(Embedder) - Embedder --> DB[(Qdrant
Points)] + MD["Markdown File"] --> Parser("Parser") + Parser --> Chunker("Chunker") + Chunker -- "Text Chunks" --> SemAn{"SemanticAnalyzer
(LLM)"} + SemAn -- "Smart Edges" --> Embedder("Embedder") + Embedder --> DB[("Qdrant
Points")] style DB fill:#f9f,stroke:#333,stroke-width:2px style SemAn fill:#ff9,stroke:#333,stroke-width:2px -- 2.43.0 From 9372cfb8caafb157870db85238ac357a9c42beca Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Dec 2025 16:58:32 +0100 Subject: [PATCH 08/24] TOC --- docs/05_Development/05_developer_guide.md | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/05_Development/05_developer_guide.md b/docs/05_Development/05_developer_guide.md index 609ad61..1bdcc87 100644 --- a/docs/05_Development/05_developer_guide.md +++ b/docs/05_Development/05_developer_guide.md @@ -14,7 +14,43 @@ context: "Umfassender Guide für Entwickler: Architektur, Modul-Interna (Deep Di Dieser Guide ist die zentrale technische Referenz für Mindnet v2.6. Er vereint das technische Verständnis der Module mit dem operativen Workflow zwischen Windows (Dev) und Linux (Runtime). --- +# Inhaltsverzeichnis +- [Mindnet Developer Guide \& Workflow](#mindnet-developer-guide--workflow) +- [Inhaltsverzeichnis](#inhaltsverzeichnis) + - [1. Einführung \& Systemüberblick](#1-einführung--systemüberblick) + - [Was ist Mindnet?](#was-ist-mindnet) + - [Kern-Philosophie](#kern-philosophie) + - [2. Architektur](#2-architektur) + - [2.1 High-Level Übersicht](#21-high-level-übersicht) + - [2.2 Datenfluss-Muster](#22-datenfluss-muster) + - [A. Ingestion (Write)](#a-ingestion-write) + - [B. Retrieval (Read)](#b-retrieval-read) + - [C. Visualisierung (Graph)](#c-visualisierung-graph) + - [3. Physische Architektur](#3-physische-architektur) + - [4. Projektstruktur \& Modul-Referenz (Deep Dive)](#4-projektstruktur--modul-referenz-deep-dive) + - [4.1 Verzeichnisbaum](#41-verzeichnisbaum) + - [4.2 Frontend Architecture (`app/frontend/`)](#42-frontend-architecture-appfrontend) + - [Frontend Design Patterns (Wichtig!)](#frontend-design-patterns-wichtig) + - [4.3 Backend Architecture (`app/`)](#43-backend-architecture-app) + - [4.4 Scripts \& Tooling (Die Admin-Toolbox)](#44-scripts--tooling-die-admin-toolbox) + - [1. Script-Übersicht](#1-script-übersicht) + - [2. Einsatzszenarien \& Bewertung](#2-einsatzszenarien--bewertung) + - [🟢 Essentiell für den Betrieb (Must-Have)](#-essentiell-für-den-betrieb-must-have) + - [🟡 Hilfreich für Entwicklung \& Debugging (Should-Have)](#-hilfreich-für-entwicklung--debugging-should-have) + - [🔵 Wartung \& Spezialfälle (Nice-to-Have)](#-wartung--spezialfälle-nice-to-have) + - [5. Maintenance \& "Kill List"](#5-maintenance--kill-list) + - [6. Lokales Setup (Development)](#6-lokales-setup-development) + - [7. Der Entwicklungs-Zyklus (Workflow)](#7-der-entwicklungs-zyklus-workflow) + - [Phase 1: Windows (Code)](#phase-1-windows-code) + - [Phase 2: Beelink (Test / Dev)](#phase-2-beelink-test--dev) + - [Phase 3: Release \& Deployment (Prod)](#phase-3-release--deployment-prod) + - [8. Erweiterungs-Guide: "Teach-the-AI"](#8-erweiterungs-guide-teach-the-ai) + - [Workflow A: Neuen Typ implementieren (z. B. `type: risk`)](#workflow-a-neuen-typ-implementieren-z-b-type-risk) + - [Workflow B: Graph-Farben ändern](#workflow-b-graph-farben-ändern) + - [9. Tests \& Debugging](#9-tests--debugging) + - [10. Troubleshooting \& One-Liners](#10-troubleshooting--one-liners) +--- ## 1. Einführung & Systemüberblick ### Was ist Mindnet? -- 2.43.0 From 4204c2c9742a45a06e6ecf0f3ba68af416851abc Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Dec 2025 17:32:38 +0100 Subject: [PATCH 09/24] neue docs --- docs/00_General/00_documentation_map.md | 1 + .../05_Development/05_genai_best_practices.md | 152 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 docs/05_Development/05_genai_best_practices.md diff --git a/docs/00_General/00_documentation_map.md b/docs/00_General/00_documentation_map.md index 1a443b9..f575fd6 100644 --- a/docs/00_General/00_documentation_map.md +++ b/docs/00_General/00_documentation_map.md @@ -58,6 +58,7 @@ Das Repository ist in **logische Domänen** unterteilt. | Datei | Inhalt & Zweck | | :--- | :--- | | `05_developer_guide.md` | **Workflow.** Hardware-Setup (Win/Pi/Beelink), Git-Flow, Test-Befehle, Modul-Interna. | +| `05_genai_best_practices.md` | **AI Workflow.** Prompt-Library, Templates und Best Practices für die Entwicklung mit LLMs. | ### 📂 06_Roadmap & 99_Archive *Zielgruppe: Projektleitung* diff --git a/docs/05_Development/05_genai_best_practices.md b/docs/05_Development/05_genai_best_practices.md new file mode 100644 index 0000000..86df623 --- /dev/null +++ b/docs/05_Development/05_genai_best_practices.md @@ -0,0 +1,152 @@ +--- +doc_type: developer_guide +audience: developer, architect +scope: genai, prompting, workflow +status: active +version: 1.0 +context: "Leitfaden für die effiziente Softwareentwicklung mit LLMs im Mindnet-Projekt." +--- + +# GenAI Development Best Practices & Prompt Library + +Dieser Leitfaden definiert Standards für die Zusammenarbeit mit KI-Modellen (ChatGPT, Claude, Gemini) im Rahmen der Mindnet-Entwicklung. Ziel ist es, Halluzinationen zu minimieren, den Kontext effizient zu nutzen und die Dokumentation synchron zum Code zu halten. + +--- + +## 1. Grundprinzipien + +### 1.1 Context is King (aber teuer) +LLMs haben ein begrenztes Kontext-Fenster. +* **Don't:** "Hier ist mein ganzer Code, fix den Bug." (Führt zu Vergessen von Details). +* **Do:** Nutze die **"Map & Fetch" Strategie**: + 1. Gib der KI eine Inhaltsübersicht (z.B. `project_scan_report.json` oder `tree`). + 2. Lass die KI entscheiden, welche Dateien sie für die Aufgabe benötigt. + 3. Lade nur diese Dateien hoch. + +### 1.2 Trust but Verify (Validierung) +* **Code:** Führe generierten Code **immer** lokal aus (Unit Tests oder Smoke Tests), bevor du ihn committest. +* **Pfade:** KIs erfinden gerne Pfade (z.B. `app/utils.py`, obwohl es `app/core/utils.py` ist). Prüfe Importe immer gegen die Projektstruktur. +* **Security:** Achte darauf, dass keine Secrets (API-Keys) in den Prompts landen und keine Secrets vom LLM halluziniert und hardcodiert werden. + +### 1.3 Atomic Chats +Nutze für verschiedene Aufgaben frische Chat-Kontexte. +* Ein Chat für "Frontend Refactoring". +* Ein neuer Chat für "Documentation Update". +* *Grund:* Alte Chats akkumulieren "Rauschen" und führen zu Fehlern. + +--- + +## 2. Prompt Library (Standard-Vorlagen) + +Nutze diese Prompts, um konsistente Ergebnisse zu erzielen. + +### 2.1 Der "Render-Safe" Prompt (System Instruction) +**Wann nutzen?** Immer am Anfang eines Chats, wenn die KI Markdown-Dateien oder Code generieren soll. +**Zweck:** Verhindert, dass die Antwort im Chat-Fenster abbricht, weil die KI Code-Blöcke falsch verschachtelt. + +```text +# SYSTEM-ANWEISUNG: SICHERES MARKDOWN-RENDERING + +Du agierst als technischer Assistent. Deine Aufgabe ist das Erstellen von Markdown-Dateien, die oft selbst Code-Blöcke enthalten. + +**DAS PROBLEM:** +Wenn du eine Markdown-Datei generierst, die Code-Blöcke (```) enthält, und diese Ausgabe selbst in einen Code-Block packst, interpretiert das Chat-Interface das erste innere ``` oft fälschlicherweise als das Ende der Ausgabe. + +**DIE REGEL (STRIKT BEFOLGEN):** +Um eine ununterbrochene Darstellung zu garantieren, musst du zwingend eine der folgenden Kapselungs-Methoden anwenden: + +### Methode A: Die 4-Backtick-Methode (Bevorzugt) +Umschließe den **gesamten** Datei-Inhalt mit **4 Backticks** (````). +Dies erlaubt dir, innerhalb der Datei normale 3 Backticks zu verwenden. + +### Methode B: Die 4-Space-Einrückung (Alternative) +Wenn du außen 3 Backticks verwendest, darfst du im Inneren **KEINE** Backticks verwenden. +Stattdessen müssen alle inneren Code-Beispiele mit **4 Leerzeichen (Spaces)** eingerückt werden. + +**ZUSAMMENFASSUNG:** +Generiere niemals verschachtelte 3-Backtick-Blöcke innerhalb von 3-Backtick-Blöcken. +``` + +--- + +### 2.2 Der "Doku-Update" Prompt (Nach WP-Abschluss) +**Wann nutzen?** Wenn ein Feature fertig codiert ist und die Doku (`docs/`) nachgezogen werden muss. +**Zweck:** Automatische Identifikation der betroffenen Doku-Dateien ohne manuelles Suchen. + +**Vorbedingung:** Lade `docs/00_General/00_documentation_map.md` und `docs/06_Roadmap/06_active_roadmap.md` hoch. + +```text +Du agierst als **Technical Documentation Lead**. + +**Kontext:** +Wir haben soeben ein Workpackage (WP) abgeschlossen. Der Code ist implementiert. +Jetzt müssen wir die Systemdokumentation (Mindnet v2.6 Modular Docs) aktualisieren. + +**Deine Aufgabe - Phase 1: Identifikation** +Analysiere die durchgeführten Änderungen dieses Workpackages (aus dem Chat-Verlauf). +Nutze die beiliegende `00_documentation_map.md`, um zu identifizieren, welche Dokumentations-Module betroffen sind. + +**Mapping-Logik:** +* Neue Features? -> `00_glossary.md`, `02_Concepts/*` +* DB/Payloads geändert? -> `03_tech_data_model.md` +* Import/Algorithmus geändert? -> `03_tech_ingestion_pipeline.md`, `03_tech_retrieval_scoring.md` +* Neue Configs? -> `03_tech_configuration.md`, `04_admin_operations.md` +* UI/UX geändert? -> `01_User_Manual/*`, `03_tech_frontend.md` + +**Output für Phase 1:** +Erstelle eine **Liste der betroffenen Dateien** mit Begründung. +Fordere mich explizit auf, dir diese Dateien hochzuladen. + +--- + +**Deine Aufgabe - Phase 2: Sequenzielle Bearbeitung** +Sobald ich die Dateien hochgeladen habe: +1. Nimm dir **eine** Datei aus der Liste vor. +2. Schreibe den kompletten, aktualisierten Inhalt (Markdown). + * *Wichtig:* Halte dich an den bestehenden Stil und die "Render-Safe"-Regel (4 Backticks). +3. **Warte** nach der Ausgabe auf mein "OK", bevor du die nächste Datei bearbeitest. + +**Bist du bereit für die Analyse?** +``` + +--- + +### 2.3 Der "Code Architect" Prompt (Refactoring & Analyse) +**Wann nutzen?** Wenn du dich in den Code einarbeiten willst oder Aufräumen musst. +**Vorbedingung:** Führe lokal `deep_scan.py` aus und lade `project_scan_report.json` hoch. + +```text +Du agierst als **Senior Software Architect**. + +**INPUT:** +Ich habe dir die Datei `project_scan_report.json` hochgeladen. Sie enthält eine Liste aller Dateien und ihrer Import-Beziehungen. + +**DIE SOLL-STRUKTUR (4 SÄULEN):** +Jede Datei muss einem dieser Zweige zugeordnet werden können: +1. **Backend:** via `app/main.py` +2. **Frontend:** via `app/frontend/ui.py` +3. **Batch/Ops:** via `scripts/` (Produktions-Tools) +4. **Tests:** via `tests/` + +**DEINE AUFGABE:** +Analysiere das JSON. Identifiziere "Zombies" (Dateien, die nirgends importiert werden und keinen klaren Entrypoint haben). +Erstelle eine: +1. **Modul-Tabelle** (Wer ruft wen auf?). +2. **Lösch-Vorschlagsliste** (Dead Code). +``` + +--- + +## 3. Workflow für ein Workpackage (WP) + +Ein typisches Mindnet-Feature wird so entwickelt: + +1. **Start:** Neuer Chat. Prompt **2.1 (Render-Safe)** eingeben. +2. **Kontext:** `project_scan_report.json` oder relevante Core-Dateien hochladen. +3. **Code:** Feature implementieren (Iterativ). +4. **Test:** Code lokal validieren. +5. **Doku:** + * Neuer Chat (optional, für sauberen Kontext). + * Prompt **2.1** + Prompt **2.2 (Doku-Update)** eingeben. + * Doku aktualisieren lassen. +6. **Commit:** Code + Doku zusammen pushen. \ No newline at end of file -- 2.43.0 From 7263fee4c70f655a41d2c28ffe59135d20eac5ef Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Dec 2025 17:55:53 +0100 Subject: [PATCH 10/24] prune --- app/core/embed.py | 82 ------------------ app/routers/qdrant_router.py | 160 ----------------------------------- app/services/llm_ollama.py | 88 ------------------- schemas/chunk.schema.json | 90 -------------------- schemas/edge.schema.json | 31 ------- schemas/note.schema.json | 45 ---------- 6 files changed, 496 deletions(-) delete mode 100644 app/core/embed.py delete mode 100644 app/routers/qdrant_router.py delete mode 100644 app/services/llm_ollama.py delete mode 100644 schemas/chunk.schema.json delete mode 100644 schemas/edge.schema.json delete mode 100644 schemas/note.schema.json diff --git a/app/core/embed.py b/app/core/embed.py deleted file mode 100644 index 56ff947..0000000 --- a/app/core/embed.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations -import os, time, json -import urllib.request -from typing import List, Dict, Any - -# Backend-Auswahl: -# - EMBED_BACKEND=ollama -> EMBED_URL=/api/embeddings (Ollama), EMBED_MODEL=z.B. nomic-embed-text -# - EMBED_BACKEND=mini -> EMBED_URL=/embed (unser MiniLM-Server), EMBED_MODEL=minilm-384 -EMBED_BACKEND = os.getenv("EMBED_BACKEND", "mini").lower() -EMBED_URL = os.getenv("EMBED_URL", "http://127.0.0.1:8990/embed") -EMBED_MODEL = os.getenv("EMBED_MODEL", "minilm-384") -EMBED_BATCH = int(os.getenv("EMBED_BATCH", "64")) -TIMEOUT = 60 - -class EmbedError(RuntimeError): ... - -def _post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]: - data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: - return json.loads(resp.read().decode("utf-8")) - -def _embed_mini(inputs: List[str], model: str, batch: int) -> List[List[float]]: - out: List[List[float]] = [] - i = 0 - while i < len(inputs): - chunk = inputs[i:i+batch] - # einfache Retries - for attempt in range(5): - try: - resp = _post_json(EMBED_URL, {"model": model, "inputs": chunk}) - vecs = resp.get("embeddings") or resp.get("vectors") or resp.get("data") - if not isinstance(vecs, list): - raise EmbedError(f"Bad embed response keys: {list(resp.keys())}") - out.extend(vecs) - break - except Exception: - if attempt == 4: - raise - time.sleep(1.5 * (attempt + 1)) - i += batch - return out - -def _embed_ollama(inputs: List[str], model: str, batch: int) -> List[List[float]]: - # Ollama /api/embeddings akzeptiert "input" als String ODER Array. - # Die Response enthält: - # - für single input: {"embedding":[...], "model":"...", ...} - # - für array input: {"embeddings":[[...],[...],...], "model":"...", ...} (je nach Version) - # Um maximal kompatibel zu sein, rufen wir pro Text einzeln auf. - out: List[List[float]] = [] - for text in inputs: - # Retries - for attempt in range(5): - try: - resp = _post_json(EMBED_URL, {"model": model, "input": text}) - if "embedding" in resp and isinstance(resp["embedding"], list): - out.append(resp["embedding"]) - elif "embeddings" in resp and isinstance(resp["embeddings"], list): - # Falls Server array zurückgibt, nimm das erste Element - vecs = resp["embeddings"] - out.append(vecs[0] if vecs else []) - else: - raise EmbedError(f"Ollama response unexpected keys: {list(resp.keys())}") - break - except Exception: - if attempt == 4: - raise - time.sleep(1.5 * (attempt + 1)) - return out - -def embed_texts(texts: List[str], model: str | None = None, batch_size: int | None = None) -> List[List[float]]: - model = model or EMBED_MODEL - batch = batch_size or EMBED_BATCH - if not texts: - return [] - if EMBED_BACKEND == "ollama": - return _embed_ollama(texts, model, batch) - # default: mini - return _embed_mini(texts, model, batch) - -def embed_one(text: str, model: str | None = None) -> List[float]: - return embed_texts([text], model=model, batch_size=1)[0] diff --git a/app/routers/qdrant_router.py b/app/routers/qdrant_router.py deleted file mode 100644 index 5042011..0000000 --- a/app/routers/qdrant_router.py +++ /dev/null @@ -1,160 +0,0 @@ -""" -Version 0.1 -""" - -from __future__ import annotations - -from typing import Any, Optional, List -import uuid - -from fastapi import APIRouter -from pydantic import BaseModel, Field -from qdrant_client import QdrantClient -from qdrant_client.http.models import ( - Distance, - VectorParams, - PointStruct, - Filter, - FieldCondition, - MatchValue, -) - -from ..config import get_settings -from ..embeddings import embed_texts - -router = APIRouter(prefix="/qdrant", tags=["qdrant"]) - -def _client() -> QdrantClient: - s = get_settings() - return QdrantClient(url=s.QDRANT_URL, api_key=s.QDRANT_API_KEY) - -def _col(name: str) -> str: - return f"{get_settings().COLLECTION_PREFIX}_{name}" - -def _uuid5(s: str) -> str: - """Deterministic UUIDv5 from arbitrary string (server-side point id).""" - return str(uuid.uuid5(uuid.NAMESPACE_URL, s)) - -# --- Models --- -class BaseMeta(BaseModel): - note_id: str = Field(..., description="Stable ID of the note (e.g., hash of vault-relative path)") - title: Optional[str] = Field(None, description="Note or chunk title") - path: Optional[str] = Field(None, description="Vault-relative path to the .md file") - Typ: Optional[str] = None - Status: Optional[str] = None - tags: Optional[List[str]] = None - Rolle: Optional[List[str]] = None # allow list - -class UpsertChunkRequest(BaseMeta): - chunk_id: str = Field(..., description="Stable ID of the chunk within the note") - text: str = Field(..., description="Chunk text content") - links: Optional[List[str]] = Field(default=None, description="Outbound links detected in the chunk") - -class UpsertNoteRequest(BaseMeta): - text: Optional[str] = Field(None, description="Full note text (optional)") - -class UpsertEdgeRequest(BaseModel): - src_note_id: str - dst_note_id: Optional[str] = None - src_chunk_id: Optional[str] = None - dst_chunk_id: Optional[str] = None - relation: str = Field(default="links_to") - link_text: Optional[str] = None - -class QueryRequest(BaseModel): - query: str - limit: int = 5 - note_id: Optional[str] = None - path: Optional[str] = None - tags: Optional[List[str]] = None - -# --- Helpers --- -def _ensure_collections(): - s = get_settings() - cli = _client() - # chunks - try: - cli.get_collection(_col("chunks")) - except Exception: - cli.recreate_collection(_col("chunks"), vectors_config=VectorParams(size=s.VECTOR_SIZE, distance=Distance.COSINE)) - # notes - try: - cli.get_collection(_col("notes")) - except Exception: - cli.recreate_collection(_col("notes"), vectors_config=VectorParams(size=s.VECTOR_SIZE, distance=Distance.COSINE)) - # edges (dummy vector of size 1) - try: - cli.get_collection(_col("edges")) - except Exception: - cli.recreate_collection(_col("edges"), vectors_config=VectorParams(size=1, distance=Distance.COSINE)) - -@router.post("/upsert_chunk", summary="Upsert a chunk into mindnet_chunks") -def upsert_chunk(req: UpsertChunkRequest) -> dict: - _ensure_collections() - cli = _client() - vec = embed_texts([req.text])[0] - payload: dict[str, Any] = req.model_dump() - payload.pop("text", None) - payload["preview"] = (req.text[:240] + "…") if len(req.text) > 240 else req.text - qdrant_id = _uuid5(f"chunk:{req.chunk_id}") - pt = PointStruct(id=qdrant_id, vector=vec, payload=payload) - cli.upsert(collection_name=_col("chunks"), points=[pt]) - return {"status": "ok", "id": qdrant_id} - -@router.post("/upsert_note", summary="Upsert a note into mindnet_notes") -def upsert_note(req: UpsertNoteRequest) -> dict: - _ensure_collections() - cli = _client() - text_for_embedding = req.text if req.text else (req.title or req.note_id) - vec = embed_texts([text_for_embedding])[0] - payload: dict[str, Any] = req.model_dump() - payload.pop("text", None) - qdrant_id = _uuid5(f"note:{req.note_id}") - pt = PointStruct(id=qdrant_id, vector=vec, payload=payload) - cli.upsert(collection_name=_col("notes"), points=[pt]) - return {"status": "ok", "id": qdrant_id} - -@router.post("/upsert_edge", summary="Upsert a graph edge into mindnet_edges") -def upsert_edge(req: UpsertEdgeRequest) -> dict: - _ensure_collections() - cli = _client() - payload = req.model_dump() - vec = [0.0] - raw_edge_id = f"{req.src_note_id}|{req.src_chunk_id or ''}->{req.dst_note_id or ''}|{req.dst_chunk_id or ''}|{req.relation}" - qdrant_id = _uuid5(f"edge:{raw_edge_id}") - pt = PointStruct(id=qdrant_id, vector=vec, payload=payload) - cli.upsert(collection_name=_col("edges"), points=[pt]) - return {"status": "ok", "id": qdrant_id} - -@router.post("/query", summary="Vector query over mindnet_chunks with optional filters") -def query(req: QueryRequest) -> dict: - _ensure_collections() - cli = _client() - vec = embed_texts([req.query])[0] - - flt: Optional[Filter] = None - conds = [] - if req.note_id: - conds.append(FieldCondition(key="note_id", match=MatchValue(value=req.note_id))) - if req.path: - conds.append(FieldCondition(key="path", match=MatchValue(value=req.path))) - if req.tags: - for t in req.tags: - conds.append(FieldCondition(key="tags", match=MatchValue(value=t))) - if conds: - flt = Filter(must=conds) - - res = cli.search(collection_name=_col("chunks"), query_vector=vec, limit=req.limit, with_payload=True, with_vectors=False, query_filter=flt) - hits = [] - for p in res: - pl = p.payload or {} - hits.append({ - "chunk_id": p.id, - "score": p.score, - "note_id": pl.get("note_id"), - "title": pl.get("title"), - "path": pl.get("path"), - "preview": pl.get("preview"), - "tags": pl.get("tags"), - }) - return {"results": hits} diff --git a/app/services/llm_ollama.py b/app/services/llm_ollama.py deleted file mode 100644 index ae5fc2f..0000000 --- a/app/services/llm_ollama.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -app/services/llm_ollama.py — Ollama-Integration & Prompt-Bau (WP-04) - -Zweck: - Prompt-Template & (optionaler) lokaler Aufruf von Ollama. Der Aufruf ist - bewusst gekapselt und kann gefahrlos deaktiviert bleiben, bis ihr ein - konkretes Modell konfigurieren wollt. -Kompatibilität: - Python 3.12+ -Version: - 0.1.0 (Erstanlage) -Stand: - 2025-10-07 -Bezug: - WP-04/05 Kontextbereitstellung für LLM -Nutzung: - from app.services.llm_ollama import build_prompt, call_ollama -Änderungsverlauf: - 0.1.0 (2025-10-07) – Erstanlage. -""" - -from __future__ import annotations -from typing import List, Dict, Optional -import subprocess -import json - -PROMPT_TEMPLATE = """System: You are a helpful expert. -User: {question} - -Context (ranked): -{contexts} - -Task: Answer precisely. At the end, list sources (note title + section) and important edge paths. -""" - - -def build_context_block(items: List[Dict]) -> str: - """Formatiert Top-K-Kontexte (Chunks) für den Prompt.""" - lines = [] - for i, it in enumerate(items, 1): - note = it.get("note_title", "") or it.get("note_id", "") - sec = it.get("section", "") or it.get("section_title", "") - sc = it.get("score", 0) - txt = it.get("text", "") or it.get("body", "") or "" - lines.append(f"{i}) {note} — {sec} [score={sc:.2f}]\n{txt}\n") - return "\n".join(lines) - - -def build_prompt(question: str, contexts: List[Dict]) -> str: - """Setzt Frage + Kontexte in ein konsistentes Template.""" - return PROMPT_TEMPLATE.format(question=question, contexts=build_context_block(contexts)) - - -def call_ollama(prompt: str, model: str = "llama3.1:8b", timeout_s: int = 120) -> Optional[str]: - """ - Optionaler lokaler Aufruf von `ollama run`. - Rückgabe: generierter Text oder None bei Fehler/Abbruch. - Hinweis: Nur nutzen, wenn Ollama lokal installiert/konfiguriert ist. - """ - try: - proc = subprocess.run( - ["ollama", "run", model], - input=prompt.encode("utf-8"), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=timeout_s, - check=False, - ) - out = proc.stdout.decode("utf-8", errors="replace") - # viele ollama Builds streamen JSON-Zeilen; robust extrahieren: - try: - # Falls JSONL, letztes "response" zusammenfassen - texts = [] - for line in out.splitlines(): - line = line.strip() - if not line: - continue - try: - obj = json.loads(line) - if "response" in obj: - texts.append(obj["response"]) - except Exception: - texts.append(line) - return "".join(texts).strip() - except Exception: - return out.strip() - except Exception: - return None diff --git a/schemas/chunk.schema.json b/schemas/chunk.schema.json deleted file mode 100644 index e992971..0000000 --- a/schemas/chunk.schema.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "mindnet_chunk", - "type": "object", - "description": "Chunk-Payload (Qdrant). Kompatibel mit Alt-Feldern und neuen Feldern für Export/Roundtrip.", - "required": ["id", "note_id", "chunk_index", "path"], - "properties": { - "id": { "type": "string" }, - "scope": { "type": "string", "enum": ["chunk"] }, - "note_id": { "type": "string" }, - "note_title": { "type": "string" }, - "note_type": { "type": "string" }, - "note_status": { "type": "string" }, - "type": { "type": "string", "description": "Legacy: früherer Chunk-Typ; kann dem Note-Typ entsprechen" }, - "area": { "type": "string" }, - "project": { "type": "string" }, - "tags": { "type": "array", "items": { "type": "string" } }, - - "note_path": { "type": "string" }, - "path": { "type": "string" }, - - "chunk_index": { "type": "integer" }, - "section_title":{ "type": ["string","null"] }, - "section_path": { "type": ["string","null"] }, - - "char_start": { "type": ["integer","null"] }, - "char_end": { "type": ["integer","null"] }, - "char_len": { "type": "integer" }, - - "token_count": { "type": "integer", "description": "Legacy: frühere Token-Zahl" }, - "token_est": { "type": "integer", "description": "Neue grobe Token-Schätzung (≈ len(text)/4)" }, - - "neighbors": { - "type": "object", - "properties": { - "prev": { "type": ["string","null"] }, - "next": { "type": ["string","null"] } - }, - "additionalProperties": false - }, - - "text": { "type": "string" }, - "text_sha256": { "type": "string", "pattern": "^sha256:[0-9a-fA-F]{64}$" }, - "lang": { "type": "string" }, - - "wikilinks": { "type": "array", "items": { "type": "string" } }, - "external_links": { - "type": "array", - "items": { - "anyOf": [ - { "type": "string" }, - { - "type": "object", - "properties": { - "href": { "type": "string" }, - "label": { "type": ["string","null"] } - }, - "required": ["href"], - "additionalProperties": false - } - ] - } - }, - "references": { - "type": "array", - "items": { - "type": "object", - "properties": { - "target_id": { "type": "string" }, - "kind": { "type": "string" } - }, - "required": ["target_id","kind"], - "additionalProperties": true - } - }, - - "embed_model": { "type": "string" }, - "embed_dim": { "type": "integer" }, - "embed_version": { "type": "integer" }, - - "created_at": { "type": "string" } - }, - - "allOf": [ - { "anyOf": [ { "required": ["token_count"] }, { "required": ["token_est"] } ] }, - { "anyOf": [ { "required": ["type"] }, { "required": ["note_type"] } ] } - ], - - "additionalProperties": true -} diff --git a/schemas/edge.schema.json b/schemas/edge.schema.json deleted file mode 100644 index 4a87630..0000000 --- a/schemas/edge.schema.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "mindnet_edge", - "type": "object", - "description": "Edge-Payload (Qdrant). Unterstützt Legacy (edge_type/src_id/dst_id) und neues Schema (kind/source_id/target_id/note_id/status).", - - "properties": { - "scope": { "type": "string", "enum": ["note","chunk"] }, - - "edge_type": { "type": "string", "description": "Legacy: z. B. references/backlink/belongs_to/prev/next" }, - "src_id": { "type": "string", "description": "Legacy: source_id" }, - "dst_id": { "type": "string", "description": "Legacy: target_id" }, - - "kind": { "type": "string", "description": "Neu: z. B. references/backlink/belongs_to/prev/next" }, - "source_id": { "type": "string" }, - "target_id": { "type": "string" }, - "note_id": { "type": "string", "description": "Owner-Note für diesen Edge (Filter/Purge)" }, - "status": { "type": "string", "description": "optional, z. B. 'unresolved'" }, - - "weight": { "type": "number" }, - "meta": { "type": "object" }, - "created_at":{ "type": "string" } - }, - - "anyOf": [ - { "required": ["src_id", "dst_id", "edge_type", "scope"] }, - { "required": ["source_id", "target_id", "kind", "scope"] } - ], - - "additionalProperties": true -} diff --git a/schemas/note.schema.json b/schemas/note.schema.json deleted file mode 100644 index 5dc1342..0000000 --- a/schemas/note.schema.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "mindnet note payload", - "type": "object", - "properties": { - "note_id": { "type": "string" }, - "title": { "type": ["string","null"] }, - "type": { "type": ["string","null"] }, - "status": { "type": ["string","null"] }, - "created": { "type": ["string","null"] }, - "updated": { "type": ["string","null"] }, - "path": { "type": ["string","null"] }, - "tags": { "type": ["array","null"], "items": { "type": "string" } }, - "area": { "type": ["string","null"] }, - "project": { "type": ["string","null"] }, - "source": { "type": ["string","null"] }, - "lang": { "type": ["string","null"] }, - "slug": { "type": ["string","null"] }, - "aliases": { "type": ["array","null"], "items": { "type": "string" } }, - - "fulltext": { "type": ["string","null"] }, - "references": { "type": ["array","null"], "items": { "type": "string" } }, - - "hash_fulltext": { "type": ["string","null"], "pattern": "^[a-f0-9]{64}$" }, - "hash_signature": { "type": ["string","null"] }, - - "hash_body": { "type": ["string","null"], "pattern": "^[a-f0-9]{64}$" }, - "hash_frontmatter": { "type": ["string","null"], "pattern": "^[a-f0-9]{64}$" }, - "hash_full": { "type": ["string","null"], "pattern": "^[a-f0-9]{64}$" }, - - "hashes": { - "type": ["object","null"], - "description": "Mapping: :: -> sha256 hex", - "patternProperties": { - "^(body|frontmatter|full):(parsed|raw):(canonical|none)$": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - } - }, - "additionalProperties": false - } - }, - "required": ["note_id"], - "additionalProperties": true -} -- 2.43.0 From 8fadec5c2c4a39b64c72ab3e834dc663fa01e2a8 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 07:25:34 +0100 Subject: [PATCH 11/24] chunker korrigiert zu scmart edges --- app/core/chunker.py | 137 ++++++++++++++++++++++++++++++++------------ 1 file changed, 100 insertions(+), 37 deletions(-) diff --git a/app/core/chunker.py b/app/core/chunker.py index 8348715..0943010 100644 --- a/app/core/chunker.py +++ b/app/core/chunker.py @@ -1,11 +1,11 @@ """ FILE: app/core/chunker.py DESCRIPTION: Zerlegt Texte in Chunks (Sliding Window oder nach Headings). Orchestriert die Smart-Edge-Allocation via SemanticAnalyzer. -VERSION: 2.5.0 +VERSION: 2.6.0 (Fix: Strict Heading Split & Header Retention) STATUS: Active DEPENDENCIES: app.services.semantic_analyzer, app.core.derive_edges, markdown_it, yaml, asyncio EXTERNAL_CONFIG: config/types.yaml -LAST_ANALYSIS: 2025-12-15 +LAST_ANALYSIS: 2025-12-16 """ from __future__ import annotations @@ -15,8 +15,6 @@ import re import math import yaml from pathlib import Path -from markdown_it import MarkdownIt -from markdown_it.token import Token import asyncio import logging @@ -27,7 +25,7 @@ from app.services.semantic_analyzer import get_semantic_analyzer try: from app.core.derive_edges import build_edges_for_note except ImportError: - # Mock für Tests + # Mock für Tests, falls Module fehlen def build_edges_for_note(note_id, chunks, note_level_references=None, include_note_scope_refs=False): return [] logger = logging.getLogger(__name__) @@ -103,7 +101,7 @@ class Chunk: suggested_edges: Optional[List[str]] = None # ========================================== -# 3. PARSING & STRATEGIES (SYNCHRON) +# 3. PARSING & STRATEGIES # ========================================== def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: @@ -125,6 +123,8 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: for line in lines: stripped = line.strip() if stripped.startswith('# '): + # H1 wird für den Titel genutzt, aber nicht als Block für sliding window + # (Außer es ist H1 im Body, aber wir ignorieren H1 hier meist als Title) continue elif stripped.startswith('## '): if buffer: @@ -134,6 +134,7 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: buffer = [] current_h2 = stripped[3:].strip() section_path = f"/{current_h2}" + # WICHTIG: Die Überschrift selbst als Block speichern! blocks.append(RawBlock("heading", stripped, 2, section_path, current_h2)) elif not stripped: if buffer: @@ -151,6 +152,15 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: return blocks, h1_title +def _create_chunk_obj(chunks_list: List[Chunk], note_id: str, txt: str, win: str, sec: Optional[str], path: str): + idx = len(chunks_list) + chunks_list.append(Chunk( + id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx, + text=txt, window=win, token_count=estimate_tokens(txt), + section_title=sec, section_path=path, neighbors_prev=None, neighbors_next=None, + suggested_edges=[] + )) + def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, doc_title: str = "", context_prefix: str = "") -> List[Chunk]: target = config.get("target", 400) max_tokens = config.get("max", 600) @@ -158,15 +168,6 @@ def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], not overlap = sum(overlap_val) // 2 if isinstance(overlap_val, tuple) else overlap_val chunks = []; buf = [] - def _create_chunk(txt, win, sec, path): - idx = len(chunks) - chunks.append(Chunk( - id=f"{note_id}#c{idx:02d}", note_id=note_id, index=idx, - text=txt, window=win, token_count=estimate_tokens(txt), - section_title=sec, section_path=path, neighbors_prev=None, neighbors_next=None, - suggested_edges=[] - )) - def flush_buffer(): nonlocal buf if not buf: return @@ -175,18 +176,24 @@ def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], not win_body = f"{context_prefix}\n{text_body}".strip() if context_prefix else text_body if estimate_tokens(text_body) <= max_tokens: - _create_chunk(text_body, win_body, buf[-1].section_title, buf[-1].section_path) + sec = buf[0].section_title if buf else None + path = buf[0].section_path if buf else "/" + _create_chunk_obj(chunks, note_id, text_body, win_body, sec, path) else: sentences = split_sentences(text_body) current_chunk_sents = [] current_len = 0 + # Basis-Info vom ersten Block im Buffer + sec = buf[0].section_title if buf else None + path = buf[0].section_path if buf else "/" + for sent in sentences: sent_len = estimate_tokens(sent) if current_len + sent_len > target and current_chunk_sents: c_txt = " ".join(current_chunk_sents) c_win = f"{context_prefix}\n{c_txt}".strip() if context_prefix else c_txt - _create_chunk(c_txt, c_win, buf[-1].section_title, buf[-1].section_path) + _create_chunk_obj(chunks, note_id, c_txt, c_win, sec, path) overlap_sents = [] ov_len = 0 @@ -207,27 +214,81 @@ def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], not if current_chunk_sents: c_txt = " ".join(current_chunk_sents) c_win = f"{context_prefix}\n{c_txt}".strip() if context_prefix else c_txt - _create_chunk(c_txt, c_win, buf[-1].section_title, buf[-1].section_path) + _create_chunk_obj(chunks, note_id, c_txt, c_win, sec, path) buf = [] for b in blocks: - if b.kind == "heading": continue + # Bei Sliding Window ignorieren wir Heading-Blocks als Split-Trigger NICHT zwingend, + # aber wir wollen Headings oft nicht "allein" stehen haben. + # Hier einfache Logik: + if b.kind == "heading": + # Optional: Buffer flushen bei neuem Header, um Kontextwechsel sauberer zu machen + flush_buffer() + current_buf_text = "\n\n".join([x.text for x in buf]) - if estimate_tokens(current_buf_text) + estimate_tokens(b.text) >= target: + if buf and (estimate_tokens(current_buf_text) + estimate_tokens(b.text) >= target): flush_buffer() + buf.append(b) - if estimate_tokens(b.text) >= target: - flush_buffer() - + flush_buffer() return chunks def _strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, doc_title: str = "") -> List[Chunk]: - return _strategy_sliding_window(blocks, config, note_id, doc_title, context_prefix=f"# {doc_title}") + """ + STRICT HEADING SPLIT (Fix v2.6.0): + Trennt den Text konsequent an jeder Überschrift der definierten Ebene. + Behält Überschriften als Teil (erste Zeile) des Chunks bei. + Kein Merging kleiner Abschnitte über Header-Grenzen hinweg. + """ + split_level = config.get("split_level", 2) + chunks = [] + + # Temporärer Speicher für den aktuellen Chunk + current_chunk_blocks = [] + + context_prefix = f"# {doc_title}" + + def flush_current_chunk(): + nonlocal current_chunk_blocks + if not current_chunk_blocks: + return + + # Text zusammenbauen + text_body = "\n\n".join([b.text for b in current_chunk_blocks]) + # Window bauen (hier einfach Text, da Kontext via Header implizit ist) + win_body = f"{context_prefix}\n{text_body}".strip() + + # Metadaten vom ersten Block (üblicherweise der Header) nehmen + first_b = current_chunk_blocks[0] + sec = first_b.section_title + path = first_b.section_path + + _create_chunk_obj(chunks, note_id, text_body, win_body, sec, path) + current_chunk_blocks = [] + + for b in blocks: + # Prüfen, ob dieser Block ein Trenner (Header auf Split-Level) ist + is_splitter = (b.kind == "heading" and b.level == split_level) + + if is_splitter: + # 1. Den bisherigen Chunk abschließen (falls vorhanden) + flush_current_chunk() + + # 2. Den neuen Chunk mit diesem Header beginnen + current_chunk_blocks.append(b) + else: + # Einfach anhängen + current_chunk_blocks.append(b) + + # Letzten Rest flushen + flush_current_chunk() + + return chunks # ========================================== -# 4. ORCHESTRATION (ASYNC) - WP-15 CORE +# 4. ORCHESTRATION (ASYNC) # ========================================== async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Optional[Dict] = None) -> List[Chunk]: @@ -240,12 +301,14 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op primary_strategy = config.get("strategy", "sliding_window") enable_smart_edges = config.get("enable_smart_edge_allocation", False) + # Performance/Cost-Guard: Bei Entwürfen keine Smart Edges if enable_smart_edges and note_status in ["draft", "initial_gen"]: logger.info(f"Chunker: Skipping Smart Edges for draft '{note_id}'.") enable_smart_edges = False blocks, doc_title = parse_blocks(md_text) + # Strategie-Auswahl if primary_strategy == "by_heading": chunks = await asyncio.to_thread(_strategy_by_heading, blocks, config, note_id, doc_title) else: @@ -254,10 +317,11 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op if not chunks: return [] + # Smart Edge Allocation (WP-15) if enable_smart_edges: - # Hier rufen wir nun die Smart Edge Allocation auf chunks = await _run_smart_edge_allocation(chunks, md_text, note_id, note_type) + # Verkettung der Chunks (next/prev) for i, ch in enumerate(chunks): ch.neighbors_prev = chunks[i-1].id if i > 0 else None ch.neighbors_next = chunks[i+1].id if i < len(chunks)-1 else None @@ -269,30 +333,25 @@ def _extract_all_edges_from_md(md_text: str, note_id: str, note_type: str) -> Li Hilfsfunktion: Erstellt einen Dummy-Chunk für den gesamten Text und ruft den Edge-Parser auf, um ALLE Kanten der Notiz zu finden. """ - # 1. Dummy Chunk erstellen, der den gesamten Text enthält - # Das ist notwendig, da build_edges_for_note Kanten nur aus Chunks extrahiert. dummy_chunk = { "chunk_id": f"{note_id}#full", "text": md_text, - "content": md_text, # Sicherstellen, dass der Parser Text findet + "content": md_text, "window": md_text, "type": note_type } - - # 2. Aufruf des Parsers (Signatur-Fix!) - # derive_edges.py: build_edges_for_note(note_id, chunks, note_level_references=None, include_note_scope_refs=False) + # Parsing aller Kanten (Inline, Wikilinks, Callouts) raw_edges = build_edges_for_note( note_id, [dummy_chunk], note_level_references=None, include_note_scope_refs=False ) - - # 3. Kanten extrahieren all_candidates = set() for e in raw_edges: kind = e.get("kind") target = e.get("target_id") + # Struktur-Kanten ignorieren wir für die Verteilung if target and kind not in ["belongs_to", "next", "prev", "backlink"]: all_candidates.add(f"{kind}:{target}") @@ -301,7 +360,7 @@ def _extract_all_edges_from_md(md_text: str, note_id: str, note_type: str) -> Li async def _run_smart_edge_allocation(chunks: List[Chunk], full_text: str, note_id: str, note_type: str) -> List[Chunk]: analyzer = get_semantic_analyzer() - # A. Alle potenziellen Kanten der Notiz sammeln (über den Dummy-Chunk Trick) + # A. Alle potenziellen Kanten der Notiz sammeln candidate_list = _extract_all_edges_from_md(full_text, note_id, note_type) if not candidate_list: @@ -314,7 +373,7 @@ async def _run_smart_edge_allocation(chunks: List[Chunk], full_text: str, note_i results_per_chunk = await asyncio.gather(*tasks) - # C. Injection & Fallback + # C. Injection & Fallback Tracking assigned_edges_global = set() for i, confirmed_edges in enumerate(results_per_chunk): @@ -322,14 +381,18 @@ async def _run_smart_edge_allocation(chunks: List[Chunk], full_text: str, note_i chunk.suggested_edges = confirmed_edges assigned_edges_global.update(confirmed_edges) + # Injection: Wir hängen die bestätigten Edges unsichtbar (fürs Embedding) oder sichtbar an + # Hier als "Pseudo-Code" im Text, damit sie embedded werden. if confirmed_edges: + # Format: [[rel:kind|target]] injection_str = "\n" + " ".join([f"[[rel:{e.split(':')[0]}|{e.split(':')[1]}]]" for e in confirmed_edges if ':' in e]) chunk.text += injection_str chunk.window += injection_str - # D. Fallback: Unassigned Kanten überall hin + # D. Fallback: Kanten, die NIRGENDS zugewiesen wurden, werden JEDEM Chunk angehängt (Sicherheit) unassigned = set(candidate_list) - assigned_edges_global if unassigned: + logger.info(f"Chunker: {len(unassigned)} unassigned edges in {note_id}. Distributing to all chunks.") fallback_str = "\n" + " ".join([f"[[rel:{e.split(':')[0]}|{e.split(':')[1]}]]" for e in unassigned if ':' in e]) for chunk in chunks: chunk.text += fallback_str -- 2.43.0 From bf8a814c587d8de956f135c1c4aca6efbb02cef9 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 09:39:35 +0100 Subject: [PATCH 12/24] script update neue Struktur --- scripts/resolve_unresolved_references.py | 387 +++++++++++------------ 1 file changed, 184 insertions(+), 203 deletions(-) diff --git a/scripts/resolve_unresolved_references.py b/scripts/resolve_unresolved_references.py index 970f336..f990202 100644 --- a/scripts/resolve_unresolved_references.py +++ b/scripts/resolve_unresolved_references.py @@ -3,241 +3,222 @@ """ resolve_unresolved_references.py — Unaufgelöste Wikilinks in Qdrant nachträglich auflösen -Version: 1.0.0 (2025-09-05) +Version: 1.1.0 (Fixed for v2.6 Architecture) Zweck ------ -- Findet Edges in {prefix}_edges mit payload.status=="unresolved" und versucht, den Zielknoten - anhand bereits vorhandener Notes in {prefix}_notes aufzulösen. -- Aktualisiert die Edges (setzt target_id, entfernt status, setzt resolution), und erzeugt - – NUR für Note-Level 'references' – die symmetrische 'backlink'-Kante. - -Warum? ------- -- Beim ersten Import können Links auf (noch) nicht existierende Notizen zeigen. -- Sobald die Zielnotiz später existiert, kann dieses Skript die Kanten reparieren. +- Findet Edges in {prefix}_edges mit payload.status=="unresolved". +- Baut einen In-Memory Index aller Notizen (Titel/Alias -> ID). +- Aktualisiert die Edges (setzt target_id, entfernt status). +- Erzeugt symmetrische 'backlink'-Kanten für 'references'. Aufruf ------ - # Dry-Run (Standard): - python3 -m scripts.resolve_unresolved_references --prefix mindnet - - # Anwenden: - python3 -m scripts.resolve_unresolved_references --prefix mindnet --apply - - # Optional: nur X Edges anfassen - python3 -m scripts.resolve_unresolved_references --prefix mindnet --apply --limit 500 - -Parameter ---------- ---prefix : Collection-Prefix (Default: aus Env COLLECION_PREFIX oder "mindnet") ---apply : Änderungen tatsächlich schreiben (ohne --apply = Dry-Run) ---limit : Max. Anzahl unaufgelöster Edges, die in diesem Lauf bearbeitet werden (Default: keine Begrenzung) ---batch : Upsert-Batchgröße (Default: 512) - -Voraussetzungen / Hinweise --------------------------- -- Bitte im aktivierten venv laufen lassen (deine Umgebung: `.venv`). -- Qdrant-URL/Key/Prefix/Vektor-Dim werden wie üblich aus ENV gelesen (sieh app/core/qdrant.py). # noqa -- Nutzt die vorhandenen Utilities: - - app/core/qdrant.py (Client/Collections) - - app/core/qdrant_points.py (points_for_edges/upsert_batch) - - app/core/derive_edges.py (build_note_index/resolve_target) - -Änderungshistorie ------------------ -1.0.0 Erstveröffentlichung. + python3 -m scripts.resolve_unresolved_references --apply """ -from __future__ import annotations import argparse +import logging import json -from typing import Any, Dict, List, Tuple, Iterable +import uuid +from typing import List, Dict, Any, Iterable -from qdrant_client import QdrantClient -from qdrant_client.http import models as rest - -from app.core.qdrant import QdrantConfig, get_client, ensure_collections, collection_names # :contentReference[oaicite:3]{index=3} -from app.core.qdrant_points import points_for_edges, upsert_batch # :contentReference[oaicite:4]{index=4} -from app.core.derive_edges import build_note_index, resolve_target # :contentReference[oaicite:5]{index=5} - - -def _scroll(client: QdrantClient, **kwargs): - """ - Wrapper um qdrant_client.scroll() für unterschiedliche Client-Versionen: - neuere: (points, next_offset) - ältere: (points, next_page_offset, _) - """ - res = client.scroll(**kwargs) - if isinstance(res, tuple): - if len(res) == 2: - points, next_off = res - else: - # ältere Signatur: (points, next_off, _) - points, next_off, _ = res[0], res[1], res[2] - else: - # sehr alte Clients -> konservativ behandeln - points, next_off = res, None - return points, next_off - - -def _load_all_notes(client: QdrantClient, notes_col: str) -> List[Dict[str, Any]]: - notes: List[Dict[str, Any]] = [] - next_off = None - while True: - pts, next_off = _scroll( - client, - collection_name=notes_col, - with_payload=True, - with_vectors=False, - limit=1024, - offset=next_off, - ) - for p in pts or []: - pl = getattr(p, "payload", {}) or {} - # Erwartet Felder: note_id, title, path etc. (gemäß Schema) # :contentReference[oaicite:6]{index=6} - if pl.get("note_id"): - notes.append(pl) - if not next_off: - break - return notes - - -def _iter_unresolved_edges(client: QdrantClient, edges_col: str) -> Iterable[rest.Record]: - """ - Liefert alle Edge-Records mit payload.status == 'unresolved' und 'target_label' (string). - """ - f = rest.Filter( - must=[ - rest.FieldCondition(key="status", match=rest.MatchValue(value="unresolved")), - ] - ) - next_off = None - while True: - pts, next_off = _scroll( - client, - collection_name=edges_col, - scroll_filter=f, - with_payload=True, - with_vectors=False, - limit=1024, - offset=next_off, - ) - for p in pts or []: - pl = getattr(p, "payload", {}) or {} - if isinstance(pl.get("target_label"), str): - yield p - if not next_off: - break +from qdrant_client import models +from app.core.qdrant import QdrantConfig, get_client +from app.core.qdrant_points import points_for_edges +# Logging Setup +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) def _make_backlink(source_note_id: str, target_note_id: str, extra: Dict[str, Any]) -> Dict[str, Any]: """ - Baue eine 'backlink'-Edge-Payload source <- target (note-level). + Hilfsfunktion: Erzeugt die Payload für den Backlink. """ - e = { - "kind": "backlink", + return { "source_id": target_note_id, "target_id": source_note_id, + "kind": "backlink", + "scope": "note", + "text": f"Backlink from {extra.get('alias') or 'note'}", + "rule_id": "derived:backlink", + "confidence": 0.9 } - # Metafelder aus dem Original übernehmen (ohne status) - copy_keys = ["raw", "alias", "heading", "resolution"] - for k in copy_keys: - if k in extra: - e[k] = extra[k] - return e +def build_lookup_index(client, collection_name: str) -> Dict[str, str]: + """ + Lädt ALLE Notizen und baut ein Mapping: + lower(title) -> note_id + lower(alias) -> note_id + """ + logger.info("Building lookup index from existing notes...") + lookup = {} + + # Scroll über alle Notizen + next_offset = None + count = 0 + while True: + records, next_offset = client.scroll( + collection_name=collection_name, + limit=1000, + offset=next_offset, + with_payload=True, + with_vectors=False + ) + + for record in records: + pl = record.payload or {} + nid = pl.get("note_id") + if not nid: continue + + # 1. Titel + title = pl.get("title") + if title: + lookup[str(title).lower().strip()] = nid + + # 2. Aliases (WP-11) + aliases = pl.get("aliases", []) + if isinstance(aliases, str): aliases = [aliases] + for a in aliases: + lookup[str(a).lower().strip()] = nid + + count += len(records) + if next_offset is None: + break + + logger.info(f"Index built. Mapped {len(lookup)} terms to {count} unique notes.") + return lookup def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--prefix", help="Collection-Prefix (Default: Env/COLLECTION_PREFIX oder 'mindnet')") - ap.add_argument("--apply", action="store_true", help="Änderungen schreiben (ohne Flag = Dry-Run)") - ap.add_argument("--limit", type=int, default=0, help="Max. Anzahl unaufgelöster Edges bearbeiten (0 = alle)") - ap.add_argument("--batch", type=int, default=512, help="Upsert-Batchgröße") - args = ap.parse_args() + parser = argparse.ArgumentParser() + parser.add_argument("--prefix", default=None, help="Collection prefix") + parser.add_argument("--apply", action="store_true", help="Write changes to DB") + parser.add_argument("--limit", type=int, default=0, help="Max edges to process (0=all)") + parser.add_argument("--batch", type=int, default=100, help="Upsert batch size") + args = parser.parse_args() - # Qdrant-Setup cfg = QdrantConfig.from_env() if args.prefix: cfg.prefix = args.prefix + client = get_client(cfg) - ensure_collections(client, cfg.prefix, cfg.dim) # sorgt u. a. für 1D-Vektor-Collection bei Edges :contentReference[oaicite:7]{index=7} - notes_col, _, edges_col = collection_names(cfg.prefix) # :contentReference[oaicite:8]{index=8} + edges_col = f"{cfg.prefix}_edges" + notes_col = f"{cfg.prefix}_notes" - # Notes laden & Index bauen - notes = _load_all_notes(client, notes_col) - idx = build_note_index(notes) # (by_id, by_slug, by_file_slug) :contentReference[oaicite:9]{index=9} - - # Unresolved-Edges scannen - to_fix: List[dict] = [] - backlinks: List[dict] = [] - processed = 0 - resolved = 0 - - for rec in _iter_unresolved_edges(client, edges_col): - if args.limit and processed >= args.limit: - break - processed += 1 - - pl = dict(rec.payload or {}) - kind = pl.get("kind") or "references" - src = pl.get("source_id") - tgt_label = pl.get("target_label") or pl.get("target_id") # Fallback - - # Zielauflösung - resolved_id, how = resolve_target(str(tgt_label), idx) # :contentReference[oaicite:10]{index=10} - if not resolved_id: - continue # weiterhin unresolved - - # Edge-Update - new_pl = dict(pl) - new_pl["target_id"] = resolved_id - new_pl["resolution"] = how - if "status" in new_pl: - del new_pl["status"] - # ID stabil lassen -> points_for_edges erzeugt UUID aus edge_id/Fallback :contentReference[oaicite:11]{index=11} - if "edge_id" not in new_pl: - # stabiler Key aus (kind, src, tgt, evtl. seq) - seq = new_pl.get("seq") or new_pl.get("order") or "" - new_pl["edge_id"] = f"{kind}:{src}->{resolved_id}#{seq}" - - to_fix.append(new_pl) - resolved += 1 - - # Nur bei Note-Level references (nicht references_at) -> Backlink erzeugen - if kind == "references": - extra = {k: new_pl.get(k) for k in ("raw", "alias", "heading")} - extra["resolution"] = how - backlinks.append(_make_backlink(source_note_id=src, target_note_id=resolved_id, extra=extra)) - - # Ergebnis ausgeben - summary = { - "prefix": cfg.prefix, - "scanned_unresolved": processed, - "resolved": resolved, - "backlinks_to_create": len(backlinks), - "apply": bool(args.apply), - } - print(json.dumps(summary, ensure_ascii=False)) - - if not args.apply: + # 1. Index aufbauen + try: + lookup_index = build_lookup_index(client, notes_col) + except Exception as e: + logger.error(f"Failed to build index: {e}") return - # Upserts (in Batches) - def _batched(items: List[dict], n: int) -> Iterable[List[dict]]: - for i in range(0, len(items), n): - yield items[i : i + n] + # 2. Unresolved Edges finden + logger.info(f"Scanning for unresolved edges in {edges_col}...") + + scroll_filter = models.Filter( + must=[ + models.FieldCondition(key="status", match=models.MatchValue(value="unresolved")) + ] + ) - # 1) Updates für reparierte Edges - for chunk in _batched(to_fix, args.batch): - col, pts = points_for_edges(cfg.prefix, chunk) # sorgt für Edge-UUID & Dummy-Vector :contentReference[oaicite:12]{index=12} - upsert_batch(client, col, pts) + unresolved_edges = [] + next_page = None + while True: + res, next_page = client.scroll( + collection_name=edges_col, + scroll_filter=scroll_filter, + limit=500, + with_payload=True, + offset=next_page + ) + unresolved_edges.extend(res) + if next_page is None or (args.limit > 0 and len(unresolved_edges) >= args.limit): + break + + if args.limit > 0: + unresolved_edges = unresolved_edges[:args.limit] - # 2) Backlinks (nur references) - for chunk in _batched(backlinks, args.batch): - col, pts = points_for_edges(cfg.prefix, chunk) - upsert_batch(client, col, pts) + logger.info(f"Found {len(unresolved_edges)} unresolved edges.") + # 3. Auflösen + to_fix = [] + backlinks = [] + resolved_count = 0 + + for pt in unresolved_edges: + pl = pt.payload + # Der gesuchte Begriff steckt oft in 'raw_target' (wenn Parser es speichert) + # oder wir nutzen die 'target_id', falls diese temporär den Namen hält (Legacy Parser Verhalten). + # Im v2.6 Parser ist die target_id bei unresolved links oft der slug oder name. + + # Strategie: Wir schauen uns das Payload an. + # Fall A: derive_edges hat target_id="[[Missing Note]]" gesetzt (selten) + # Fall B: target_id ist der Slug/Titel in Kleinbuchstaben (häufig) + # Fall C: Es gibt ein Feld 'raw' oder 'text' + + candidate = pl.get("target_id") + + # Versuch der Auflösung + target_nid = lookup_index.get(str(candidate).lower().strip()) + + if target_nid: + # TREFFER! + new_pl = pl.copy() + new_pl["target_id"] = target_nid + new_pl.pop("status", None) # Status entfernen -> ist jetzt resolved + new_pl["resolution"] = "healed_by_script" + + # Neue Edge ID generieren (Clean architecture) + # Wir behalten die alte ID NICHT, da die ID oft target_id enthält und wir Duplikate vermeiden wollen. + # Alternativ: Update auf bestehender ID. Wir machen hier ein Update. + + to_fix.append({ + "id": pt.id, + "payload": new_pl + }) + + # Backlink erzeugen? Nur wenn es eine Referenz ist + if pl.get("kind") == "references": + backlinks.append(_make_backlink( + source_note_id=pl.get("source_id"), + target_note_id=target_nid, + extra={"alias": candidate} + )) + + resolved_count += 1 + + logger.info(f"Resolvable: {resolved_count}/{len(unresolved_edges)}") + + if not args.apply: + logger.info("DRY RUN. Use --apply to execute.") + return + + # 4. Schreiben + if to_fix: + logger.info(f"Updating {len(to_fix)} edges...") + # Qdrant Update: Wir überschreiben den Point. + # Achtung: client.upsert erwartet PointStructs. + + points_to_upsert = [ + models.PointStruct(id=u["id"], payload=u["payload"], vector={}) + for u in to_fix + ] + + # Batchweise + for i in range(0, len(points_to_upsert), args.batch): + batch = points_to_upsert[i:i+args.batch] + client.upsert(collection_name=edges_col, points=batch) + + if backlinks: + logger.info(f"Creating {len(backlinks)} backlinks...") + # Hier nutzen wir den Helper aus qdrant_points für saubere IDs + col, bl_points = points_for_edges(backlinks, cfg.prefix) + # batchweise + for i in range(0, len(bl_points), args.batch): + batch = bl_points[i:i+args.batch] + client.upsert(collection_name=col, points=batch) + + logger.info("Done.") if __name__ == "__main__": - main() + main() \ No newline at end of file -- 2.43.0 From cf49715c66a4c59b5c50c53366f3bd1a177f7cac Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 09:53:16 +0100 Subject: [PATCH 13/24] neue doku --- .../05_AI_dev_prompt_templates.md | 76 ----------- docs/05_Development/05_developer_guide.md | 127 +++++++++--------- 2 files changed, 66 insertions(+), 137 deletions(-) delete mode 100644 docs/05_Development/05_AI_dev_prompt_templates.md diff --git a/docs/05_Development/05_AI_dev_prompt_templates.md b/docs/05_Development/05_AI_dev_prompt_templates.md deleted file mode 100644 index 452e9c2..0000000 --- a/docs/05_Development/05_AI_dev_prompt_templates.md +++ /dev/null @@ -1,76 +0,0 @@ -# SYSTEM-ANWEISUNG: SICHERS MARKDOWN-RENDERING - -Du agierst als technischer Dokumentations-Assistent. Deine Aufgabe ist das Erstellen von Markdown-Dateien (`.md`), die oft selbst Code-Blöcke (Python, JSON, YAML, Bash) enthalten. - -**DAS PROBLEM:** -Wenn du eine Markdown-Datei generierst, die Code-Blöcke (```) enthält, und diese Ausgabe selbst in einen Code-Block packst, interpretiert das Chat-Interface das erste innere ` ``` ` oft fälschlicherweise als das Ende der Ausgabe. Das "zerreißt" die Datei und macht das Kopieren unmöglich. - -**DIE REGEL (STRIKT BEFOLGEN):** -Um eine ununterbrochene Darstellung zu garantieren, musst du zwingend eine der folgenden Kapselungs-Methoden anwenden: - -### Methode A: Die 4-Backtick-Methode (Bevorzugt) -Umschließe den **gesamten** Datei-Inhalt mit **4 Backticks** statt 3. -Dies erlaubt dir, innerhalb der Datei normale 3 Backticks zu verwenden. - -Beispiel für deinen Output: -````markdown ---- -title: Beispiel ---- -Hier ist Python Code: -```python -print("Hello") -### Methode B: Die 4-Space-Einrückung (Alternative) -Wenn du außen 3 Backticks verwendest, darfst du im Inneren **KEINE** Backticks verwenden. -Stattdessen müssen alle inneren Code-Beispiele mit **4 Leerzeichen (Spaces)** eingerückt werden. - -Beispiel für deinen Output: -```markdown -Hier ist Python Code: - - print("Hello") -``` - -**ZUSAMMENFASSUNG:** -Generiere niemals verschachtelte 3-Backtick-Blöcke innerhalb von 3-Backtick-Blöcken. Nutze immer **4 Backticks** für den äußersten Container. -``` - ---- - -Du agierst als **Technical Documentation Lead**. - -**Kontext:** -Wir haben soeben das Workpackage (WP) abgeschlossen. Der Code ist implementiert, getestet und die Änderungen sind im Chat-Verlauf dokumentiert. -Jetzt müssen wir die Systemdokumentation (Mindnet v2.6 Modular Docs) aktualisieren, um den neuen Stand widerzuspiegeln. - -**Deine Aufgabe - Phase 1: Identifikation** -Analysiere die durchgeführten Änderungen dieses Workpackages. -Nutze die beiliegende `00_documentation_map.md`, um zu identifizieren, welche Dokumentations-Module von diesen Änderungen betroffen sind. - -**Beachte die Mapping-Logik:** -* Haben wir neue Features/Konzepte eingeführt? -> `00_glossary.md`, `02_Concepts/*` -* Haben wir die Datenbank/Payloads geändert? -> `03_tech_data_model.md` -* Hat sich der Import/Algorithmus geändert? -> `03_tech_ingestion_pipeline.md`, `03_tech_retrieval_scoring.md` -* Muss der Admin etwas Neues konfigurieren? -> `03_tech_configuration.md`, `04_admin_operations.md` -* Ändert sich etwas für den Nutzer/Autor? -> `01_User_Manual/*` - -**Output für Phase 1:** -Erstelle eine **Liste der betroffenen Dateien** mit einer kurzen Begründung pro Datei (z.B. "Muss neuen Parameter X aufnehmen"). -Fordere mich dann explizit auf, dir diese spezifischen Dateien hochzuladen. - ---- - -**Deine Aufgabe - Phase 2: Sequenzielle Bearbeitung (Warte auf Dateien)** -Sobald ich die Dateien hochgeladen habe, aktualisieren wir sie **Schritt für Schritt**. -1. Nimm dir **eine** Datei aus der Liste vor. -2. Schreibe den kompletten, aktualisierten Inhalt dieser Datei als Markdown-Codeblock. - * *Wichtig:* Halte dich strikt an den bestehenden Stil (Frontmatter, JSON-Beispiele, Warnhinweise). - * *Wichtig:* Füge Änderungen nahtlos ein, lösche nichts Relevantes. -3. **Warte** nach der Ausgabe der Datei auf mein "OK" oder "Weiter", bevor du die nächste Datei bearbeitest. - -**Sonderaufgabe Roadmap:** -Aktualisiere am Ende immer die `06_active_roadmap.md`: -* Setze den Status des aktuellen WPs auf "Fertig/Live". -* Verschiebe Details in die Historie-Tabelle (falls relevant). - -**Bist du bereit für die Analyse? (Ich habe Map und Roadmap hochgeladen).** \ No newline at end of file diff --git a/docs/05_Development/05_developer_guide.md b/docs/05_Development/05_developer_guide.md index 1bdcc87..17ed425 100644 --- a/docs/05_Development/05_developer_guide.md +++ b/docs/05_Development/05_developer_guide.md @@ -3,7 +3,7 @@ doc_type: developer_guide audience: developer scope: workflow, testing, architecture, modules status: active -version: 2.6 +version: 2.6.1 context: "Umfassender Guide für Entwickler: Architektur, Modul-Interna (Deep Dive), Setup, Git-Workflow und Erweiterungs-Anleitungen." --- @@ -14,6 +14,7 @@ context: "Umfassender Guide für Entwickler: Architektur, Modul-Interna (Deep Di Dieser Guide ist die zentrale technische Referenz für Mindnet v2.6. Er vereint das technische Verständnis der Module mit dem operativen Workflow zwischen Windows (Dev) und Linux (Runtime). --- + # Inhaltsverzeichnis - [Mindnet Developer Guide \& Workflow](#mindnet-developer-guide--workflow) - [Inhaltsverzeichnis](#inhaltsverzeichnis) @@ -35,9 +36,6 @@ Dieser Guide ist die zentrale technische Referenz für Mindnet v2.6. Er vereint - [4.4 Scripts \& Tooling (Die Admin-Toolbox)](#44-scripts--tooling-die-admin-toolbox) - [1. Script-Übersicht](#1-script-übersicht) - [2. Einsatzszenarien \& Bewertung](#2-einsatzszenarien--bewertung) - - [🟢 Essentiell für den Betrieb (Must-Have)](#-essentiell-für-den-betrieb-must-have) - - [🟡 Hilfreich für Entwicklung \& Debugging (Should-Have)](#-hilfreich-für-entwicklung--debugging-should-have) - - [🔵 Wartung \& Spezialfälle (Nice-to-Have)](#-wartung--spezialfälle-nice-to-have) - [5. Maintenance \& "Kill List"](#5-maintenance--kill-list) - [6. Lokales Setup (Development)](#6-lokales-setup-development) - [7. Der Entwicklungs-Zyklus (Workflow)](#7-der-entwicklungs-zyklus-workflow) @@ -51,6 +49,7 @@ Dieser Guide ist die zentrale technische Referenz für Mindnet v2.6. Er vereint - [10. Troubleshooting \& One-Liners](#10-troubleshooting--one-liners) --- + ## 1. Einführung & Systemüberblick ### Was ist Mindnet? @@ -123,29 +122,30 @@ graph LR Die hybride Suche für Chat & RAG. ```mermaid graph LR - Query([Query]) --> Embed(Embedding) - Embed --> Hybrid{Hybrid Search} + Query(["Query"]) --> Embed("Embedding") + Embed --> Hybrid{"Hybrid Search"} subgraph Search Components - Vec[Vector Score] - Graph[Graph/Edge Bonus] + Vec["Vector Score"] + Graph["Graph/Edge Bonus"] end Vec --> Hybrid Graph --> Hybrid - Hybrid --> Rank(Re-Ranking) - Rank --> Ctx[LLM Context] + Hybrid --> Rank("Re-Ranking") + Rank --> Ctx["LLM Context"] ``` #### C. Visualisierung (Graph) Der optimierte Pfad für das Frontend. ```mermaid graph LR - UI[Frontend UI] --> Service(GraphService) - Service -- "Direct Read" --> DB[(Qdrant
Edges Collection)] - DB --> Cyto[Cytoscape
Rendering] + UI["Frontend UI"] --> Service("GraphService") + Service -- "Direct Read" --> DB[("Qdrant
Edges Collection")] + DB --> Cyto["Cytoscape
Rendering"] ``` + --- ## 3. Physische Architektur @@ -185,16 +185,16 @@ Das Frontend ist eine Streamlit-App, die sich wie eine Single-Page-Application ( | Modul | Status | Verantwortung | | :--- | :--- | :--- | -| **`ui.py`** | 🟢 Core | **Main Router.** Initialisiert Session-State und entscheidet anhand der Sidebar-Auswahl, welche View gerendert wird. | -| **`ui_config.py`** | 🟢 Config | **Constants.** Zentraler Ort für Farben (`GRAPH_COLORS`), API-URLs und Timeouts. Änderungen am Look & Feel passieren hier. | -| **`ui_chat.py`** | 🟢 View | **Chat UI.** Rendert Nachrichtenverlauf, Intent-Badges, Quellen-Expanders und Feedback-Buttons. | -| **`ui_editor.py`** | 🟢 View | **Editor UI.** Markdown-Editor mit Live-Vorschau. Integriert "Intelligence" (KI-Link-Vorschläge). | -| **`ui_graph_cytoscape.py`**| 🟢 View | **Modern Graph.** Interaktiver Graph basierend auf Cytoscape.js (COSE Layout). | -| **`ui_graph.py`** | 🟡 Legacy | **Graph UI (Fallback).** Alte Implementierung mittels `streamlit-agraph`. | -| **`ui_callbacks.py`** | 🟢 Logic | **State Controller.** Handhabt komplexe State-Übergänge (z.B. Graph -> Editor). | -| **`ui_utils.py`** | 🟢 Logic | **Helper.** Enthält den **Healing Parser** (`parse_markdown_draft`), der defektes JSON/YAML von LLMs repariert. | -| **`ui_api.py`** | 🟢 Data | **API Client.** Wrapper für Backend REST-Calls. | -| **`ui_graph_service.py`** | 🟢 Data | **Performance Hack.** Greift direkt auf Qdrant zu (bypass API), um Graphen schnell zu laden. | +| **`ui.py`** | 🟢 **Core** | **Main Router.** Initialisiert Session-State und entscheidet anhand der Sidebar-Auswahl, welche View gerendert wird. | +| **`ui_config.py`** | 🟢 **Config** | **Constants.** Zentraler Ort für Farben (`GRAPH_COLORS`), API-URLs und Timeouts. Änderungen am Look & Feel passieren hier. | +| **`ui_chat.py`** | 🟢 **View** | **Chat UI.** Rendert Nachrichtenverlauf, Intent-Badges, Quellen-Expanders und Feedback-Buttons. | +| **`ui_editor.py`** | 🟢 **View** | **Editor UI.** Markdown-Editor mit Live-Vorschau. Integriert "Intelligence" (KI-Link-Vorschläge). | +| **`ui_graph_cytoscape.py`**| 🟢 **View** | **Modern Graph.** Interaktiver Graph basierend auf Cytoscape.js (COSE Layout). | +| **`ui_graph.py`** | 🟡 **Legacy** | **Graph UI (Fallback).** Alte Implementierung mittels `streamlit-agraph`. | +| **`ui_callbacks.py`** | 🟢 **Logic** | **State Controller.** Handhabt komplexe State-Übergänge (z.B. Graph -> Editor). | +| **`ui_utils.py`** | 🟢 **Logic** | **Helper.** Enthält den **Healing Parser** (`parse_markdown_draft`), der defektes JSON/YAML von LLMs repariert. | +| **`ui_api.py`** | 🟢 **Data** | **API Client.** Wrapper für Backend REST-Calls. | +| **`ui_graph_service.py`** | 🟢 **Data** | **Performance Hack.** Greift direkt auf Qdrant zu (bypass API), um Graphen schnell zu laden. | #### Frontend Design Patterns (Wichtig!) @@ -212,37 +212,35 @@ Das Frontend ist eine Streamlit-App, die sich wie eine Single-Page-Application ( 2. Nur wenn das fehlschlägt, wird der Text aus den Datenbank-Chunks rekonstruiert ("Stitching"). Dies verhindert, dass veraltete Datenbank-Stände die echten Dateien überschreiben. ---- - ### 4.3 Backend Architecture (`app/`) -Das Backend stellt die Logik via REST-API bereit. +Das Backend ist das Herzstück. Es stellt die Logik via REST-API bereit. -| Modul | Typ | Verantwortung | -| :--- | :--- | :--- | -| **Core Engine** | | | -| `core/ingestion.py` | Engine | **Pipeline Controller.** Koordiniert den 13-Schritte-Import, Parsing, Hash-Check und DB-Upserts. | -| `core/retriever.py` | Engine | **Search Engine.** Berechnet Hybrid-Score: `(Semantic * W) + (Edge Bonus * 0.25) + (Centrality * 0.05)`. | -| `core/chunker.py` | Engine | **Segmentation.** Zerlegt Text intelligent. Orchestriert `SemanticAnalyzer` für Smart Edges. | -| `core/derive_edges.py`| Engine | **Link Extractor.** Findet Wikilinks, Callouts und Typed Relations im Text. | -| `core/qdrant_points.py`| Mapper | **Object Mapper.** Wandelt Payloads in Qdrant `PointStruct`s um. | -| `core/graph_adapter.py` | Algo | **Graph Logic.** Baut In-Memory Graphen für Re-Ranking und Pfad-Analysen. | -| **Router (API)** | | | -| `routers/chat.py` | Router | **Hybrid Router.** Entscheidet: RAG-Antwort vs. Interview-Modus. | -| `routers/ingest.py` | Router | **Write API.** Nimmt Markdown entgegen, steuert Ingestion und Discovery-Analyse. | -| `routers/query.py` | Router | **Search API.** Klassischer Hybrid-Retriever Endpunkt. | -| `routers/graph.py` | Router | **Viz API.** Liefert Knoten/Kanten für Frontend. | -| **Services** | | | -| `services/llm_service.py`| Service | **Traffic Control.** Async Client für Ollama. Nutzt **Semaphore**, um Hintergrund-Jobs (Import) zu drosseln. | -| `services/discovery.py`| Service | **Intelligence.** "Matrix Logic" für Link-Vorschläge (WP-11). | -| `services/semantic_analyzer.py`| Service | **Filter.** KI-Validierung von Kanten im Hintergrund. | -| `services/feedback_service.py`| Service | **Logging.** Schreibt Interaktions-Logs (JSONL). | - ---- +| Layer | Datei | Status | Verantwortung | +| :--- | :--- | :--- | :--- | +| **Entry** | `app/main.py` | 🟢 **Core** | **Entrypoint.** Initialisiert FastAPI, CORS, und bindet alle Router ein. | +| **Config** | `app/config.py` | 🟢 **Core** | **Settings.** Zentrale Konfiguration (Pydantic). Lädt Env-Vars für Qdrant, LLM und Pfade. | +| **Router** | `app/routers/chat.py` | 🟢 **API** | **Conversation API.** Haupt-Endpunkt für Chat. Entscheidet zwischen Interview- und RAG-Modus. | +| | `app/routers/ingest.py` | 🟢 **API** | **Write API.** Nimmt Markdown entgegen, steuert Ingestion und Discovery-Analyse. | +| | `app/routers/query.py` | 🟢 **API** | **Search API.** Klassischer Hybrid-Retriever Endpunkt. | +| | `app/routers/graph.py` | 🟢 **API** | **Viz API.** Liefert Knoten/Kanten für Frontend-Graphen (Cytoscape). | +| | `app/routers/tools.py` | 🟢 **API** | **Agent Specs.** Liefert JSON-Schemas für die Integration in externe Agenten (Ollama/MCP). | +| **Engine** | `app/core/ingestion.py` | ⚙️ **Core** | **Pipeline Controller.** Koordiniert Parsing, Hashing (Change-Detection) und DB-Upserts. | +| | `app/core/retriever.py` | ⚙️ **Core** | **Search Engine.** Berechnet Scores (Vektor + Graph + Centrality) und baut Erklärungen. | +| | `app/core/chunker.py` | ⚙️ **Core** | **Segmentation.** Zerlegt Text intelligent. Orchestriert `SemanticAnalyzer` für Smart Edges. | +| | `app/core/parser.py` | ⚙️ **Core** | **I/O.** Liest Markdown robust (Encoding-Fallback), trennt Frontmatter/Body. | +| | `app/core/derive_edges.py`| ⚙️ **Core** | **Link Extractor.** Findet Wikilinks, Callouts und Typed Relations im Text. | +| | `app/core/note_payload.py`| ⚙️ **Core** | **Builder.** Erzeugt JSON für `mindnet_notes`. Vererbt Configs (Frontmatter > Type > Default). | +| | `app/core/qdrant_points.py`| ⚙️ **Core** | **Object Mapper.** Wandelt Payloads in Qdrant `PointStruct`s um. Handhabt UUIDs. | +| **Services** | `app/services/llm_service.py`| 🧠 **AI** | **AI Client.** Async Client für Ollama. Verwaltet Concurrency (Semaphore). | +| | `app/services/embeddings_client.py`| 🧠 **AI** | **Vector Client.** Unified Client für Embeddings (Ollama/Nomic). Ersetzt lokale Modelle. | +| | `app/services/discovery.py`| 🧠 **AI** | **Intelligence.** "Matrix Logic" für Link-Vorschläge (WP-11). | +| | `app/services/semantic_analyzer.py`| 🧠 **AI** | **Filter.** KI-Validierung von Kanten im Hintergrund (Background Priority). | ### 4.4 Scripts & Tooling (Die Admin-Toolbox) Der Ordner `scripts/` enthält verifizierte Werkzeuge für den Betrieb. + #### 1. Script-Übersicht | Skript | Status | Zweck | Argumente & Parameter | Beispielaufruf | @@ -255,22 +253,21 @@ Der Ordner `scripts/` enthält verifizierte Werkzeuge für den Betrieb. | **`resolve_unresolved_references.py`** | 🔵 **Maint** | **Link-Healer.** Repariert "tote" Links in der DB nachträglich und erzeugt Backlinks. | `--prefix TEXT`
`--limit INT`
`--apply` (Schreibt Änderungen) | `python3 -m scripts.resolve_unresolved_references --apply` | | **`export_markdown.py`** | ⚪ **Utility** | **Reverse ETL.** Exportiert den Datenbank-Inhalt zurück in Markdown-Dateien (Backup/Recovery). | `--out PATH` (Ziel)
`--note-id ID`
`--include-edges {yaml,footer}`
`--flatten-paths` | `python3 -m scripts.export_markdown --out ./backup` | ---- - #### 2. Einsatzszenarien & Bewertung -### 🟢 Essentiell für den Betrieb (Must-Have) -* **`import_markdown.py`**: Das Arbeitspferd. Ohne dieses Skript kommen keine Daten ins System (außer man nutzt die API einzeln). Es ist für Cronjobs optimiert. -* **`reset_qdrant.py`**: Zwingend notwendig für CI/CD-Pipelines oder saubere Neustarts bei Schema-Änderungen. -* **`health_check_mindnet.py`**: Ideal für Docker-Healthchecks oder Monitoring-Tools (Nagios, Uptime Kuma), da es keine Python-Abhängigkeiten zur App hat. +* **🟢 Essentiell für den Betrieb (Must-Have):** + * **`import_markdown.py`**: Das Arbeitspferd. Ohne dieses Skript kommen keine Daten ins System. + * **`reset_qdrant.py`**: Zwingend notwendig für CI/CD-Pipelines. + * **`health_check_mindnet.py`**: Ideal für Docker-Healthchecks. -### 🟡 Hilfreich für Entwicklung & Debugging (Should-Have) -* **`payload_dryrun.py`**: Sehr wertvoll, wenn man an der `config/types.yaml` oder dem `chunker.py` arbeitet. Man sieht sofort, wie das JSON aussieht, ohne die Datenbank "zuzumüllen". -* **`edges_full_check.py`**: Wichtiges Diagnose-Tool, wenn der Graph im Frontend "komisch" aussieht oder Kanten fehlen. +* **🟡 Hilfreich für Entwicklung & Debugging (Should-Have):** + * **`payload_dryrun.py`**: Wertvoll, wenn man an der `config/types.yaml` arbeitet. + * **`edges_full_check.py`**: Wichtiges Diagnose-Tool für den Graphen. + +* **🔵 Wartung & Spezialfälle (Nice-to-Have):** + * **`resolve_unresolved_references.py`**: Sinnvoll in einem "Knowledge Garden", wo oft Links auf noch nicht existierende Notizen gesetzt werden ("Red Links"). Dieses Skript "heilt" den Graphen nachträglich. + * **`export_markdown.py`**: Ein Notfall-Tool. Da Mindnet nach dem Prinzip "Filesystem First" arbeitet, ist ein Export aus der DB selten nötig, kann aber bei versehentlichem Löschen von Dateien lebensrettend sein. -### 🔵 Wartung & Spezialfälle (Nice-to-Have) -* **`resolve_unresolved_references.py`**: Sinnvoll in einem "Knowledge Garden", wo oft Links auf noch nicht existierende Notizen gesetzt werden ("Red Links"). Dieses Skript "heilt" den Graphen nachträglich. -* **`export_markdown.py`**: Ein Notfall-Tool. Da Mindnet nach dem Prinzip "Filesystem First" arbeitet, ist ein Export aus der DB selten nötig, kann aber bei versehentlichem Löschen von Dateien lebensrettend sein. --- ## 5. Maintenance & "Kill List" @@ -280,12 +277,14 @@ Folgende Dateien wurden im Audit v2.6 als veraltet, redundant oder "Zombie-Code" | Datei | Diagnose | Empfohlene Aktion | | :--- | :--- | :--- | | `app/embed_server.py` | **Zombie.** Alter Standalone-Server. | 🗑️ Löschen | -| `app/embeddings.py` | **Zombie.** Veraltete lokale Lib. | 🗑️ Löschen | +| `app/embeddings.py` | **Zombie.** Veraltete lokale Lib. **(Achtung: Erst Importe in `main.py` entfernen!)** | 🗑️ Löschen | +| `app/routers/embed_router.py` | **Zombie.** Nutzt `embeddings.py`. | 🗑️ Löschen | +| `app/routers/qdrant_router.py`| **Deprecated.** Keine Logik, nur CRUD. | 🗑️ Löschen | | `app/core/edges.py` | **Redundant.** Ersetzt durch `derive_edges.py`. | 🗑️ Löschen | | `app/core/ranking.py` | **Redundant.** Logik in `retriever.py` integriert. | 🗑️ Löschen | | `app/core/type_registry.py` | **Redundant.** Logik in `ingestion.py` integriert. | 🗑️ Löschen | | `app/core/env_vars.py` | **Veraltet.** Ersetzt durch `config.py`. | 🗑️ Löschen | -| `app/routers/qdrant_router.py`| **Deprecated.** Keine Logik, nur CRUD. | 📂 Verschieben nach `scripts/archive/` | +| `app/services/llm_ollama.py` | **Veraltet.** Ersetzt durch `llm_service.py`. | 🗑️ Löschen | --- @@ -432,6 +431,12 @@ python tests/test_feedback_smoke.py --url http://localhost:8002/query python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet_dev" --yes ``` +**Graphen reparieren (Red Links auflösen):** +Nutze dies, wenn Kanten im Graphen ins Leere zeigen (weil die Notiz beim Import noch nicht da war). +```bash +python3 -m scripts.resolve_unresolved_references --apply +``` + **Einen einzelnen File inspizieren (Parser-Sicht):** ```bash python3 tests/inspect_one_note.py --file ./vault/MeinFile.md -- 2.43.0 From c61b66b49df5b1235996bf0b80bbfe31a5810bae Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 12:07:28 +0100 Subject: [PATCH 14/24] neue chunker, Fehler und Strategie Korrektur --- app/core/chunker.py | 129 +++++++++++++++++------------ app/services/semantic_analyzer.py | 63 ++++++++++---- config/types.yaml | 132 +++++++++++++++++------------- 3 files changed, 197 insertions(+), 127 deletions(-) diff --git a/app/core/chunker.py b/app/core/chunker.py index 0943010..1d6f625 100644 --- a/app/core/chunker.py +++ b/app/core/chunker.py @@ -1,7 +1,7 @@ """ FILE: app/core/chunker.py DESCRIPTION: Zerlegt Texte in Chunks (Sliding Window oder nach Headings). Orchestriert die Smart-Edge-Allocation via SemanticAnalyzer. -VERSION: 2.6.0 (Fix: Strict Heading Split & Header Retention) +VERSION: 2.9.0 (Feat: Hybrid Strict Splitting with Size Safety) STATUS: Active DEPENDENCIES: app.services.semantic_analyzer, app.core.derive_edges, markdown_it, yaml, asyncio EXTERNAL_CONFIG: config/types.yaml @@ -25,7 +25,7 @@ from app.services.semantic_analyzer import get_semantic_analyzer try: from app.core.derive_edges import build_edges_for_note except ImportError: - # Mock für Tests, falls Module fehlen + # Mock für Tests def build_edges_for_note(note_id, chunks, note_level_references=None, include_note_scope_refs=False): return [] logger = logging.getLogger(__name__) @@ -122,10 +122,14 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: for line in lines: stripped = line.strip() - if stripped.startswith('# '): - # H1 wird für den Titel genutzt, aber nicht als Block für sliding window - # (Außer es ist H1 im Body, aber wir ignorieren H1 hier meist als Title) - continue + if stripped.startswith('# '): + if buffer: + content = "\n".join(buffer).strip() + if content: + blocks.append(RawBlock("paragraph", content, None, section_path, current_h2)) + buffer = [] + blocks.append(RawBlock("heading", stripped, 1, section_path, current_h2)) + elif stripped.startswith('## '): if buffer: content = "\n".join(buffer).strip() @@ -134,8 +138,16 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]: buffer = [] current_h2 = stripped[3:].strip() section_path = f"/{current_h2}" - # WICHTIG: Die Überschrift selbst als Block speichern! blocks.append(RawBlock("heading", stripped, 2, section_path, current_h2)) + + elif stripped.startswith('### '): + if buffer: + content = "\n".join(buffer).strip() + if content: + blocks.append(RawBlock("paragraph", content, None, section_path, current_h2)) + buffer = [] + blocks.append(RawBlock("heading", stripped, 3, section_path, current_h2)) + elif not stripped: if buffer: content = "\n".join(buffer).strip() @@ -175,19 +187,18 @@ def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], not text_body = "\n\n".join([b.text for b in buf]) win_body = f"{context_prefix}\n{text_body}".strip() if context_prefix else text_body + # Basis-Info vom ersten Block im Buffer + sec = buf[0].section_title if buf else None + path = buf[0].section_path if buf else "/" + if estimate_tokens(text_body) <= max_tokens: - sec = buf[0].section_title if buf else None - path = buf[0].section_path if buf else "/" _create_chunk_obj(chunks, note_id, text_body, win_body, sec, path) else: + # Fallback: Wenn Block zu groß, intern splitten (Sentence-Level) sentences = split_sentences(text_body) current_chunk_sents = [] current_len = 0 - # Basis-Info vom ersten Block im Buffer - sec = buf[0].section_title if buf else None - path = buf[0].section_path if buf else "/" - for sent in sentences: sent_len = estimate_tokens(sent) if current_len + sent_len > target and current_chunk_sents: @@ -219,11 +230,7 @@ def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], not buf = [] for b in blocks: - # Bei Sliding Window ignorieren wir Heading-Blocks als Split-Trigger NICHT zwingend, - # aber wir wollen Headings oft nicht "allein" stehen haben. - # Hier einfache Logik: if b.kind == "heading": - # Optional: Buffer flushen bei neuem Header, um Kontextwechsel sauberer zu machen flush_buffer() current_buf_text = "\n\n".join([x.text for x in buf]) @@ -237,30 +244,34 @@ def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], not def _strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, doc_title: str = "") -> List[Chunk]: """ - STRICT HEADING SPLIT (Fix v2.6.0): - Trennt den Text konsequent an jeder Überschrift der definierten Ebene. - Behält Überschriften als Teil (erste Zeile) des Chunks bei. - Kein Merging kleiner Abschnitte über Header-Grenzen hinweg. + MODUS: Structured / Heading Split + - split_level: Ebene für logische Trennung (z.B. H2). + - strict_heading_split: + True: Trennt an jedem Header <= split_level. + NEU v2.9: Wenn Inhalt > max_tokens, wird trotzdem gesplittet (Safety Split). + False: Fasst zusammen bis 'target' erreicht ist. """ split_level = config.get("split_level", 2) - chunks = [] + target = config.get("target", 400) + max_limit = config.get("max", 600) + strict_mode = config.get("strict_heading_split", False) - # Temporärer Speicher für den aktuellen Chunk + chunks = [] current_chunk_blocks = [] context_prefix = f"# {doc_title}" + def has_content(blk_list): + return any(b.kind != "heading" for b in blk_list) + def flush_current_chunk(): nonlocal current_chunk_blocks if not current_chunk_blocks: return - # Text zusammenbauen text_body = "\n\n".join([b.text for b in current_chunk_blocks]) - # Window bauen (hier einfach Text, da Kontext via Header implizit ist) win_body = f"{context_prefix}\n{text_body}".strip() - # Metadaten vom ersten Block (üblicherweise der Header) nehmen first_b = current_chunk_blocks[0] sec = first_b.section_title path = first_b.section_path @@ -268,19 +279,49 @@ def _strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id _create_chunk_obj(chunks, note_id, text_body, win_body, sec, path) current_chunk_blocks = [] + def get_current_size(): + txt = "\n\n".join([b.text for b in current_chunk_blocks]) + return estimate_tokens(txt) + for b in blocks: - # Prüfen, ob dieser Block ein Trenner (Header auf Split-Level) ist - is_splitter = (b.kind == "heading" and b.level == split_level) + # 1. Header Logic (Struktur-Trigger) + is_splitter = (b.kind == "heading" and b.level is not None and b.level <= split_level) if is_splitter: - # 1. Den bisherigen Chunk abschließen (falls vorhanden) - flush_current_chunk() + is_higher_hierarchy = (b.level < split_level) - # 2. Den neuen Chunk mit diesem Header beginnen - current_chunk_blocks.append(b) + if strict_mode: + # STRICT: + # Wir splitten immer, außer der Vor-Chunk ist leer. + if current_chunk_blocks and has_content(current_chunk_blocks): + flush_current_chunk() + current_chunk_blocks.append(b) + else: + # SOFT: + # Split bei Hierarchie-Wechsel ODER wenn voll. + if is_higher_hierarchy: + flush_current_chunk() + current_chunk_blocks.append(b) + elif current_chunk_blocks and get_current_size() >= target: + flush_current_chunk() + current_chunk_blocks.append(b) + else: + current_chunk_blocks.append(b) else: - # Einfach anhängen - current_chunk_blocks.append(b) + # 2. Content Logic (Safety Trigger für Monster-Abschnitte) + # Bevor wir den Block anhängen: Würde er das Fass zum Überlaufen bringen? + # Wir nutzen hier 'max' als harte Grenze für den Safety-Split. + current_size = get_current_size() + block_size = estimate_tokens(b.text) + + if current_chunk_blocks and (current_size + block_size > max_limit): + # NOTBREMSE: Chunk wird zu groß. + # Wir splitten hier, auch wenn kein Header da ist. + # Der Kontext (Section Title) bleibt erhalten, da er aus `current_h2` kommt (siehe parse_blocks). + flush_current_chunk() + current_chunk_blocks.append(b) + else: + current_chunk_blocks.append(b) # Letzten Rest flushen flush_current_chunk() @@ -301,14 +342,12 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op primary_strategy = config.get("strategy", "sliding_window") enable_smart_edges = config.get("enable_smart_edge_allocation", False) - # Performance/Cost-Guard: Bei Entwürfen keine Smart Edges if enable_smart_edges and note_status in ["draft", "initial_gen"]: logger.info(f"Chunker: Skipping Smart Edges for draft '{note_id}'.") enable_smart_edges = False blocks, doc_title = parse_blocks(md_text) - # Strategie-Auswahl if primary_strategy == "by_heading": chunks = await asyncio.to_thread(_strategy_by_heading, blocks, config, note_id, doc_title) else: @@ -317,11 +356,9 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op if not chunks: return [] - # Smart Edge Allocation (WP-15) if enable_smart_edges: chunks = await _run_smart_edge_allocation(chunks, md_text, note_id, note_type) - # Verkettung der Chunks (next/prev) for i, ch in enumerate(chunks): ch.neighbors_prev = chunks[i-1].id if i > 0 else None ch.neighbors_next = chunks[i+1].id if i < len(chunks)-1 else None @@ -329,10 +366,6 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op return chunks def _extract_all_edges_from_md(md_text: str, note_id: str, note_type: str) -> List[str]: - """ - Hilfsfunktion: Erstellt einen Dummy-Chunk für den gesamten Text und ruft - den Edge-Parser auf, um ALLE Kanten der Notiz zu finden. - """ dummy_chunk = { "chunk_id": f"{note_id}#full", "text": md_text, @@ -340,7 +373,6 @@ def _extract_all_edges_from_md(md_text: str, note_id: str, note_type: str) -> Li "window": md_text, "type": note_type } - # Parsing aller Kanten (Inline, Wikilinks, Callouts) raw_edges = build_edges_for_note( note_id, [dummy_chunk], @@ -351,29 +383,23 @@ def _extract_all_edges_from_md(md_text: str, note_id: str, note_type: str) -> Li for e in raw_edges: kind = e.get("kind") target = e.get("target_id") - # Struktur-Kanten ignorieren wir für die Verteilung if target and kind not in ["belongs_to", "next", "prev", "backlink"]: all_candidates.add(f"{kind}:{target}") - return list(all_candidates) async def _run_smart_edge_allocation(chunks: List[Chunk], full_text: str, note_id: str, note_type: str) -> List[Chunk]: analyzer = get_semantic_analyzer() - - # A. Alle potenziellen Kanten der Notiz sammeln candidate_list = _extract_all_edges_from_md(full_text, note_id, note_type) if not candidate_list: return chunks - # B. LLM Filterung pro Chunk (Parallel) tasks = [] for chunk in chunks: tasks.append(analyzer.assign_edges_to_chunk(chunk.text, candidate_list, note_type)) results_per_chunk = await asyncio.gather(*tasks) - # C. Injection & Fallback Tracking assigned_edges_global = set() for i, confirmed_edges in enumerate(results_per_chunk): @@ -381,18 +407,13 @@ async def _run_smart_edge_allocation(chunks: List[Chunk], full_text: str, note_i chunk.suggested_edges = confirmed_edges assigned_edges_global.update(confirmed_edges) - # Injection: Wir hängen die bestätigten Edges unsichtbar (fürs Embedding) oder sichtbar an - # Hier als "Pseudo-Code" im Text, damit sie embedded werden. if confirmed_edges: - # Format: [[rel:kind|target]] injection_str = "\n" + " ".join([f"[[rel:{e.split(':')[0]}|{e.split(':')[1]}]]" for e in confirmed_edges if ':' in e]) chunk.text += injection_str chunk.window += injection_str - # D. Fallback: Kanten, die NIRGENDS zugewiesen wurden, werden JEDEM Chunk angehängt (Sicherheit) unassigned = set(candidate_list) - assigned_edges_global if unassigned: - logger.info(f"Chunker: {len(unassigned)} unassigned edges in {note_id}. Distributing to all chunks.") fallback_str = "\n" + " ".join([f"[[rel:{e.split(':')[0]}|{e.split(':')[1]}]]" for e in unassigned if ':' in e]) for chunk in chunks: chunk.text += fallback_str diff --git a/app/services/semantic_analyzer.py b/app/services/semantic_analyzer.py index aa9eafd..24ca205 100644 --- a/app/services/semantic_analyzer.py +++ b/app/services/semantic_analyzer.py @@ -1,10 +1,10 @@ """ FILE: app/services/semantic_analyzer.py DESCRIPTION: KI-gestützte Kanten-Validierung. Nutzt LLM (Background-Priority), um Kanten präzise einem Chunk zuzuordnen. -VERSION: 2.0.0 +VERSION: 2.1.0 (Fix: Strict Edge String Validation against LLM Hallucinations) STATUS: Active DEPENDENCIES: app.services.llm_service, json, logging -LAST_ANALYSIS: 2025-12-15 +LAST_ANALYSIS: 2025-12-16 """ import json @@ -21,6 +21,34 @@ class SemanticAnalyzer: def __init__(self): self.llm = LLMService() + def _is_valid_edge_string(self, edge_str: str) -> bool: + """ + Prüft, ob ein String eine valide Kante im Format 'kind:target' ist. + Verhindert, dass LLM-Geschwätz ("Here is the list: ...") als Kante durchrutscht. + """ + if not isinstance(edge_str, str) or ":" not in edge_str: + return False + + parts = edge_str.split(":", 1) + kind = parts[0].strip() + target = parts[1].strip() + + # Regel 1: Ein 'kind' (Beziehungstyp) darf keine Leerzeichen enthalten. + # Erlaubt: "derived_from", "related_to" + # Verboten: "derived end of instruction", "Here is the list" + if " " in kind: + return False + + # Regel 2: Plausible Länge für den Typ + if len(kind) > 40 or len(kind) < 2: + return False + + # Regel 3: Target darf nicht leer sein + if not target: + return False + + return True + async def assign_edges_to_chunk(self, chunk_text: str, all_edges: List[str], note_type: str) -> List[str]: """ Sendet einen Chunk und eine Liste potenzieller Kanten an das LLM. @@ -59,14 +87,13 @@ class SemanticAnalyzer: ) try: - # 4. LLM Call mit Traffic Control (NEU: priority="background") - # Wir nutzen die "Slow Lane", damit der User im Chat nicht warten muss. + # 4. LLM Call mit Traffic Control response_json = await self.llm.generate_raw_response( prompt=final_prompt, force_json=True, max_retries=5, base_delay=5.0, - priority="background" # <--- WICHTIG: Drosselung aktivieren + priority="background" ) # LOG: Raw Response Preview @@ -91,30 +118,38 @@ class SemanticAnalyzer: valid_edges = [] # 6. Robuste Validierung (List vs Dict) + # Wir sammeln erst alle Strings ein + raw_candidates = [] + if isinstance(data, list): - # Standardfall: ["kind:target", ...] - valid_edges = [str(e) for e in data if isinstance(e, str) and ":" in e] + raw_candidates = data elif isinstance(data, dict): - # Abweichende Formate behandeln logger.info(f"ℹ️ [SemanticAnalyzer] LLM lieferte Dict statt Liste. Versuche Reparatur. Keys: {list(data.keys())}") - for key, val in data.items(): # Fall A: {"edges": ["kind:target"]} if key.lower() in ["edges", "results", "kanten", "matches"] and isinstance(val, list): - valid_edges.extend([str(e) for e in val if isinstance(e, str) and ":" in e]) + raw_candidates.extend(val) - # Fall B: {"kind": "target"} + # Fall B: {"kind": "target"} (Beziehung als Key) elif isinstance(val, str): - valid_edges.append(f"{key}:{val}") + raw_candidates.append(f"{key}:{val}") # Fall C: {"kind": ["target1", "target2"]} elif isinstance(val, list): for target in val: if isinstance(target, str): - valid_edges.append(f"{key}:{target}") + raw_candidates.append(f"{key}:{target}") - # Safety: Filtere nur Kanten, die halbwegs valide aussehen + # 7. Strict Validation Loop + for e in raw_candidates: + e_str = str(e) + if self._is_valid_edge_string(e_str): + valid_edges.append(e_str) + else: + logger.debug(f" [SemanticAnalyzer] Invalid edge format rejected: '{e_str}'") + + # Safety: Filtere nur Kanten, die halbwegs valide aussehen (Doppelcheck) final_result = [e for e in valid_edges if ":" in e] # LOG: Ergebnis diff --git a/config/types.yaml b/config/types.yaml index a3385e0..5a465a7 100644 --- a/config/types.yaml +++ b/config/types.yaml @@ -1,4 +1,4 @@ -version: 2.4.0 # Optimized for Async Intelligence & Hybrid Router +version: 2.6.0 # Final WP-15 Config: Smart Edges & Strict/Soft Chunking # ============================================================================== # 1. CHUNKING PROFILES @@ -7,7 +7,6 @@ version: 2.4.0 # Optimized for Async Intelligence & Hybrid Router chunking_profiles: # A. SHORT & FAST - # Für Glossar, Tasks, Risiken. Kleine Schnipsel. sliding_short: strategy: sliding_window enable_smart_edge_allocation: false @@ -16,7 +15,6 @@ chunking_profiles: overlap: [30, 50] # B. STANDARD & FAST - # Der "Traktor": Robust für Quellen, Journal, Daily Logs. sliding_standard: strategy: sliding_window enable_smart_edge_allocation: false @@ -24,10 +22,8 @@ chunking_profiles: max: 650 overlap: [50, 100] - # C. SMART FLOW (Performance-Safe Mode) - # Für Konzepte, Projekte, Erfahrungen. - # HINWEIS: 'enable_smart_edge_allocation' ist vorerst FALSE, um Ollama - # bei der Generierung nicht zu überlasten. Später wieder aktivieren. + # C. SMART FLOW (Text-Fluss) + # Nutzt Sliding Window, aber mit LLM-Kanten-Analyse. sliding_smart_edges: strategy: sliding_window enable_smart_edge_allocation: true @@ -35,12 +31,38 @@ chunking_profiles: max: 600 overlap: [50, 80] - # D. SMART STRUCTURE - # Für Profile, Werte, Prinzipien. Trennt hart an Überschriften (H2). + # D. SMART STRUCTURE (Soft Split) + # Trennt bevorzugt an H2, fasst aber kleine Abschnitte zusammen ("Soft Mode"). structured_smart_edges: strategy: by_heading enable_smart_edge_allocation: true split_level: 2 + strict_heading_split: false + max: 600 + target: 400 + overlap: [50, 80] + + # E. SMART STRUCTURE STRICT (H2 Hard Split) + # Trennt ZWINGEND an jeder H2. + # Verhindert, dass "Vater" und "Partner" (Profile) oder Werte verschmelzen. + structured_smart_edges_strict: + strategy: by_heading + enable_smart_edge_allocation: true + split_level: 2 + strict_heading_split: true # Hard Mode + max: 600 + target: 400 + overlap: [50, 80] + + # F. SMART STRUCTURE DEEP (H3 Hard Split + Merge-Check) + # Spezialfall für "Leitbild Prinzipien": + # - Trennt H1, H2, H3 hart. + # - Aber: Merged "leere" H2 (Tier 2) mit der folgenden H3 (MP1). + structured_smart_edges_strict_L3: + strategy: by_heading + enable_smart_edge_allocation: true + split_level: 3 + strict_heading_split: true max: 600 target: 400 overlap: [50, 80] @@ -59,24 +81,13 @@ defaults: types: - # --- KERNTYPEN (Hoch priorisiert & Smart) --- + # --- KERNTYPEN --- experience: chunking_profile: sliding_smart_edges retriever_weight: 0.90 edge_defaults: ["derived_from", "references"] - # Hybrid Classifier: Wenn diese Worte fallen, ist es eine Experience - detection_keywords: - - "passiert" - - "erlebt" - - "gefühl" - - "situation" - - "stolz" - - "geärgert" - - "reaktion" - - "moment" - - "konflikt" - # Ghostwriter Schema: Sprechende Anweisungen für besseren Textfluss + detection_keywords: ["passiert", "erlebt", "gefühl", "situation", "reaktion"] schema: - "Situation (Was ist passiert?)" - "Meine Reaktion (Was habe ich getan?)" @@ -87,48 +98,37 @@ types: chunking_profile: sliding_smart_edges retriever_weight: 0.97 edge_defaults: ["references", "depends_on"] - detection_keywords: - - "projekt" - - "vorhaben" - - "ziel ist" - - "meilenstein" - - "planen" - - "starten" - - "mission" + detection_keywords: ["projekt", "vorhaben", "ziel ist", "planen", "starten"] schema: - "Mission & Zielsetzung" - "Aktueller Status & Blockaden" - "Nächste konkrete Schritte" - - "Stakeholder & Ressourcen" decision: - chunking_profile: structured_smart_edges - retriever_weight: 1.00 # MAX: Entscheidungen sind Gesetz + # Strict, damit jede Entscheidung atomar bleibt + chunking_profile: structured_smart_edges_strict + retriever_weight: 1.00 edge_defaults: ["caused_by", "references"] - detection_keywords: - - "entschieden" - - "wahl" - - "optionen" - - "alternativen" - - "beschluss" - - "adr" + detection_keywords: ["entschieden", "wahl", "optionen", "alternativen", "adr"] schema: - "Kontext & Problemstellung" - - "Betrachtete Optionen (Alternativen)" + - "Betrachtete Optionen" - "Die Entscheidung" - - "Begründung (Warum diese Wahl?)" + - "Begründung" # --- PERSÖNLICHKEIT & IDENTITÄT --- value: - chunking_profile: structured_smart_edges + # Strict, damit Werte nicht verschwimmen + chunking_profile: structured_smart_edges_strict retriever_weight: 1.00 edge_defaults: ["related_to"] detection_keywords: ["wert", "wichtig ist", "moral", "ethik"] - schema: ["Definition", "Warum mir das wichtig ist", "Leitsätze für den Alltag"] + schema: ["Definition", "Warum mir das wichtig ist", "Leitsätze"] principle: - chunking_profile: structured_smart_edges + # L3 Strict für P3/P3a und Tier2/MP1 Logik + chunking_profile: structured_smart_edges_strict_L3 retriever_weight: 0.95 edge_defaults: ["derived_from", "references"] detection_keywords: ["prinzip", "regel", "grundsatz", "leitlinie"] @@ -138,11 +138,11 @@ types: chunking_profile: sliding_short retriever_weight: 0.90 edge_defaults: ["related_to"] - detection_keywords: ["glaube", "überzeugung", "denke dass", "meinung"] schema: ["Der Glaubenssatz", "Ursprung & Reflexion"] profile: - chunking_profile: structured_smart_edges + # Strict: Jede Rolle (H2) muss ein eigener Chunk sein + chunking_profile: structured_smart_edges_strict retriever_weight: 0.70 edge_defaults: ["references", "related_to"] schema: ["Rolle / Identität", "Fakten & Daten", "Historie"] @@ -159,8 +159,8 @@ types: chunking_profile: sliding_short retriever_weight: 0.85 edge_defaults: ["related_to", "blocks"] - detection_keywords: ["risiko", "gefahr", "bedrohung", "problem", "angst"] - schema: ["Beschreibung des Risikos", "Mögliche Auswirkungen", "Gegenmaßnahmen"] + detection_keywords: ["risiko", "gefahr", "bedrohung"] + schema: ["Beschreibung des Risikos", "Auswirkungen", "Gegenmaßnahmen"] # --- BASIS & WISSEN --- @@ -168,10 +168,7 @@ types: chunking_profile: sliding_smart_edges retriever_weight: 0.60 edge_defaults: ["references", "related_to"] - schema: - - "Definition" - - "Kontext & Hintergrund" - - "Verwandte Konzepte" + schema: ["Definition", "Kontext", "Verwandte Konzepte"] task: chunking_profile: sliding_short @@ -183,19 +180,36 @@ types: chunking_profile: sliding_standard retriever_weight: 0.80 edge_defaults: ["references", "related_to"] - schema: ["Log-Eintrag", "Gedanken & Erkenntnisse"] + schema: ["Log-Eintrag", "Gedanken"] source: chunking_profile: sliding_standard retriever_weight: 0.50 edge_defaults: [] - schema: - - "Metadaten (Autor, URL, Datum)" - - "Kernaussage / Zusammenfassung" - - "Zitate & Notizen" + schema: ["Metadaten", "Zusammenfassung", "Zitate"] glossary: chunking_profile: sliding_short retriever_weight: 0.40 edge_defaults: ["related_to"] - schema: ["Begriff", "Definition"] \ No newline at end of file + schema: ["Begriff", "Definition"] + + person: + chunking_profile: sliding_standard + retriever_weight: 0.50 + edge_defaults: ["related_to"] + schema: ["Rolle", "Beziehung", "Kontext"] + + event: + chunking_profile: sliding_standard + retriever_weight: 0.60 + edge_defaults: ["related_to"] + schema: ["Datum & Ort", "Teilnehmer", "Ergebnisse"] + + # --- FALLBACK --- + + default: + chunking_profile: sliding_standard + retriever_weight: 1.00 + edge_defaults: ["references"] + schema: ["Inhalt"] \ No newline at end of file -- 2.43.0 From 7639bb8472644c147f9061b126cba43e97282b52 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 14:03:48 +0100 Subject: [PATCH 15/24] docs aktulisiert --- docs/06_Roadmap/06_active_roadmap.md | 44 ++++-- docs/06_Roadmap/06_handover_prompts.md | 202 +++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 docs/06_Roadmap/06_handover_prompts.md diff --git a/docs/06_Roadmap/06_active_roadmap.md b/docs/06_Roadmap/06_active_roadmap.md index fa9c220..5be3d57 100644 --- a/docs/06_Roadmap/06_active_roadmap.md +++ b/docs/06_Roadmap/06_active_roadmap.md @@ -2,18 +2,18 @@ doc_type: roadmap audience: product_owner, developer status: active -version: 2.6 +version: 2.7 context: "Aktuelle Planung für kommende Features (ab WP16), Release-Strategie und Historie der abgeschlossenen WPs." --- # Mindnet Active Roadmap -**Aktueller Stand:** v2.6.0 (Post-WP19) -**Fokus:** Visualisierung, Exploration & Deep Search. +**Aktueller Stand:** v2.6.0 (Post-WP15/WP19) +**Fokus:** Visualisierung, Exploration & Intelligent Ingestion. ## 1. Programmstatus -Wir haben mit der Implementierung des Graph Explorers (WP19) einen Meilenstein in **Phase E (Maintenance & Scaling)** erreicht. Die Architektur ist nun modular. Der nächste Schritt (WP19a) vertieft die Analyse-Fähigkeiten. +Wir haben mit der Implementierung des Graph Explorers (WP19) und der Smart Edge Allocation (WP15) die Basis für ein intelligentes, robustes System gelegt. Der nächste Schritt (WP19a) vertieft die Analyse, während WP16 die "Eingangs-Intelligenz" erhöht. | Phase | Fokus | Status | | :--- | :--- | :--- | @@ -43,14 +43,14 @@ Eine Übersicht der implementierten Features zum schnellen Auffinden von Funktio | **WP-10** | Web UI | Streamlit-Frontend als Ersatz für das Terminal. | | **WP-10a**| Draft Editor | GUI-Komponente zum Bearbeiten und Speichern generierter Notizen. | | **WP-11** | Backend Intelligence | `nomic-embed-text` (768d) und Matrix-Logik für Kanten-Typisierung. | -| **WP-15** | Smart Edge Allocation | LLM-Filter für Kanten in Chunks + Traffic Control (Semaphore). | +| **WP-15** | Smart Edge Allocation | LLM-Filter für Kanten in Chunks + Traffic Control (Semaphore) + Strict Chunking. | | **WP-19** | Graph Visualisierung | **Frontend Modularisierung:** Umbau auf `ui_*.py`.
**Graph Engines:** Parallelbetrieb von Cytoscape (COSE) und Agraph.
**Tools:** "Single Source of Truth" Editor, Persistenz via URL. | --- ## 3. Offene Workpackages (Planung) -Diese Features stehen als nächstes an. +Diese Features stehen als nächstes an oder befinden sich in der Umsetzung. ### WP-19a – Graph Intelligence & Discovery (Sprint-Fokus) **Status:** 🚀 Startklar @@ -59,11 +59,26 @@ Diese Features stehen als nächstes an. * **Filter-Logik:** "Zeige nur Wege, die zu `type:decision` führen". * **Chunk Inspection:** Umschaltbare Granularität (Notiz vs. Chunk) zur Validierung des Smart Chunkers. -### WP-16 – Auto-Discovery & Enrichment +### WP-14 – Review / Refactoring / Dokumentation +**Status:** 🟡 Laufend (Phase E) +**Ziel:** Technische Schulden abbauen, die durch schnelle Feature-Entwicklung (WP15/WP19) entstanden sind. +* **Refactoring `chunker.py`:** Die Datei ist monolithisch geworden (Parsing, Strategien, LLM-Orchestrierung). + * *Lösung:* Aufteilung in ein Package `app/core/chunking/` mit Modulen (`strategies.py`, `orchestration.py`, `utils.py`). +* **Dokumentation:** Kontinuierliche Synchronisation von Code und Docs (v2.6 Stand). + +### WP-16 – Auto-Discovery & Intelligent Ingestion **Status:** 🟡 Geplant -**Ziel:** Automatisches Erkennen von fehlenden Kanten in "dummem" Text *vor* der Speicherung. -* **Problem:** Nutzer vergessen Wikilinks. -* **Lösung:** Ein "Enricher" scannt Text vor dem Import, findet Keywords (z.B. "Mindnet") und schlägt Links vor (`[[Mindnet]]`). +**Ziel:** Das System soll "dumme" Textdateien beim Import automatisch analysieren, strukturieren und anreichern, bevor sie gespeichert werden. +**Kern-Features:** +1. **Smart Link Enricher:** Automatisches Erkennen von fehlenden Kanten in Texten ohne explizite Wikilinks. Ein "Enricher" scannt Text vor dem Import, findet Keywords (z.B. "Mindnet") und schlägt Links vor (`[[Mindnet]]`). +2. **Structure Analyzer (Auto-Strategy):** + * *Problem:* Manuelle Zuweisung von `chunking_profile` in `types.yaml` ist starr. + * *Lösung:* Vorschalten einer Analysestufe im Importer (`chunker.py`), die die Text-Topologie prüft und die Strategie wählt. + * *Metrik 1 (Heading Density):* Verhältnis `Anzahl Überschriften / Wortanzahl`. Hohe Dichte (> 1/200) -> Indikator für `structured_smart_edges`. Niedrige Dichte -> `sliding_smart_edges`. + * *Metrik 2 (Variance):* Regelmäßigkeit der Abstände zwischen Headings. +3. **Context-Aware Hierarchy Merging:** + * *Problem:* Leere Zwischenüberschriften (z.B. "Tier 2") gingen früher als bedeutungslose Chunks verloren oder wurden isoliert. + * *Lösung:* Generalisierung der Logik aus WP-15, die leere Eltern-Elemente automatisch mit dem ersten Kind-Element verschmilzt ("Tier 2 + MP1"), um den Kontext für das Embedding zu wahren. ### WP-17 – Conversational Memory (Gedächtnis) **Status:** 🟡 Geplant @@ -77,6 +92,11 @@ Diese Features stehen als nächstes an. * **Feature:** Cronjob `check_graph_integrity.py`. * **Funktion:** Findet "Dangling Edges" (Links auf gelöschte Notizen) und repariert/löscht sie. +### WP-13 – MCP-Integration & Agenten-Layer +**Status:** 🟡 Geplant +**Ziel:** mindnet als MCP-Server bereitstellen, damit Agenten (Claude Desktop, OpenAI) standardisierte Tools nutzen können. +* **Umfang:** MCP-Server mit Tools (`mindnet_query`, `mindnet_explain`, etc.). + ### WP-20 – Cloud Hybrid Mode (Optional) **Status:** ⚪ Optional **Ziel:** "Turbo-Modus" für Massen-Imports. @@ -91,4 +111,6 @@ graph TD WP19(Graph Viz) --> WP19a(Discovery) WP19a --> WP17(Memory) WP15(Smart Edges) --> WP16(Auto-Discovery) - WP03(Import) --> WP18(Health Check) \ No newline at end of file + WP15 --> WP14(Refactoring) + WP03(Import) --> WP18(Health Check) + WP03/WP04 --> WP13(MCP) \ No newline at end of file diff --git a/docs/06_Roadmap/06_handover_prompts.md b/docs/06_Roadmap/06_handover_prompts.md new file mode 100644 index 0000000..e5e5726 --- /dev/null +++ b/docs/06_Roadmap/06_handover_prompts.md @@ -0,0 +1,202 @@ +--- +doc_type: operations +audience: developer, admin +status: active +version: 1.0 +context: "Sammlung von Initialisierungs-Prompts für neue Chat-Sessions. Jeder Prompt entspricht dem Projektauftrag für ein spezifisches Workpackage." +--- + +# Mindnet WP-Handover Prompts + +**Verwendung:** +Kopiere den entsprechenden Block in ein **neues** Chat-Fenster, um die KI-Instanz exakt auf den Kontext und die Ziele des Workpackages einzustellen. + +--- + +## WP-19a: Graph Intelligence & Discovery + +**Status:** 🚀 Startklar +**Fokus:** Frontend-Erweiterung, Semantische Suche, Filterung. + +```text +Du bist der Lead Developer für "Mindnet", ein lokales RAG-System (Python/FastAPI/Streamlit/Qdrant). +Wir starten jetzt **WP-19a: Graph Intelligence & Discovery**. + +**Status Quo (v2.6.0):** +- Backend: Async API mit `/query` (Hybrid Search) und `/chat`. +- Frontend: Modularisiertes Streamlit (`ui.py`, `ui_graph.py`). +- Daten: Qdrant Indizes (`notes`, `chunks`, `edges`) sind gefüllt. + +**Dein Auftrag (WP-19a):** +Implementiere "Deep Dive" Werkzeuge im Frontend, um den Graphen nicht nur zu sehen, sondern zu verstehen. +1. **Neues UI-Modul:** Erstelle `app/frontend/ui_discovery.py`. +2. **Discovery Tab:** Implementiere eine Oberfläche für: + - Semantische Suche (ohne Chat-Modus). + - Wildcard-Filter ("Zeige alle Notes vom Typ 'decision'"). + - Pfad-Analyse ("Wie sind Note A und Note B verbunden?"). +3. **Chunk Inspection:** Baue einen Toggle, der im Graph-Explorer zwischen "Note-View" (grob) und "Chunk-View" (fein) umschaltet. + +**Regeln:** +- Nutze `st.session_state` für Persistenz zwischen Re-Renders. +- Halte die Business-Logik im Backend (ggf. neue Endpoints in `app/routers/query.py`). +- Gib vollständigen, lauffähigen Code aus. + +Bitte bestätige die Übernahme und skizziere die Architektur für `ui_discovery.py`. +``` + +--- + +## WP-13: MCP Integration & Agenten-Layer + +**Status:** 🟡 Geplant +**Fokus:** Schnittstelle für externe Agenten (Claude Desktop). + +```text +Du bist der Lead Developer für "Mindnet" (Python/FastAPI/Qdrant). +Wir starten jetzt **WP-13: MCP Integration**. + +**Status Quo (v2.6.0):** +- Das System läuft stabil asynchron. +- `types.yaml` steuert die Logik. +- Es existieren Services für Retrieval und Graph-Access. + +**Dein Auftrag (WP-13):** +Implementiere einen MCP-Server (Model Context Protocol), der Mindnet als "Tool" für Claude Desktop verfügbar macht. +1. **Server:** Erstelle `app/mcp_server.py` basierend auf dem `mcp`-SDK. +2. **Tools:** Implementiere folgende Tools: + - `search_notes(query)`: Nutzt unseren Hybriden Retriever. + - `read_note(id)`: Liest Fulltext einer Notiz. + - `list_connections(id)`: Zeigt Edges an. + - `Notes(content)`: (Optional) Nutzt die Ingestion-Pipeline. +3. **Integration:** Nutze die bestehenden Services (`Retriever`, `QdrantClient`) wieder – kein redundanter Code! +4. **Async:** Achte auf Kompatibilität zwischen MCP und unserem `asyncio` Core. + +**Regeln:** +- Halte dich strikt an die Architektur in `mindnet_technical_architecture.md`. +- Aktualisiere den `admin_guide.md` mit Anweisungen zur Einbindung in Claude Desktop config. + +Bitte bestätige die Übernahme und zeige einen ersten Entwurf für `app/mcp_server.py`. +``` + +--- + +## WP-14: Review & Refactoring + +**Status:** 🟡 Laufend +**Fokus:** Code-Qualität, Modularisierung, Technische Schulden. + +```text +Du bist der Software-Architekt für "Mindnet". +Wir starten **WP-14: Review & Refactoring**. + +**Status Quo (v2.6.0):** +- Das System ist funktional mächtig, aber einige Dateien (z.B. `chunker.py`) sind monolithisch geworden. +- Dokumentation und Code müssen synchronisiert werden. + +**Dein Auftrag (WP-14):** +1. **Refactoring `chunker.py`:** Zerlege den Monolithen in ein sauberes Package `app/core/chunking/`. + - `strategies.py`: Enthält `sliding_window` und `by_heading` Logik. + - `orchestration.py`: Enthält `assemble_chunks` und Smart-Edge-Flow. + - `utils.py`: Helper. +2. **Cleanup:** Entferne veraltete / auskommentierte Code-Blöcke im gesamten Projekt. +3. **Doc-Sync:** Prüfe, ob alle Parameter in `types.yaml` auch im Code verwendet werden (Dead Config Detection). + +**Regeln:** +- Funktionalität darf sich nicht ändern (Regression Tests!). +- Imports in `scripts/import_markdown.py` müssen angepasst werden. + +Bitte bestätige und beginne mit der Strukturierung des neuen `chunking` Packages. +``` + +--- + +## WP-16: Auto-Discovery & Intelligent Ingestion + +**Status:** 🟡 Geplant +**Fokus:** Automatisierung beim Import, "Smarter Text". + +```text +Du bist der Lead Developer für "Mindnet". +Wir starten **WP-16: Auto-Discovery & Intelligent Ingestion**. + +**Status Quo (v2.6.0):** +- Import verlässt sich auf explizite Wikilinks und manuelle Profil-Wahl in `types.yaml`. +- Smart Edges filtern nur vorhandene Links. + +**Dein Auftrag (WP-16):** +Mache den Import intelligenter, bevor Daten gespeichert werden. +1. **Structure Analyzer:** Erweitere den Chunker um eine Vor-Analyse. + - Berechne "Heading Density" (Überschriften pro Wort). + - Wähle automatisch `structured` (hohe Dichte) oder `sliding` (niedrige Dichte), wenn `profile: auto` gesetzt ist. +2. **Smart Link Enricher:** Implementiere einen Service, der im Text nach Keywords sucht, die als Titel anderer Notizen existieren (Exact Match & Fuzzy Match). + - Schlage diese als `suggested_edges` vor. +3. **Hierarchy Merging:** Generalisiere die Logik, dass leere Überschriften ("Tier 2") automatisch mit dem folgenden Inhalt verschmelzen. + +**Regeln:** +- Performance beachten! Der Import darf nicht ewig dauern. +- Änderungen am Content nur im RAM, Original-Datei bleibt unangetastet (außer User will Writeback). + +Bitte bestätige und skizziere den Algorithmus für den Structure Analyzer. +``` + +--- + +## WP-17: Conversational Memory + +**Status:** 🟡 Geplant +**Fokus:** Dialog-Qualität, Kontext. + +```text +Du bist der AI-Engineer für "Mindnet". +Wir starten **WP-17: Conversational Memory**. + +**Status Quo (v2.6.0):** +- Chat ist "stateless". Jede Anfrage wird isoliert betrachtet. +- RAG funktioniert, aber Rückfragen ("Was meinst du damit?") scheitern. + +**Dein Auftrag (WP-17):** +Implementiere ein Kurzzeitgedächtnis für den Chat. +1. **API Update:** Erweitere `ChatRequest` (DTO) um ein Feld `history: List[Message]`. +2. **Frontend:** Passe `ui.py` an, um die letzten N Nachrichten mitzusenden. +3. **Token Management:** Implementiere eine Logik im `LLMService`, die das Kontext-Fenster (z.B. 4k Token) balanciert: + - System Prompt (fest) + - RAG Chunks (hoch gewichtet) + - Chat History (auffüllen bis Limit) +4. **Prompting:** Integriere `{chat_history}` in das Template in `prompts.yaml`. + +**Regeln:** +- Nutze effizientes Truncation (älteste Nachrichten zuerst weg). +- History darf RAG-Wissen nicht verdrängen (Reserviere min. 60% für RAG). + +Bitte bestätige und zeige das aktualisierte Pydantic-Modell für `ChatRequest`. +``` + +--- + +## WP-18: Graph Health & Maintenance + +**Status:** 🟡 Geplant +**Fokus:** Datenintegrität, Garbage Collection. + +```text +Du bist der DevOps Engineer für "Mindnet". +Wir starten **WP-18: Graph Health & Maintenance**. + +**Status Quo (v2.6.0):** +- Import ist asynchron. Bei Timeouts kann der DB-Stand vom File-System abweichen (Hash-Mismatch). +- Gelöschte Notizen hinterlassen "Dangling Edges". + +**Dein Auftrag (WP-18):** +1. **Transactional Ingestion:** Implementiere einen "Two-Phase Commit" für den Import. + - Der File-Hash für die Änderungserkennung darf erst aktualisiert werden, wenn der Qdrant-Upsert *bestätigt* erfolgreich war. +2. **Integrity Script:** Erstelle `scripts/check_graph_integrity.py`. + - Prüfe: Gibt es Edges, deren `target_id` nicht in `notes` existiert? + - Aktion: Report oder Auto-Delete. +3. **Resolve References:** Erweitere das Skript um Logik, die "Unresolved Targets" (Text-Links) nachträglich in echte UUID-Links wandelt, wenn die Ziel-Notiz später importiert wurde. + +**Regeln:** +- Sicherheit geht vor Geschwindigkeit. +- Keine Datenlöschung ohne Log-Eintrag. + +Bitte bestätige und skizziere die Logik für den Transactional Hash Update. +``` \ No newline at end of file -- 2.43.0 From 6011b96fc12303176db116cca2318d77408f7681 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 14:11:17 +0100 Subject: [PATCH 16/24] neue ui Save-logik --- app/frontend/ui_editor.py | 38 ++++++++++---------- app/routers/ingest.py | 74 +++++++++++++++++++++++++++------------ 2 files changed, 71 insertions(+), 41 deletions(-) diff --git a/app/frontend/ui_editor.py b/app/frontend/ui_editor.py index ec9d34d..7d103e6 100644 --- a/app/frontend/ui_editor.py +++ b/app/frontend/ui_editor.py @@ -1,10 +1,10 @@ """ FILE: app/frontend/ui_editor.py -DESCRIPTION: Markdown-Editor mit Live-Vorschau und Metadaten-Feldern. Unterstützt Intelligence-Features (Link-Vorschläge) und unterscheidet Create/Update-Modus. -VERSION: 2.6.0 +DESCRIPTION: Markdown-Editor mit Live-Vorschau. +Refactored für WP-14: Asynchrones Feedback-Handling (Queued State). +VERSION: 2.7.0 (Fix: Async Save UI) STATUS: Active DEPENDENCIES: streamlit, uuid, re, datetime, ui_utils, ui_api -LAST_ANALYSIS: 2025-12-15 """ import streamlit as st import uuid @@ -76,14 +76,11 @@ def render_draft_editor(msg): # --- UI LAYOUT --- - # Header Info (Debug Pfad anzeigen, damit wir sicher sind) origin_fname = st.session_state.get(f"{key_base}_origin_filename") if origin_fname: - # Dateiname extrahieren für saubere Anzeige display_name = str(origin_fname).split("/")[-1] st.success(f"📂 **Update-Modus**: `{display_name}`") - # Debugging: Zeige vollen Pfad im Expander with st.expander("Dateipfad Details", expanded=False): st.code(origin_fname) st.markdown(f'
', unsafe_allow_html=True) @@ -173,21 +170,33 @@ def render_draft_editor(msg): save_label = "💾 Update speichern" if origin_fname else "💾 Neu anlegen & Indizieren" if st.button(save_label, type="primary", key=f"{key_base}_save"): - with st.spinner("Speichere im Vault..."): + with st.spinner("Sende an Backend..."): if origin_fname: - # UPDATE: Ziel ist der exakte Pfad target_file = origin_fname else: - # CREATE: Neuer Dateiname raw_title = final_meta.get("title", "draft") target_file = f"{datetime.now().strftime('%Y%m%d')}-{slugify(raw_title)[:60]}.md" result = save_draft_to_vault(final_doc, filename=target_file) + + # --- WP-14 CHANGE START: Handling Async Response --- if "error" in result: st.error(f"Fehler: {result['error']}") else: - st.success(f"Gespeichert: {result.get('file_path')}") + status = result.get("status", "success") + file_path = result.get("file_path", "unbekannt") + + if status == "queued": + # Neuer Status für Async Processing + st.info(f"✅ **Eingereiht:** Datei `{file_path}` wurde gespeichert.") + st.caption("Die KI-Analyse und Indizierung läuft im Hintergrund. Du kannst weiterarbeiten.") + else: + # Legacy / Synchroner Fall + st.success(f"Gespeichert: {file_path}") + st.balloons() + # --- WP-14 CHANGE END --- + with b2: if st.button("📋 Code anzeigen", key=f"{key_base}_btn_copy"): st.code(final_doc, language="markdown") @@ -197,25 +206,18 @@ def render_draft_editor(msg): def render_manual_editor(): """ Rendert den manuellen Editor. - PRÜFT, ob eine Edit-Anfrage aus dem Graphen vorliegt! """ - target_msg = None - - # 1. Prüfen: Gibt es Nachrichten im Verlauf? if st.session_state.messages: last_msg = st.session_state.messages[-1] - - # 2. Ist die letzte Nachricht eine Edit-Anfrage? (Erkennbar am query_id prefix 'edit_') qid = str(last_msg.get("query_id", "")) if qid.startswith("edit_"): target_msg = last_msg - # 3. Fallback: Leeres Template, falls keine Edit-Anfrage vorliegt if not target_msg: target_msg = { "content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n", - "query_id": f"manual_{uuid.uuid4()}" # Eigene ID, damit neuer State entsteht + "query_id": f"manual_{uuid.uuid4()}" } render_draft_editor(target_msg) \ No newline at end of file diff --git a/app/routers/ingest.py b/app/routers/ingest.py index 9603171..cfac79d 100644 --- a/app/routers/ingest.py +++ b/app/routers/ingest.py @@ -1,16 +1,17 @@ """ FILE: app/routers/ingest.py -DESCRIPTION: Endpunkte für WP-11. Nimmt Markdown entgegen, steuert Ingestion und Discovery (Link-Vorschläge). -VERSION: 0.6.0 +DESCRIPTION: Endpunkte für WP-11. Nimmt Markdown entgegen. +Refactored für WP-14: Nutzt BackgroundTasks für non-blocking Save. +VERSION: 0.7.0 (Fix: Timeout WP-14) STATUS: Active DEPENDENCIES: app.core.ingestion, app.services.discovery, fastapi, pydantic -LAST_ANALYSIS: 2025-12-15 """ import os import time import logging -from fastapi import APIRouter, HTTPException +import asyncio +from fastapi import APIRouter, HTTPException, BackgroundTasks from pydantic import BaseModel from typing import Optional, Dict, Any @@ -20,7 +21,7 @@ from app.services.discovery import DiscoveryService logger = logging.getLogger(__name__) router = APIRouter() -# Services Init (Global oder via Dependency Injection) +# Services Init discovery_service = DiscoveryService() class AnalyzeRequest(BaseModel): @@ -36,7 +37,32 @@ class SaveResponse(BaseModel): status: str file_path: str note_id: str - stats: Dict[str, Any] + message: str # Neu für UX Feedback + stats: Dict[str, Any] # Kann leer sein bei async processing + +# --- Background Task Wrapper --- +async def run_ingestion_task(markdown_content: str, filename: str, vault_root: str, folder: str): + """ + Führt die Ingestion im Hintergrund aus, damit der Request nicht blockiert. + """ + logger.info(f"🔄 Background Task started: Ingesting {filename}...") + try: + ingest_service = IngestionService() + result = await ingest_service.create_from_text( + markdown_content=markdown_content, + filename=filename, + vault_root=vault_root, + folder=folder + ) + # Hier könnte man später Notification-Services (Websockets) triggern + if result.get("status") == "error": + logger.error(f"❌ Background Ingestion Error for {filename}: {result.get('error')}") + else: + logger.info(f"✅ Background Task finished: {filename} ({result.get('chunks_count')} Chunks)") + + except Exception as e: + logger.error(f"❌ Critical Background Task Failure: {e}", exc_info=True) + @router.post("/analyze") async def analyze_draft(req: AnalyzeRequest): @@ -44,7 +70,6 @@ async def analyze_draft(req: AnalyzeRequest): WP-11 Intelligence: Liefert Link-Vorschläge via DiscoveryService. """ try: - # Hier rufen wir jetzt den verbesserten Service auf result = await discovery_service.analyze_draft(req.text, req.type) return result except Exception as e: @@ -52,9 +77,10 @@ async def analyze_draft(req: AnalyzeRequest): return {"suggestions": [], "error": str(e)} @router.post("/save", response_model=SaveResponse) -async def save_note(req: SaveRequest): +async def save_note(req: SaveRequest, background_tasks: BackgroundTasks): """ - WP-11 Persistence: Speichert und indiziert. + WP-14 Fix: Startet Ingestion im Hintergrund (Fire & Forget). + Verhindert Timeouts bei aktiver Smart-Edge-Allocation (WP-15). """ try: vault_root = os.getenv("MINDNET_VAULT_ROOT", "./vault") @@ -65,29 +91,31 @@ async def save_note(req: SaveRequest): except: pass final_filename = req.filename or f"draft_{int(time.time())}.md" - ingest_service = IngestionService() - # Async Call - result = await ingest_service.create_from_text( + # Wir geben sofort eine ID zurück (optimistisch), + # auch wenn die echte ID erst nach dem Parsing feststeht. + # Für UI-Feedback nutzen wir den Filename. + + # Task in die Queue schieben + background_tasks.add_task( + run_ingestion_task, markdown_content=req.markdown_content, filename=final_filename, vault_root=abs_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.get("path", "unknown"), - note_id=result.get("note_id", "unknown"), + status="queued", + file_path=os.path.join(req.folder, final_filename), + note_id="pending", + message="Speicherung & KI-Analyse im Hintergrund gestartet.", stats={ - "chunks": result.get("chunks_count", 0), - "edges": result.get("edges_count", 0) + "chunks": -1, # Indikator für Async + "edges": -1 } ) - 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 + logger.error(f"Save dispatch failed: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Save dispatch failed: {str(e)}") \ No newline at end of file -- 2.43.0 From 156c2c2fd573ed4e7853ef4b621852107106a11c Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 14:48:04 +0100 Subject: [PATCH 17/24] neue hash-wert berechnung --- app/core/ingestion.py | 23 ++++++--- docs/00_General/00_glossary.md | 11 +++-- .../03_tech_configuration.md | 12 +++-- .../03_tech_ingestion_pipeline.md | 47 +++++++++++++------ 4 files changed, 61 insertions(+), 32 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index be690ba..ab2e46a 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -1,11 +1,10 @@ """ FILE: app/core/ingestion.py DESCRIPTION: Haupt-Ingestion-Logik. Liest Markdown, prüft Hashes (Change Detection), zerlegt in Chunks und schreibt in Qdrant. -VERSION: 2.5.2 +VERSION: 2.5.3 (Fix: Hash-Mode Full for Metadata Detection) STATUS: Active DEPENDENCIES: app.core.parser, app.core.note_payload, app.core.chunker, app.core.derive_edges, app.core.qdrant*, app.services.embeddings_client EXTERNAL_CONFIG: config/types.yaml -LAST_ANALYSIS: 2025-12-15 """ import os import logging @@ -94,7 +93,8 @@ class IngestionService: apply: bool = False, purge_before: bool = False, note_scope_refs: bool = False, - hash_mode: str = "body", + # FIX: Default auf "full", damit Metadata-Änderungen erkannt werden + hash_mode: str = "full", hash_source: str = "parsed", hash_normalize: str = "canonical" ) -> Dict[str, Any]: @@ -150,17 +150,27 @@ class IngestionService: logger.error(f"Payload build failed: {e}") return {**result, "error": f"Payload build failed: {str(e)}"} - # 4. Change Detection (Das fehlende Stück!) + # 4. Change Detection (Updated Logic) 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) + + # Robustere Abfrage: Falls 'hashes' im Payload fehlt, None zurückgeben + old_hashes = (old_payload or {}).get("hashes") + if isinstance(old_hashes, dict): + old_hash = old_hashes.get(key_current) + else: + # Fallback für Legacy Payloads ohne Hash-Dict + old_hash = None + new_hash = note_pl.get("hashes", {}).get(key_current) + # Wenn wir keinen alten Hash haben (z.B. neues Hash-Schema "full"), erzwingen wir Update hash_changed = (old_hash != new_hash) + 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 @@ -177,7 +187,6 @@ class IngestionService: # --- Config Loading (Clean) --- chunk_config = get_chunk_config(note_type) - # Hier greift die Logik aus types.yaml (smart=True/False) chunks = await assemble_chunks(fm["id"], body_text, fm["type"], config=chunk_config) chunk_pls = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text) @@ -240,7 +249,7 @@ class IngestionService: logger.error(f"Upsert failed: {e}", exc_info=True) return {**result, "error": f"DB Upsert failed: {e}"} - # --- Qdrant Helper (Restored) --- + # --- Qdrant Helper --- def _fetch_note_payload(self, note_id: str) -> Optional[dict]: from qdrant_client.http import models as rest diff --git a/docs/00_General/00_glossary.md b/docs/00_General/00_glossary.md index 29dbb5d..ac6cb3e 100644 --- a/docs/00_General/00_glossary.md +++ b/docs/00_General/00_glossary.md @@ -2,7 +2,7 @@ doc_type: glossary audience: all status: active -version: 2.6 +version: 2.6.0 context: "Definitionen zentraler Begriffe und Entitäten im Mindnet-System." --- @@ -13,24 +13,25 @@ context: "Definitionen zentraler Begriffe und Entitäten im Mindnet-System." ## Kern-Entitäten * **Note:** Repräsentiert eine Markdown-Datei. Die fachliche Haupteinheit. -* **Chunk:** Ein Textabschnitt einer Note (meist 512 Tokens). Die technische Sucheinheit (Vektor). +* **Chunk:** Ein Textabschnitt einer Note. Die technische Sucheinheit (Vektor). Durch neue Strategien kann dies ein Fließtext-Abschnitt oder ein logisches Kapitel (Heading) sein. * **Edge:** Eine gerichtete Verbindung zwischen zwei Knoten (Chunks oder Notes). * **Vault:** Der lokale Ordner mit den Markdown-Dateien (Source of Truth). * **Frontmatter:** Der YAML-Header am Anfang einer Notiz (enthält `id`, `type`, `title`). ## Komponenten -* **Importer:** Das Python-Skript (`ingestion.py`), das Markdown liest und in Qdrant schreibt. +* **Importer:** Das Python-Skript (`import_markdown.py`), das Markdown liest und in Qdrant schreibt. * **Retriever:** Die Komponente, die sucht. Nutzt hybrides Scoring (Semantik + Graph). * **Decision Engine:** Teil des Routers, der entscheidet, wie auf eine Anfrage reagiert wird (z.B. Strategie wählen). * **Hybrid Router v5:** Die Logik, die erkennt, ob der User eine Frage stellt (`RAG`) oder einen Befehl gibt (`INTERVIEW`). * **Draft Editor:** Die Web-UI-Komponente, in der generierte Notizen bearbeitet werden. -* **Traffic Control:** Ein Mechanismus im `LLMService`, der Chat-Anfragen priorisiert und Hintergrund-Jobs (wie Import) drosselt. +* **Traffic Control (WP15):** Ein Mechanismus im `LLMService`, der Prioritäten verwaltet (`realtime` für Chat vs. `background` für Import) und Hintergrund-Tasks mittels Semaphoren drosselt. ## Konzepte & Features * **Active Intelligence:** Feature im Web-Editor, das während des Schreibens automatisch Links vorschlägt. -* **Smart Edge Allocation (WP15):** Ein KI-Verfahren, das prüft, ob ein Link in einer Notiz für einen spezifischen Textabschnitt relevant ist. +* **Smart Edge Allocation (WP15):** Ein KI-Verfahren, das prüft, ob ein Link in einer Notiz für einen spezifischen Textabschnitt relevant ist, statt ihn blind allen Chunks zuzuordnen. +* **Strict Heading Split:** Chunking-Strategie, bei der Überschriften (z.B. H2) als harte Grenzen dienen. Verhindert das Vermischen von Themen (z.B. zwei unterschiedliche Rollen in einem Chunk). * **Healing Parser:** UI-Funktion, die fehlerhaften Output des LLMs (z.B. defektes YAML) automatisch repariert. * **Explanation Layer:** Die Schicht, die dem Nutzer erklärt, *warum* ein Suchergebnis gefunden wurde (z.B. "Weil Projekt X davon abhängt"). * **Provenance:** Die Herkunft einer Kante. diff --git a/docs/03_Technical_References/03_tech_configuration.md b/docs/03_Technical_References/03_tech_configuration.md index 70d90bb..2ee969f 100644 --- a/docs/03_Technical_References/03_tech_configuration.md +++ b/docs/03_Technical_References/03_tech_configuration.md @@ -3,7 +3,7 @@ doc_type: technical_reference audience: developer, admin scope: configuration, env status: active -version: 2.6 +version: 2.6.0 context: "Referenztabellen für Umgebungsvariablen und YAML-Konfigurationen." --- @@ -30,7 +30,7 @@ Diese Variablen steuern die Infrastruktur, Timeouts und Feature-Flags. | `MINDNET_OLLAMA_URL` | `http://127.0.0.1:11434`| URL zum LLM-Server. | | `MINDNET_LLM_TIMEOUT` | `300.0` | Timeout in Sekunden (Erhöht für CPU Cold-Starts). | | `MINDNET_API_TIMEOUT` | `300.0` | Frontend Timeout (Erhöht für Smart Edge Wartezeiten). | -| `MINDNET_LLM_BACKGROUND_LIMIT`| `2` | **Traffic Control:** Max. parallele Import-Tasks. | +| `MINDNET_LLM_BACKGROUND_LIMIT`| `2` | **Traffic Control (Neu):** Max. parallele Import-Tasks (Semaphore). | | `MINDNET_VAULT_ROOT` | `./vault` | Pfad für Write-Back Operationen (Drafts). | | `MINDNET_HASH_COMPARE` | `Body` | Import-Strategie: `Body`, `Frontmatter` oder `Full`. | | `MINDNET_HASH_SOURCE` | `parsed` | Hash-Quelle: `parsed`, `raw` oder `file`. | @@ -47,16 +47,18 @@ Steuert das Import-Verhalten, Chunking und die Kanten-Logik pro Typ. | :--- | :--- | :--- | :--- | :--- | | **concept** | `sliding_smart_edges` | 0.60 | Ja | Abstrakte Begriffe. | | **project** | `sliding_smart_edges` | 0.97 | Ja | Aktive Vorhaben. | -| **decision** | `structured_smart_edges` | 1.00 | Ja | Entscheidungen (ADRs). | +| **decision** | `structured_smart_edges_strict` | 1.00 | Ja | Entscheidungen (ADRs). Atomar. | | **experience** | `sliding_smart_edges` | 0.90 | Ja | Persönliche Learnings. | | **journal** | `sliding_standard` | 0.80 | Nein | Logs / Dailies. | -| **value** | `structured_smart_edges` | 1.00 | Ja | Werte/Prinzipien. | +| **value** | `structured_smart_edges_strict` | 1.00 | Ja | Werte/Prinzipien. Atomar. | | **risk** | `sliding_short` | 0.90 | Nein | Risiken. | | **person** | `sliding_standard` | 0.50 | Nein | Profile. | | **source** | `sliding_standard` | 0.50 | Nein | Externe Quellen. | | **event** | `sliding_standard` | 0.60 | Nein | Meetings. | -| **goal** | `sliding_standard` | 0.95 | Nein | Strategische Ziele. | +| **goal** | `sliding_smart_edges` | 0.95 | Nein | Strategische Ziele. | | **belief** | `sliding_short` | 0.90 | Nein | Glaubenssätze. | +| **profile** | `structured_smart_edges_strict` | 0.70 | Nein | Rollenprofile. Strict Split. | +| **principle** | `structured_smart_edges_strict_L3`| 0.95 | Nein | Prinzipien. Tiefer Split (H3). | | **default** | `sliding_standard` | 1.00 | Nein | Fallback. | *Hinweis: `Smart Edges?` entspricht dem YAML-Key `enable_smart_edge_allocation: true`.* diff --git a/docs/03_Technical_References/03_tech_ingestion_pipeline.md b/docs/03_Technical_References/03_tech_ingestion_pipeline.md index 03e8fb2..ca905f5 100644 --- a/docs/03_Technical_References/03_tech_ingestion_pipeline.md +++ b/docs/03_Technical_References/03_tech_ingestion_pipeline.md @@ -3,8 +3,8 @@ doc_type: technical_reference audience: developer, devops scope: backend, ingestion, smart_edges status: active -version: 2.6 -context: "Detaillierte technische Beschreibung der Import-Pipeline, Quality Gates und CLI-Befehle." +version: 2.6.0 +context: "Detaillierte technische Beschreibung der Import-Pipeline, Chunking-Strategien und CLI-Befehle." --- # Ingestion Pipeline & Smart Processing @@ -21,16 +21,16 @@ Der Prozess ist **asynchron** und **idempotent**. 2. **Frontmatter extrahieren:** Validierung von Pflichtfeldern (`id`, `type`, `title`). 3. **Typauflösung:** Bestimmung des `type` via `types.yaml`. 4. **Note-Payload generieren:** Erstellen des JSON-Objekts für `mindnet_notes`. -5. **Chunking anwenden:** Zerlegung des Textes basierend auf dem `chunk_profile` (siehe unten). +5. **Chunking anwenden:** Zerlegung des Textes basierend auf dem `chunk_profile` (siehe Kap. 3). 6. **Smart Edge Allocation (WP15):** * Wenn `enable_smart_edge_allocation: true`: Der `SemanticAnalyzer` sendet Chunks an das LLM. - * **Traffic Control:** Request nutzt `priority="background"`. Semaphore (Limit: 2) drosselt die Last. + * **Traffic Control:** Request nutzt `priority="background"`. Semaphore (Limit via `.env`) drosselt die Last. * **Resilienz:** Bei Timeout (Ollama) greift ein Fallback (Broadcasting an alle Chunks). 7. **Inline-Kanten finden:** Parsing von `[[rel:...]]`. 8. **Callout-Kanten finden:** Parsing von `> [!edge]`. 9. **Default-Edges erzeugen:** Anwendung der `edge_defaults` aus Registry. 10. **Strukturkanten erzeugen:** `belongs_to`, `next`, `prev`. -11. **Embedding (Async):** Generierung via `nomic-embed-text` (768d). +11. **Embedding (Async):** Generierung via `nomic-embed-text` (768 Dim). 12. **Strict Mode:** Abbruch bei leeren Embeddings oder Dimension 0. 13. **Diagnose:** Integritäts-Check nach dem Lauf. @@ -58,7 +58,7 @@ export COLLECTION_PREFIX="mindnet" > Das Flag `--purge-before-upsert` ist kritisch. Es löscht vor dem Schreiben einer Note ihre alten Chunks/Edges. Ohne dieses Flag entstehen **"Geister-Chunks"** (alte Textabschnitte, die im Markdown gelöscht wurden, aber im Index verbleiben). ### 2.2 Full Rebuild (Clean Slate) -Notwendig bei Änderungen an `types.yaml` oder Modell-Wechsel. +Notwendig bei Änderungen an `types.yaml` (z.B. neue Chunking-Profile) oder Modell-Wechsel. ```bash # 0. Modell sicherstellen @@ -75,18 +75,35 @@ python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply -- ## 3. Chunking & Payload -Das Chunking ist profilbasiert. +Das Chunking ist profilbasiert und in `types.yaml` konfiguriert. Seit v2.6 unterscheiden wir zwischen **Sliding Window** und **Heading Split**. -| Profil | Max Token | Overlap | Einsatz | +### 3.1 Profile und Strategien + +| Profil | Strategie | Parameter | Einsatzgebiet | | :--- | :--- | :--- | :--- | -| `sliding_short` | 128 | 20 | Logs, Chats. | -| `sliding_standard` | 512 | 50 | Massendaten. | -| `sliding_smart_edges`| 512 | 50 | Wichtige Inhalte (Experience, Project). | -| `structured_smart` | n/a | n/a | Trennt strikt an Headings (für ADRs). | +| `sliding_short` | `sliding_window` | Max: 350, Target: 200 | Kurze Logs, Chats, Risiken. | +| `sliding_standard` | `sliding_window` | Max: 650, Target: 450 | Massendaten (Journal, Quellen). | +| `sliding_smart_edges`| `sliding_window` | Max: 600, Target: 400 | Fließtexte mit hohem Wert (Projekte, Erfahrungen). | +| `structured_smart_edges` | `by_heading` | `strict: false` (Soft) | Strukturierte Texte, wo kleine Abschnitte gemergt werden dürfen. | +| `structured_smart_edges_strict` | `by_heading` | `strict: true` (Hard) | **Atomare Einheiten**: Entscheidungen, Werte, Profile. | -**Payload-Felder:** -* `text`: Der reine Inhalt (Anzeige). -* `window`: Inhalt plus Overlap (für Embedding). +### 3.2 Die `by_heading` Logik (Neu in v2.6) + +Die Strategie `by_heading` zerlegt Texte anhand ihrer Struktur (Überschriften). + +* **Split Level:** Definiert die Tiefe (z.B. `2` = H1 & H2 triggern Split). +* **Modus "Strict" (`strict_heading_split: true`):** + * Jede Überschrift (`<= split_level`) erzwingt einen neuen Chunk. + * *Ausnahme:* Wenn der vorherige Chunk leer war (nur Überschriften), wird gemergt (Context-Aware Merge). + * *Safety:* Wird ein Abschnitt zu lang (> `max`), wird trotzdem getrennt (Hybrid-Fallback). +* **Modus "Soft" (`strict_heading_split: false`):** + * Überschriften auf dem Split-Level (z.B. H2) lösen nur dann einen neuen Chunk aus, wenn der aktuelle Chunk die `target`-Größe erreicht hat. + * Überschriften *oberhalb* (z.B. H1) erzwingen immer einen Split (Hierarchie-Reset). + +### 3.3 Payload-Felder (Qdrant) + +* `text`: Der reine Inhalt (Anzeige im UI). +* `window`: Inhalt plus Overlap (für Embedding). Bei `by_heading` enthält dies oft den Kontext der Eltern-Überschrift. --- -- 2.43.0 From 6df9b54626dc792f902a63b7017ce0a43893dd11 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 14:53:56 +0100 Subject: [PATCH 18/24] note_payload berechnet und speichert den Hash Wert --- app/core/note_payload.py | 98 +++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 31 deletions(-) diff --git a/app/core/note_payload.py b/app/core/note_payload.py index 36108a9..8d57735 100644 --- a/app/core/note_payload.py +++ b/app/core/note_payload.py @@ -1,11 +1,10 @@ """ FILE: app/core/note_payload.py -DESCRIPTION: Baut das JSON-Objekt für 'mindnet_notes'. Wendet Vererbung für Configs an (Frontmatter > Type > Default). -VERSION: 2.1.0 +DESCRIPTION: Baut das JSON-Objekt für 'mindnet_notes'. Inkludiert Hash-Berechnung für Change Detection. +VERSION: 2.2.0 (Fix: Missing Hash Calculation) STATUS: Active -DEPENDENCIES: yaml, os, json, pathlib +DEPENDENCIES: yaml, os, json, pathlib, hashlib EXTERNAL_CONFIG: config/types.yaml -LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations @@ -14,6 +13,7 @@ from typing import Any, Dict, Tuple, Optional import os import json import pathlib +import hashlib try: import yaml # type: ignore @@ -31,7 +31,6 @@ def _as_dict(x) -> Dict[str, Any]: return dict(x) out: Dict[str, Any] = {} - # bekannte Attribute übernehmen, sofern vorhanden for attr in ( "frontmatter", "body", @@ -50,7 +49,6 @@ def _as_dict(x) -> Dict[str, Any]: if val is not None: out[attr] = val - # Fallback: wenn immer noch leer, raw speichern if not out: out["raw"] = str(x) @@ -58,14 +56,12 @@ def _as_dict(x) -> Dict[str, Any]: def _pick_args(*args, **kwargs) -> Tuple[Optional[str], Optional[dict]]: - """Extrahiert optionale Zusatzargumente wie path und types_cfg.""" path = kwargs.get("path") or (args[0] if args else None) types_cfg = kwargs.get("types_cfg") or kwargs.get("types") or None return path, types_cfg def _env_float(name: str, default: float) -> float: - """Liest einen Float-Wert aus der Umgebung, mit robustem Fallback.""" try: return float(os.environ.get(name, default)) except Exception: @@ -73,7 +69,6 @@ def _env_float(name: str, default: float) -> float: def _ensure_list(x) -> list: - """Garantiert eine String-Liste.""" if x is None: return [] if isinstance(x, list): @@ -82,13 +77,47 @@ def _ensure_list(x) -> list: return [str(i) for i in x] return [str(x)] +# --- NEW: Hash Logic --- +def _compute_hash(content: str) -> str: + """Berechnet einen SHA-256 Hash für den gegebenen String.""" + if not content: + return "" + return hashlib.sha256(content.encode("utf-8")).hexdigest() + +def _get_hash_source_content(n: Dict[str, Any], mode: str) -> str: + """ + Stellt den String zusammen, der gehasht werden soll. + mode="body": Nur der Textinhalt. + mode="full": Textinhalt + relevante Metadaten (Titel, Typ, Tags, Status). + """ + body = str(n.get("body") or "") + + if mode == "body": + return body + + if mode == "full": + # Wir nehmen die wichtigsten strukturellen Metadaten dazu. + # Wichtig: Sortierte Keys für deterministisches Verhalten! + fm = n.get("frontmatter") or {} + + # Extrahieren relevanter Meta-Felder, die Änderungen auslösen sollen + meta_parts = [] + for k in sorted(["title", "type", "status", "tags", "chunk_profile"]): + val = fm.get(k) + if val is not None: + meta_parts.append(f"{k}:{val}") + + meta_str = "|".join(meta_parts) + return f"{meta_str}||{body}" + + return body + # --------------------------------------------------------------------------- # Type-Registry laden # --------------------------------------------------------------------------- def _load_types_config(explicit_cfg: Optional[dict] = None) -> dict: - """Lädt die Type-Registry aus YAML/JSON oder nutzt ein explizit übergebenes Dict.""" if explicit_cfg and isinstance(explicit_cfg, dict): return explicit_cfg @@ -105,7 +134,6 @@ def _load_types_config(explicit_cfg: Optional[dict] = None) -> dict: def _cfg_for_type(note_type: str, reg: dict) -> dict: - """Liefert die Konfiguration für einen konkreten Notiztyp aus der Registry.""" if not isinstance(reg, dict): return {} types = reg.get("types") if isinstance(reg.get("types"), dict) else reg @@ -113,7 +141,6 @@ def _cfg_for_type(note_type: str, reg: dict) -> dict: def _cfg_defaults(reg: dict) -> dict: - """Liefert den Default-Block aus der Registry (defaults/global).""" if not isinstance(reg, dict): return {} for key in ("defaults", "default", "global"): @@ -130,20 +157,16 @@ def _cfg_defaults(reg: dict) -> dict: def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: """ Baut das Note-Payload für mindnet_notes auf. - - Erwartete Felder im Payload: - - note_id: stabile ID aus Frontmatter (id) oder Note-Objekt - - title: Titel der Notiz - - type: Notiztyp (z. B. concept, project, journal, ...) - - path: Pfad im Vault - - 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) + Inkludiert Hash-Berechnung. """ n = _as_dict(note) path_arg, types_cfg_explicit = _pick_args(*args, **kwargs) reg = _load_types_config(types_cfg_explicit) + + # Hash Config extrahieren (Defaults wie in ingestion.py) + hash_mode = kwargs.get("hash_mode", "full") + hash_source = kwargs.get("hash_source", "parsed") + hash_normalize = kwargs.get("hash_normalize", "canonical") fm = n.get("frontmatter") or {} fm_type = fm.get("type") or n.get("type") or "concept" @@ -152,7 +175,7 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: cfg_type = _cfg_for_type(note_type, reg) cfg_def = _cfg_defaults(reg) - # --- retriever_weight: Frontmatter > Typ-Config > Registry-Defaults > ENV --- + # --- retriever_weight --- default_rw = _env_float("MINDNET_DEFAULT_RETRIEVER_WEIGHT", 1.0) retriever_weight = fm.get("retriever_weight") if retriever_weight is None: @@ -165,7 +188,7 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: except Exception: retriever_weight = default_rw - # --- chunk_profile: Frontmatter > Typ-Config > Registry-Defaults > ENV --- + # --- chunk_profile --- chunk_profile = fm.get("chunk_profile") if chunk_profile is None: chunk_profile = cfg_type.get( @@ -178,7 +201,7 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: if not isinstance(chunk_profile, str): chunk_profile = "medium" - # --- edge_defaults: Frontmatter > Typ-Config > Registry-Defaults --- + # --- edge_defaults --- edge_defaults = fm.get("edge_defaults") if edge_defaults is None: edge_defaults = cfg_type.get( @@ -187,7 +210,7 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: ) edge_defaults = _ensure_list(edge_defaults) - # --- Basis-Metadaten (IDs, Titel, Pfad) --- + # --- Basis-Metadaten --- note_id = n.get("note_id") or n.get("id") or fm.get("id") title = n.get("title") or fm.get("title") or "" path = n.get("path") or path_arg @@ -203,28 +226,41 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: "chunk_profile": chunk_profile, "edge_defaults": edge_defaults, } + + # --- FIX: Hash Calculation --- + # Wir berechnen den Hash basierend auf dem Modus + content_to_hash = _get_hash_source_content(n, hash_mode) + computed_hash = _compute_hash(content_to_hash) + + # Der Key muss exakt dem entsprechen, was ingestion.py erwartet: + # key_current = f"{hash_mode}:{hash_source}:{hash_normalize}" + hash_key = f"{hash_mode}:{hash_source}:{hash_normalize}" + + payload["hashes"] = { + hash_key: computed_hash + } - # Tags / Keywords übernehmen + # Tags / Keywords tags = fm.get("tags") or fm.get("keywords") or n.get("tags") if tags: payload["tags"] = _ensure_list(tags) - # WP-11: Aliases übernehmen (für Discovery Service) + # Aliases aliases = fm.get("aliases") if aliases: payload["aliases"] = _ensure_list(aliases) - # Zeitliche Metadaten (sofern vorhanden) + # Zeit 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) + # Fulltext if "body" in n and n["body"]: payload["fulltext"] = str(n["body"]) - # JSON-Roundtrip zur harten Validierung (ASCII beibehalten) + # JSON Validation json.loads(json.dumps(payload, ensure_ascii=False)) return payload \ No newline at end of file -- 2.43.0 From a272c39613f25cee6e744689537b5947b96cbba6 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 15:43:11 +0100 Subject: [PATCH 19/24] =?UTF-8?q?bug=5Ffixing,=20mindnet=5Fnotes=20enth?= =?UTF-8?q?=C3=A4lt=20beide=20Hash=20und=20schreibt=20auch=20das=20richtig?= =?UTF-8?q?e=20chunking=5Fprofile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/ingestion.py | 41 ++++++++++++++--------- app/core/note_payload.py | 72 +++++++++++++++++++++------------------- 2 files changed, 63 insertions(+), 50 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index ab2e46a..9b96478 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -1,7 +1,7 @@ """ FILE: app/core/ingestion.py -DESCRIPTION: Haupt-Ingestion-Logik. Liest Markdown, prüft Hashes (Change Detection), zerlegt in Chunks und schreibt in Qdrant. -VERSION: 2.5.3 (Fix: Hash-Mode Full for Metadata Detection) +DESCRIPTION: Haupt-Ingestion-Logik. Liest Markdown, steuert Change-Detection (via ENV) und schreibt in Qdrant. +VERSION: 2.6.0 (Feat: Flexible Change Detection & Full Logic Restoration) STATUS: Active DEPENDENCIES: app.core.parser, app.core.note_payload, app.core.chunker, app.core.derive_edges, app.core.qdrant*, app.services.embeddings_client EXTERNAL_CONFIG: config/types.yaml @@ -56,9 +56,11 @@ def resolve_note_type(requested: Optional[str], reg: dict) -> str: def effective_chunk_profile(note_type: str, reg: dict) -> str: t_cfg = reg.get("types", {}).get(note_type, {}) - if t_cfg and t_cfg.get("chunk_profile"): - return t_cfg.get("chunk_profile") - return reg.get("defaults", {}).get("chunk_profile", "default") + # FIX: Konsistenz mit note_payload.py - Prüfe erst den korrekten Key + if t_cfg: + if t_cfg.get("chunking_profile"): return t_cfg.get("chunking_profile") + if t_cfg.get("chunk_profile"): return t_cfg.get("chunk_profile") # Legacy + return reg.get("defaults", {}).get("chunking_profile", "sliding_standard") def effective_retriever_weight(note_type: str, reg: dict) -> float: t_cfg = reg.get("types", {}).get(note_type, {}) @@ -79,6 +81,9 @@ class IngestionService: self.registry = load_type_registry() self.embedder = EmbeddingsClient() + # ACTIVE HASH MODE aus ENV lesen (Default: full) + self.active_hash_mode = os.getenv("MINDNET_CHANGE_DETECTION_MODE", "full") + try: ensure_collections(self.client, self.prefix, self.dim) ensure_payload_indexes(self.client, self.prefix) @@ -93,8 +98,8 @@ class IngestionService: apply: bool = False, purge_before: bool = False, note_scope_refs: bool = False, - # FIX: Default auf "full", damit Metadata-Änderungen erkannt werden - hash_mode: str = "full", + # Hash-Mode wird nicht mehr übergeben, sondern via ENV gesteuert. + # Source und Normalize bleiben konfigurierbar. hash_source: str = "parsed", hash_normalize: str = "canonical" ) -> Dict[str, Any]: @@ -132,11 +137,11 @@ class IngestionService: fm["retriever_weight"] = float(weight) # 3. Build Note Payload + # Ruft make_note_payload auf, welches JETZT alle Hash-Varianten berechnet. 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 @@ -150,25 +155,31 @@ class IngestionService: logger.error(f"Payload build failed: {e}") return {**result, "error": f"Payload build failed: {str(e)}"} - # 4. Change Detection (Updated Logic) + # 4. Change Detection (Updated Logic with ENV Strategy) 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}" - # Robustere Abfrage: Falls 'hashes' im Payload fehlt, None zurückgeben + # Wir bauen den Key basierend auf der ENV-Einstellung + check_key = f"{self.active_hash_mode}:{hash_source}:{hash_normalize}" + old_hashes = (old_payload or {}).get("hashes") + + # Fallback Logik für alte Daten if isinstance(old_hashes, dict): - old_hash = old_hashes.get(key_current) + old_hash = old_hashes.get(check_key) + elif isinstance(old_hashes, str): + # Sehr alte Legacy Daten hatten Hash direkt als String (meist Body) + # Wenn wir im Body-Modus sind, ist das okay, sonst Force Update + old_hash = old_hashes if self.active_hash_mode == "body" else None else: - # Fallback für Legacy Payloads ohne Hash-Dict old_hash = None - new_hash = note_pl.get("hashes", {}).get(key_current) + new_hash = note_pl.get("hashes", {}).get(check_key) - # Wenn wir keinen alten Hash haben (z.B. neues Hash-Schema "full"), erzwingen wir Update + # Vergleich hash_changed = (old_hash != new_hash) chunks_missing, edges_missing = self._artifacts_missing(note_id) diff --git a/app/core/note_payload.py b/app/core/note_payload.py index 8d57735..957a97e 100644 --- a/app/core/note_payload.py +++ b/app/core/note_payload.py @@ -1,7 +1,10 @@ """ FILE: app/core/note_payload.py -DESCRIPTION: Baut das JSON-Objekt für 'mindnet_notes'. Inkludiert Hash-Berechnung für Change Detection. -VERSION: 2.2.0 (Fix: Missing Hash Calculation) +DESCRIPTION: Baut das JSON-Objekt. +FEATURES: + 1. Multi-Hash: Berechnet immer 'body' AND 'full' Hashes für flexible Change Detection. + 2. Config-Fix: Liest korrekt 'chunking_profile' aus types.yaml (statt Legacy 'chunk_profile'). +VERSION: 2.3.0 STATUS: Active DEPENDENCIES: yaml, os, json, pathlib, hashlib EXTERNAL_CONFIG: config/types.yaml @@ -77,7 +80,7 @@ def _ensure_list(x) -> list: return [str(i) for i in x] return [str(x)] -# --- NEW: Hash Logic --- +# --- Hash Logic --- def _compute_hash(content: str) -> str: """Berechnet einen SHA-256 Hash für den gegebenen String.""" if not content: @@ -87,8 +90,6 @@ def _compute_hash(content: str) -> str: def _get_hash_source_content(n: Dict[str, Any], mode: str) -> str: """ Stellt den String zusammen, der gehasht werden soll. - mode="body": Nur der Textinhalt. - mode="full": Textinhalt + relevante Metadaten (Titel, Typ, Tags, Status). """ body = str(n.get("body") or "") @@ -96,13 +97,12 @@ def _get_hash_source_content(n: Dict[str, Any], mode: str) -> str: return body if mode == "full": - # Wir nehmen die wichtigsten strukturellen Metadaten dazu. - # Wichtig: Sortierte Keys für deterministisches Verhalten! fm = n.get("frontmatter") or {} - - # Extrahieren relevanter Meta-Felder, die Änderungen auslösen sollen + # Wichtig: Sortierte Keys für deterministisches Verhalten! + # Wir nehmen alle steuernden Metadaten auf meta_parts = [] - for k in sorted(["title", "type", "status", "tags", "chunk_profile"]): + # Hier checken wir keys, die eine Neu-Indizierung rechtfertigen würden + for k in sorted(["title", "type", "status", "tags", "chunking_profile", "chunk_profile", "retriever_weight"]): val = fm.get(k) if val is not None: meta_parts.append(f"{k}:{val}") @@ -157,14 +157,13 @@ def _cfg_defaults(reg: dict) -> dict: def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: """ Baut das Note-Payload für mindnet_notes auf. - Inkludiert Hash-Berechnung. + Inkludiert Hash-Berechnung (Body & Full) und korrigierte Config-Lookups. """ n = _as_dict(note) path_arg, types_cfg_explicit = _pick_args(*args, **kwargs) reg = _load_types_config(types_cfg_explicit) - # Hash Config extrahieren (Defaults wie in ingestion.py) - hash_mode = kwargs.get("hash_mode", "full") + # Hash Config (Parameter für Source/Normalize, Mode ist hardcoded auf 'beide') hash_source = kwargs.get("hash_source", "parsed") hash_normalize = kwargs.get("hash_normalize", "canonical") @@ -188,18 +187,21 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: except Exception: retriever_weight = default_rw - # --- chunk_profile --- - chunk_profile = fm.get("chunk_profile") + # --- chunk_profile (FIXED LOGIC) --- + # 1. Frontmatter Override (beide Schreibweisen erlaubt) + chunk_profile = fm.get("chunking_profile") or fm.get("chunk_profile") + + # 2. Type Config (Korrekter Key 'chunking_profile' aus types.yaml) if chunk_profile is None: - chunk_profile = cfg_type.get( - "chunk_profile", - cfg_def.get( - "chunk_profile", - os.environ.get("MINDNET_DEFAULT_CHUNK_PROFILE", "medium"), - ), - ) - if not isinstance(chunk_profile, str): - chunk_profile = "medium" + chunk_profile = cfg_type.get("chunking_profile") + + # 3. Default Config (Fallback auf sliding_standard statt medium) + if chunk_profile is None: + chunk_profile = cfg_def.get("chunking_profile", "sliding_standard") + + # 4. Safety Fallback + if not isinstance(chunk_profile, str) or not chunk_profile: + chunk_profile = "sliding_standard" # --- edge_defaults --- edge_defaults = fm.get("edge_defaults") @@ -225,20 +227,20 @@ def make_note_payload(note: Any, *args, **kwargs) -> Dict[str, Any]: "retriever_weight": retriever_weight, "chunk_profile": chunk_profile, "edge_defaults": edge_defaults, + "hashes": {} # Init Hash Dict } - # --- FIX: Hash Calculation --- - # Wir berechnen den Hash basierend auf dem Modus - content_to_hash = _get_hash_source_content(n, hash_mode) - computed_hash = _compute_hash(content_to_hash) + # --- MULTI-HASH CALCULATION (Strategy Decoupling) --- + # Wir berechnen immer BEIDE Strategien und speichern sie. + # ingestion.py entscheidet dann anhand der ENV-Variable, welcher verglichen wird. + modes_to_calc = ["body", "full"] - # Der Key muss exakt dem entsprechen, was ingestion.py erwartet: - # key_current = f"{hash_mode}:{hash_source}:{hash_normalize}" - hash_key = f"{hash_mode}:{hash_source}:{hash_normalize}" - - payload["hashes"] = { - hash_key: computed_hash - } + for mode in modes_to_calc: + content_to_hash = _get_hash_source_content(n, mode) + computed_hash = _compute_hash(content_to_hash) + # Key Schema: mode:source:normalize (z.B. "full:parsed:canonical") + key = f"{mode}:{hash_source}:{hash_normalize}" + payload["hashes"][key] = computed_hash # Tags / Keywords tags = fm.get("tags") or fm.get("keywords") or n.get("tags") -- 2.43.0 From b3833f2051228d1ea12946a13e6143f8b6912ea3 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 15:50:24 +0100 Subject: [PATCH 20/24] chunk_payload nimmt auch das richtige chunking_profile --- app/core/chunk_payload.py | 80 +++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/app/core/chunk_payload.py b/app/core/chunk_payload.py index 5cc3000..d864b82 100644 --- a/app/core/chunk_payload.py +++ b/app/core/chunk_payload.py @@ -1,11 +1,13 @@ """ FILE: app/core/chunk_payload.py -DESCRIPTION: Baut das JSON-Objekt für 'mindnet_chunks'. Inkludiert Nachbarschafts-IDs (prev/next) und Titel. -VERSION: 2.0.0 +DESCRIPTION: Baut das JSON-Objekt für 'mindnet_chunks'. +FEATURES: + - Inkludiert Nachbarschafts-IDs (prev/next) und Titel. + - FIX: Korrektes Auslesen von 'chunking_profile' (Frontmatter > Type > Default). +VERSION: 2.1.0 STATUS: Active DEPENDENCIES: yaml, os EXTERNAL_CONFIG: config/types.yaml -LAST_ANALYSIS: 2025-12-15 """ from __future__ import annotations from typing import Any, Dict, List, Optional @@ -36,21 +38,32 @@ def _get_defaults(reg: dict) -> dict: return {} def _as_float(x: Any): - try: - return float(x) - except Exception: - return None + try: return float(x) + except Exception: return None -def _resolve_chunk_profile(note_type: str, reg: dict) -> str: +def _resolve_chunk_profile_from_config(note_type: str, reg: dict) -> Optional[str]: + """ + Liest das Profil aus der Config (Type > Default). + Prüft 'chunking_profile' UND 'chunk_profile'. + """ + # 1. Type Level types = _get_types_map(reg) if isinstance(types, dict): t = types.get(note_type, {}) - if isinstance(t, dict) and isinstance(t.get("chunk_profile"), str): - return t["chunk_profile"] + if isinstance(t, dict): + # Prüfe beide Schreibweisen + cp = t.get("chunking_profile") or t.get("chunk_profile") + if isinstance(cp, str) and cp: + return cp + + # 2. Defaults Level defs = _get_defaults(reg) - if isinstance(defs, dict) and isinstance(defs.get("chunk_profile"), str): - return defs["chunk_profile"] - return "default" + if isinstance(defs, dict): + cp = defs.get("chunking_profile") or defs.get("chunk_profile") + if isinstance(cp, str) and cp: + return cp + + return None def _resolve_retriever_weight(note_type: str, reg: dict) -> float: types = _get_types_map(reg) @@ -58,20 +71,16 @@ def _resolve_retriever_weight(note_type: str, reg: dict) -> float: t = types.get(note_type, {}) if isinstance(t, dict) and (t.get("retriever_weight") is not None): v = _as_float(t.get("retriever_weight")) - if v is not None: - return float(v) + if v is not None: return float(v) defs = _get_defaults(reg) if isinstance(defs, dict) and (defs.get("retriever_weight") is not None): v = _as_float(defs.get("retriever_weight")) - if v is not None: - return float(v) + if v is not None: return float(v) return 1.0 def _as_list(x): - if x is None: - return [] - if isinstance(x, list): - return x + if x is None: return [] + if isinstance(x, list): return x return [x] def make_chunk_payloads(note: Dict[str, Any], @@ -81,17 +90,29 @@ def make_chunk_payloads(note: Dict[str, Any], note_text: str = "", types_cfg: Optional[dict] = None, file_path: Optional[str] = None) -> List[Dict[str, Any]]: + """ + Erstellt die Payloads für die Chunks. + """ 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 Extraction 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 - cp = _resolve_chunk_profile(note_type, reg) + # --- FIX: Profil-Ermittlung --- + # 1. Frontmatter (Override) + cp = fm.get("chunking_profile") or fm.get("chunk_profile") + + # 2. Config (Type / Default) + if not cp: + cp = _resolve_chunk_profile_from_config(note_type, reg) + + # 3. Hard Fallback + if not cp: + cp = "sliding_standard" # Statt "default" + rw = _resolve_retriever_weight(note_type, reg) tags = fm.get("tags") or [] @@ -100,7 +121,7 @@ def make_chunk_payloads(note: Dict[str, Any], out: List[Dict[str, Any]] = [] for idx, ch in enumerate(chunks_from_chunker): - # Attribute oder Keys (Chunk-Objekt oder Dict) + # Attribute sicher extrahieren cid = getattr(ch, "id", None) or (ch.get("id") if isinstance(ch, dict) else None) nid = getattr(ch, "note_id", None) or (ch.get("note_id") if isinstance(ch, dict) else fm.get("id")) index = getattr(ch, "index", None) or (ch.get("index") if isinstance(ch, dict) else idx) @@ -112,7 +133,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 + "title": title, "index": int(index), "ord": int(index) + 1, "type": note_type, @@ -125,9 +146,10 @@ def make_chunk_payloads(note: Dict[str, Any], "path": note_path, "source_path": file_path or note_path, "retriever_weight": float(rw), - "chunk_profile": cp, + "chunk_profile": cp, # Jetzt korrekt } - # Aufräumen von Alt-Feldern + + # Cleanup for alias in ("chunk_num", "Chunk_Number"): pl.pop(alias, None) -- 2.43.0 From c8cdf218f28336e18190f79e3eb959e6fb8620b0 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 15:52:50 +0100 Subject: [PATCH 21/24] bug fix --- app/core/chunk_payload.py | 59 ++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/app/core/chunk_payload.py b/app/core/chunk_payload.py index d864b82..3936ab7 100644 --- a/app/core/chunk_payload.py +++ b/app/core/chunk_payload.py @@ -1,10 +1,11 @@ """ FILE: app/core/chunk_payload.py -DESCRIPTION: Baut das JSON-Objekt für 'mindnet_chunks'. +DESCRIPTION: Baut das JSON-Objekt für 'mindnet_chunks'. FEATURES: - Inkludiert Nachbarschafts-IDs (prev/next) und Titel. - - FIX: Korrektes Auslesen von 'chunking_profile' (Frontmatter > Type > Default). -VERSION: 2.1.0 + - FIX 1: Korrektes Auslesen von 'chunking_profile' (Frontmatter > Type > Default). + - FIX 2: Korrektes Vererben von 'retriever_weight' (Frontmatter > Type > Default). +VERSION: 2.2.0 STATUS: Active DEPENDENCIES: yaml, os EXTERNAL_CONFIG: config/types.yaml @@ -42,40 +43,39 @@ def _as_float(x: Any): except Exception: return None def _resolve_chunk_profile_from_config(note_type: str, reg: dict) -> Optional[str]: - """ - Liest das Profil aus der Config (Type > Default). - Prüft 'chunking_profile' UND 'chunk_profile'. - """ # 1. Type Level types = _get_types_map(reg) if isinstance(types, dict): t = types.get(note_type, {}) if isinstance(t, dict): - # Prüfe beide Schreibweisen cp = t.get("chunking_profile") or t.get("chunk_profile") - if isinstance(cp, str) and cp: - return cp - + if isinstance(cp, str) and cp: return cp # 2. Defaults Level defs = _get_defaults(reg) if isinstance(defs, dict): cp = defs.get("chunking_profile") or defs.get("chunk_profile") - if isinstance(cp, str) and cp: - return cp - + if isinstance(cp, str) and cp: return cp return None -def _resolve_retriever_weight(note_type: str, reg: dict) -> float: +def _resolve_retriever_weight_from_config(note_type: str, reg: dict) -> float: + """ + Liest Weight nur aus Config (Type > Default). + Wird aufgerufen, wenn im Frontmatter nichts steht. + """ + # 1. Type Level types = _get_types_map(reg) if isinstance(types, dict): t = types.get(note_type, {}) if isinstance(t, dict) and (t.get("retriever_weight") is not None): v = _as_float(t.get("retriever_weight")) if v is not None: return float(v) + + # 2. Defaults Level defs = _get_defaults(reg) if isinstance(defs, dict) and (defs.get("retriever_weight") is not None): v = _as_float(defs.get("retriever_weight")) if v is not None: return float(v) + return 1.0 def _as_list(x): @@ -101,19 +101,26 @@ def make_chunk_payloads(note: Dict[str, Any], reg = types_cfg if isinstance(types_cfg, dict) else _load_types() - # --- FIX: Profil-Ermittlung --- - # 1. Frontmatter (Override) + # --- Profil-Ermittlung (Fix aus v2.1.0) --- cp = fm.get("chunking_profile") or fm.get("chunk_profile") - - # 2. Config (Type / Default) if not cp: cp = _resolve_chunk_profile_from_config(note_type, reg) - - # 3. Hard Fallback if not cp: - cp = "sliding_standard" # Statt "default" + cp = "sliding_standard" - rw = _resolve_retriever_weight(note_type, reg) + # --- FIX 2: Retriever Weight Ermittlung --- + # 1. Frontmatter Override prüfen + rw = fm.get("retriever_weight") + + # 2. Falls nicht im Frontmatter, aus Config laden + if rw is None: + rw = _resolve_retriever_weight_from_config(note_type, reg) + + # 3. Sicherstellen, dass es ein Float ist + try: + rw = float(rw) + except Exception: + rw = 1.0 tags = fm.get("tags") or [] if isinstance(tags, str): @@ -121,7 +128,7 @@ def make_chunk_payloads(note: Dict[str, Any], out: List[Dict[str, Any]] = [] for idx, ch in enumerate(chunks_from_chunker): - # Attribute sicher extrahieren + # Attribute extrahieren cid = getattr(ch, "id", None) or (ch.get("id") if isinstance(ch, dict) else None) nid = getattr(ch, "note_id", None) or (ch.get("note_id") if isinstance(ch, dict) else fm.get("id")) index = getattr(ch, "index", None) or (ch.get("index") if isinstance(ch, dict) else idx) @@ -145,8 +152,8 @@ def make_chunk_payloads(note: Dict[str, Any], "section": getattr(ch, "section", None) or (ch.get("section") if isinstance(ch, dict) else ""), "path": note_path, "source_path": file_path or note_path, - "retriever_weight": float(rw), - "chunk_profile": cp, # Jetzt korrekt + "retriever_weight": float(rw), # Jetzt korrekt vererbt + "chunk_profile": cp, } # Cleanup -- 2.43.0 From a4272c17a9c777dd8cc88c354767e9f44cf205a9 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 16:07:27 +0100 Subject: [PATCH 22/24] Notizen overvride von chunk_profile und retriever_weight in der Notiz --- app/core/ingestion.py | 188 +++++++++++++++++++++--------------------- 1 file changed, 96 insertions(+), 92 deletions(-) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index 9b96478..13b5db3 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -1,7 +1,9 @@ """ FILE: app/core/ingestion.py -DESCRIPTION: Haupt-Ingestion-Logik. Liest Markdown, steuert Change-Detection (via ENV) und schreibt in Qdrant. -VERSION: 2.6.0 (Feat: Flexible Change Detection & Full Logic Restoration) +DESCRIPTION: Haupt-Ingestion-Logik. +FIX: Korrekte Priorisierung von Frontmatter für chunk_profile und retriever_weight. + Lade Chunk-Config basierend auf dem effektiven Profil, nicht nur dem Notiz-Typ. +VERSION: 2.7.0 (Fix: Frontmatter Overrides & Config Loading) STATUS: Active DEPENDENCIES: app.core.parser, app.core.note_payload, app.core.chunker, app.core.derive_edges, app.core.qdrant*, app.services.embeddings_client EXTERNAL_CONFIG: config/types.yaml @@ -54,18 +56,42 @@ def resolve_note_type(requested: Optional[str], reg: dict) -> str: if requested and requested in types: return requested return "concept" -def effective_chunk_profile(note_type: str, reg: dict) -> str: +def effective_chunk_profile_name(fm: dict, note_type: str, reg: dict) -> str: + """ + Ermittelt den Namen des Chunk-Profils. + Prio: 1. Frontmatter -> 2. Type-Config -> 3. Default + """ + # 1. Frontmatter Override + override = fm.get("chunking_profile") or fm.get("chunk_profile") + if override and isinstance(override, str): + return override + + # 2. Type Config t_cfg = reg.get("types", {}).get(note_type, {}) - # FIX: Konsistenz mit note_payload.py - Prüfe erst den korrekten Key if t_cfg: - if t_cfg.get("chunking_profile"): return t_cfg.get("chunking_profile") - if t_cfg.get("chunk_profile"): return t_cfg.get("chunk_profile") # Legacy + cp = t_cfg.get("chunking_profile") or t_cfg.get("chunk_profile") + if cp: return cp + + # 3. Global Default return reg.get("defaults", {}).get("chunking_profile", "sliding_standard") -def effective_retriever_weight(note_type: str, reg: dict) -> float: +def effective_retriever_weight(fm: dict, note_type: str, reg: dict) -> float: + """ + Ermittelt das Retriever Weight. + Prio: 1. Frontmatter -> 2. Type-Config -> 3. Default + """ + # 1. Frontmatter Override + override = fm.get("retriever_weight") + if override is not None: + try: return float(override) + except: pass + + # 2. Type Config t_cfg = reg.get("types", {}).get(note_type, {}) if t_cfg and "retriever_weight" in t_cfg: return float(t_cfg["retriever_weight"]) + + # 3. Global Default return float(reg.get("defaults", {}).get("retriever_weight", 1.0)) @@ -90,6 +116,23 @@ class IngestionService: except Exception as e: logger.warning(f"DB init warning: {e}") + def _get_chunk_config_by_profile(self, profile_name: str, note_type: str) -> Dict[str, Any]: + """ + Lädt die konkrete Config (target, max, overlap) für einen Profilnamen. + """ + # Suche direkt in den definierten Profilen der Registry + profiles = self.registry.get("chunking_profiles", {}) + if profile_name in profiles: + cfg = profiles[profile_name].copy() + # Tuple-Fix für Overlap (wie in chunker.py) + if "overlap" in cfg and isinstance(cfg["overlap"], list): + cfg["overlap"] = tuple(cfg["overlap"]) + return cfg + + # Fallback: Wenn Profilname unbekannt, nutze Standard für den Typ via Chunker + logger.warning(f"Profile '{profile_name}' not found in registry. Falling back to type defaults.") + return get_chunk_config(note_type) + async def process_file( self, file_path: str, @@ -98,46 +141,37 @@ class IngestionService: apply: bool = False, purge_before: bool = False, note_scope_refs: bool = False, - # Hash-Mode wird nicht mehr übergeben, sondern via ENV gesteuert. - # Source und Normalize bleiben konfigurierbar. hash_source: str = "parsed", hash_normalize: str = "canonical" ) -> Dict[str, Any]: - """ - Verarbeitet eine einzelne Datei (ASYNC). - Inklusive Change Detection (Hash-Check) gegen Qdrant. - """ - result = { - "path": file_path, - "status": "skipped", - "changed": False, - "error": None - } + + result = {"path": file_path, "status": "skipped", "changed": False, "error": None} # 1. Parse & Frontmatter Validation try: parsed = read_markdown(file_path) - if not parsed: - return {**result, "error": "Empty or unreadable file"} - + if not parsed: return {**result, "error": "Empty or unreadable file"} 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 + # 2. Type & Config Resolution (FIXED) + # Wir ermitteln erst den Typ 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 = fm.get("retriever_weight") - if weight is None: - weight = effective_retriever_weight(note_type, self.registry) - fm["retriever_weight"] = float(weight) + # Dann ermitteln wir die effektiven Werte unter Berücksichtigung des Frontmatters! + # Hier lag der Fehler: Vorher wurde einfach überschrieben. + effective_profile = effective_chunk_profile_name(fm, note_type, self.registry) + effective_weight = effective_retriever_weight(fm, note_type, self.registry) + + # Wir schreiben die effektiven Werte zurück ins FM, damit note_payload sie sicher hat + fm["chunk_profile"] = effective_profile + fm["retriever_weight"] = effective_weight # 3. Build Note Payload - # Ruft make_note_payload auf, welches JETZT alle Hash-Varianten berechnet. try: note_pl = make_note_payload( parsed, @@ -146,42 +180,33 @@ class IngestionService: hash_source=hash_source, file_path=file_path ) - if not note_pl.get("fulltext"): - note_pl["fulltext"] = getattr(parsed, "body", "") or "" - note_pl["retriever_weight"] = fm["retriever_weight"] + # Text Body Fallback + if not note_pl.get("fulltext"): note_pl["fulltext"] = getattr(parsed, "body", "") or "" + + # Update Payload with explicit effective values (Sicherheit) + note_pl["retriever_weight"] = effective_weight + note_pl["chunk_profile"] = effective_profile 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 (Updated Logic with ENV Strategy) + # 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 - - # Wir bauen den Key basierend auf der ENV-Einstellung check_key = f"{self.active_hash_mode}:{hash_source}:{hash_normalize}" old_hashes = (old_payload or {}).get("hashes") - - # Fallback Logik für alte Daten - if isinstance(old_hashes, dict): - old_hash = old_hashes.get(check_key) - elif isinstance(old_hashes, str): - # Sehr alte Legacy Daten hatten Hash direkt als String (meist Body) - # Wenn wir im Body-Modus sind, ist das okay, sonst Force Update - old_hash = old_hashes if self.active_hash_mode == "body" else None - else: - old_hash = None + if isinstance(old_hashes, dict): old_hash = old_hashes.get(check_key) + elif isinstance(old_hashes, str) and self.active_hash_mode == "body": old_hash = old_hashes + else: old_hash = None new_hash = note_pl.get("hashes", {}).get(check_key) - - # Vergleich hash_changed = (old_hash != new_hash) - 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 @@ -192,17 +217,19 @@ class IngestionService: if not apply: return {**result, "status": "dry-run", "changed": True, "note_id": note_id} - # 5. Processing (Chunking, Embedding, Edges) + # 5. Processing try: body_text = getattr(parsed, "body", "") or "" - # --- Config Loading (Clean) --- - chunk_config = get_chunk_config(note_type) + # FIX: Wir laden jetzt die Config für das SPEZIFISCHE Profil + # (z.B. wenn User "sliding_short" wollte, laden wir dessen Params) + chunk_config = self._get_chunk_config_by_profile(effective_profile, note_type) chunks = await assemble_chunks(fm["id"], body_text, fm["type"], config=chunk_config) + + # chunk_payloads werden mit den aktualisierten FM-Werten gebaut chunk_pls = make_chunk_payloads(fm, note_pl["path"], chunks, note_text=body_text) - # Embedding vecs = [] if chunk_pls: texts = [c.get("window") or c.get("text") or "" for c in chunk_pls] @@ -217,7 +244,6 @@ class IngestionService: logger.error(f"Embedding failed: {e}") raise RuntimeError(f"Embedding failed: {e}") - # Edges try: edges = build_edges_for_note( note_id, @@ -232,7 +258,7 @@ class IngestionService: logger.error(f"Processing failed: {e}", exc_info=True) return {**result, "error": f"Processing failed: {str(e)}"} - # 6. Upsert Action + # 6. Upsert try: if purge_before and has_old: self._purge_artifacts(note_id) @@ -259,9 +285,8 @@ class IngestionService: except Exception as e: logger.error(f"Upsert failed: {e}", exc_info=True) return {**result, "error": f"DB Upsert failed: {e}"} - - # --- Qdrant Helper --- - + + # ... (Restliche Methoden wie _fetch_note_payload bleiben unverändert) ... def _fetch_note_payload(self, note_id: str) -> Optional[dict]: from qdrant_client.http import models as rest col = f"{self.prefix}_notes" @@ -291,38 +316,17 @@ class IngestionService: self.client.delete(collection_name=f"{self.prefix}_{suffix}", points_selector=selector) except Exception: pass - async def create_from_text( - self, - markdown_content: str, - filename: str, - vault_root: str, - folder: str = "00_Inbox" - ) -> Dict[str, Any]: - """ - WP-11 Persistence API Entrypoint. - """ - target_dir = os.path.join(vault_root, folder) - os.makedirs(target_dir, exist_ok=True) - - file_path = os.path.join(target_dir, filename) - - try: - # Robust Write: Ensure Flush & Sync - with open(file_path, "w", encoding="utf-8") as f: - f.write(markdown_content) - f.flush() - os.fsync(f.fileno()) - - await asyncio.sleep(0.1) - - logger.info(f"Written file to {file_path}") - except Exception as e: - return {"status": "error", "error": f"Disk write failed: {str(e)}"} - - return await self.process_file( - file_path=file_path, - vault_root=vault_root, - apply=True, - force_replace=True, - purge_before=True - ) \ No newline at end of file + async def create_from_text(self, markdown_content: str, filename: str, vault_root: str, folder: str = "00_Inbox") -> Dict[str, Any]: + target_dir = os.path.join(vault_root, folder) + os.makedirs(target_dir, exist_ok=True) + file_path = os.path.join(target_dir, filename) + try: + with open(file_path, "w", encoding="utf-8") as f: + f.write(markdown_content) + f.flush() + os.fsync(f.fileno()) + await asyncio.sleep(0.1) + logger.info(f"Written file to {file_path}") + except Exception as e: + return {"status": "error", "error": f"Disk write failed: {str(e)}"} + return await self.process_file(file_path=file_path, vault_root=vault_root, apply=True, force_replace=True, purge_before=True) \ No newline at end of file -- 2.43.0 From edbd8f0ca896fca20fc0fb3efe315df5a956bb93 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 17:07:12 +0100 Subject: [PATCH 23/24] chunk_payload soll nun auch die Overrides schreiben --- app/core/chunk_payload.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/app/core/chunk_payload.py b/app/core/chunk_payload.py index 3936ab7..9058753 100644 --- a/app/core/chunk_payload.py +++ b/app/core/chunk_payload.py @@ -3,9 +3,8 @@ FILE: app/core/chunk_payload.py DESCRIPTION: Baut das JSON-Objekt für 'mindnet_chunks'. FEATURES: - Inkludiert Nachbarschafts-IDs (prev/next) und Titel. - - FIX 1: Korrektes Auslesen von 'chunking_profile' (Frontmatter > Type > Default). - - FIX 2: Korrektes Vererben von 'retriever_weight' (Frontmatter > Type > Default). -VERSION: 2.2.0 + - FIX 3: Robuste Erkennung des Inputs (Frontmatter-Dict vs. Note-Objekt), damit Overrides ankommen. +VERSION: 2.3.0 STATUS: Active DEPENDENCIES: yaml, os EXTERNAL_CONFIG: config/types.yaml @@ -92,31 +91,43 @@ def make_chunk_payloads(note: Dict[str, Any], file_path: Optional[str] = None) -> List[Dict[str, Any]]: """ Erstellt die Payloads für die Chunks. + + Argument 'note' kann sein: + A) Ein komplexes Objekt/Dict mit Key "frontmatter" (Legacy / Tests) + B) Direkt das Frontmatter-Dictionary (Call aus ingestion.py) """ - fm = (note or {}).get("frontmatter", {}) or {} + + # --- FIX 3: Intelligente Erkennung der Input-Daten --- + # Wir prüfen: Ist 'note' ein Container MIT 'frontmatter', oder IST es das 'frontmatter'? + if isinstance(note, dict) and "frontmatter" in note and isinstance(note["frontmatter"], dict): + # Fall A: Container (wir müssen auspacken) + fm = note["frontmatter"] + else: + # Fall B: Direktes Dict (so ruft ingestion.py es auf!) + fm = note or {} + note_type = fm.get("type") or note.get("type") or "concept" - # Title Extraction + # Title Extraction (Fallback Chain) 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() - # --- Profil-Ermittlung (Fix aus v2.1.0) --- + # --- Profil-Ermittlung --- + # Da wir 'fm' jetzt korrekt haben, funktionieren diese lookups: cp = fm.get("chunking_profile") or fm.get("chunk_profile") + if not cp: cp = _resolve_chunk_profile_from_config(note_type, reg) if not cp: cp = "sliding_standard" - # --- FIX 2: Retriever Weight Ermittlung --- - # 1. Frontmatter Override prüfen + # --- Retriever Weight Ermittlung --- rw = fm.get("retriever_weight") - # 2. Falls nicht im Frontmatter, aus Config laden if rw is None: rw = _resolve_retriever_weight_from_config(note_type, reg) - # 3. Sicherstellen, dass es ein Float ist try: rw = float(rw) except Exception: @@ -152,8 +163,8 @@ def make_chunk_payloads(note: Dict[str, Any], "section": getattr(ch, "section", None) or (ch.get("section") if isinstance(ch, dict) else ""), "path": note_path, "source_path": file_path or note_path, - "retriever_weight": float(rw), # Jetzt korrekt vererbt - "chunk_profile": cp, + "retriever_weight": float(rw), + "chunk_profile": cp, # Jetzt endlich mit dem Override-Wert! } # Cleanup -- 2.43.0 From 25ec3880bb07df648eca043200fcdc4fdcb2fd9c Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 16 Dec 2025 18:46:11 +0100 Subject: [PATCH 24/24] Dokumentation --- docs/00_General/00_glossary.md | 3 +- docs/01_User_Manual/01_knowledge_design.md | 32 ++++----- .../03_tech_configuration.md | 21 ++++-- .../03_tech_data_model.md | 25 ++++--- .../03_tech_frontend.md | 18 ++++- .../03_tech_ingestion_pipeline.md | 65 ++++++++++++------- 6 files changed, 105 insertions(+), 59 deletions(-) diff --git a/docs/00_General/00_glossary.md b/docs/00_General/00_glossary.md index ac6cb3e..9aedfd8 100644 --- a/docs/00_General/00_glossary.md +++ b/docs/00_General/00_glossary.md @@ -31,7 +31,8 @@ context: "Definitionen zentraler Begriffe und Entitäten im Mindnet-System." * **Active Intelligence:** Feature im Web-Editor, das während des Schreibens automatisch Links vorschlägt. * **Smart Edge Allocation (WP15):** Ein KI-Verfahren, das prüft, ob ein Link in einer Notiz für einen spezifischen Textabschnitt relevant ist, statt ihn blind allen Chunks zuzuordnen. -* **Strict Heading Split:** Chunking-Strategie, bei der Überschriften (z.B. H2) als harte Grenzen dienen. Verhindert das Vermischen von Themen (z.B. zwei unterschiedliche Rollen in einem Chunk). +* **Strict Heading Split:** Chunking-Strategie, bei der Überschriften (z.B. H2) als harte Grenzen dienen. Verhindert das Vermischen von Themen (z.B. zwei unterschiedliche Rollen in einem Chunk). Besitzt ein "Safety Net" für zu lange Abschnitte. +* **Soft Heading Split:** Chunking-Strategie, die Überschriften respektiert, aber kleine Abschnitte zusammenfasst, um Vektor-Kontext zu füllen ("Fuller Chunks"). * **Healing Parser:** UI-Funktion, die fehlerhaften Output des LLMs (z.B. defektes YAML) automatisch repariert. * **Explanation Layer:** Die Schicht, die dem Nutzer erklärt, *warum* ein Suchergebnis gefunden wurde (z.B. "Weil Projekt X davon abhängt"). * **Provenance:** Die Herkunft einer Kante. diff --git a/docs/01_User_Manual/01_knowledge_design.md b/docs/01_User_Manual/01_knowledge_design.md index c2cbcd7..8aee9bb 100644 --- a/docs/01_User_Manual/01_knowledge_design.md +++ b/docs/01_User_Manual/01_knowledge_design.md @@ -3,7 +3,7 @@ doc_type: user_manual audience: user, author scope: vault, markdown, schema status: active -version: 2.6 +version: 2.7.0 context: "Regelwerk für das Erstellen von Notizen im Vault. Die 'Source of Truth' für Autoren." --- @@ -46,11 +46,12 @@ tags: [ki, entwicklung] # Taxonomie --- ``` -**Optionale Felder:** +**Optionale Felder & Overrides (Advanced):** * `aliases`: [Alpha Projekt] – Wichtig für "Active Intelligence" (Exact Match). * `visibility`: internal (default) / public. - -> **Wichtig:** Felder wie `retriever_weight` oder `chunk_profile` werden zentral über `types.yaml` gesteuert und müssen nicht mehr manuell gesetzt werden (Virtual Schema Layer). +* **NEU:** Du kannst die KI-Steuerung manuell überschreiben, wenn dir der Standard für den Typ nicht passt: + * `chunking_profile`: Zwingt den Chunker in einen Modus (z.B. `structured_smart_edges_strict`). + * `retriever_weight`: Setzt die Wichtigkeit manuell hoch/runter (z.B. `1.5` für extrem wichtig). --- @@ -136,11 +137,9 @@ Ich habe gelernt: Das ist oft das Zeichen kurz vor dem Durchbruch. --- -## 6. Best Practices & Beispiele (Klassik) +## 6. Best Practices & Beispiele -Hier sind vollständige Vorlagen für häufige Typen. - -### 6.1 Beispiel: Projekt-Notiz +### 6.1 Beispiel: Projekt-Notiz (Standard) Projekte profitieren von `depends_on`, um Abhängigkeiten zu klären. ```markdown @@ -159,11 +158,11 @@ Wir bauen ein persönliches Wissensnetz. Wir nutzen [[rel:depends_on Qdrant]] für die Vektorsuche und [[rel:depends_on FastAPI]] für das Backend. ## Architektur -Das Konzept basiert auf [[RAG Architecture]]. (Automatisch 'depends_on' durch Typ-Default, falls konfiguriert). +Das Konzept basiert auf [[RAG Architecture]]. ``` -### 6.2 Beispiel: Entscheidung (Decision Record) -Entscheidungen sind hoch gewichtet (`retriever_weight: 1.0`). +### 6.2 Beispiel: Advanced Tuning (Manuelles Override) +Hier zwingen wir das System, eine Entscheidung extrem kleinteilig (`strict`) zu zerlegen und in der Suche maximal zu priorisieren. ```markdown --- @@ -172,6 +171,9 @@ title: ADR: Wahl von Qdrant type: decision status: final tags: [architektur, db] +# OVERRIDES: Wir wollen diese Notiz extrem wichtig machen und strikt trennen +chunking_profile: structured_smart_edges_strict +retriever_weight: 1.5 --- # Entscheidung: Qdrant @@ -180,13 +182,11 @@ Wir haben uns für Qdrant entschieden. ## Alternativen Wir haben auch [[rel:similar_to Pinecone]] und [[rel:similar_to Weaviate]] betrachtet. - -## Begründung -Qdrant erlaubt lokalen Betrieb und [[rel:solves Payload Filtering Requirements]]. ``` --- -## 7. Langfristige Stabilität +## 7. Virtual Schema Layer -Wir nutzen das Prinzip des **Virtual Schema Layers**. Wir kodieren keine Logik (wie `chunk_size`) in die Notizen. Das wird zentral in der `types.yaml` verwaltet. Das bedeutet für dich: Du kannst dich rein auf den Inhalt konzentrieren. Wenn wir die Chunking-Strategie ändern, müssen wir nicht 1000 Markdown-Dateien anfassen. \ No newline at end of file +Grundsätzlich gilt das Prinzip des **Virtual Schema Layers**. Die Logik (wie `chunk_size`) wird zentral in der `types.yaml` verwaltet. +**Aber:** Als Power-User hast du über die oben genannten Overrides (`chunking_profile`) jederzeit die Möglichkeit, aus diesem Standard auszubrechen, wenn eine spezifische Notiz eine Sonderbehandlung benötigt. \ No newline at end of file diff --git a/docs/03_Technical_References/03_tech_configuration.md b/docs/03_Technical_References/03_tech_configuration.md index 2ee969f..283ab24 100644 --- a/docs/03_Technical_References/03_tech_configuration.md +++ b/docs/03_Technical_References/03_tech_configuration.md @@ -3,7 +3,7 @@ doc_type: technical_reference audience: developer, admin scope: configuration, env status: active -version: 2.6.0 +version: 2.7.0 context: "Referenztabellen für Umgebungsvariablen und YAML-Konfigurationen." --- @@ -32,8 +32,7 @@ Diese Variablen steuern die Infrastruktur, Timeouts und Feature-Flags. | `MINDNET_API_TIMEOUT` | `300.0` | Frontend Timeout (Erhöht für Smart Edge Wartezeiten). | | `MINDNET_LLM_BACKGROUND_LIMIT`| `2` | **Traffic Control (Neu):** Max. parallele Import-Tasks (Semaphore). | | `MINDNET_VAULT_ROOT` | `./vault` | Pfad für Write-Back Operationen (Drafts). | -| `MINDNET_HASH_COMPARE` | `Body` | Import-Strategie: `Body`, `Frontmatter` oder `Full`. | -| `MINDNET_HASH_SOURCE` | `parsed` | Hash-Quelle: `parsed`, `raw` oder `file`. | +| `MINDNET_CHANGE_DETECTION_MODE` | `full` | **Change Detection (Neu):** `full` (Text + Meta) oder `body` (nur Text). | --- @@ -41,9 +40,17 @@ Diese Variablen steuern die Infrastruktur, Timeouts und Feature-Flags. Steuert das Import-Verhalten, Chunking und die Kanten-Logik pro Typ. -**Referenztabelle (Stand v2.6):** +### 2.1 Konfigurations-Hierarchie (Override-Logik) +Seit Version 2.7.0 gilt für `chunking_profile` und `retriever_weight` folgende Priorität: -| Typ (`type`) | Chunk Profile | Retriever Weight | Smart Edges? | Beschreibung | +1. **Frontmatter (Höchste Prio):** Ein Wert direkt in der Markdown-Datei überschreibt alles. + * *Beispiel:* `chunking_profile: structured_smart_edges_strict` im Header einer Notiz erzwingt diesen Splitter, egal welcher Typ eingestellt ist. +2. **Type Config:** Der Standardwert für den `type` (z.B. `concept`) aus `types.yaml`. +3. **Global Default:** Fallback aus `defaults` in `types.yaml`. + +### 2.2 Typ-Referenztabelle + +| Typ (`type`) | Chunk Profile (Standard) | Retriever Weight | Smart Edges? | Beschreibung | | :--- | :--- | :--- | :--- | :--- | | **concept** | `sliding_smart_edges` | 0.60 | Ja | Abstrakte Begriffe. | | **project** | `sliding_smart_edges` | 0.97 | Ja | Aktive Vorhaben. | @@ -58,7 +65,9 @@ Steuert das Import-Verhalten, Chunking und die Kanten-Logik pro Typ. | **goal** | `sliding_smart_edges` | 0.95 | Nein | Strategische Ziele. | | **belief** | `sliding_short` | 0.90 | Nein | Glaubenssätze. | | **profile** | `structured_smart_edges_strict` | 0.70 | Nein | Rollenprofile. Strict Split. | -| **principle** | `structured_smart_edges_strict_L3`| 0.95 | Nein | Prinzipien. Tiefer Split (H3). | +| **principle** | `structured_smart_edges_strict_L3`| 0.95 | Nein | Prinzipien. Tiefer Split (H3) für Mikro-Prinzipien. | +| **task** | `sliding_short` | 0.80 | Nein | Aufgaben. | +| **glossary** | `sliding_short` | 0.40 | Nein | Begriffsdefinitionen. | | **default** | `sliding_standard` | 1.00 | Nein | Fallback. | *Hinweis: `Smart Edges?` entspricht dem YAML-Key `enable_smart_edge_allocation: true`.* diff --git a/docs/03_Technical_References/03_tech_data_model.md b/docs/03_Technical_References/03_tech_data_model.md index 961123c..00e63c2 100644 --- a/docs/03_Technical_References/03_tech_data_model.md +++ b/docs/03_Technical_References/03_tech_data_model.md @@ -3,7 +3,7 @@ doc_type: technical_reference audience: developer, architect scope: database, qdrant, schema status: active -version: 2.6 +version: 2.7.0 context: "Exakte Definition der Datenmodelle (Payloads) in Qdrant und Index-Anforderungen." --- @@ -31,13 +31,20 @@ Repräsentiert die Metadaten einer Markdown-Datei (1:1 Beziehung). "note_id": "string (keyword)", // UUIDv5 (deterministisch) oder Slug "title": "string (text)", // Titel aus Frontmatter "type": "string (keyword)", // Logischer Typ (z.B. 'project', 'concept') - "retriever_weight": "float", // Numerische Wichtigkeit (0.0-1.0), aus types.yaml - "chunk_profile": "string", // Genutztes Profil (z.B. 'sliding_smart_edges') + "retriever_weight": "float", // Effektive Wichtigkeit (Frontmatter > Type > Default) + "chunk_profile": "string", // Effektives Profil (Frontmatter > Type > Default) "edge_defaults": ["string"], // Liste der aktiven Default-Kanten "tags": ["string"], // Liste von Tags aus Frontmatter + "aliases": ["string"], // Synonyme für Discovery (WP-11) "created": "string (iso-date)", // Erstellungsdatum "updated": "integer", // Timestamp (File Modification Time) - "fulltext": "string (no-index)" // Gesamter Text (nur für Recovery/Export) + "fulltext": "string (no-index)", // Gesamter Text (nur für Recovery/Export) + + // NEU in v2.7: Multi-Hash für flexible Change Detection + "hashes": { + "body:parsed:canonical": "string", // Hash nur über den Text-Body + "full:parsed:canonical": "string" // Hash über Text + Metadaten (Tags, Title, Config) + } } ``` @@ -68,10 +75,12 @@ Die atomare Sucheinheit. Enthält den Vektor. "text": "string (text)", // Reintext für Anzeige (ohne Overlap) "window": "string (text)", // Text + Overlap (Basis für Embedding) "ord": "integer", // Laufende Nummer (1..N) für Sortierung - "retriever_weight": "float", // Kopie aus Note (für Query-Speed) - "chunk_profile": "string", // Vererbt von Note + "retriever_weight": "float", // Geerbt von Note (für schnelles Re-Ranking) + "chunk_profile": "string", // Geerbt von Note (für Debugging/Filtering) "neighbors_prev": ["string"], // ID des Vorgängers (Linked List) - "neighbors_next": ["string"] // ID des Nachfolgers + "neighbors_next": ["string"], // ID des Nachfolgers + "section": "string", // Pfad/Überschrift, zu der der Chunk gehört + "source_path": "string" // Relativer Pfad zur Datei } ``` @@ -99,7 +108,7 @@ Gerichtete Kanten zwischen Knoten. Stark erweitert in v2.6 für Provenienz-Track "note_id": "string (keyword)", // Owner Note ID (Ursprung der Kante) // Provenance & Quality (WP03/WP15) - "provenance": "keyword", // 'explicit', 'rule', 'smart' (NEU) + "provenance": "keyword", // 'explicit', 'rule', 'smart', 'structure' "rule_id": "string (keyword)", // Traceability: 'inline:rel', 'explicit:wikilink', 'smart:llm' "confidence": "float" // Vertrauenswürdigkeit (0.0 - 1.0) } diff --git a/docs/03_Technical_References/03_tech_frontend.md b/docs/03_Technical_References/03_tech_frontend.md index 9af4009..5d61203 100644 --- a/docs/03_Technical_References/03_tech_frontend.md +++ b/docs/03_Technical_References/03_tech_frontend.md @@ -3,7 +3,7 @@ doc_type: technical_reference audience: developer, frontend_architect scope: architecture, graph_viz, state_management status: active -version: 2.6 +version: 2.7.0 context: "Technische Dokumentation des modularen Streamlit-Frontends, der Graph-Engines und des Editors." --- @@ -28,7 +28,7 @@ Seit Version 2.6 ist das Frontend (`app/frontend/`) kein Monolith mehr, sondern | `ui_utils.py` | **Helper.** Markdown-Parsing (`parse_markdown_draft`) und String-Normalisierung. | | `ui_graph_service.py`| **Data Logic.** Holt Daten aus Qdrant und bereitet Nodes/Edges auf (unabhängig von der Vis-Library). | | `ui_graph_cytoscape.py`| **View: Graph.** Implementierung mit `st-cytoscape` (COSE Layout). | -| `ui_editor.py` | **View: Editor.** Logik für Drafts und manuelles Editieren. | +| `ui_editor.py` | **View: Editor.** Logik für Drafts, manuelles Editieren und **Async Feedback**. | ### 1.2 Konfiguration (`ui_config.py`) @@ -117,7 +117,19 @@ Der `switch_to_editor_callback` in `ui_callbacks.py` implementiert folgende Kask }) st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor" -Dies garantiert, dass der Editor immer den **echten, aktuellen Stand** der Markdown-Datei anzeigt. +### 3.3 Async Save Pattern (Neu in v2.7 / WP-14) +Um Timeouts bei der Smart-Edge-Berechnung zu vermeiden, nutzt der Editor ein **"Fire & Forget"** Muster. + +1. **Request:** UI sendet Markdown an `/ingest/save`. +2. **Backend:** + * Validiert Request. + * Speichert Datei auf Disk (Persistenz garantiert). + * Startet `BackgroundTasks` für LLM-Analyse und Embedding. + * Returniert sofort `status: queued`. +3. **UI Feedback:** + * Editor zeigt "Erfolgreich eingereiht". + * User muss nicht warten. + * (ToDo: WebSocket Notification bei Abschluss). --- diff --git a/docs/03_Technical_References/03_tech_ingestion_pipeline.md b/docs/03_Technical_References/03_tech_ingestion_pipeline.md index ca905f5..f77b9fb 100644 --- a/docs/03_Technical_References/03_tech_ingestion_pipeline.md +++ b/docs/03_Technical_References/03_tech_ingestion_pipeline.md @@ -3,7 +3,7 @@ doc_type: technical_reference audience: developer, devops scope: backend, ingestion, smart_edges status: active -version: 2.6.0 +version: 2.7.0 context: "Detaillierte technische Beschreibung der Import-Pipeline, Chunking-Strategien und CLI-Befehle." --- @@ -11,28 +11,37 @@ context: "Detaillierte technische Beschreibung der Import-Pipeline, Chunking-Str **Quellen:** `pipeline_playbook.md`, `Handbuch.md` -Die Ingestion transformiert Markdown in den Graphen. Entrypoint: `scripts/import_markdown.py`. +Die Ingestion transformiert Markdown in den Graphen. Entrypoint: `scripts/import_markdown.py` (CLI) oder `routers/ingest.py` (API). -## 1. Der Import-Prozess (13-Schritte-Workflow) +## 1. Der Import-Prozess (14-Schritte-Workflow) Der Prozess ist **asynchron** und **idempotent**. -1. **Markdown lesen:** Rekursives Scannen des Vaults. -2. **Frontmatter extrahieren:** Validierung von Pflichtfeldern (`id`, `type`, `title`). -3. **Typauflösung:** Bestimmung des `type` via `types.yaml`. -4. **Note-Payload generieren:** Erstellen des JSON-Objekts für `mindnet_notes`. -5. **Chunking anwenden:** Zerlegung des Textes basierend auf dem `chunk_profile` (siehe Kap. 3). -6. **Smart Edge Allocation (WP15):** +1. **Trigger & Async Dispatch:** + * **API (`/save`):** Nimmt Request entgegen, validiert und startet Background-Task ("Fire & Forget"). Antwortet sofort mit `202/Queued`. + * **CLI:** Iteriert über Dateien und nutzt `asyncio.Semaphore` zur Drosselung. +2. **Markdown lesen:** Rekursives Scannen des Vaults. +3. **Frontmatter extrahieren:** Validierung von Pflichtfeldern (`id`, `type`, `title`). +4. **Config Resolution:** + * Bestimmung von `chunking_profile` und `retriever_weight`. + * **Priorität:** 1. Frontmatter (Override) -> 2. `types.yaml` (Type) -> 3. Default. +5. **Note-Payload generieren:** + * Erstellen des JSON-Objekts für `mindnet_notes`. + * **Multi-Hash Calculation:** Berechnet Hashtabellen für `body` (nur Text) und `full` (Text + Metadaten). +6. **Change Detection:** + * Vergleich des Hashes mit Qdrant. + * Strategie wählbar via ENV `MINDNET_CHANGE_DETECTION_MODE` (`full` oder `body`). +7. **Chunking anwenden:** Zerlegung des Textes basierend auf dem ermittelten Profil (siehe Kap. 3). +8. **Smart Edge Allocation (WP15):** * Wenn `enable_smart_edge_allocation: true`: Der `SemanticAnalyzer` sendet Chunks an das LLM. * **Traffic Control:** Request nutzt `priority="background"`. Semaphore (Limit via `.env`) drosselt die Last. * **Resilienz:** Bei Timeout (Ollama) greift ein Fallback (Broadcasting an alle Chunks). -7. **Inline-Kanten finden:** Parsing von `[[rel:...]]`. -8. **Callout-Kanten finden:** Parsing von `> [!edge]`. -9. **Default-Edges erzeugen:** Anwendung der `edge_defaults` aus Registry. -10. **Strukturkanten erzeugen:** `belongs_to`, `next`, `prev`. -11. **Embedding (Async):** Generierung via `nomic-embed-text` (768 Dim). -12. **Strict Mode:** Abbruch bei leeren Embeddings oder Dimension 0. -13. **Diagnose:** Integritäts-Check nach dem Lauf. +9. **Inline-Kanten finden:** Parsing von `[[rel:...]]`. +10. **Callout-Kanten finden:** Parsing von `> [!edge]`. +11. **Default-Edges erzeugen:** Anwendung der `edge_defaults` aus Registry. +12. **Strukturkanten erzeugen:** `belongs_to`, `next`, `prev`. +13. **Embedding (Async):** Generierung via `nomic-embed-text` (768 Dim). +14. **Diagnose:** Integritäts-Check nach dem Lauf. --- @@ -44,6 +53,8 @@ Für regelmäßige Updates (Cronjob). Erkennt Änderungen via Hash. ```bash export QDRANT_URL="http://localhost:6333" export COLLECTION_PREFIX="mindnet" +# Steuert, wann eine Datei als "geändert" gilt +export MINDNET_CHANGE_DETECTION_MODE="full" # Nutzt das Venv der Produktionsumgebung /home/llmadmin/mindnet/.venv/bin/python3 -m scripts.import_markdown \ @@ -68,6 +79,7 @@ ollama pull nomic-embed-text python3 -m scripts.reset_qdrant --mode wipe --prefix "mindnet" --yes # 2. Vollständiger Import (Force) +# --force ignoriert alle Hashes und schreibt alles neu python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply --force ``` @@ -75,7 +87,7 @@ python3 -m scripts.import_markdown --vault ./vault --prefix "mindnet" --apply -- ## 3. Chunking & Payload -Das Chunking ist profilbasiert und in `types.yaml` konfiguriert. Seit v2.6 unterscheiden wir zwischen **Sliding Window** und **Heading Split**. +Das Chunking ist profilbasiert und in `types.yaml` konfiguriert. ### 3.1 Profile und Strategien @@ -86,24 +98,27 @@ Das Chunking ist profilbasiert und in `types.yaml` konfiguriert. Seit v2.6 unter | `sliding_smart_edges`| `sliding_window` | Max: 600, Target: 400 | Fließtexte mit hohem Wert (Projekte, Erfahrungen). | | `structured_smart_edges` | `by_heading` | `strict: false` (Soft) | Strukturierte Texte, wo kleine Abschnitte gemergt werden dürfen. | | `structured_smart_edges_strict` | `by_heading` | `strict: true` (Hard) | **Atomare Einheiten**: Entscheidungen, Werte, Profile. | +| `structured_smart_edges_strict_L3`| `by_heading` | `strict: true`, `level: 3` | Tief geschachtelte Prinzipien (Tier 2/MP1 Logik). | -### 3.2 Die `by_heading` Logik (Neu in v2.6) +### 3.2 Die `by_heading` Logik (v2.9 Hybrid) -Die Strategie `by_heading` zerlegt Texte anhand ihrer Struktur (Überschriften). +Die Strategie `by_heading` zerlegt Texte anhand ihrer Struktur (Überschriften). Sie unterstützt seit v2.9 ein "Safety Net" gegen zu große Chunks. * **Split Level:** Definiert die Tiefe (z.B. `2` = H1 & H2 triggern Split). * **Modus "Strict" (`strict_heading_split: true`):** * Jede Überschrift (`<= split_level`) erzwingt einen neuen Chunk. - * *Ausnahme:* Wenn der vorherige Chunk leer war (nur Überschriften), wird gemergt (Context-Aware Merge). - * *Safety:* Wird ein Abschnitt zu lang (> `max`), wird trotzdem getrennt (Hybrid-Fallback). + * *Merge-Check:* Wenn der vorherige Chunk leer war (nur Überschriften), wird gemergt (verhindert verwaiste Überschriften). + * *Safety Net:* Wird ein Abschnitt zu lang (> `max` Token), wird auch ohne Überschrift getrennt. * **Modus "Soft" (`strict_heading_split: false`):** - * Überschriften auf dem Split-Level (z.B. H2) lösen nur dann einen neuen Chunk aus, wenn der aktuelle Chunk die `target`-Größe erreicht hat. - * Überschriften *oberhalb* (z.B. H1) erzwingen immer einen Split (Hierarchie-Reset). + * **Hierarchie-Check:** Überschriften *oberhalb* des Split-Levels (z.B. H1 bei Level 2) erzwingen **immer** einen Split. + * **Füll-Logik:** Überschriften *auf* dem Split-Level (z.B. H2) lösen nur dann einen neuen Chunk aus, wenn der aktuelle Chunk die `target`-Größe erreicht hat. + * *Safety Net:* Auch hier greift das `max` Token Limit. ### 3.3 Payload-Felder (Qdrant) -* `text`: Der reine Inhalt (Anzeige im UI). -* `window`: Inhalt plus Overlap (für Embedding). Bei `by_heading` enthält dies oft den Kontext der Eltern-Überschrift. +* `text`: Der reine Inhalt (Anzeige im UI). Überschriften bleiben erhalten. +* `window`: Inhalt plus Overlap (für Embedding). Bei `by_heading` wird der Kontext (Eltern-Überschrift) oft vorangestellt. +* `chunk_profile`: Das effektiv genutzte Profil (zur Nachverfolgung). --- -- 2.43.0