#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ FILE: scripts/resolve_unresolved_references.py VERSION: 2.1.0 (2025-12-15) STATUS: Active COMPATIBILITY: v2.9.1 (Post-WP14/WP-15b) Zweck: ------- Löst unaufgelöste Wikilinks in Qdrant nachträglich auf. Findet Edges mit status=="unresolved" und versucht sie über Titel/Alias aufzulösen. Funktionsweise: --------------- 1. Baut Lookup-Index aus allen Notizen: - Mapping: lower(title) -> note_id - Mapping: lower(alias) -> note_id 2. Findet alle Edges mit status=="unresolved" 3. Versucht Auflösung über Lookup-Index 4. Aktualisiert erfolgreich aufgelöste Edges: - Setzt target_id - Entfernt status - Fügt resolution="healed_by_script" hinzu 5. Erzeugt Backlinks für erfolgreich aufgelöste references-Edges Ergebnis-Interpretation: ------------------------ - Log-Ausgabe: Fortschritt und Statistiken - "Resolvable: X/Y": Anzahl aufgelösbarer Edges - Ohne --apply: Dry-Run (keine DB-Änderungen) - Exit-Code 0: Erfolgreich Verwendung: ----------- - Nach Import von neuen Notizen, die bestehende Links auflösen - Reparatur von "Red Links" (Links auf noch nicht existierende Notizen) - Wartung des Graphen nach größeren Änderungen Hinweise: --------- - Sucht nach status=="unresolved" in Edge-Payloads - Auflösung erfolgt über Titel und Aliases (case-insensitive) - Erzeugt automatisch Backlinks für references-Edges - Batch-Verarbeitung für Performance Aufruf: ------- python3 -m scripts.resolve_unresolved_references --apply python3 -m scripts.resolve_unresolved_references --prefix mindnet --limit 1000 --apply Parameter: ---------- --prefix TEXT Collection-Präfix (Default: ENV COLLECTION_PREFIX) --apply Führt tatsächliche DB-Änderungen durch (sonst Dry-Run) --limit INT Maximale Anzahl zu verarbeitender Edges (0=alle, Default: 0) --batch INT Batch-Größe für Upserts (Default: 100) Änderungen: ----------- v2.1.0 (2025-12-15): Dokumentation aktualisiert v1.1.0: Fixed for v2.6 Architecture v1.0.0: Initial Release """ import argparse import logging import json import uuid from typing import List, Dict, Any, Iterable from qdrant_client import models from app.core.database.qdrant import QdrantConfig, get_client from app.core.database.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]: """ Hilfsfunktion: Erzeugt die Payload für den 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 } 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(): 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() cfg = QdrantConfig.from_env() if args.prefix: cfg.prefix = args.prefix client = get_client(cfg) edges_col = f"{cfg.prefix}_edges" notes_col = f"{cfg.prefix}_notes" # 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 # 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")) ] ) 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] 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()