mindnet/app/core/chunk_payload.py

219 lines
7.5 KiB
Python
Raw Normal View History

2025-11-08 21:22:17 +01:00
# app/core/chunk_payload.py
2025-11-08 21:26:42 +01:00
# Version: 1.2.0 (2025-11-08)
# Purpose:
# Build robust Qdrant payloads for CHUNK points.
#
# Highlights:
# - Works with dict-like chunks and simple objects; supports (text, idx) tuples.
# - Accepts legacy/extra kwargs (e.g., vault_root) without failing.
# - Copies canonical note fields onto each chunk (note_id/title/type/tags/path).
# - Sets 'text' and 'chunk_index' per chunk.
# - Reliably propagates `retriever_weight` onto every chunk if provided in
# frontmatter or explicitly.
#
# Usage:
# payloads = make_chunk_payloads(note, chunks, retriever_weight=None, base_payload=None, vault_root="/path/to/vault")
#
# Changelog:
# 1.2.0 (2025-11-08) Accept legacy kwargs, robust getters, propagate retriever_weight.
# 1.1.0 (2025-11-08) Initial robust rewrite with attribute/dict support.
2025-11-08 21:22:17 +01:00
2025-11-08 14:24:40 +01:00
from __future__ import annotations
2025-09-09 11:04:50 +02:00
2025-11-08 21:26:42 +01:00
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
2025-11-08 21:22:17 +01:00
def _get(obj: Any, key: str, default: Any = None) -> Any:
if obj is None:
return default
if hasattr(obj, key):
try:
val = getattr(obj, key)
2025-11-08 21:26:42 +01:00
return default if val is None else val
2025-11-08 21:22:17 +01:00
except Exception:
pass
if isinstance(obj, dict):
if key in obj:
val = obj.get(key, default)
2025-11-08 21:26:42 +01:00
return default if val is None else val
2025-11-08 21:22:17 +01:00
return default
def _get_frontmatter(note: Any) -> Dict[str, Any]:
fm = _get(note, "frontmatter", None)
if isinstance(fm, dict):
return fm
meta = _get(note, "meta", None)
if isinstance(meta, dict) and isinstance(meta.get("frontmatter"), dict):
return meta["frontmatter"]
return {}
def _get_from_frontmatter(fm: Dict[str, Any], key: str, default: Any = None) -> Any:
if not isinstance(fm, dict):
return default
if key in fm:
val = fm.get(key, default)
2025-11-08 21:26:42 +01:00
return default if val is None else val
2025-11-08 21:22:17 +01:00
return default
def _coerce_tags(val: Any) -> List[str]:
2025-11-08 21:19:18 +01:00
if val is None:
2025-11-08 21:22:17 +01:00
return []
if isinstance(val, list):
return [str(x) for x in val]
if isinstance(val, str):
parts = [t.strip() for t in val.split(",")]
return [p for p in parts if p]
return []
2025-11-08 21:19:18 +01:00
2025-11-08 21:22:17 +01:00
2025-11-08 21:26:42 +01:00
def _resolve_retriever_weight(fm: Dict[str, Any], explicit: Optional[float]) -> Optional[float]:
2025-11-08 21:19:18 +01:00
if explicit is not None:
2025-11-08 21:26:42 +01:00
try:
return float(explicit)
except Exception:
return None
2025-11-08 21:22:17 +01:00
val = _get_from_frontmatter(fm, "retriever_weight", None)
if isinstance(val, (int, float)):
return float(val)
retr = fm.get("retriever")
if isinstance(retr, dict):
v = retr.get("weight")
if isinstance(v, (int, float)):
return float(v)
2025-11-08 21:19:18 +01:00
return None
2025-11-08 21:22:17 +01:00
2025-11-08 21:26:42 +01:00
def _resolve_path(note: Any, fm: Dict[str, Any], vault_root: Optional[str]) -> Optional[str]:
path = _get_from_frontmatter(fm, "path", None)
if path is None:
path = _get(note, "path", None) or _get(note, "source", None) or _get(note, "filepath", None)
if path is None:
return None
try:
if vault_root:
vr = Path(vault_root)
rel = Path(path)
try:
return str(rel.relative_to(vr))
except Exception:
return str(rel)
except Exception:
pass
return str(path)
2025-11-08 21:22:17 +01:00
2025-11-08 21:26:42 +01:00
def _resolve_note_fields(note: Any, vault_root: Optional[str]) -> Dict[str, Any]:
fm = _get_frontmatter(note)
note_id = _get_from_frontmatter(fm, "id", None) or _get(note, "note_id", None) or _get(note, "id", None)
title = _get_from_frontmatter(fm, "title", None) or _get(note, "title", None)
ntype = _get_from_frontmatter(fm, "type", None) or _get(note, "type", None)
tags = _coerce_tags(_get_from_frontmatter(fm, "tags", None) or _get(note, "tags", None))
path = _resolve_path(note, fm, vault_root)
return {"note_id": note_id, "title": title, "type": ntype, "tags": tags, "path": path, "frontmatter": fm}
2025-11-08 21:22:17 +01:00
2025-11-08 21:26:42 +01:00
def _extract_chunk_text_and_index(chunk: Any, fallback_index: int) -> Tuple[str, int]:
# (text, idx) tuple
2025-11-08 21:22:17 +01:00
if isinstance(chunk, tuple) and len(chunk) == 2 and isinstance(chunk[0], str):
txt, idx = chunk
try:
idx_int = int(idx)
except Exception:
idx_int = fallback_index
return txt, idx_int
2025-11-08 21:26:42 +01:00
# string
2025-11-08 21:22:17 +01:00
if isinstance(chunk, str):
return chunk, fallback_index
2025-11-08 21:26:42 +01:00
# dict
2025-11-08 21:22:17 +01:00
if isinstance(chunk, dict):
2025-11-08 21:26:42 +01:00
txt = chunk.get("text") or chunk.get("window") or chunk.get("body") or chunk.get("content")
2025-11-08 21:22:17 +01:00
if isinstance(txt, str):
idx = chunk.get("index")
try:
idx_int = int(idx) if idx is not None else fallback_index
except Exception:
idx_int = fallback_index
return txt, idx_int
2025-11-08 21:26:42 +01:00
# object with attributes
2025-11-08 21:22:17 +01:00
for attr in ("text", "window", "body", "content"):
if hasattr(chunk, attr):
try:
txt = getattr(chunk, attr)
except Exception:
txt = None
if isinstance(txt, str):
idx = None
if hasattr(chunk, "index"):
try:
idx = getattr(chunk, "index")
except Exception:
idx = None
try:
idx_int = int(idx) if idx is not None else fallback_index
except Exception:
idx_int = fallback_index
return txt, idx_int
raise ValueError("Unsupported chunk format: cannot extract text/index")
2025-09-30 12:18:16 +02:00
def make_chunk_payloads(
2025-11-08 21:22:17 +01:00
note: Any,
2025-11-08 21:26:42 +01:00
chunks,
2025-11-08 21:22:17 +01:00
*,
2025-11-08 21:19:18 +01:00
retriever_weight: Optional[float] = None,
2025-11-08 21:22:17 +01:00
base_payload: Optional[Dict[str, Any]] = None,
2025-11-08 21:26:42 +01:00
vault_root: Optional[str] = None,
**kwargs,
2025-09-30 12:18:16 +02:00
) -> List[Dict[str, Any]]:
2025-11-08 21:26:42 +01:00
"""Build Qdrant payloads for chunks from a parsed note and iterable of chunks.
Parameters
----------
note : Any
Parsed note (dict or object with attributes).
chunks : Iterable[Any]
Chunks; supports str, dicts with 'text'/'window'/'body'/'content', objects with same, or (text, idx) tuples.
retriever_weight : Optional[float]
Optional override; if None, value is read from frontmatter.
base_payload : Optional[Dict[str, Any]]
Extra fields to copy onto each chunk.
vault_root : Optional[str]
Optional base path to compute relative 'path' if possible.
**kwargs :
Ignored extra options to remain compatible with callers.
2025-11-08 14:24:40 +01:00
"""
2025-11-08 21:26:42 +01:00
note_fields = _resolve_note_fields(note, vault_root)
2025-11-08 21:22:17 +01:00
fm = note_fields["frontmatter"]
rw = _resolve_retriever_weight(fm, retriever_weight)
2025-11-08 21:19:18 +01:00
2025-11-08 21:22:17 +01:00
common: Dict[str, Any] = {}
2025-11-08 21:26:42 +01:00
if isinstance(base_payload, dict):
2025-11-08 21:22:17 +01:00
common.update({k: v for k, v in base_payload.items() if v is not None})
2025-11-08 21:19:18 +01:00
2025-11-08 21:22:17 +01:00
if note_fields.get("note_id") is not None:
common["note_id"] = note_fields["note_id"]
if note_fields.get("title") is not None:
common["title"] = note_fields["title"]
if note_fields.get("type") is not None:
common["type"] = note_fields["type"]
if note_fields.get("tags"):
common["tags"] = note_fields["tags"]
if note_fields.get("path") is not None:
common["path"] = note_fields["path"]
if rw is not None:
common["retriever_weight"] = rw
2025-11-08 21:26:42 +01:00
out: List[Dict[str, Any]] = []
2025-11-08 21:22:17 +01:00
for i, ch in enumerate(chunks):
text, idx = _extract_chunk_text_and_index(ch, i)
2025-11-08 21:26:42 +01:00
payload = dict(common)
2025-11-08 21:22:17 +01:00
payload["chunk_index"] = idx
payload["text"] = text
out.append(payload)
2025-11-08 21:19:18 +01:00
return out