mindnet/scripts/import_markdown.py

240 lines
10 KiB
Python
Raw Normal View History

2025-11-08 16:18:31 +01:00
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
2025-11-08 16:18:31 +01:00
"""
FILE: scripts/import_markdown.py
VERSION: 2.6.0 (2026-01-10)
STATUS: Active (Core)
COMPATIBILITY: IngestionProcessor v3.4.1+
Zweck:
-------
Hauptwerkzeug zum Importieren von Markdown-Dateien aus einem lokalen Obsidian-Vault in die
Qdrant Vektor-Datenbank. Das Script ist darauf optimiert, die strukturelle Integrität des
Wissensgraphen zu wahren und die manuelle Nutzer-Autorität vor automatisierten System-Eingriffen
zu schützen.
Hintergrund der 2-Phasen-Strategie (Authority-First):
------------------------------------------------------
Um das Problem der "Ghost-IDs" und der asynchronen Überschreibungen zu lösen, implementiert
dieses Script eine strikte Trennung der Schreibvorgänge:
1. PHASE 1: Authority Processing (Batch-Modus)
- Alle Dateien werden gescannt und verarbeitet.
- Notizen, Chunks und explizite (vom Nutzer gesetzte) Kanten werden sofort geschrieben.
- Durch die Verwendung von 'wait=True' in der Datenbank-Layer wird sichergestellt,
dass diese Informationen physisch indiziert sind, bevor der nächste Schritt erfolgt.
- Symmetrische Gegenkanten werden während dieser Phase lediglich im Speicher gepuffert.
2. PHASE 2: Global Symmetry Commitment (Finaler Schritt)
- Erst nach Abschluss aller Batches wird die Methode commit_vault_symmetries() aufgerufen.
- Diese prüft die gepufferten Symmetrie-Vorschläge gegen die bereits existierende
Nutzer-Autorität in der Datenbank.
- Existiert bereits eine manuelle Kante für dieselbe Verbindung, wird die automatische
Symmetrie unterdrückt.
Detaillierte Funktionsweise:
----------------------------
1. PASS 1: Global Pre-Scan
- Scannt rekursiv alle Markdown-Dateien im Vault.
- Schließt System-Ordner wie .trash, .obsidian, .sync sowie Vorlagen konsequent aus.
- Extrahiert Note-Kontext (ID, Titel, Dateiname) ohne DB-Schreibzugriff.
- Füllt den LocalBatchCache im IngestionService, der als Single-Source-of-Truth für
die spätere Link-Auflösung (Kanonisierung) dient.
- Dies stellt sicher, dass Wikilinks wie [[Klaus]] korrekt zu Zeitstempel-IDs wie
202601031726-klaus aufgelöst werden, BEVOR eine UUID für die Kante berechnet wird.
2. PASS 2: Semantic Processing
- Verarbeitet Dateien in konfigurierten Batches (Standard: 20 Dateien).
- Implementiert Cloud-Resilienz durch Semaphoren (max. 5 parallele Zugriffe).
- Nutzt die Mixture of Experts (MoE) Architektur zur semantischen Validierung von Links.
- Führt eine Hash-basierte Change Detection durch, um unnötige Schreibvorgänge zu vermeiden.
- Schreibt die Ergebnisse (Notes, Chunks, Explicit Edges) konsistent nach Qdrant.
Ergebnis-Interpretation:
------------------------
- Log-Ausgabe: Liefert detaillierte Informationen über den Fortschritt, LLM-Entscheidungen
und die finale Symmetrie-Validierung.
- Statistiken: Gibt am Ende eine Zusammenfassung über verarbeitete, übersprungene und
fehlerhafte Dateien aus.
- Dry-Run: Ohne den Parameter --apply werden keine physischen Änderungen an der Datenbank
vorgenommen, der gesamte Workflow (inkl. LLM-Anfragen) wird jedoch simuliert.
Verwendung:
-----------
- Regelmäßiger Import nach Änderungen im Vault.
- Initialer Aufbau eines neuen Wissensgraphen.
- Erzwingung einer Re-Indizierung mittels --force.
2025-11-08 16:18:31 +01:00
"""
import asyncio
2025-11-08 16:18:31 +01:00
import os
import argparse
import logging
import sys
from pathlib import Path
from typing import List, Dict, Any
2025-11-08 16:18:31 +01:00
from dotenv import load_dotenv
2025-12-10 21:41:43 +01:00
# Root Logger Setup:INFO-Level für volle Transparenz der fachlichen Prozesse
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s'
)
# Sicherstellung, dass das Root-Verzeichnis im Python-Pfad liegt
sys.path.append(os.getcwd())
2025-11-11 17:19:22 +01:00
# App-spezifische Imports
from app.core.ingestion import IngestionService
2025-12-26 22:07:25 +01:00
from app.core.parser import pre_scan_markdown
2025-12-10 22:22:56 +01:00
logger = logging.getLogger("importer")
async def main_async(args):
"""
Haupt-Workflow der Ingestion. Koordiniert die zwei Durchläufe (Pass 1/2)
und die zwei Schreibphasen (Phase 1/2).
"""
vault_path = Path(args.vault).resolve()
if not vault_path.exists():
logger.error(f"Vault-Pfad existiert nicht: {vault_path}")
return
2025-11-08 15:25:18 +01:00
# 1. Initialisierung des zentralen Ingestion-Services
logger.info(f"Initializing IngestionService (Prefix: {args.prefix})")
2025-12-10 21:41:43 +01:00
service = IngestionService(collection_prefix=args.prefix)
logger.info(f"Scanning {vault_path}...")
all_files_raw = list(vault_path.rglob("*.md"))
2025-12-10 21:41:43 +01:00
# --- GLOBALER ORDNER-FILTER ---
# Diese Liste stellt sicher, dass keine System-Leichen oder temporäre Dateien
# den Graphen korrumpieren oder zu ID-Kollisionen führen.
files = []
ignore_list = [".trash", ".obsidian", ".sync", "templates", "_system", ".git"]
for f in all_files_raw:
f_str = str(f)
# Filtert Ordner aus der ignore_list und versteckte Verzeichnisse
if not any(folder in f_str for folder in ignore_list) and not "/." in f_str:
files.append(f)
files.sort()
logger.info(f"Found {len(files)} relevant markdown files (filtered trash/system/hidden).")
2025-12-26 22:07:25 +01:00
# =========================================================================
# PASS 1: Global Pre-Scan
# Ziel: Aufbau eines vollständigen Mappings von Bezeichnungen zu stabilen IDs.
2025-12-26 22:07:25 +01:00
# =========================================================================
logger.info(f"🔍 [Pass 1] Global Pre-Scan: Building context cache for {len(files)} files...")
2025-12-26 22:07:25 +01:00
for f_path in files:
try:
# Extrahiert Frontmatter und Metadaten ohne DB-Last
2025-12-26 22:07:25 +01:00
ctx = pre_scan_markdown(str(f_path))
if ctx:
# Mehrfache Indizierung für maximale Trefferrate bei Wikilinks
2025-12-26 22:07:25 +01:00
service.batch_cache[ctx.note_id] = ctx
2025-12-27 06:31:57 +01:00
service.batch_cache[ctx.title] = ctx
# Auch den Dateinamen ohne Endung als Alias hinterlegen
service.batch_cache[os.path.splitext(f_path.name)[0]] = ctx
except Exception as e:
logger.warning(f"⚠️ Pre-scan fehlgeschlagen für {f_path.name}: {e}")
2025-12-26 22:07:25 +01:00
# =========================================================================
# PHASE 1: Authority Processing (Batch-Lauf)
# Ziel: Verarbeitung der Dateiinhalte und Speicherung der Nutzer-Autorität.
2025-12-26 22:07:25 +01:00
# =========================================================================
stats = {"processed": 0, "skipped": 0, "errors": 0}
# Semaphore begrenzt die Parallelität zum Schutz der lokalen oder Cloud-API
sem = asyncio.Semaphore(5)
async def process_with_limit(f_path):
"""Kapselt den Prozess-Aufruf mit Ressourcen-Limitierung."""
async with sem:
try:
# Verwendet process_file (v3.4.1), das explizite Kanten sofort schreibt
# und Symmetrien für Phase 2 im Service-Puffer sammelt.
return await service.process_file(
file_path=str(f_path),
vault_root=str(vault_path),
force_replace=args.force,
apply=args.apply,
purge_before=True
)
except Exception as e:
return {"status": "error", "error": str(e), "path": str(f_path)}
logger.info(f"🚀 [Phase 1] Starting semantic processing in batches...")
batch_size = 20
for i in range(0, len(files), batch_size):
batch = files[i:i+batch_size]
logger.info(f"--- Processing Batch {i//batch_size + 1} ({len(batch)} files) ---")
# Parallelisierung innerhalb des Batches (begrenzt durch sem)
tasks = [process_with_limit(f) for f in batch]
results = await asyncio.gather(*tasks)
for res in results:
# Robuste Auswertung der Rückgabe-Dictionaries
if not isinstance(res, dict):
stats["errors"] += 1
continue
status = res.get("status")
if status == "success":
stats["processed"] += 1
elif status == "error":
stats["errors"] += 1
logger.error(f"❌ Fehler in {res.get('path')}: {res.get('error')}")
elif status == "unchanged":
stats["skipped"] += 1
else:
stats["skipped"] += 1
# =========================================================================
# PHASE 2: Global Symmetry Commitment
# Ziel: Finale Integrität. Triggert erst, wenn Phase 1 komplett indiziert ist.
# =========================================================================
if args.apply:
logger.info(f"🔄 [Phase 2] Starting global symmetry injection for the entire vault...")
try:
# Diese Methode prüft den Puffer gegen die nun vollständige Datenbank
sym_res = await service.commit_vault_symmetries()
if sym_res.get("status") == "success":
logger.info(f"✅ Phase 2 abgeschlossen. Hinzugefügt: {sym_res.get('added', 0)} geschützte Symmetrien.")
else:
logger.info(f"⏭️ Phase 2 übersprungen: {sym_res.get('reason', 'Keine Daten')}")
except Exception as e:
logger.error(f"❌ Fehler in Phase 2: {e}")
else:
logger.info("⏭️ [Phase 2] Dry-Run: Keine Symmetrie-Injektion durchgeführt.")
logger.info(f"--- Import beendet ---")
logger.info(f"Statistiken: {stats}")
2025-12-10 21:41:43 +01:00
def main():
"""Einstiegspunkt und Argument-Parsing."""
load_dotenv()
# Standard-Präfix aus Umgebungsvariable oder Fallback
default_prefix = os.getenv("COLLECTION_PREFIX", "mindnet")
parser = argparse.ArgumentParser(description="Mindnet Ingester: Two-Phase Markdown Import")
parser.add_argument("--vault", default="./vault", help="Pfad zum Obsidian Vault")
parser.add_argument("--prefix", default=default_prefix, help="Qdrant Collection Präfix")
parser.add_argument("--force", action="store_true", help="Erzwingt Neu-Indizierung aller Dateien")
parser.add_argument("--apply", action="store_true", help="Schreibt physisch in die Datenbank")
args = parser.parse_args()
try:
asyncio.run(main_async(args))
except KeyboardInterrupt:
logger.info("Import durch Nutzer abgebrochen.")
except Exception as e:
logger.critical(f"FATALER FEHLER: {e}", exc_info=True)
sys.exit(1)
if __name__ == "__main__":
2025-12-10 21:41:43 +01:00
main()