#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ export_markdown.py — Export aus Qdrant → Markdown (Vault-Struktur) Version: 1.3 (2025-09-09) Kurzbeschreibung - Exportiert Notes (+ rekonstruierten Body aus Chunks) aus Qdrant in Dateien mit YAML-Frontmatter. - Nutzt ENV-Variablen aus .env (QDRANT_URL, QDRANT_API_KEY, COLLECTION_PREFIX, VECTOR_DIM). - Optionales CLI-Argument --prefix überschreibt COLLECTION_PREFIX, damit alle Tools konsistent sind. - Unterstützung von Mehrfachauswahl per --note-id (mehrfach angeben). Anwendungsfälle - Kompletter Vault-Neuaufbau aus Qdrant - Teil-Export einzelner Notizen - Sicherung / Migration Voraussetzungen - Aktiviertes venv (empfohlen): `source .venv/bin/activate` - Laufender Qdrant (URL/API-Key passend zu deiner Umgebung) - Sammlungen: _notes, _chunks - Chunk-Payload enthält Text in `text` (Fallback: `raw`), Reihenfolge über `seq` oder Nummer in `chunk_id`. Aufrufe (Beispiele) - Prefix über ENV (empfohlen): export COLLECTION_PREFIX="mindnet" python3 -m scripts.export_markdown --out ./_exportVault - Prefix über CLI (überschreibt ENV): python3 -m scripts.export_markdown --out ./_exportVault --prefix mindnet - Nur bestimmte Notizen exportieren: python3 -m scripts.export_markdown --out ./_exportVault \ --prefix mindnet \ --note-id 20250821-architektur-ki-trainerassistent-761cfe \ --note-id 20250821-personal-mind-ki-projekt-7b0d79 - Existierende Dateien überschreiben: python3 -m scripts.export_markdown --out ./_exportVault --prefix mindnet --overwrite Parameter - --out PATH (Pflicht) Ziel-Verzeichnis des Export-Vaults (wird angelegt). - --prefix TEXT (Optional) Collection-Prefix; überschreibt ENV COLLECTION_PREFIX. - --note-id ID (Optional, mehrfach) Export auf bestimmte Note-IDs begrenzen. - --overwrite (Optional) Bereits existierende Dateien überschreiben (default: skip). - --dry-run (Optional) Nur anzeigen, was geschrieben würde; keine Dateien anlegen. Änderungen ggü. v1.2 - Neues optionales CLI-Argument --prefix (ENV-Fallback bleibt). - Robustere Qdrant-Scroll-Logik (neue Client-Signatur: (points, next_offset)). - Verbesserte Sortierung der Chunks (seq > Nummer aus chunk_id > Fallback). - Defensiver Umgang mit Frontmatter (nur sinnvolle Felder; Datumswerte als Strings). """ from __future__ import annotations import os import sys import argparse import json from pathlib import Path from typing import Dict, List, Optional, Tuple from dotenv import load_dotenv from qdrant_client.http import models as rest from app.core.qdrant import QdrantConfig, get_client # ensure_collections ist für Export nicht nötig # ------------------------- # Hilfsfunktionen # ------------------------- def collections(prefix: str) -> Tuple[str, str, str]: return f"{prefix}_notes", f"{prefix}_chunks", f"{prefix}_edges" def scroll_all(client, collection: str, flt: Optional[rest.Filter] = None, with_payload=True, with_vectors=False): """Iterator über alle Punkte einer Collection (neue Qdrant-Client-Signatur).""" next_offset = None while True: points, next_offset = client.scroll( collection_name=collection, scroll_filter=flt, with_payload=with_payload, with_vectors=with_vectors, limit=256, offset=next_offset, ) for p in points: yield p if next_offset is None: break def ensure_dir(path: Path): path.parent.mkdir(parents=True, exist_ok=True) def select_frontmatter(note_pl: Dict) -> Dict: """ Reduziert Payload auf für den Vault sinnvolle Frontmatter-Felder. Pflichtfelder laut Schema: note_id (id), title, type, status, created, path Optional: updated, tags """ # Backward-compat: manche Payloads nutzen 'id' statt 'note_id' note_id = note_pl.get("note_id") or note_pl.get("id") fm = { "id": note_id, "title": note_pl.get("title"), "type": note_pl.get("type"), "status": note_pl.get("status"), "created": note_pl.get("created"), "path": note_pl.get("path"), } # optional if note_pl.get("updated") is not None: fm["updated"] = note_pl.get("updated") if note_pl.get("tags"): fm["tags"] = note_pl.get("tags") return fm def yaml_block(frontmatter: Dict) -> str: """ Sehr einfache YAML-Serialisierung (ohne zusätzliche Abhängigkeiten). Annahme: Werte sind Strings/Listen; Datumsangaben bereits als Strings. """ lines = ["---"] for k, v in frontmatter.items(): if v is None: continue if isinstance(v, list): # einfache Listen-Notation lines.append(f"{k}:") for item in v: lines.append(f" - {item}") else: # Strings ggf. doppelt quoten, wenn Sonderzeichen enthalten s = str(v) if any(ch in s for ch in [":", "-", "#", "{", "}", "[", "]", ","]): lines.append(f'{k}: "{s}"') else: lines.append(f"{k}: {s}") lines.append("---") return "\n".join(lines) def chunk_sort_key(pl: Dict) -> Tuple[int, int]: """ Bestimme eine stabile Sortierreihenfolge: 1) seq (falls vorhanden) 2) Nummer aus chunk_id (…#) 3) Fallback: 0 """ seq = pl.get("seq") if isinstance(seq, int): return (0, seq) cid = pl.get("chunk_id") or pl.get("id") or "" n = 0 if "#" in cid: try: n = int(cid.split("#", 1)[1]) except ValueError: n = 0 return (1, n) def reconstruct_body(chunk_payloads: List[Dict]) -> str: parts: List[str] = [] chunk_payloads_sorted = sorted(chunk_payloads, key=chunk_sort_key) for pl in chunk_payloads_sorted: txt = pl.get("text") or pl.get("raw") or "" parts.append(txt.rstrip("\n")) return "\n\n".join(parts).rstrip() + "\n" def safe_write(out_path: Path, content: str, overwrite: bool) -> str: if out_path.exists() and not overwrite: return "skip" ensure_dir(out_path) out_path.write_text(content, encoding="utf-8") return "write" def fetch_note_chunks(client, chunks_col: str, note_id: str) -> List[Dict]: flt = rest.Filter( must=[rest.FieldCondition(key="note_id", match=rest.MatchValue(value=note_id))] ) out: List[Dict] = [] for p in scroll_all(client, chunks_col, flt, with_payload=True, with_vectors=False): if p.payload: out.append(p.payload) return out def make_export_path(export_root: Path, note_pl: Dict) -> Path: # prefer payload 'path', sonst Titel-basierte Fallback-Datei rel = (note_pl.get("path") or f"{note_pl.get('title') or 'Note'}.md").strip("/") # Normalisierung Windows-Backslashes etc. rel = rel.replace("\\", "/") return export_root.joinpath(rel) # ------------------------- # Main # ------------------------- def main(): load_dotenv() ap = argparse.ArgumentParser() ap.add_argument("--out", required=True, help="Ziel-Verzeichnis für den Export-Vault") ap.add_argument("--prefix", help="Collection-Prefix (überschreibt ENV COLLECTION_PREFIX)") ap.add_argument("--note-id", action="append", help="Nur bestimmte Note-ID exportieren (mehrfach möglich)") ap.add_argument("--overwrite", action="store_true", help="Existierende Dateien überschreiben") ap.add_argument("--dry-run", action="store_true", help="Nur anzeigen, keine Dateien schreiben") args = ap.parse_args() # Qdrant-Konfiguration cfg = QdrantConfig.from_env() if args.prefix: # CLI-Präfix hat Vorrang cfg.prefix = args.prefix client = get_client(cfg) notes_col, chunks_col, _ = collections(cfg.prefix) # Filter für Noten-Auswahl note_filter: Optional[rest.Filter] = None if args.note_id: should = [rest.FieldCondition(key="note_id", match=rest.MatchValue(value=nid)) for nid in args.note_id] note_filter = rest.Filter(should=should) export_root = Path(args.out).resolve() export_root.mkdir(parents=True, exist_ok=True) total = 0 written = 0 skipped = 0 # Notes aus Qdrant holen for p in scroll_all(client, notes_col, note_filter, with_payload=True, with_vectors=False): pl = p.payload or {} note_id = pl.get("note_id") or pl.get("id") title = pl.get("title") # Frontmatter & Body fm = select_frontmatter(pl) yaml = yaml_block(fm) chunks = fetch_note_chunks(client, chunks_col, note_id) body = reconstruct_body(chunks) content = f"{yaml}\n{body}" out_path = make_export_path(export_root, pl) decision = "dry-run" if not args.dry_run: decision = safe_write(out_path, content, args.overwrite) if decision == "write": written += 1 elif decision == "skip": skipped += 1 total += 1 print(json.dumps({ "note_id": note_id, "title": title, "file": str(out_path), "chunks": len(chunks), "decision": decision }, ensure_ascii=False)) print(json.dumps({ "summary": { "notes_total": total, "written": written, "skipped": skipped, "out_dir": str(export_root) } }, ensure_ascii=False)) if __name__ == "__main__": main()