2025-11-08 16:18:31 +01:00
|
|
|
#!/usr/bin/env python3
|
2025-12-28 10:40:28 +01:00
|
|
|
# -*- coding: utf-8 -*-
|
2025-11-08 16:18:31 +01:00
|
|
|
"""
|
2025-12-28 10:40:28 +01:00
|
|
|
FILE: scripts/import_markdown.py
|
2026-01-10 08:06:07 +01:00
|
|
|
VERSION: 2.6.0 (2026-01-10)
|
2025-12-28 10:40:28 +01:00
|
|
|
STATUS: Active (Core)
|
2026-01-10 10:56:47 +01:00
|
|
|
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
|
|
|
"""
|
2026-01-10 10:56:47 +01:00
|
|
|
|
2025-12-11 12:40:39 +01:00
|
|
|
import asyncio
|
2025-11-08 16:18:31 +01:00
|
|
|
import os
|
2025-12-11 12:40:39 +01:00
|
|
|
import argparse
|
|
|
|
|
import logging
|
2026-01-10 07:35:50 +01:00
|
|
|
import sys
|
2025-12-11 12:40:39 +01:00
|
|
|
from pathlib import Path
|
2026-01-10 10:56:47 +01:00
|
|
|
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
|
|
|
|
2026-01-10 10:56:47 +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
|
2025-12-11 12:40:39 +01:00
|
|
|
sys.path.append(os.getcwd())
|
2025-11-11 17:19:22 +01:00
|
|
|
|
2026-01-10 10:56:47 +01:00
|
|
|
# App-spezifische Imports
|
2025-12-11 12:40:39 +01:00
|
|
|
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
|
|
|
|
2025-12-11 12:40:39 +01:00
|
|
|
logger = logging.getLogger("importer")
|
|
|
|
|
|
|
|
|
|
async def main_async(args):
|
2026-01-10 10:56:47 +01:00
|
|
|
"""
|
|
|
|
|
Haupt-Workflow der Ingestion. Koordiniert die zwei Durchläufe (Pass 1/2)
|
|
|
|
|
und die zwei Schreibphasen (Phase 1/2).
|
|
|
|
|
"""
|
2025-12-11 12:40:39 +01:00
|
|
|
vault_path = Path(args.vault).resolve()
|
|
|
|
|
if not vault_path.exists():
|
2026-01-10 10:56:47 +01:00
|
|
|
logger.error(f"Vault-Pfad existiert nicht: {vault_path}")
|
2025-12-11 12:40:39 +01:00
|
|
|
return
|
2025-11-08 15:25:18 +01:00
|
|
|
|
2026-01-10 10:56:47 +01:00
|
|
|
# 1. Initialisierung des zentralen Ingestion-Services
|
2025-12-11 12:40:39 +01:00
|
|
|
logger.info(f"Initializing IngestionService (Prefix: {args.prefix})")
|
2025-12-10 21:41:43 +01:00
|
|
|
service = IngestionService(collection_prefix=args.prefix)
|
|
|
|
|
|
2025-12-11 12:40:39 +01:00
|
|
|
logger.info(f"Scanning {vault_path}...")
|
2026-01-10 10:56:47 +01:00
|
|
|
all_files_raw = list(vault_path.rglob("*.md"))
|
2025-12-10 21:41:43 +01:00
|
|
|
|
2026-01-10 08:06:07 +01:00
|
|
|
# --- GLOBALER ORDNER-FILTER ---
|
2026-01-10 10:56:47 +01:00
|
|
|
# Diese Liste stellt sicher, dass keine System-Leichen oder temporäre Dateien
|
|
|
|
|
# den Graphen korrumpieren oder zu ID-Kollisionen führen.
|
2026-01-10 07:35:50 +01:00
|
|
|
files = []
|
2026-01-10 10:56:47 +01:00
|
|
|
ignore_list = [".trash", ".obsidian", ".sync", "templates", "_system", ".git"]
|
|
|
|
|
|
|
|
|
|
for f in all_files_raw:
|
2026-01-10 07:35:50 +01:00
|
|
|
f_str = str(f)
|
2026-01-10 10:56:47 +01:00
|
|
|
# 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:
|
2026-01-10 07:35:50 +01:00
|
|
|
files.append(f)
|
|
|
|
|
|
|
|
|
|
files.sort()
|
2026-01-10 10:56:47 +01:00
|
|
|
logger.info(f"Found {len(files)} relevant markdown files (filtered trash/system/hidden).")
|
2025-12-11 12:40:39 +01:00
|
|
|
|
2025-12-26 22:07:25 +01:00
|
|
|
# =========================================================================
|
2026-01-10 07:35:50 +01:00
|
|
|
# PASS 1: Global Pre-Scan
|
2026-01-10 10:56:47 +01:00
|
|
|
# Ziel: Aufbau eines vollständigen Mappings von Bezeichnungen zu stabilen IDs.
|
2025-12-26 22:07:25 +01:00
|
|
|
# =========================================================================
|
2026-01-10 10:56:47 +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:
|
2026-01-10 10:56:47 +01:00
|
|
|
# Extrahiert Frontmatter und Metadaten ohne DB-Last
|
2025-12-26 22:07:25 +01:00
|
|
|
ctx = pre_scan_markdown(str(f_path))
|
|
|
|
|
if ctx:
|
2026-01-10 10:56:47 +01:00
|
|
|
# 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
|
2026-01-10 10:56:47 +01:00
|
|
|
# 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
|
|
|
|
|
|
|
|
# =========================================================================
|
2026-01-10 10:56:47 +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}
|
2026-01-10 10:56:47 +01:00
|
|
|
# Semaphore begrenzt die Parallelität zum Schutz der lokalen oder Cloud-API
|
|
|
|
|
sem = asyncio.Semaphore(5)
|
2025-12-11 12:40:39 +01:00
|
|
|
|
|
|
|
|
async def process_with_limit(f_path):
|
2026-01-10 10:56:47 +01:00
|
|
|
"""Kapselt den Prozess-Aufruf mit Ressourcen-Limitierung."""
|
2025-12-11 12:40:39 +01:00
|
|
|
async with sem:
|
|
|
|
|
try:
|
2026-01-10 10:56:47 +01:00
|
|
|
# Verwendet process_file (v3.4.1), das explizite Kanten sofort schreibt
|
|
|
|
|
# und Symmetrien für Phase 2 im Service-Puffer sammelt.
|
2026-01-10 07:35:50 +01:00
|
|
|
return await service.process_file(
|
2026-01-10 10:56:47 +01:00
|
|
|
file_path=str(f_path),
|
|
|
|
|
vault_root=str(vault_path),
|
|
|
|
|
force_replace=args.force,
|
|
|
|
|
apply=args.apply,
|
|
|
|
|
purge_before=True
|
2025-12-11 12:40:39 +01:00
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return {"status": "error", "error": str(e), "path": str(f_path)}
|
|
|
|
|
|
2026-01-10 10:56:47 +01:00
|
|
|
logger.info(f"🚀 [Phase 1] Starting semantic processing in batches...")
|
|
|
|
|
|
2025-12-11 12:40:39 +01:00
|
|
|
batch_size = 20
|
|
|
|
|
for i in range(0, len(files), batch_size):
|
|
|
|
|
batch = files[i:i+batch_size]
|
2026-01-10 10:56:47 +01:00
|
|
|
logger.info(f"--- Processing Batch {i//batch_size + 1} ({len(batch)} files) ---")
|
|
|
|
|
|
|
|
|
|
# Parallelisierung innerhalb des Batches (begrenzt durch sem)
|
2025-12-11 12:40:39 +01:00
|
|
|
tasks = [process_with_limit(f) for f in batch]
|
|
|
|
|
results = await asyncio.gather(*tasks)
|
2026-01-10 10:56:47 +01:00
|
|
|
|
2025-12-11 12:40:39 +01:00
|
|
|
for res in results:
|
2026-01-10 10:56:47 +01:00
|
|
|
# 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
|
2026-01-10 07:35:50 +01:00
|
|
|
|
|
|
|
|
# =========================================================================
|
2026-01-10 10:56:47 +01:00
|
|
|
# PHASE 2: Global Symmetry Commitment
|
|
|
|
|
# Ziel: Finale Integrität. Triggert erst, wenn Phase 1 komplett indiziert ist.
|
2026-01-10 07:35:50 +01:00
|
|
|
# =========================================================================
|
|
|
|
|
if args.apply:
|
2026-01-10 08:06:07 +01:00
|
|
|
logger.info(f"🔄 [Phase 2] Starting global symmetry injection for the entire vault...")
|
2026-01-10 10:56:47 +01:00
|
|
|
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.")
|
2026-01-10 07:35:50 +01:00
|
|
|
|
2026-01-10 10:56:47 +01:00
|
|
|
logger.info(f"--- Import beendet ---")
|
|
|
|
|
logger.info(f"Statistiken: {stats}")
|
2025-12-10 21:41:43 +01:00
|
|
|
|
2025-12-11 12:40:39 +01:00
|
|
|
def main():
|
2026-01-10 10:56:47 +01:00
|
|
|
"""Einstiegspunkt und Argument-Parsing."""
|
2025-12-11 12:40:39 +01:00
|
|
|
load_dotenv()
|
2026-01-10 10:56:47 +01:00
|
|
|
|
|
|
|
|
# Standard-Präfix aus Umgebungsvariable oder Fallback
|
2025-12-11 12:40:39 +01:00
|
|
|
default_prefix = os.getenv("COLLECTION_PREFIX", "mindnet")
|
2026-01-10 10:56:47 +01:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
2025-12-11 12:40:39 +01:00
|
|
|
args = parser.parse_args()
|
2026-01-10 10:56:47 +01:00
|
|
|
|
2026-01-10 07:35:50 +01:00
|
|
|
try:
|
|
|
|
|
asyncio.run(main_async(args))
|
2026-01-10 10:56:47 +01:00
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
logger.info("Import durch Nutzer abgebrochen.")
|
2026-01-10 07:35:50 +01:00
|
|
|
except Exception as e:
|
2026-01-10 10:56:47 +01:00
|
|
|
logger.critical(f"FATALER FEHLER: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
2025-09-09 19:47:16 +02:00
|
|
|
|
2025-09-01 17:37:39 +02:00
|
|
|
if __name__ == "__main__":
|
2025-12-10 21:41:43 +01:00
|
|
|
main()
|