""" note_payload.py — Mindnet core payload builder (v0.5, 2025-11-08) Purpose ------- Builds a **JSON-serializable** payload dict for a single note to be stored in the `_notes` collection. The function is defensive against both attribute- and dict-like ParsedNote inputs, unknown kwargs, and ensures `retriever_weight` is always present as a float. Key guarantees -------------- - Accepts extra positional/keyword args without error (for importer compatibility). - Tolerant of attribute vs dict access for ParsedNote. - Always sets 'retriever_weight' in the payload (float). - Never includes non-serializable objects (functions, PosixPath, datetime, etc.). Public API ---------- make_note_payload(parsed_note, *args, retriever_weight=None, vault_root=None, type_defaults=None, **kwargs) -> dict """ from __future__ import annotations from pathlib import Path from typing import Any, Dict, Optional, Union, Iterable, Mapping import datetime, math Json = Union[None, bool, int, float, str, list, dict] # ------------------------- helpers ------------------------- def _is_mapping(x: Any) -> bool: return isinstance(x, Mapping) def _get(obj: Any, *names: str, default: Any=None) -> Any: """Try attribute lookup, then mapping (dict) lookup, first hit wins.""" for n in names: if hasattr(obj, n): try: return getattr(obj, n) except Exception: pass if _is_mapping(obj) and n in obj: try: return obj[n] except Exception: pass return default def _to_float(x: Any, default: float=1.0) -> float: if x is None: return float(default) if isinstance(x, (int, float)) and math.isfinite(x): return float(x) try: s = str(x).strip().replace(',', '.') return float(s) except Exception: return float(default) def _ensure_list(x: Any) -> list: if x is None: return [] if isinstance(x, list): return x if isinstance(x, (set, tuple)): return list(x) return [x] def _sanitize(obj: Any) -> Json: """Recursively convert to JSON-serializable primitives; drop callables.""" if obj is None or isinstance(obj, (bool, int, float, str)): return obj if callable(obj): return None if isinstance(obj, (list, tuple, set)): return [_sanitize(v) for v in obj] if isinstance(obj, dict): out = {} for k, v in obj.items(): if callable(v): continue out[str(k)] = _sanitize(v) return out if isinstance(obj, Path): return str(obj) if isinstance(obj, datetime.datetime): return obj.isoformat() if hasattr(obj, "__str__"): try: return str(obj) except Exception: return None return None def _compute_retriever_weight(explicit: Any, frontmatter: dict, type_defaults: Optional[dict], note_type: Optional[str]) -> float: if explicit is not None: return _to_float(explicit, 1.0) # common frontmatter keys for key in ("retriever_weight", "retriever.weight", "retrieverWeight"): if key in frontmatter: return _to_float(frontmatter.get(key), 1.0) # type defaults map like: {"concept": {"retriever_weight": 0.9}, ...} if type_defaults and note_type: tdef = type_defaults.get(note_type) or {} for key in ("retriever_weight", "retriever.weight", "retrieverWeight"): if key in tdef: return _to_float(tdef.get(key), 1.0) return 1.0 # ------------------------- public API ------------------------- def make_note_payload(parsed_note: Any, *args, retriever_weight: Optional[float]=None, vault_root: Optional[str]=None, type_defaults: Optional[dict]=None, **kwargs) -> Dict[str, Json]: """ Build a JSON-safe payload for the note. Parameters (tolerant; unknown args are ignored) ---------- parsed_note : object or dict Expected fields/keys (best-effort): note_id/id, title, type, path/rel_path, frontmatter, tags, aliases, chunk_profile. retriever_weight : float|None Overrides frontmatter/type-defaults if provided. vault_root : str|None Optional; used to produce a normalized relative path. type_defaults : dict|None Optional map for per-type defaults. Returns ------- dict suitable for Qdrant payload """ fm = _get(parsed_note, "frontmatter", "fm", default={}) if not isinstance(fm, dict): fm = {} note_id = _get(parsed_note, "note_id", "id", default=fm.get("id")) title = _get(parsed_note, "title", default=fm.get("title")) ntype = _get(parsed_note, "type", default=fm.get("type")) raw_path = _get(parsed_note, "path", "rel_path", "relpath", default=fm.get("path")) tags = _ensure_list(_get(parsed_note, "tags", default=fm.get("tags"))) aliases = _ensure_list(_get(parsed_note, "aliases", default=fm.get("aliases"))) chunk_profile = _get(parsed_note, "chunk_profile", "profile", default=fm.get("chunk_profile")) created = _get(parsed_note, "created", default=fm.get("created")) updated = _get(parsed_note, "updated", default=fm.get("updated")) # normalize path relative to vault root if both available rel_path = raw_path if raw_path and vault_root: try: rel_path = str(Path(raw_path)).replace(str(Path(vault_root)), "").lstrip("/\\") except Exception: rel_path = str(raw_path) rw = _compute_retriever_weight(retriever_weight, fm, type_defaults, ntype) payload = { "note_id": note_id, "title": title, "type": ntype, "path": rel_path or raw_path, "tags": tags, "aliases": aliases, "chunk_profile": chunk_profile, "created": created, "updated": updated, "retriever_weight": float(rw), } # Add selected FM fields if present (safe subset) for key in ("status", "priority", "owner", "source"): if key in fm: payload[key] = fm.get(key) return _sanitize(payload)