From f3b6166daa2ca410f35053704ee5ad260bcb0620 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 8 Sep 2025 12:22:05 +0200 Subject: [PATCH] app/core/note_payload.py aktualisiert --- app/core/note_payload.py | 215 ++++++++++++++++++++++++++++++++++----- 1 file changed, 187 insertions(+), 28 deletions(-) diff --git a/app/core/note_payload.py b/app/core/note_payload.py index 7e481a4..06c1680 100644 --- a/app/core/note_payload.py +++ b/app/core/note_payload.py @@ -1,39 +1,198 @@ +# app/core/note_payload.py +# ----------------------------------------------------------------------------- +# Name: note_payload.py +# Version: 1.2.0 (2025-09-08) +# Zweck: Erzeugt den Qdrant-Payload für Notes, inkl. deterministischer +# Hash-Bildung zur Idempotenz-Erkennung. +# +# Neu in 1.2.0: +# - Konfigurierbare Hash-Strategie via Umgebungsvariable MINDNET_HASH_MODE +# * 'body' (Default, rückwärtskompatibel): nur Body geht in den Hash +# * 'body+frontmatter' : Body + Frontmatter gehen in den Hash +# * 'frontmatter' : nur Frontmatter geht in den Hash +# - Kanonische Serialisierung des Frontmatter (sortierte Keys, stabile JSON-Encodierung) +# +# Aufrufparameter / Steuerung: +# - Über Umgebungsvariable: +# export MINDNET_HASH_MODE=body+frontmatter +# oder direkt am Befehl: +# MINDNET_HASH_MODE=frontmatter python3 -m scripts.import_markdown --vault ./vault --apply +# +# Hinweise: +# - Diese Datei ist rückwärtskompatibel: Wenn die Variable nicht gesetzt ist +# oder ein unbekannter Wert verwendet wird, fällt die Logik auf 'body' zurück. +# - Die Datei-Zeitstempel (mtime/ctime) werden NICHT verwendet. +# +# Lizenz: MIT +# ----------------------------------------------------------------------------- + from __future__ import annotations -import os + import hashlib -from typing import Dict -from .parser import ParsedNote -from .parser import extract_wikilinks +import json +import os +from typing import Any, Dict, Optional, Tuple -def sha256_text(text: str) -> str: - h = hashlib.sha256() - h.update(text.encode("utf-8")) - return h.hexdigest() -def make_note_payload(parsed: ParsedNote, vault_root: str) -> Dict: - fm = parsed.frontmatter - body = parsed.body - rel_path = os.path.relpath(parsed.path, vault_root) if vault_root else parsed.path +# -----------------------------------------------------------------------------# +# Dienstfunktionen +# -----------------------------------------------------------------------------# - payload = { - "note_id": fm.get("id"), +def sha256_text(s: str) -> str: + """Bildet SHA-256 über den gegebenen Unicode-String (UTF-8).""" + return hashlib.sha256(s.encode("utf-8")).hexdigest() + + +def canonicalize_frontmatter(fm: Dict[str, Any]) -> str: + """ + Serialisiert das Frontmatter deterministisch: + - JSON mit sortierten Keys + - Keine überflüssigen Whitespaces (kompakte Separatoren) + - UTF-8, keine ASCII-Escapes + Achtung: Datumswerte müssen (wie im Projekt vereinbart) Strings sein. + """ + return json.dumps( + fm, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":") + ) + + +def get_hash_mode_from_env() -> str: + """ + Liest die Hash-Strategie aus der Umgebungsvariable MINDNET_HASH_MODE. + Erlaubte Werte: + - 'body' (Default) + - 'body+frontmatter' + - 'frontmatter' + Unbekannte Werte -> 'body'. + """ + val = (os.environ.get("MINDNET_HASH_MODE") or "").strip().lower() + if val in ("body", "body+frontmatter", "frontmatter"): + return val + return "body" + + +def compute_hash(body: str, frontmatter: Dict[str, Any], mode: Optional[str] = None) -> str: + """ + Berechnet den Hash gemäß der gewünschten Strategie. + Args: + body: Markdown-Body (ohne Frontmatter), bereits als Text + frontmatter: geparstes Frontmatter-Objekt (Dict) + mode: 'body' | 'body+frontmatter' | 'frontmatter' | None (= aus ENV) + Returns: + Hex-String (sha256) + """ + strategy = (mode or get_hash_mode_from_env()).lower() + + # Kanonische Strings bilden + body_str = (body or "").strip() + fm_str = canonicalize_frontmatter(frontmatter or {}) + + if strategy == "frontmatter": + return sha256_text(fm_str) + + if strategy == "body+frontmatter": + # Trennmarker, um Kollisionen (z.B. 'ab'+'c' vs 'a'+'bc') auszuschließen + combo = body_str + "\n\n---\n\n" + fm_str + return sha256_text(combo) + + # Default / 'body' + return sha256_text(body_str) + + +# -----------------------------------------------------------------------------# +# Hauptfunktion für Note-Payload +# -----------------------------------------------------------------------------# + +def make_note_payload(parsed: Dict[str, Any], vault_root: Optional[str] = None) -> Dict[str, Any]: + """ + Baut den Payload für eine Note auf Basis der geparsten Markdown-Datei. + + Erwartete Struktur von `parsed` (wie vom Parser geliefert): + { + "frontmatter": { + "id": "...", # note_id (String, Pflicht im Schema) + "title": "...", # Titel (String) + "type": "...", # Notiztyp (String) + "status": "...", # Status (String) + "created": "...", # ISO-String, Pflicht im Schema + "updated": "...", # ISO-String (empfohlen) + "tags": [...], # optional + ... + }, + "body": "..." # Markdown-Inhalt ohne Frontmatter + } + + Rückgabe-Payload (Beispielauszug, kompatibel mit mindnet_notes Schema): + { + "note_id": "...", + "title": "...", + "type": "...", + "status": "...", + "created": "...", + "updated": "...", + "path": "...", # falls vom Parser geliefert + "tags": [...], # optional + "hash_fulltext": "sha256...", + ... (weitere, projektdefinierte Felder) + } + + Hash-Bildung: + - Gesteuert über MINDNET_HASH_MODE (s. Kopf dieses Moduls). + - Datei-Zeitstempel werden NICHT verwendet. + + Rückwärtskompatibilität: + - Standard bleibt 'body' (nur Body beeinflusst den Hash). + """ + fm: Dict[str, Any] = dict(parsed.get("frontmatter") or {}) + body: str = parsed.get("body") or "" + + # Hash nach konfigurierter Strategie berechnen + hash_fulltext = compute_hash(body=body, frontmatter=fm, mode=None) + + # Basis-Payload zusammenstellen + payload: Dict[str, Any] = { + "note_id": fm.get("id") or fm.get("note_id"), "title": fm.get("title"), "type": fm.get("type"), "status": fm.get("status"), "created": fm.get("created"), "updated": fm.get("updated"), - "tags": fm.get("tags") or [], - "priority": fm.get("priority"), - "effort_min": fm.get("effort_min"), - "due": fm.get("due"), - "people": fm.get("people") or [], - "aliases": fm.get("aliases") or [], - "depends_on": fm.get("depends_on") or [], - "assigned_to": fm.get("assigned_to") or [], - "references": list(sorted(set(extract_wikilinks(body)))), - "lang": fm.get("lang") or None, - "path": rel_path.replace("\\", "/"), - "hash_fulltext": sha256_text(body.strip()), + "path": fm.get("path"), # optional, falls der Parser path im FM ablegt + "tags": fm.get("tags"), # optional + "hash_fulltext": hash_fulltext, } - # Entferne None-Werte für saubere Payloads - return {k: v for k, v in payload.items() if v not in (None, [])} + + # Weitere projekt-/parser-spezifische Felder durchreichen (falls vorhanden) + # Wichtig: keine nicht-deterministischen Felder in den Hash aufnehmen! + passthrough_keys = [ + "area", "project", "source", "lang", "slug", + # ... hier ggf. weitere bekannte, harmlose FM-Felder zulassen + ] + for k in passthrough_keys: + if k in fm: + payload[k] = fm[k] + + return payload + + +# -----------------------------------------------------------------------------# +# Optional: kleines Self-Test-Snippet (nur lokal ausführen) +# -----------------------------------------------------------------------------# +if __name__ == "__main__": + demo_fm = { + "id": "demo-123", + "title": "Demo", + "type": "note", + "status": "active", + "created": "2025-09-08T10:00:00+00:00", + "updated": "2025-09-08T10:00:00+00:00", + "tags": ["demo", "test"] + } + demo_body = "# Überschrift\n\nText." + for m in ("body", "body+frontmatter", "frontmatter"): + os.environ["MINDNET_HASH_MODE"] = m + h = compute_hash(demo_body, demo_fm) + print(f"{m:>18}: {h}")